Initial Commit
This commit is contained in:
328
openspec/archive/2026-05-17-add-crypto-keys/design.md
Normal file
328
openspec/archive/2026-05-17-add-crypto-keys/design.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user