Files
suaveplan/openspec/specs/crypto-keys/spec.md
2026-05-27 23:29:53 +01:00

18 KiB
Raw Blame History

Capability Spec: crypto-keys

Package: @suaveplan/crypto-keys Status: SHIPPED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

Purpose

@suaveplan/crypto-keys provides hierarchical keychain chain-signing for the genesis crypto ecosystem. Two pluggable interfaces (ChainHasher for what gets signed, ChainCodec for the wire format) compose into a ChainedKeySigner that produces and verifies signed chains across Ed25519, secp256k1, P-256, P-384, and P-521 curves. Two ergonomic surfaces (signWithSlot for genesis-keychain Ed25519 slots, signRaw for any of the five curves with raw key material), plus a hierarchical factory layer (createRootKeysWithChain / createServerKeysWithChain / rollServerKeysWithChain), cover the common root → server → user issuance pattern. Universal-tier; depends only on genesis crypto primitives and @suaveplan/cbor.

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 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: <hasher name>, levels: [{ a: <algorithm>, p: <signer pubkey>, k: <payload>, s: <signature> }, ...] }. 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<Uint8Array> — 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<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 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 === <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" 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