17 KiB
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<ChainKeysErrorCode> 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
ChainKeysErrorwhosecodematches the expectedCHAIN_KEYS.*constant
Scenario: SuaveplanError contract preserved
- WHEN a
ChainKeysErrorwraps an underlying exception (e.g. from@suaveplan/crypto-curve) - THEN
error.causeis the original;error instanceof SuaveplanErroris 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;nameis"raw"
Scenario: DomainHasher rejects invalid tags
- WHEN
new DomainHasher({ domainTag: "" })is constructed - THEN a
ChainKeysErroris thrown withcode === "CHAIN_KEYS.INVALID_DOMAIN_TAG"
Scenario: MerkleHasher commits to parent content
- WHEN
MerkleHasher.hash(keys, parent)is called twice with the samekeysbut differentparent.signaturevalues - 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 raisesCHAIN_KEYS.INVALID_FORMAT. Length-overrun raisesCHAIN_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 raisesCHAIN_KEYS.UNSUPPORTED_ALGORITHM.CborChainCodec— wire format[version=1: u8][cbor]where the CBOR payload is{ v: 1, h: <hasher name>, levels: [{ a: <algorithm>, p: <signer pubkey>, k: <payload>, s: <signature> }, ...] }. Uses@suaveplan/cbordeterministic 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
TlvChainCodecand decoded back - THEN the decoded levels deep-equal the input
Scenario: TLV rejects bad version byte
- WHEN
TlvChainCodec.decodeis called on bytes whose first byte is99 - THEN a
ChainKeysErroris thrown withcode === "CHAIN_KEYS.INVALID_FORMAT"
Scenario: RecursiveEd25519Codec rejects non-Ed25519
- WHEN
RecursiveEd25519Codec.encodeis called with a level whosealgorithmis"p256" - THEN a
ChainKeysErroris thrown withcode === "CHAIN_KEYS.UNSUPPORTED_ALGORITHM"andalgorithm === "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<Uint8Array>— Ed25519 sign-slots only in v0.0.0; non-Ed25519 raisesCHAIN_KEYS.UNSUPPORTED_ALGORITHM. Extracts(algorithm, publicKey, privateKey)from the slot.async signRaw(options: { payload, algorithm, privateKey, publicKey, parent? }): Promise<Uint8Array>— 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<VerificationResult>async parse(chainBytes: Uint8Array): Promise<{ depth: number; levels: readonly ChainLevel[] }>async stripToDepth(chainBytes: Uint8Array, depth: number): Promise<Uint8Array>
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 whereslotMissingPrivate.privateis undefined - THEN a
ChainKeysErroris thrown withcode === "CHAIN_KEYS.INVALID_KEYCHAIN"
Scenario: signWithSlot rejects non-Ed25519 slots
- WHEN
signer.signWithSlot(payload, slotWithAlgorithm: X25519)is called - THEN a
ChainKeysErroris thrown withcode === "CHAIN_KEYS.UNSUPPORTED_ALGORITHM"andalgorithm === <X25519 id>
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"andsignerPublicKeymatches the supplied public key; reverifying with the same(algorithm, publicKey)astrustedRootsucceeds
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.validistrue,result.depth === 3, andresult.errorsis empty
Scenario: tamper with leaf signature → INVALID_SIGNATURE
- WHEN any byte of the leaf-level signature in the chain is flipped and
verifyis called - THEN
result.validisfalseandresult.errorsincludes a message naming the failing depth; subsequentverifycalls do not throw
Scenario: trust mismatch
- WHEN
verify(chain, { algorithm: "ed25519", publicKey: unrelatedPubKey })is called - THEN
result.validisfalseandresult.errorsincludes"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 === 3andlevels[0]is the leaf
Scenario: parse rejects malformed bytes
- WHEN
signer.parse(new Uint8Array([255, 255, 0]))is called - THEN a
ChainKeysErroris thrown withcode === "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 === 2andlevels[0]matches the original leaf
Scenario: depth out of range raises INVALID_DEPTH
- WHEN
signer.stripToDepth(chain, 0)orsigner.stripToDepth(chain, 99)is called on a 3-level chain - THEN a
ChainKeysErroris thrown withcode === "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 callssigner.verify(finalChain, { algorithm: "ed25519", publicKey: rootKeys.sign.public.keyBytes }) - THEN
result.valid === trueandresult.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
ChainKeysErroris thrown withcode === "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