# Design — @suaveplan/crypto-keys Non-normative. Architecture, wire-format details, algorithm-dispatch rationale, and integration notes. The normative contract lives in `./specs/crypto-keys/spec.md`. ## File layout ```text packages/crypto/crypto-keys/ package.json # @suaveplan/crypto-keys v0.0.0, sideEffects: false tsconfig.json tsconfig.lib.json vite.config.ts vitest.config.ts # createTestConfig() + 100% Istanbul thresholds README.md src/ index.ts # explicit named re-exports (rule §6a) error-codes/ error-codes.ts # CHAIN_KEYS_CODES + ChainKeysError error-codes.test.ts error-codes.md index.ts types/ chain-level.ts # ChainLevel, VerificationResult, ParsedChainLevel chain-level.md hasher.ts # ChainHasher interface hasher.md codec.ts # ChainCodec interface codec.md hashers/ raw/ raw.ts raw.test.ts raw.md index.ts domain/ domain.ts domain.test.ts domain.md index.ts merkle/ merkle.ts merkle.test.ts merkle.md index.ts codecs/ tlv/ tlv.ts tlv.test.ts tlv.md index.ts recursive-ed25519/ recursive-ed25519.ts recursive-ed25519.test.ts recursive-ed25519.md index.ts cbor/ cbor.ts cbor.test.ts cbor.md index.ts signer/ signer.ts # ChainedKeySigner class signer.test.ts signer.types.ts # ISigner / SignerOptions signer.md index.ts factory/ factory.ts # createRootKeysWithChain / createServerKeysWithChain / rollServerKeysWithChain factory.test.ts factory.md index.ts ``` ## Tier rationale: universal The package consumes only `@suaveplan/crypto`, `@suaveplan/crypto-curve`, `@suaveplan/crypto-hash`, `@suaveplan/cbor`, `@suaveplan/error`, `@suaveplan/types`, and `tslib` — all universal-tier and free of `node:`/`bun:` imports. The chain-signer itself uses only `Uint8Array`, `DataView`, `performance.now()` (for optional timing in tests), and async/await. No filesystem, no streams, no node:crypto. Lands at `packages/crypto/crypto-keys/`. ## Algorithm dispatch The package defines `SignAlgorithmId` — the string-literal union of the 5 signing curves exposed by `@suaveplan/crypto-curve` — and dispatches to the matching primitive per level: | `SignAlgorithmId` | Wire byte | Sign function | Signature length | |---|---|---|---| | `"ed25519"` | `0` | `crypto-curve/ed25519::sign(digest, privateKey)` | 64 bytes | | `"secp256k1"` | `1` | `crypto-curve/secp256k1::sign(digest, privateKey)` | 64 bytes | | `"p256"` | `2` | `crypto-curve/p256::sign(digest, privateKey)` | DER-encoded; variable 64–72 bytes | | `"p384"` | `3` | `crypto-curve/p384::sign(digest, privateKey)` | DER-encoded; variable 96–104 bytes | | `"p521"` | `4` | `crypto-curve/p521::sign(digest, privateKey)` | DER-encoded; variable 132–139 bytes | Verification mirrors the table with `verify(digest, signature, publicKey)`. Unknown wire bytes raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. Two ergonomic surfaces: - **`signWithSlot(payload, signSlot, parent?)`** — convenience for sign-slots from the genesis `@suaveplan/crypto` keychain. Genesis crypto v1 only generates Ed25519 sign-slots (`ALGORITHMS.ED25519 = 0`; the other slots in v1 are X25519, AEADs, and KDFs — none signing-capable). Non-Ed25519 sign-slots raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. When genesis crypto v2 adds the other curves to the keychain layer, this method's dispatch table extends with no API break. - **`signRaw({ payload, algorithm, privateKey, publicKey, parent? })`** — fully general. Caller supplies the algorithm tag, raw key material, and an arbitrary payload. Works today for any of the 5 curves. Use this when chain levels involve algorithms the genesis keychain doesn't yet generate (e.g. a secp256k1 root with Ed25519 children). If `signWithSlot` is called with a slot that lacks private material, the signer raises `CHAIN_KEYS.INVALID_KEYCHAIN`. If `signRaw` is called with `privateKey.length` / `publicKey.length` mismatching the algorithm's expected sizes, the signer raises `CHAIN_KEYS.INVALID_KEYCHAIN`. ## Wire formats ### TLV codec (default) ```text [version: u8 = 1] [hasher: u8 — 0=raw, 1=domain, 2=merkle] per level (in order, leaf first; root last): [algorithm: u8 — 0=ed25519, 1=secp256k1, 2=p256, 3=p384, 4=p521] [pub_len: u8][signer_pub: pub_len bytes] [payload_len: u16 big-endian][payload: payload_len bytes] [sig_len: u16 big-endian][sig: sig_len bytes] ``` - Per-level `signer_pub` records the public key that signed THIS level — the verifier reads it directly and dispatches `crypto-curve.verify(...)`. Self-contained: no external state required to walk the chain. - The parser walks the byte stream from offset 2 (past the two header bytes), consuming a level per iteration until the buffer is exhausted. - `version` and `hasher` are validated against known values; unknown → `CHAIN_KEYS.INVALID_FORMAT`. - `pub_len` bounds: 32 (Ed25519/secp256k1), 33 (compressed secp256k1), 65 (uncompressed), or 64/96/132 for P-curve coordinates depending on encoding choice — accepted up to 140 bytes (covers all 5 curves). `payload_len` and `sig_len` are bounded by `u16::max = 65535` for forward-compat (typical payloads are `encodePublicKeychain` output, well under 1 KB). Out-of-bounds reads → `CHAIN_KEYS.INVALID_FORMAT`. ### Recursive-Ed25519 codec ```text [version: u8 = 1] [hasher: u8] [signer_pub: 32 bytes — fixed Ed25519 public key] [payload_len: u16 big-endian][payload: payload_len bytes] [sig: 64 bytes] [parent_chain? — bytes [2..) of a parent recursive chain, optional] ``` The recursion boundary is unambiguous: 32-byte fixed pub-key, u16-prefixed payload, 64-byte fixed signature. The parser slices off the header, peels one level per iteration. **Restricted to Ed25519** — `signRaw` or `signWithSlot` calls with any other algorithm raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` at encode time. Decoded levels carry `algorithm: "ed25519"` unconditionally. ### CBOR codec ```text [version: u8 = 1] [cbor: serialised CBOR map] ``` CBOR payload shape: ```text { "v": 1, // matches the version byte; redundant safety check "h": "raw" | "domain" | "merkle", // hasher name (string) "levels": [ { "a": "ed25519" | "secp256k1" | "p256" | "p384" | "p521", "p": , // signer's public key "k": , // payload (typically encodePublicKeychain output) "s": // signature }, ... ] } ``` Uses `@suaveplan/cbor`'s deterministic encoder. Unknown `h` or `a` strings raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` (algorithm) or `CHAIN_KEYS.INVALID_FORMAT` (hasher). Levels are an ordered array; index 0 is the leaf, last index is the root, matching the TLV codec. ## Hash modes ### RawHasher ```ts hash(encodedKeys, _parent) → encodedKeys ``` Signs the raw `encodePublicKeychain` output bytes. Mirrors upstream's `[keys][sig]` layout. Default. ### DomainHasher ```ts constructor({ domainTag: string }) // tag must be 1–64 ASCII bytes hash(encodedKeys, _parent) → sha256(utf8(domainTag) || encodedKeys) ``` Adds a per-application domain separator so chain signatures cannot be replayed against other sign sites. Empty or oversize `domainTag` → `CHAIN_KEYS.INVALID_DOMAIN_TAG` at construction time. The tag is **not** included in the wire format — the verifier must construct the same `DomainHasher` with the same tag. ### MerkleHasher ```ts hash(encodedKeys, parent) → parent === undefined ? sha256(encodedKeys) // root : sha256(encodedKeys || sha256(parent.encodedKeys || parent.signature)) ``` Each non-root level commits to its parent's content (encoded keys + signature). Tampering with a parent invalidates every descendant's signature. The verifier reproduces the parent root hash from the decoded chain — no external state required. ## ChainedKeySigner API ```ts class ChainedKeySigner { constructor(options?: { hasher?: ChainHasher; // default: new RawHasher() codec?: ChainCodec; // default: new TlvChainCodec() }); // Slot-based convenience: extracts (algorithm, publicKey, privateKey) from // a genesis-keychain sign-slot. Only Ed25519 sign-slots are supported in // v0.0.0 (matches genesis keychain v1's algorithm registry); other slot // algorithms raise CHAIN_KEYS.UNSUPPORTED_ALGORITHM. async signWithSlot( payload: Uint8Array, signSlot: KeySlotPrivate, parent?: Uint8Array, ): Promise; // Fully general: caller supplies (algorithm, privateKey, publicKey) for any // of the 5 signing algorithms exposed by @suaveplan/crypto-curve. async signRaw(options: { payload: Uint8Array; algorithm: SignAlgorithmId; privateKey: Uint8Array; publicKey: Uint8Array; parent?: Uint8Array; }): Promise; // Verify against an expected root (algorithm + public key). The chain's // wire format records (algorithm, signerPublicKey) per level, so the // verifier dispatches to the right crypto-curve primitive automatically. async verify( chainBytes: Uint8Array, trustedRoot: { algorithm: SignAlgorithmId; publicKey: Uint8Array }, ): Promise; // Decode-only (no signature verification). async parse( chainBytes: Uint8Array, ): Promise<{ depth: number; levels: readonly ChainLevel[] }>; // Re-encode the leaf-most `depth` levels only. async stripToDepth( chainBytes: Uint8Array, depth: number, ): Promise; } ``` `ChainLevel` is `{ algorithm: SignAlgorithmId; signerPublicKey: Uint8Array; payload: Uint8Array; signature: Uint8Array }`. Note `payload` is **opaque to the package** — typically `encodePublicKeychain(...)` output for keychain chains, but any byte sequence (canonical JSON, CBOR, manifest digest, etc.) is valid. `VerificationResult` is `{ valid: boolean; depth: number; levels: readonly ChainLevel[]; errors: readonly string[] }`. On any failure (bad signature at any depth, root mismatch, codec parse error) `valid` is `false` and `errors` carries human-readable diagnostics; the structural `levels` array still reflects whatever the codec could decode. `stripToDepth(chainBytes, 1)` returns just the leaf level (no parents). `stripToDepth(chainBytes, n)` for `n >= chain.length` is rejected with `CHAIN_KEYS.INVALID_DEPTH`. `n < 1` is also rejected. ## Hierarchical factory layer ```ts async function createRootKeysWithChain(options?: { signer?: ChainedKeySigner; generate?: GenerateKeychainOptions; // forwarded to crypto::generateKeychain }): Promise<{ keys: Keychain; chain: Uint8Array }>; async function createServerKeysWithChain(options: { rootKeys: Keychain; rootChain: Uint8Array; signer?: ChainedKeySigner; generate?: GenerateKeychainOptions; }): Promise<{ keys: Keychain; chain: Uint8Array }>; async function rollServerKeysWithChain(options: { rootKeys: Keychain; rootChain: Uint8Array; currentVersion: number; signer?: ChainedKeySigner; }): Promise<{ keys: Keychain; chain: Uint8Array }>; ``` Implementation outline: ```ts async function createRootKeysWithChain(options = {}) { const signer = options.signer ?? new ChainedKeySigner(); const keys = await generateKeychain(options.generate); const publicKeys = extractPublicKeys(keys); const payload = encodePublicKeychain(publicKeys); const chain = await signer.signWithSlot(payload, keys.sign.private); return { keys, chain }; } async function createServerKeysWithChain({ rootKeys, rootChain, signer, generate }) { const signerActual = signer ?? new ChainedKeySigner(); const keys = await generateKeychain({ ...generate, masterKey: rootKeys.master.private, }); const publicKeys = extractPublicKeys(keys); const payload = encodePublicKeychain(publicKeys); const chain = await signerActual.signWithSlot(payload, rootKeys.sign.private, rootChain); return { keys, chain }; } async function rollServerKeysWithChain({ rootKeys, rootChain, currentVersion, signer }) { const signerActual = signer ?? new ChainedKeySigner(); const keys = await generateKeychain({ masterKey: rootKeys.master.private, version: currentVersion + 1, }); const publicKeys = extractPublicKeys(keys); const payload = encodePublicKeychain(publicKeys); const chain = await signerActual.signWithSlot(payload, rootKeys.sign.private, rootChain); return { keys, chain }; } ``` All three factory functions use `signWithSlot` (Ed25519-only path) since `generateKeychain` produces Ed25519 sign-slots. Consumers needing multi-curve chains use `signRaw` directly. `rootKeys.master` / `rootKeys.sign` must have `private` material — otherwise `CHAIN_KEYS.INVALID_KEYCHAIN` with `seedName: undefined`, `algorithm: undefined` (the discriminants don't apply at this layer; the message itemises which slot was missing). ## Error code map ```text CHAIN_KEYS.UNSUPPORTED_ALGORITHM // sign-slot algorithm not handled by codec/signer combination CHAIN_KEYS.INVALID_FORMAT // codec parse failure (bad version, truncation, length overrun) CHAIN_KEYS.INVALID_SIGNATURE // signature failed verification at some depth CHAIN_KEYS.TRUST_MISMATCH // root signer public key !== supplied trustedRoot CHAIN_KEYS.INVALID_DEPTH // stripToDepth out of range (< 1 or > chain depth) CHAIN_KEYS.INVALID_KEYCHAIN // signSlot missing private bytes; rootKeys missing required slots CHAIN_KEYS.INVALID_DOMAIN_TAG // DomainHasher constructed with empty / oversize tag ``` `ChainKeysError` discriminants: ```ts class ChainKeysError extends SuaveplanError { readonly depth?: number; // populated for INVALID_SIGNATURE / INVALID_DEPTH readonly algorithm?: AlgorithmId; // populated for UNSUPPORTED_ALGORITHM } ``` The class follows the same pattern as `SeedError` in `@suaveplan/seed`: extends `SuaveplanError` literal-typed, threads `cause`, surfaces discriminants on the instance directly (not on the `context` map) for round-trip safety. ## Constant-time signature compare The verifier compares the parsed-root public-key bytes with `trustedRoot.public` using a constant-time helper from `@suaveplan/crypto-utils` (`timingSafeEqual` if available; otherwise the package's own implementation as long as it's a single, audited byte-by-byte loop without short-circuit). This avoids leaking a timing side-channel on whether the root matches. ## Test plan - **Unit per feature module:** every hasher / codec / signer / factory function has a `.test.ts` with full branch coverage. - **Round-trip property tests** (`fast-check`): for every (hasher × codec × algorithm) combination, signing then verifying with the same trusted root succeeds; tampering with any byte in the wire format causes verification to fail. - **Algorithm dispatch tests:** each of the 5 algorithms produces a recoverable chain through the TLV codec. - **RecursiveEd25519Codec restriction:** rejects non-Ed25519 algorithm at encode time. - **CborChainCodec interoperability:** decode-then-encode produces byte-identical output (deterministic CBOR encoder). - **Hierarchical factory:** 3-level chain (root → server → user); `verify` succeeds against root; tampering with the server level fails. - **`stripToDepth`:** stripping to a partial depth produces a chain that verifies as far as it goes when reverified with the right intermediate trust anchor. Tests use `@suaveplan/testing/runner` (rule §11). `vitest.config.ts` uses `createTestConfig()` from `@suaveplan/testing/config`. Coverage gate is 100% statement/branch/function/line via Istanbul. ## Out-of-package decisions - **No OTel.** Pure data transform; observability lives at the call site. - **No schema-registry.** The version byte in the wire format covers forward-compatibility for the foreseeable future. - **No vault helpers.** `packVault` / `unpackVault` / `VaultManager` / `VaultStreams` live in the deferred `crypto-vault` extension. - **No bitpacker-shape compatibility.** Upstream `BitPacker(publicKeychainSchema)` output is **incompatible** with genesis `encodePublicKeychain` output; the package will not accept upstream-encoded chains.