329 lines
16 KiB
Markdown
329 lines
16 KiB
Markdown
# 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.
|