Files
2026-05-27 23:29:53 +01:00

16 KiB
Raw Permalink Blame History

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

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)

[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

[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

[version: u8 = 1]
[cbor: serialised CBOR map]

CBOR payload shape:

{
  "v": 1,                                    // matches the version byte; redundant safety check
  "h": "raw" | "domain" | "merkle",          // hasher name (string)
  "levels": [
    {
      "a": "ed25519" | "secp256k1" | "p256" | "p384" | "p521",
      "p": <bytes>,                          // signer's public key
      "k": <bytes>,                          // payload (typically encodePublicKeychain output)
      "s": <bytes>                           // 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

hash(encodedKeys, _parent) → encodedKeys

Signs the raw encodePublicKeychain output bytes. Mirrors upstream's [keys][sig] layout. Default.

DomainHasher

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

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

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<Uint8Array>;

  // 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<Uint8Array>;

  // 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<VerificationResult>;

  // 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<Uint8Array>;
}

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

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:

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

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:

class ChainKeysError extends SuaveplanError<ChainKeysErrorCode> {
  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<TCode> 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.