14 KiB
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:
ChainHasherinterface —hash(encodedKeys, parent?): Uint8Array. Produces the bytes a signer signs for one chain level. Three shipped implementations:RawHasher— returnsencodedKeysunchanged. Default.DomainHasher({ domainTag: string })— returnssha256(domainTag || encodedKeys). Domain tag is mandatory at construction; empty/oversize strings raiseCHAIN_KEYS.INVALID_DOMAIN_TAG.MerkleHasher— for non-root levels returnssha256(encodedKeys || sha256(parent.encodedKeys || parent.signature)); for root returnssha256(encodedKeys). Each level commits to its parent's content.
- ADDED:
ChainCodecinterface —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 raisesCHAIN_KEYS.UNSUPPORTED_ALGORITHM. Keys-section length is the fixedencodePublicKeychainoutput 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:
ChainedKeySignerclass — constructor accepts{ hasher?, codec? }with defaultsRawHasherandTlvChainCodec. 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/cryptokeychain. In v0.0.0 only Ed25519 sign-slots are supported (the only signing-capable algorithm in the v1 keychain grammar); other slot algorithms raiseCHAIN_KEYS.UNSUPPORTED_ALGORITHM. ExtractsalgorithmandpublicKeyfrom 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)matchestrustedRootvia 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-mostdepthlevels only.depth < 1ordepth > chain.lengthraisesCHAIN_KEYS.INVALID_DEPTH.
- ADDED:
SignAlgorithmIdtype —"ed25519" | "secp256k1" | "p256" | "p384" | "p521". Distinct from@suaveplan/crypto'sAlgorithmIdnumeric enum (which covers all crypto algorithms, not just signing);SignAlgorithmIdis the string-tagged subset this package signs/verifies. The TLV codec mapsSignAlgorithmId↔u8enum bytes. - ADDED:
ChainLeveltype —{ algorithm: SignAlgorithmId; signerPublicKey: Uint8Array; payload: Uint8Array; signature: Uint8Array }. Thepayloadis whatever the signer was asked to sign (typicallyencodePublicKeychain(...)output for keychain chains, but the package treats it as opaque bytes). ThesignerPublicKeyis 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 usinggenerateKeychain({ version: currentVersion + 1, masterKey: rootKeys.master.private }).
- ADDED:
CHAIN_KEYS_CODESregistry viadefineCodes("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 optionaldepth?: numberandalgorithm?: AlgorithmIddiagnostic 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-vaultpackage, not here. A follow-upextend-crypto-vault-vault-managerproposal will own them. Reason: the chain-signing primitive and the vault-as-key-manager are independent layers; keeping them in separate packages preventscrypto-keysconsumers from pulling in@suaveplan/driveand@suaveplan/streamsthey don't need. - The upstream bitpacker keychain Pack schemas (
fullKeychainSchema,publicKeychainSchema,baseKeySchema*) and the helpers built on them (packPublicKeys,viewPublicKeys,prepareKeysForPacking). Genesis@suaveplan/cryptoalready providesencodePublicKeychain/decodePublicKeychain/extractPublicKeysover 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-registryversioning 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/cryptokeychain 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-algorithmsign(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/cborare 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.