Files
suaveplan/openspec/archive/2026-05-17-add-crypto-keys/proposal.md
2026-05-27 23:29:53 +01:00

14 KiB
Raw Blame History

Add @suaveplan/crypto-keys — hierarchical keychain chain-signing

Package: @suaveplan/crypto-keys Status: PROPOSED Type: genesis-original (inspired-by-upstream)

  • Design: ./design.md
  • Spec delta: ./specs/crypto-keys/spec.md

Why

Upstream @suaveplan/keys at ../suaveplan/packages/universal/keys/ shipped a hierarchical key-signing primitive (ChainedKeySigner) that genesis has no equivalent for. The pattern is well-defined: a chain of public keychains, each one signed by its parent's sign-key, with the root self-signed and verification walking back up the chain to a trusted root. Use cases include: root → server → user key issuance with end-to-end provenance; attestation of derived keychains in multi-tenant deployments; recoverable root-of-trust trees in vault systems. None of these are achievable today on genesis with the existing @suaveplan/crypto, @suaveplan/crypto-curve, @suaveplan/crypto-vault, or @suaveplan/vault packages — they cover primitives, vault storage, and password-based recovery, but not the chain-of-signatures pattern itself.

Direct port is not viable. Upstream ChainedKeySigner is tightly coupled to obsolete machinery: it uses BitPacker(publicKeychainSchema) for the wire format and a high-level sign(signKey, packed) API that don't exist on genesis. Genesis's @suaveplan/crypto re-implemented the keychain layer with a v1 derivation grammar (encodePublicKeychain, per-slot AlgorithmId, KEY_SLOT_PUBLIC_BYTE_SIZE, slotAlgorithmMatchesDefault, ALGORITHMS registry) and @suaveplan/crypto-curve exposes only per-algorithm sign(digest, privateKey) primitives. The chain-signing pattern needs to be re-designed on top of these primitives, not transliterated.

This proposal defines that redesign as a fresh genesis-original package built around two pluggable interfaces (ChainHasher for what-gets-signed and ChainCodec for the wire format), three shipped implementations of each (raw / domain-tagged / merkle hashers; TLV / recursive-Ed25519 / CBOR codecs), the ChainedKeySigner class that composes them, and a hierarchical-key-factory layer (createRootKeysWithChain / createServerKeysWithChain / rollServerKeysWithChain) on top.

What changes

  • ADDED: ChainHasher interface — hash(encodedKeys, parent?): Uint8Array. Produces the bytes a signer signs for one chain level. Three shipped implementations:
    • RawHasher — returns encodedKeys unchanged. Default.
    • DomainHasher({ domainTag: string }) — returns sha256(domainTag || encodedKeys). Domain tag is mandatory at construction; empty/oversize strings raise CHAIN_KEYS.INVALID_DOMAIN_TAG.
    • MerkleHasher — for non-root levels returns sha256(encodedKeys || sha256(parent.encodedKeys || parent.signature)); for root returns sha256(encodedKeys). Each level commits to its parent's content.
  • ADDED: ChainCodec interface — encode(levels): Uint8Array, decode(bytes): ChainLevel[]. Three shipped implementations:
    • TlvChainCodec (default) — [version=1: u8][hasher: u8] header, then per level [algorithm: u8][keys_len: u16 BE][keys][sig_len: u16 BE][sig]. Self-describing; per-algorithm signatures supported.
    • RecursiveEd25519Codec — [version=1: u8][hasher: u8][keys][sig: 64][parent?] recursive. Restricted to Ed25519; any other algorithm raises CHAIN_KEYS.UNSUPPORTED_ALGORITHM. Keys-section length is the fixed encodePublicKeychain output size (read from the genesis crypto constant), so the recursion boundary is unambiguous without a length prefix.
    • CborChainCodec — [version=1: u8][cbor] where the CBOR payload is { v: 1, hasher: <name>, levels: [{ a: <algorithm>, k: <keys>, s: <signature> }, ...] }. Depends on @suaveplan/cbor.
  • ADDED: ChainedKeySigner class — constructor accepts { hasher?, codec? } with defaults RawHasher and TlvChainCodec. Two signing surfaces (slot-based ergonomic helper + raw-key fully-general) plus shared verify / parse / strip:
    • async signWithSlot(payload: Uint8Array, signSlot: KeySlotPrivate, parent?: Uint8Array): Promise<Uint8Array> — convenience for sign-slots from genesis @suaveplan/crypto keychain. In v0.0.0 only Ed25519 sign-slots are supported (the only signing-capable algorithm in the v1 keychain grammar); other slot algorithms raise CHAIN_KEYS.UNSUPPORTED_ALGORITHM. Extracts algorithm and publicKey from the slot.
    • async signRaw(options: { payload: Uint8Array; algorithm: SignAlgorithmId; privateKey: Uint8Array; publicKey: Uint8Array; parent?: Uint8Array }): Promise<Uint8Array> — fully general; supports all 5 signing algorithms exposed by @suaveplan/crypto-curve (ed25519, secp256k1, p256, p384, p521). Caller supplies the algorithm tag, private+public key material, and an arbitrary payload. Dispatches to the matching crypto-curve primitive.
    • async verify(chainBytes: Uint8Array, trustedRoot: { algorithm: SignAlgorithmId; publicKey: Uint8Array }): Promise<VerificationResult> — decodes the chain via the configured codec, walks every level recomputing bytes-to-sign via the hasher, dispatches verification to crypto-curve by each level's stored algorithm, and checks the root level's (algorithm, signerPublicKey) matches trustedRoot via constant-time compare. Returns { valid, depth, levels, errors }.
    • async parse(chainBytes: Uint8Array): Promise<{ depth: number; levels: readonly ChainLevel[] }> — decode-only (no verification).
    • async stripToDepth(chainBytes: Uint8Array, depth: number): Promise<Uint8Array> — re-encodes the leaf-most depth levels only. depth < 1 or depth > chain.length raises CHAIN_KEYS.INVALID_DEPTH.
  • ADDED: SignAlgorithmId type — "ed25519" | "secp256k1" | "p256" | "p384" | "p521". Distinct from @suaveplan/crypto's AlgorithmId numeric enum (which covers all crypto algorithms, not just signing); SignAlgorithmId is the string-tagged subset this package signs/verifies. The TLV codec maps SignAlgorithmId ↔ u8 enum bytes.
  • ADDED: ChainLevel type — { algorithm: SignAlgorithmId; signerPublicKey: Uint8Array; payload: Uint8Array; signature: Uint8Array }. The payload is whatever the signer was asked to sign (typically encodePublicKeychain(...) output for keychain chains, but the package treats it as opaque bytes). The signerPublicKey is recorded per-level so verifiers can run end-to-end without parsing each level's payload. VerificationResult — { valid, depth, levels, errors }.
  • ADDED: Hierarchical factory functions — Ed25519-only convenience wrappers over generateKeychain + signWithSlot:
    • createRootKeysWithChain({ signer?, ...generateOptions }): Promise<{ keys: Keychain; chain: Uint8Array }>
    • createServerKeysWithChain({ rootKeys, rootChain, signer?, ...generateOptions }): Promise<{ keys; chain }>
    • rollServerKeysWithChain({ rootKeys, rootChain, currentVersion, signer? }): Promise<{ keys; chain }> — bumps the keychain version using generateKeychain({ version: currentVersion + 1, masterKey: rootKeys.master.private }).
  • ADDED: CHAIN_KEYS_CODES registry via defineCodes("CHAIN_KEYS", [...]) — 7 codes: UNSUPPORTED_ALGORITHM, INVALID_FORMAT, INVALID_SIGNATURE, TRUST_MISMATCH, INVALID_DEPTH, INVALID_KEYCHAIN, INVALID_DOMAIN_TAG.
  • ADDED: ChainKeysError extends SuaveplanError<ChainKeysErrorCode> with optional depth?: number and algorithm?: AlgorithmId diagnostic discriminants.
  • ADDED: Subpath exports for every feature module — ./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.

Out of scope

  • VaultManager / VaultFactory / VaultStreams / packVault / unpackVault / openVault. These are vault-orchestration concerns (password+mnemonic recovery, vault rollover, ChunkedPacker integration). They belong in an extension of the existing @suaveplan/crypto-vault package, not here. A follow-up extend-crypto-vault-vault-manager proposal will own them. Reason: the chain-signing primitive and the vault-as-key-manager are independent layers; keeping them in separate packages prevents crypto-keys consumers from pulling in @suaveplan/drive and @suaveplan/streams they don't need.
  • The upstream bitpacker keychain Pack schemas (fullKeychainSchema, publicKeychainSchema, baseKeySchema*) and the helpers built on them (packPublicKeys, viewPublicKeys, prepareKeysForPacking). Genesis @suaveplan/crypto already provides encodePublicKeychain / decodePublicKeychain / extractPublicKeys over the v1 derivation grammar. Re-shipping the upstream bitpacker layer would create two incompatible wire formats for the same data. Reason: genesis crypto's v1 grammar supersedes the upstream pack schemas with stronger algorithm enforcement and validity windows.
  • OpenTelemetry instrumentation. No spans, counters, or meters on this initial version. The package is a pure data transform (encode/decode/sign/verify); the call sites that wrap it will own their observability. Reason: emitting OTel from a leaf primitive doubles the surface for marginal value; revisit once a real consumer asks.
  • Schema-registry integration. @suaveplan/schema-registry versioning of the wire format is overkill for a single chain-format version that's already self-describing via its version byte. Reason: the version byte covers forward-compatibility; registry overhead isn't justified for an internal binary contract.
  • Streaming verification. The chain wire format is a small fixed-shape blob (bounded by 8 * KEY_SLOT_PUBLIC_BYTE_SIZE * max_depth + signatures + framing); streaming adds complexity for no realistic payload size. Reason: chains are always small enough to verify in memory.

Source mapping

Upstream reference: ../suaveplan/packages/universal/keys/src/lib/chain-signer.ts (419 lines) plus the hierarchical factory chunks of keys-common.ts (createRootKeysWithChain / createServerKeysWithChain / rollServerKeysWithChain). The upstream supplies the contract sketch (sign/verify/parse/stripToDepth shape, root → server → user pattern, recursive wire format) but every implementation detail is re-derived from genesis primitives. The package is therefore genesis-original, not a 1:1 migration.

Parity / divergence checklist

Capability Upstream Migrated Notes
Hierarchical sign-verify-parse-strip surface ✅ signKeysWithChain / verifyChain / extractKeys/ getChainDepth / stripToDepth ⬆️ Modernized Methods renamed to sign/verify/parse/stripToDepth (idiomatic async API); extractKeys and getChainDepth fold into parse(...)'s return
Bitpacker-based keychain wire format ✅ via BitPacker(publicKeychainSchema) 🚫 Replaced Uses encodePublicKeychain from genesis @suaveplan/crypto — the v1 derivation grammar
Fixed secp256k1 signing with recovery ✅ via sign(signKey, packed, true) ⬆️ Enhanced Dual surface: slot-based (signWithSlot, Ed25519-only in v0.0.0 matching genesis keychain v1) plus raw-key (signRaw, all 5 curves via @suaveplan/crypto-curve primitives)
[keys][sig][parent_chain?] recursive wire format ✅ ⬆️ Generalized TLV codec ships as default; recursive-Ed25519 codec preserves the upstream shape under its constraint; CBOR codec adds an extensibility option
compareKeys byte-wise equality ✅ ⬆️ Constant-time Verifier uses constant-time compare from @suaveplan/crypto-utils
Raw throw new Error("...") ✅ ⬆️ Replaced All raised errors extend SuaveplanError with stable CHAIN_KEYS.* codes
SignedKeyChain interface ✅ recursive ⬆️ Generalized ChainLevel[] array shape; codecs free to encode flat or recursive
ChainVerificationResult ✅ ✅ Preserved as VerificationResult
Domain-tagged signing 🚫 upstream ⬆️ Added DomainHasher({ domainTag }) ships as an opt-in
Merkle-style parent commitment 🚫 upstream ⬆️ Added MerkleHasher ships as an opt-in
CBOR codec 🚫 upstream ⬆️ Added CborChainCodec ships for extensibility
Hierarchical factory layer (createRoot/Server/RollServerKeysWithChain) ✅ ✅ Preserved but adapted to genesis generateKeychain + injected ChainedKeySigner
Vault-side helpers (packVault/unpackVault/VaultManager/VaultStreams) ✅ in upstream keys 🚫 deferred Belong in a follow-up crypto-vault extension; out of scope here

Reference docs consulted

  • ✅ ../suaveplan/packages/universal/keys/src/lib/chain-signer.ts — full read
  • ✅ ../suaveplan/packages/universal/keys/src/lib/keys-common.ts — full read (extracted factory section)
  • ✅ ../suaveplan/packages/universal/keys/src/keys.types.ts — full read (informs the divergence rationale)
  • ✅ ../suaveplan/enterprise/keys.md — read; notes that the upstream package's purpose is broader than what's scoped here, with clear sectioning of what stays out
  • ✅ /home/paul/web/genesis/packages/crypto/crypto/src/keychain/keychain.ts — full read (the genesis Keychain / PublicKeychain / encode/decode surface)
  • ✅ /home/paul/web/genesis/packages/crypto/crypto/src/keychain/index.ts — full read (public exports of @suaveplan/crypto keychain layer)
  • ✅ /home/paul/web/genesis/packages/crypto/crypto-curve/src/primitives/*/impl/ — sampled all 5 algorithm primitives (Ed25519 / secp256k1 / p256 / p384 / p521) to confirm per-algorithm sign(digest, privateKey) / verify(...) signatures

Impact

Affected dependents

No dependents today. @suaveplan/crypto-keys is being added net-new. The deferred crypto-vault extension will be the first consumer, importing ChainedKeySigner to wire vault-signing semantics.

Risk

  • Low to medium. Pure data transform with well-defined primitives. The novel pieces are the wire format (three codecs, each with self-describing version bytes) and the hierarchical factory layer; both are deterministic, in-memory, and exercised by property-based tests on round-trips.
  • No breaking changes to any existing package. Genesis @suaveplan/crypto / crypto-curve / crypto-hash / cbor are consumed read-only.
  • Test surface is non-trivial: 3 hashers × 3 codecs × 5 sign algorithms = 45 combinations the round-trip tests cover. Property-based fast-check generators reduce the manual surface considerably.

Migration notes

None. Net-new package on genesis.