16 KiB
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/cryptokeychain. 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 raiseCHAIN_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_pubrecords the public key that signed THIS level — the verifier reads it directly and dispatchescrypto-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.
versionandhasherare validated against known values; unknown →CHAIN_KEYS.INVALID_FORMAT.pub_lenbounds: 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_lenandsig_lenare bounded byu16::max = 65535for forward-compat (typical payloads areencodePublicKeychainoutput, 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.tswith 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);
verifysucceeds 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/VaultStreamslive in the deferredcrypto-vaultextension. - No bitpacker-shape compatibility. Upstream
BitPacker(publicKeychainSchema)output is incompatible with genesisencodePublicKeychainoutput; the package will not accept upstream-encoded chains.