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

329 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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": <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
```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<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
```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<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.