# Capability Spec Delta: crypto-keys (crypto-keys) **Package**: `@suaveplan/crypto-keys` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Universal-tier purity The package SHALL ship as a universal-tier package — no imports from `node:*`, `bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto` in any file under `src/`. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. The `package.json` SHALL NOT declare a `"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL declare `"sideEffects": false`. #### Scenario: no platform imports in src/ - **WHEN** the CI step `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http)['\"]" packages/crypto/crypto-keys/src/` runs - **THEN** the command produces zero matches **Verified by:** `src/signer/signer.test.ts::"signer module has no node/bun imports (tier purity)"` AND `src/codecs/tlv/tlv.test.ts::"tlv codec has no node/bun imports (tier purity)"` --- ### Requirement: Stable CHAIN_KEYS_CODES error registry The package SHALL declare an error-code registry via `defineCodes("CHAIN_KEYS", [...])` containing exactly the codes `UNSUPPORTED_ALGORITHM`, `INVALID_FORMAT`, `INVALID_SIGNATURE`, `TRUST_MISMATCH`, `INVALID_DEPTH`, `INVALID_KEYCHAIN`, `INVALID_DOMAIN_TAG`. Every public error path SHALL throw or wrap a `ChainKeysError extends SuaveplanError` carrying one of those codes. `ChainKeysError` SHALL expose optional `depth?: number` and `algorithm?: AlgorithmId` diagnostic discriminants. The package SHALL NOT throw raw `Error` instances from any public method. #### Scenario: every code is reachable from a real call site - **WHEN** the test suite exercises unsupported algorithm, malformed wire bytes, bad signature, trust mismatch, out-of-range depth, missing private slot, and invalid domain tag - **THEN** each path raises a `ChainKeysError` whose `code` matches the expected `CHAIN_KEYS.*` constant #### Scenario: SuaveplanError contract preserved - **WHEN** a `ChainKeysError` wraps an underlying exception (e.g. from `@suaveplan/crypto-curve`) - **THEN** `error.cause` is the original; `error instanceof SuaveplanError` is true; the code remains stable across renames **Verified by:** `src/error-codes/error-codes.test.ts::"every CHAIN_KEYS.* code is reachable from a call site"` AND `src/error-codes/error-codes.test.ts::"ChainKeysError preserves cause and SuaveplanError contract"` --- ### Requirement: ChainHasher interface with three shipped implementations The package SHALL provide a `ChainHasher` interface with `hash(encodedKeys: Uint8Array, parent?: ParsedChainLevel): Uint8Array` and a `name: "raw" | "domain" | "merkle"` property. The package SHALL ship three implementations: `RawHasher`, `DomainHasher`, and `MerkleHasher`. `RawHasher.hash(keys)` SHALL return `keys` unchanged. `DomainHasher` SHALL be constructed with `{ domainTag: string }` where the tag is 1–64 ASCII bytes; out-of-range tags raise `CHAIN_KEYS.INVALID_DOMAIN_TAG`. `DomainHasher.hash(keys)` SHALL return `sha256(utf8(domainTag) || keys)`. `MerkleHasher.hash(keys, parent)` SHALL return `sha256(keys)` when `parent` is undefined and `sha256(keys || sha256(parent.encodedKeys || parent.signature))` otherwise. #### Scenario: RawHasher returns keys unchanged - **WHEN** `new RawHasher().hash(new Uint8Array([1, 2, 3]))` is called - **THEN** the result equals `new Uint8Array([1, 2, 3])` byte-for-byte; `name` is `"raw"` #### Scenario: DomainHasher rejects invalid tags - **WHEN** `new DomainHasher({ domainTag: "" })` is constructed - **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_DOMAIN_TAG"` #### Scenario: MerkleHasher commits to parent content - **WHEN** `MerkleHasher.hash(keys, parent)` is called twice with the same `keys` but different `parent.signature` values - **THEN** the two returned hashes are different **Verified by:** `src/hashers/raw/raw.test.ts::"hash returns keys unchanged"` AND `src/hashers/domain/domain.test.ts::"rejects empty domainTag with CHAIN_KEYS.INVALID_DOMAIN_TAG"` AND `src/hashers/merkle/merkle.test.ts::"non-root commits to parent signature"` --- ### Requirement: ChainCodec interface with three shipped implementations The package SHALL provide a `ChainCodec` interface with `encode(levels: readonly ChainLevel[]): Uint8Array`, `decode(bytes: Uint8Array): readonly ChainLevel[]`, and a `name: "tlv" | "recursive-ed25519" | "cbor"` property. `ChainLevel` is `{ algorithm: SignAlgorithmId; signerPublicKey: Uint8Array; payload: Uint8Array; signature: Uint8Array }`. The package SHALL ship three implementations: - **`TlvChainCodec`** — wire format `[version=1: u8][hasher: u8]` then per level `[algorithm: u8][pub_len: u8][signer_pub][payload_len: u16 BE][payload][sig_len: u16 BE][sig]`. Levels emitted leaf-first. Unknown version or hasher byte raises `CHAIN_KEYS.INVALID_FORMAT`. Length-overrun raises `CHAIN_KEYS.INVALID_FORMAT`. - **`RecursiveEd25519Codec`** — wire format `[version=1: u8][hasher: u8][signer_pub: 32][payload_len: u16 BE][payload][sig: 64][parent?]` recursive. Restricted to Ed25519; encoding any other algorithm raises `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. - **`CborChainCodec`** — wire format `[version=1: u8][cbor]` where the CBOR payload is `{ v: 1, h: , levels: [{ a: , p: , k: , s: }, ...] }`. Uses `@suaveplan/cbor` deterministic encoder. All three codecs SHALL satisfy `decode(encode(levels)).deepEqual(levels)` for valid input. #### Scenario: TLV round-trip preserves levels - **WHEN** a 3-level chain with mixed algorithms is encoded by `TlvChainCodec` and decoded back - **THEN** the decoded levels deep-equal the input #### Scenario: TLV rejects bad version byte - **WHEN** `TlvChainCodec.decode` is called on bytes whose first byte is `99` - **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_FORMAT"` #### Scenario: RecursiveEd25519Codec rejects non-Ed25519 - **WHEN** `RecursiveEd25519Codec.encode` is called with a level whose `algorithm` is `"p256"` - **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.UNSUPPORTED_ALGORITHM"` and `algorithm === "p256"` #### Scenario: CBOR codec produces deterministic output - **WHEN** the same chain is encoded twice by `CborChainCodec` - **THEN** the two output buffers are byte-for-byte identical **Verified by:** `src/codecs/tlv/tlv.test.ts::"round-trip preserves 3-level chain with mixed algorithms"` AND `src/codecs/tlv/tlv.test.ts::"rejects bad version byte with CHAIN_KEYS.INVALID_FORMAT"` AND `src/codecs/recursive-ed25519/recursive-ed25519.test.ts::"rejects non-Ed25519 with CHAIN_KEYS.UNSUPPORTED_ALGORITHM"` AND `src/codecs/cbor/cbor.test.ts::"encoder is deterministic across calls"` --- ### Requirement: ChainedKeySigner dual-surface composition The package SHALL provide a `ChainedKeySigner` class with constructor `(options?: { hasher?: ChainHasher; codec?: ChainCodec })` defaulting to `new RawHasher()` and `new TlvChainCodec()`. The class SHALL implement TWO signing surfaces plus shared verify/parse/strip: - `async signWithSlot(payload: Uint8Array, signSlot: KeySlotPrivate, parent?: Uint8Array): Promise` — Ed25519 sign-slots only in v0.0.0; non-Ed25519 raises `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. Extracts `(algorithm, publicKey, privateKey)` from the slot. - `async signRaw(options: { payload, algorithm, privateKey, publicKey, parent? }): Promise` — supports all 5 signing algorithms (`ed25519`, `secp256k1`, `p256`, `p384`, `p521`). Caller supplies raw key material and the algorithm tag. - `async verify(chainBytes: Uint8Array, trustedRoot: { algorithm: SignAlgorithmId; publicKey: Uint8Array }): Promise` - `async parse(chainBytes: Uint8Array): Promise<{ depth: number; levels: readonly ChainLevel[] }>` - `async stripToDepth(chainBytes: Uint8Array, depth: number): Promise` #### Scenario: defaults compose Raw + TLV - **WHEN** `new ChainedKeySigner()` is constructed with no options - **THEN** signing produces a TLV-encoded chain whose header byte sequence is `[1, 0]` (version 1, hasher 0 = raw) #### Scenario: signWithSlot rejects KeySlotPublic mistakenly passed as signSlot - **WHEN** `signer.signWithSlot(payload, slotMissingPrivate, undefined)` is called where `slotMissingPrivate.private` is undefined - **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_KEYCHAIN"` #### Scenario: signWithSlot rejects non-Ed25519 slots - **WHEN** `signer.signWithSlot(payload, slotWithAlgorithm: X25519)` is called - **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.UNSUPPORTED_ALGORITHM"` and `algorithm === ` #### Scenario: signRaw dispatches across all 5 algorithms - **WHEN** `signer.signRaw({ algorithm: "p384", ... })` is called with valid P-384 key material - **THEN** the chain level records `algorithm === "p384"` and `signerPublicKey` matches the supplied public key; reverifying with the same `(algorithm, publicKey)` as `trustedRoot` succeeds **Verified by:** `src/signer/signer.test.ts::"defaults compose RawHasher + TlvChainCodec"` AND `src/signer/signer.test.ts::"signWithSlot with public-only slot raises INVALID_KEYCHAIN"` AND `src/signer/signer.test.ts::"signWithSlot rejects non-Ed25519 with UNSUPPORTED_ALGORITHM"` AND `src/signer/signer.test.ts::"signRaw dispatches across Ed25519/secp256k1/p256/p384/p521"` --- ### Requirement: verify walks every level and checks trusted root `ChainedKeySigner.verify` SHALL decode the chain via the configured codec, walk every level (leaf to root), recompute the bytes-to-sign via the configured hasher for that level, and dispatch verification to the matching `@suaveplan/crypto-curve` primitive by the level's stored algorithm. Verification SHALL fail (return `{ valid: false }` with diagnostics) if any signature fails or if the root level's `(algorithm, signerPublicKey)` does not match `trustedRoot` (algorithm equality + constant-time bytes equality on the public key). The constant-time compare SHALL come from `@suaveplan/crypto-utils::constantTimeEqual`. #### Scenario: end-to-end verify succeeds for a 3-level chain - **WHEN** a 3-level chain (root → server → user) is built and `signer.verify(chain, { algorithm: "ed25519", publicKey: rootSignPubKey })` is called - **THEN** `result.valid` is `true`, `result.depth === 3`, and `result.errors` is empty #### Scenario: tamper with leaf signature → INVALID_SIGNATURE - **WHEN** any byte of the leaf-level signature in the chain is flipped and `verify` is called - **THEN** `result.valid` is `false` and `result.errors` includes a message naming the failing depth; subsequent `verify` calls do not throw #### Scenario: trust mismatch - **WHEN** `verify(chain, { algorithm: "ed25519", publicKey: unrelatedPubKey })` is called - **THEN** `result.valid` is `false` and `result.errors` includes `"TRUST_MISMATCH"` **Verified by:** `src/signer/signer.test.ts::"verify succeeds on 3-level chain against trusted root"` AND `src/signer/signer.test.ts::"tamper with leaf signature fails verification"` AND `src/signer/signer.test.ts::"unrelated trustedRoot fails with TRUST_MISMATCH"` --- ### Requirement: parse decodes without verifying `ChainedKeySigner.parse` SHALL decode the chain via the configured codec and return `{ depth, levels }` without verifying any signature. Malformed wire bytes SHALL raise `ChainKeysError` with `code === "CHAIN_KEYS.INVALID_FORMAT"`. #### Scenario: parse succeeds on valid chain bytes - **WHEN** `signer.parse(chain)` is called on a valid 3-level chain - **THEN** the returned `depth === 3` and `levels[0]` is the leaf #### Scenario: parse rejects malformed bytes - **WHEN** `signer.parse(new Uint8Array([255, 255, 0]))` is called - **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_FORMAT"` **Verified by:** `src/signer/signer.test.ts::"parse decodes 3-level chain without verification"` AND `src/signer/signer.test.ts::"parse rejects malformed bytes with INVALID_FORMAT"` --- ### Requirement: stripToDepth re-encodes leaf-most levels `ChainedKeySigner.stripToDepth(chainBytes, depth)` SHALL re-encode the leaf-most `depth` levels via the configured codec. `depth < 1` or `depth > chain.length` SHALL raise `ChainKeysError` with `code === "CHAIN_KEYS.INVALID_DEPTH"` and a populated `depth` discriminant. #### Scenario: strip a 3-level chain to depth 2 - **WHEN** `signer.stripToDepth(chain, 2)` is called on a 3-level chain - **THEN** the resulting bytes decode to `depth === 2` and `levels[0]` matches the original leaf #### Scenario: depth out of range raises INVALID_DEPTH - **WHEN** `signer.stripToDepth(chain, 0)` or `signer.stripToDepth(chain, 99)` is called on a 3-level chain - **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_DEPTH"` **Verified by:** `src/signer/signer.test.ts::"stripToDepth(2) produces a 2-level chain matching the original leaf"` AND `src/signer/signer.test.ts::"stripToDepth rejects out-of-range depth with INVALID_DEPTH"` --- ### Requirement: hierarchical factory functions The package SHALL provide `createRootKeysWithChain(options?)`, `createServerKeysWithChain({ rootKeys, rootChain, signer?, generate? })`, and `rollServerKeysWithChain({ rootKeys, rootChain, currentVersion, signer? })`. Each SHALL return `{ keys, chain }`. `createRootKeysWithChain` SHALL generate a fresh keychain and self-sign the chain root. `createServerKeysWithChain` SHALL derive a server keychain from `rootKeys.master.private` and sign with `rootKeys.sign.private`, attaching the parent `rootChain`. `rollServerKeysWithChain` SHALL behave as `createServerKeysWithChain` with `generate.version = currentVersion + 1`. Each factory SHALL accept an injected `signer?` parameter defaulting to `new ChainedKeySigner()` and SHALL raise `ChainKeysError` with `code === "CHAIN_KEYS.INVALID_KEYCHAIN"` when required private slots are missing. #### Scenario: 3-level chain end-to-end - **WHEN** the test runs `createRootKeysWithChain()` → `createServerKeysWithChain(...)` → `createServerKeysWithChain(...)` and calls `signer.verify(finalChain, { algorithm: "ed25519", publicKey: rootKeys.sign.public.keyBytes })` - **THEN** `result.valid === true` and `result.depth === 3` #### Scenario: rollServerKeysWithChain bumps version - **WHEN** `rollServerKeysWithChain({ rootKeys, rootChain, currentVersion: 5 })` is invoked - **THEN** the resulting keychain's slot version metadata reflects version 6 #### Scenario: missing root.master → INVALID_KEYCHAIN - **WHEN** `createServerKeysWithChain({ rootKeys: { ...rootKeys, master: undefined }, ... })` is invoked - **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_KEYCHAIN"` **Verified by:** `src/factory/factory.test.ts::"three-level chain verifies against trusted root"` AND `src/factory/factory.test.ts::"rollServerKeysWithChain bumps keychain version"` AND `src/factory/factory.test.ts::"missing root.master raises INVALID_KEYCHAIN"` --- ### Requirement: Subpath exports tree-shake per feature module The `package.json` SHALL declare subpath exports for every public feature module so consumers can import them without dragging the entire package surface. Required subpaths: `.` (root barrel), `./error-codes/error-codes`, `./types/chain-level`, `./types/hasher`, `./types/codec`, `./hashers/raw/raw`, `./hashers/domain/domain`, `./hashers/merkle/merkle`, `./codecs/tlv/tlv`, `./codecs/recursive-ed25519/recursive-ed25519`, `./codecs/cbor/cbor`, `./signer/signer`, `./factory/factory`. Each subpath SHALL resolve to a built `.js` + `.d.ts` pair under `dist/`. `sideEffects` SHALL be `false`. #### Scenario: tree-shake subpath import resolves to feature module - **WHEN** a consumer writes `import { TlvChainCodec } from "@suaveplan/crypto-keys/codecs/tlv/tlv"` - **THEN** the resolved file is the dedicated codec module, not the root barrel **Verified by:** `src/codecs/tlv/tlv.test.ts::"subpath export resolves to tlv codec module"` AND `src/signer/signer.test.ts::"subpath export resolves to signer module"` --- ### Requirement: Banned patterns absent from src/ The package SHALL ship with zero matches of `@deprecated`, `TODO`, `FIXME`, `ts-ignore`, `ts-expect-error`, `it.skip`, `test.skip`, `describe.skip`, `.only`, raw `any`, or `console.log` in any `.ts` or `.tsx` file under `src/`. The package SHALL ship with zero `// biome-ignore` or other static-analysis suppression comments. All test files SHALL import runner primitives from `@suaveplan/testing/runner` (rule §7). #### Scenario: clean static-analysis grep - **WHEN** the CI step `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\\.skip\\|\\.only\\|\\bany\\b\\|console\\.log" packages/crypto/crypto-keys/src/ --include="*.ts"` runs - **THEN** the command produces zero matches #### Scenario: test runner imports go through wrapper - **WHEN** `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/crypto/crypto-keys/src/` runs - **THEN** the command produces zero matches **Verified by:** repo-wide CI grep gates (banned-patterns scan + `bun scripts/lint/check-test-runner-imports.ts`) — no in-package test stub is acceptable per AGENTS.md §2b's "no tautological tests" rule