Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View File

@@ -0,0 +1,98 @@
# 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: `ChainHasher` interface — `hash(encodedKeys, parent?): Uint8Array`. Produces the bytes a signer signs for one chain level. Three shipped implementations:
- `RawHasher` — returns `encodedKeys` unchanged. Default.
- `DomainHasher({ domainTag: string })` — returns `sha256(domainTag || encodedKeys)`. Domain tag is mandatory at construction; empty/oversize strings raise `CHAIN_KEYS.INVALID_DOMAIN_TAG`.
- `MerkleHasher` — for non-root levels returns `sha256(encodedKeys || sha256(parent.encodedKeys || parent.signature))`; for root returns `sha256(encodedKeys)`. Each level commits to its parent's content.
- ADDED: `ChainCodec` interface — `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 raises `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. Keys-section length is the fixed `encodePublicKeychain` output 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: `ChainedKeySigner` class — constructor accepts `{ hasher?, codec? }` with defaults `RawHasher` and `TlvChainCodec`. 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/crypto` keychain. In v0.0.0 only Ed25519 sign-slots are supported (the only signing-capable algorithm in the v1 keychain grammar); other slot algorithms raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. Extracts `algorithm` and `publicKey` from 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)` matches `trustedRoot` via 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-most `depth` levels only. `depth < 1` or `depth > chain.length` raises `CHAIN_KEYS.INVALID_DEPTH`.
- ADDED: `SignAlgorithmId` type — `"ed25519" | "secp256k1" | "p256" | "p384" | "p521"`. Distinct from `@suaveplan/crypto`'s `AlgorithmId` numeric enum (which covers all crypto algorithms, not just signing); `SignAlgorithmId` is the string-tagged subset this package signs/verifies. The TLV codec maps `SignAlgorithmId` ↔ `u8` enum bytes.
- ADDED: `ChainLevel` type — `{ algorithm: SignAlgorithmId; signerPublicKey: Uint8Array; payload: Uint8Array; signature: Uint8Array }`. The `payload` is whatever the signer was asked to sign (typically `encodePublicKeychain(...)` output for keychain chains, but the package treats it as opaque bytes). The `signerPublicKey` is 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 using `generateKeychain({ version: currentVersion + 1, masterKey: rootKeys.master.private })`.
- ADDED: `CHAIN_KEYS_CODES` registry via `defineCodes("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 optional `depth?: number` and `algorithm?: AlgorithmId` diagnostic 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-vault` package, not here. A follow-up `extend-crypto-vault-vault-manager` proposal will own them. Reason: the chain-signing primitive and the vault-as-key-manager are independent layers; keeping them in separate packages prevents `crypto-keys` consumers from pulling in `@suaveplan/drive` and `@suaveplan/streams` they don't need.
- **The upstream bitpacker keychain Pack schemas** (`fullKeychainSchema`, `publicKeychainSchema`, `baseKeySchema*`) and the helpers built on them (`packPublicKeys`, `viewPublicKeys`, `prepareKeysForPacking`). Genesis `@suaveplan/crypto` already provides `encodePublicKeychain` / `decodePublicKeychain` / `extractPublicKeys` over 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-registry` versioning 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/crypto` keychain 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-algorithm `sign(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` / `cbor` are 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.

View File

@@ -0,0 +1,243 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-keys (crypto-keys)
**Package**: `@suaveplan/crypto-keys`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The package SHALL ship as a universal-tier package — no imports from `node:*`, `bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto` in any file under `src/`. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. The `package.json` SHALL NOT declare a `"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL declare `"sideEffects": false`.
#### Scenario: no platform imports in src/
- **WHEN** the CI step `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http)['\"]" packages/crypto/crypto-keys/src/` runs
- **THEN** the command produces zero matches
**Verified by:** `src/signer/signer.test.ts::"signer module has no node/bun imports (tier purity)"` AND `src/codecs/tlv/tlv.test.ts::"tlv codec has no node/bun imports (tier purity)"`
---
### Requirement: Stable CHAIN_KEYS_CODES error registry
The package SHALL declare an error-code registry via `defineCodes("CHAIN_KEYS", [...])` containing exactly the codes `UNSUPPORTED_ALGORITHM`, `INVALID_FORMAT`, `INVALID_SIGNATURE`, `TRUST_MISMATCH`, `INVALID_DEPTH`, `INVALID_KEYCHAIN`, `INVALID_DOMAIN_TAG`. Every public error path SHALL throw or wrap a `ChainKeysError extends SuaveplanError<ChainKeysErrorCode>` carrying one of those codes. `ChainKeysError` SHALL expose optional `depth?: number` and `algorithm?: AlgorithmId` diagnostic discriminants. The package SHALL NOT throw raw `Error` instances from any public method.
#### Scenario: every code is reachable from a real call site
- **WHEN** the test suite exercises unsupported algorithm, malformed wire bytes, bad signature, trust mismatch, out-of-range depth, missing private slot, and invalid domain tag
- **THEN** each path raises a `ChainKeysError` whose `code` matches the expected `CHAIN_KEYS.*` constant
#### Scenario: SuaveplanError contract preserved
- **WHEN** a `ChainKeysError` wraps an underlying exception (e.g. from `@suaveplan/crypto-curve`)
- **THEN** `error.cause` is the original; `error instanceof SuaveplanError` is true; the code remains stable across renames
**Verified by:** `src/error-codes/error-codes.test.ts::"every CHAIN_KEYS.* code is reachable from a call site"` AND `src/error-codes/error-codes.test.ts::"ChainKeysError preserves cause and SuaveplanError contract"`
---
### Requirement: ChainHasher interface with three shipped implementations
The package SHALL provide a `ChainHasher` interface with `hash(encodedKeys: Uint8Array, parent?: ParsedChainLevel): Uint8Array` and a `name: "raw" | "domain" | "merkle"` property. The package SHALL ship three implementations: `RawHasher`, `DomainHasher`, and `MerkleHasher`. `RawHasher.hash(keys)` SHALL return `keys` unchanged. `DomainHasher` SHALL be constructed with `{ domainTag: string }` where the tag is 1–64 ASCII bytes; out-of-range tags raise `CHAIN_KEYS.INVALID_DOMAIN_TAG`. `DomainHasher.hash(keys)` SHALL return `sha256(utf8(domainTag) || keys)`. `MerkleHasher.hash(keys, parent)` SHALL return `sha256(keys)` when `parent` is undefined and `sha256(keys || sha256(parent.encodedKeys || parent.signature))` otherwise.
#### Scenario: RawHasher returns keys unchanged
- **WHEN** `new RawHasher().hash(new Uint8Array([1, 2, 3]))` is called
- **THEN** the result equals `new Uint8Array([1, 2, 3])` byte-for-byte; `name` is `"raw"`
#### Scenario: DomainHasher rejects invalid tags
- **WHEN** `new DomainHasher({ domainTag: "" })` is constructed
- **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_DOMAIN_TAG"`
#### Scenario: MerkleHasher commits to parent content
- **WHEN** `MerkleHasher.hash(keys, parent)` is called twice with the same `keys` but different `parent.signature` values
- **THEN** the two returned hashes are different
**Verified by:** `src/hashers/raw/raw.test.ts::"hash returns keys unchanged"` AND `src/hashers/domain/domain.test.ts::"rejects empty domainTag with CHAIN_KEYS.INVALID_DOMAIN_TAG"` AND `src/hashers/merkle/merkle.test.ts::"non-root commits to parent signature"`
---
### Requirement: ChainCodec interface with three shipped implementations
The package SHALL provide a `ChainCodec` interface with `encode(levels: readonly ChainLevel[]): Uint8Array`, `decode(bytes: Uint8Array): readonly ChainLevel[]`, and a `name: "tlv" | "recursive-ed25519" | "cbor"` property. `ChainLevel` is `{ algorithm: SignAlgorithmId; signerPublicKey: Uint8Array; payload: Uint8Array; signature: Uint8Array }`. The package SHALL ship three implementations:
- **`TlvChainCodec`** — wire format `[version=1: u8][hasher: u8]` then per level `[algorithm: u8][pub_len: u8][signer_pub][payload_len: u16 BE][payload][sig_len: u16 BE][sig]`. Levels emitted leaf-first. Unknown version or hasher byte raises `CHAIN_KEYS.INVALID_FORMAT`. Length-overrun raises `CHAIN_KEYS.INVALID_FORMAT`.
- **`RecursiveEd25519Codec`** — wire format `[version=1: u8][hasher: u8][signer_pub: 32][payload_len: u16 BE][payload][sig: 64][parent?]` recursive. Restricted to Ed25519; encoding any other algorithm raises `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`.
- **`CborChainCodec`** — wire format `[version=1: u8][cbor]` where the CBOR payload is `{ v: 1, h: <hasher name>, levels: [{ a: <algorithm>, p: <signer pubkey>, k: <payload>, s: <signature> }, ...] }`. Uses `@suaveplan/cbor` deterministic encoder.
All three codecs SHALL satisfy `decode(encode(levels)).deepEqual(levels)` for valid input.
#### Scenario: TLV round-trip preserves levels
- **WHEN** a 3-level chain with mixed algorithms is encoded by `TlvChainCodec` and decoded back
- **THEN** the decoded levels deep-equal the input
#### Scenario: TLV rejects bad version byte
- **WHEN** `TlvChainCodec.decode` is called on bytes whose first byte is `99`
- **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_FORMAT"`
#### Scenario: RecursiveEd25519Codec rejects non-Ed25519
- **WHEN** `RecursiveEd25519Codec.encode` is called with a level whose `algorithm` is `"p256"`
- **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.UNSUPPORTED_ALGORITHM"` and `algorithm === "p256"`
#### Scenario: CBOR codec produces deterministic output
- **WHEN** the same chain is encoded twice by `CborChainCodec`
- **THEN** the two output buffers are byte-for-byte identical
**Verified by:** `src/codecs/tlv/tlv.test.ts::"round-trip preserves 3-level chain with mixed algorithms"` AND `src/codecs/tlv/tlv.test.ts::"rejects bad version byte with CHAIN_KEYS.INVALID_FORMAT"` AND `src/codecs/recursive-ed25519/recursive-ed25519.test.ts::"rejects non-Ed25519 with CHAIN_KEYS.UNSUPPORTED_ALGORITHM"` AND `src/codecs/cbor/cbor.test.ts::"encoder is deterministic across calls"`
---
### Requirement: ChainedKeySigner dual-surface composition
The package SHALL provide a `ChainedKeySigner` class with constructor `(options?: { hasher?: ChainHasher; codec?: ChainCodec })` defaulting to `new RawHasher()` and `new TlvChainCodec()`. The class SHALL implement TWO signing surfaces plus shared verify/parse/strip:
- `async signWithSlot(payload: Uint8Array, signSlot: KeySlotPrivate, parent?: Uint8Array): Promise<Uint8Array>` — Ed25519 sign-slots only in v0.0.0; non-Ed25519 raises `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. Extracts `(algorithm, publicKey, privateKey)` from the slot.
- `async signRaw(options: { payload, algorithm, privateKey, publicKey, parent? }): Promise<Uint8Array>` — supports all 5 signing algorithms (`ed25519`, `secp256k1`, `p256`, `p384`, `p521`). Caller supplies raw key material and the algorithm tag.
- `async verify(chainBytes: Uint8Array, trustedRoot: { algorithm: SignAlgorithmId; publicKey: Uint8Array }): Promise<VerificationResult>`
- `async parse(chainBytes: Uint8Array): Promise<{ depth: number; levels: readonly ChainLevel[] }>`
- `async stripToDepth(chainBytes: Uint8Array, depth: number): Promise<Uint8Array>`
#### Scenario: defaults compose Raw + TLV
- **WHEN** `new ChainedKeySigner()` is constructed with no options
- **THEN** signing produces a TLV-encoded chain whose header byte sequence is `[1, 0]` (version 1, hasher 0 = raw)
#### Scenario: signWithSlot rejects KeySlotPublic mistakenly passed as signSlot
- **WHEN** `signer.signWithSlot(payload, slotMissingPrivate, undefined)` is called where `slotMissingPrivate.private` is undefined
- **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_KEYCHAIN"`
#### Scenario: signWithSlot rejects non-Ed25519 slots
- **WHEN** `signer.signWithSlot(payload, slotWithAlgorithm: X25519)` is called
- **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.UNSUPPORTED_ALGORITHM"` and `algorithm === <X25519 id>`
#### Scenario: signRaw dispatches across all 5 algorithms
- **WHEN** `signer.signRaw({ algorithm: "p384", ... })` is called with valid P-384 key material
- **THEN** the chain level records `algorithm === "p384"` and `signerPublicKey` matches the supplied public key; reverifying with the same `(algorithm, publicKey)` as `trustedRoot` succeeds
**Verified by:** `src/signer/signer.test.ts::"defaults compose RawHasher + TlvChainCodec"` AND `src/signer/signer.test.ts::"signWithSlot with public-only slot raises INVALID_KEYCHAIN"` AND `src/signer/signer.test.ts::"signWithSlot rejects non-Ed25519 with UNSUPPORTED_ALGORITHM"` AND `src/signer/signer.test.ts::"signRaw dispatches across Ed25519/secp256k1/p256/p384/p521"`
---
### Requirement: verify walks every level and checks trusted root
`ChainedKeySigner.verify` SHALL decode the chain via the configured codec, walk every level (leaf to root), recompute the bytes-to-sign via the configured hasher for that level, and dispatch verification to the matching `@suaveplan/crypto-curve` primitive by the level's stored algorithm. Verification SHALL fail (return `{ valid: false }` with diagnostics) if any signature fails or if the root level's `(algorithm, signerPublicKey)` does not match `trustedRoot` (algorithm equality + constant-time bytes equality on the public key). The constant-time compare SHALL come from `@suaveplan/crypto-utils::constantTimeEqual`.
#### Scenario: end-to-end verify succeeds for a 3-level chain
- **WHEN** a 3-level chain (root → server → user) is built and `signer.verify(chain, { algorithm: "ed25519", publicKey: rootSignPubKey })` is called
- **THEN** `result.valid` is `true`, `result.depth === 3`, and `result.errors` is empty
#### Scenario: tamper with leaf signature → INVALID_SIGNATURE
- **WHEN** any byte of the leaf-level signature in the chain is flipped and `verify` is called
- **THEN** `result.valid` is `false` and `result.errors` includes a message naming the failing depth; subsequent `verify` calls do not throw
#### Scenario: trust mismatch
- **WHEN** `verify(chain, { algorithm: "ed25519", publicKey: unrelatedPubKey })` is called
- **THEN** `result.valid` is `false` and `result.errors` includes `"TRUST_MISMATCH"`
**Verified by:** `src/signer/signer.test.ts::"verify succeeds on 3-level chain against trusted root"` AND `src/signer/signer.test.ts::"tamper with leaf signature fails verification"` AND `src/signer/signer.test.ts::"unrelated trustedRoot fails with TRUST_MISMATCH"`
---
### Requirement: parse decodes without verifying
`ChainedKeySigner.parse` SHALL decode the chain via the configured codec and return `{ depth, levels }` without verifying any signature. Malformed wire bytes SHALL raise `ChainKeysError` with `code === "CHAIN_KEYS.INVALID_FORMAT"`.
#### Scenario: parse succeeds on valid chain bytes
- **WHEN** `signer.parse(chain)` is called on a valid 3-level chain
- **THEN** the returned `depth === 3` and `levels[0]` is the leaf
#### Scenario: parse rejects malformed bytes
- **WHEN** `signer.parse(new Uint8Array([255, 255, 0]))` is called
- **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_FORMAT"`
**Verified by:** `src/signer/signer.test.ts::"parse decodes 3-level chain without verification"` AND `src/signer/signer.test.ts::"parse rejects malformed bytes with INVALID_FORMAT"`
---
### Requirement: stripToDepth re-encodes leaf-most levels
`ChainedKeySigner.stripToDepth(chainBytes, depth)` SHALL re-encode the leaf-most `depth` levels via the configured codec. `depth < 1` or `depth > chain.length` SHALL raise `ChainKeysError` with `code === "CHAIN_KEYS.INVALID_DEPTH"` and a populated `depth` discriminant.
#### Scenario: strip a 3-level chain to depth 2
- **WHEN** `signer.stripToDepth(chain, 2)` is called on a 3-level chain
- **THEN** the resulting bytes decode to `depth === 2` and `levels[0]` matches the original leaf
#### Scenario: depth out of range raises INVALID_DEPTH
- **WHEN** `signer.stripToDepth(chain, 0)` or `signer.stripToDepth(chain, 99)` is called on a 3-level chain
- **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_DEPTH"`
**Verified by:** `src/signer/signer.test.ts::"stripToDepth(2) produces a 2-level chain matching the original leaf"` AND `src/signer/signer.test.ts::"stripToDepth rejects out-of-range depth with INVALID_DEPTH"`
---
### Requirement: hierarchical factory functions
The package SHALL provide `createRootKeysWithChain(options?)`, `createServerKeysWithChain({ rootKeys, rootChain, signer?, generate? })`, and `rollServerKeysWithChain({ rootKeys, rootChain, currentVersion, signer? })`. Each SHALL return `{ keys, chain }`. `createRootKeysWithChain` SHALL generate a fresh keychain and self-sign the chain root. `createServerKeysWithChain` SHALL derive a server keychain from `rootKeys.master.private` and sign with `rootKeys.sign.private`, attaching the parent `rootChain`. `rollServerKeysWithChain` SHALL behave as `createServerKeysWithChain` with `generate.version = currentVersion + 1`. Each factory SHALL accept an injected `signer?` parameter defaulting to `new ChainedKeySigner()` and SHALL raise `ChainKeysError` with `code === "CHAIN_KEYS.INVALID_KEYCHAIN"` when required private slots are missing.
#### Scenario: 3-level chain end-to-end
- **WHEN** the test runs `createRootKeysWithChain()` → `createServerKeysWithChain(...)` → `createServerKeysWithChain(...)` and calls `signer.verify(finalChain, { algorithm: "ed25519", publicKey: rootKeys.sign.public.keyBytes })`
- **THEN** `result.valid === true` and `result.depth === 3`
#### Scenario: rollServerKeysWithChain bumps version
- **WHEN** `rollServerKeysWithChain({ rootKeys, rootChain, currentVersion: 5 })` is invoked
- **THEN** the resulting keychain's slot version metadata reflects version 6
#### Scenario: missing root.master → INVALID_KEYCHAIN
- **WHEN** `createServerKeysWithChain({ rootKeys: { ...rootKeys, master: undefined }, ... })` is invoked
- **THEN** a `ChainKeysError` is thrown with `code === "CHAIN_KEYS.INVALID_KEYCHAIN"`
**Verified by:** `src/factory/factory.test.ts::"three-level chain verifies against trusted root"` AND `src/factory/factory.test.ts::"rollServerKeysWithChain bumps keychain version"` AND `src/factory/factory.test.ts::"missing root.master raises INVALID_KEYCHAIN"`
---
### Requirement: Subpath exports tree-shake per feature module
The `package.json` SHALL declare subpath exports for every public feature module so consumers can import them without dragging the entire package surface. Required subpaths: `.` (root barrel), `./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`. Each subpath SHALL resolve to a built `.js` + `.d.ts` pair under `dist/`. `sideEffects` SHALL be `false`.
#### Scenario: tree-shake subpath import resolves to feature module
- **WHEN** a consumer writes `import { TlvChainCodec } from "@suaveplan/crypto-keys/codecs/tlv/tlv"`
- **THEN** the resolved file is the dedicated codec module, not the root barrel
**Verified by:** `src/codecs/tlv/tlv.test.ts::"subpath export resolves to tlv codec module"` AND `src/signer/signer.test.ts::"subpath export resolves to signer module"`
---
### Requirement: Banned patterns absent from src/
The package SHALL ship with zero matches of `@deprecated`, `TODO`, `FIXME`, `ts-ignore`, `ts-expect-error`, `it.skip`, `test.skip`, `describe.skip`, `.only`, raw `any`, or `console.log` in any `.ts` or `.tsx` file under `src/`. The package SHALL ship with zero `// biome-ignore` or other static-analysis suppression comments. All test files SHALL import runner primitives from `@suaveplan/testing/runner` (rule §7).
#### Scenario: clean static-analysis grep
- **WHEN** the CI step `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\\.skip\\|\\.only\\|\\bany\\b\\|console\\.log" packages/crypto/crypto-keys/src/ --include="*.ts"` runs
- **THEN** the command produces zero matches
#### Scenario: test runner imports go through wrapper
- **WHEN** `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/crypto/crypto-keys/src/` runs
- **THEN** the command produces zero matches
**Verified by:** repo-wide CI grep gates (banned-patterns scan + `bun scripts/lint/check-test-runner-imports.ts`) — no in-package test stub is acceptable per AGENTS.md §2b's "no tautological tests" rule

View File

@@ -0,0 +1,108 @@
# Tasks — crypto-keys
> Every checkbox MUST reflect work actually done in this change.
## 1. Spec authoring
- [x] 1.1 Read `openspec/AGENTS.md` §0, §0a, §0b, §0c, §6a, §7, §13, §14 end-to-end
- [x] 1.2 Read `../suaveplan/packages/universal/keys/src/lib/chain-signer.ts` in full
- [x] 1.3 Read `../suaveplan/packages/universal/keys/src/lib/keys-common.ts` (factory section)
- [x] 1.4 Read `../suaveplan/enterprise/keys.md` to confirm scope boundary
- [x] 1.5 Read genesis `packages/crypto/crypto/src/keychain/keychain.ts` and `keychain/index.ts` (public surface)
- [x] 1.6 Skim every `packages/crypto/crypto-curve/src/primitives/*/impl/*.noble/*.ts` and confirm sign/verify signature
- [x] 1.7 Draft `proposal.md` with scope, divergence table, source mapping, affected dependents
- [x] 1.8 Draft `design.md` covering file layout, three codecs, three hashers, algorithm dispatch, error-code map, factory layer
- [x] 1.9 Draft `specs/crypto-keys/spec.md` with `### Requirement: / #### Scenario: / **Verified by:**` form
- [x] 1.10 Run `openspec validate add-crypto-keys` and fix any structural failures
## 2. Scaffolding
- [x] 2.1 Create `packages/crypto/crypto-keys/` mirroring `packages/database/seed/` layout
- [x] 2.2 Write `package.json` v0.0.0, `sideEffects: false`, subpath exports for every feature module (error-codes/error-codes, types/{chain-level,hasher,codec}, hashers/{raw,domain,merkle}/<name>, codecs/{tlv,recursive-ed25519,cbor}/<name>, signer/signer, factory/factory)
- [x] 2.3 Wire deps: `@suaveplan/crypto: workspace:^`, `@suaveplan/crypto-curve: workspace:^`, `@suaveplan/crypto-hash: workspace:^`, `@suaveplan/cbor: workspace:^`, `@suaveplan/error: workspace:^`, `@suaveplan/types: workspace:^`, `tslib: catalog:build`
- [x] 2.4 Wire devDeps: `@suaveplan/testing: workspace:^`, `@types/node: catalog:`, `@vitest/coverage-istanbul: catalog:testing`, `fast-check: catalog:testing`, `typescript: catalog:`, `vite: catalog:build`, `vite-plugin-dts: catalog:build`, `vitest: catalog:testing`
- [x] 2.5 Write `tsconfig.json`, `tsconfig.lib.json` extending the genesis base
- [x] 2.6 Write `vite.config.ts` (multi-entry, ES-only, preserveModules)
- [x] 2.7 Write `vitest.config.ts` using a project-local config with `pool: 'forks'` + 100% Istanbul thresholds
- [x] 2.8 Write `tests/setup/polyfills.ts` (universal tier, no polyfills needed — same pattern as seed)
- [x] 2.9 Write `README.md` (≥ 200 words, all 6 required sections per CLAUDE.md docs gate)
## 3. Implementation — errors & shared types
- [x] 3.1 `src/error-codes/error-codes.ts` — `defineCodes("CHAIN_KEYS", [...])` with 7 codes; `ChainKeysError extends SuaveplanError<ChainKeysErrorCode>` with `depth?` / `algorithm?` discriminants
- [x] 3.2 `src/error-codes/error-codes.test.ts` — every code reachable from a `ChainKeysError` construction; `cause` chain preservation; discriminants populate
- [x] 3.3 `src/error-codes/error-codes.md` — full doc with required sections
- [x] 3.4 `src/error-codes/index.ts` — explicit named re-exports
- [x] 3.5 `src/types/chain-level.ts` — `ChainLevel`, `VerificationResult`, `ParsedChainLevel` interfaces; `chain-level.md` sibling
- [x] 3.6 `src/types/hasher.ts` — `ChainHasher` interface (`hash`, `name`); `hasher.md` sibling
- [x] 3.7 `src/types/codec.ts` — `ChainCodec` interface (`encode`, `decode`, `name`); `codec.md` sibling
## 4. Implementation — hashers (3)
- [x] 4.1 `src/hashers/raw/raw.ts` — `RawHasher implements ChainHasher` (identity hash)
- [x] 4.2 `src/hashers/raw/raw.test.ts` — identity property; ignores parent; name is "raw"
- [x] 4.3 `src/hashers/raw/raw.md` + `index.ts`
- [x] 4.4 `src/hashers/domain/domain.ts` — `DomainHasher implements ChainHasher` with constructor-supplied `domainTag` (1–64 ASCII bytes); raises `CHAIN_KEYS.INVALID_DOMAIN_TAG`
- [x] 4.5 `src/hashers/domain/domain.test.ts` — `sha256(utf8(tag) || encodedKeys)`; constructor validation (empty / oversize); ignores parent; name is "domain"
- [x] 4.6 `src/hashers/domain/domain.md` + `index.ts`
- [x] 4.7 `src/hashers/merkle/merkle.ts` — `MerkleHasher implements ChainHasher`; root hash without parent, parent root hash for non-root
- [x] 4.8 `src/hashers/merkle/merkle.test.ts` — root path; non-root commits to parent; tampering with parent invalidates child
- [x] 4.9 `src/hashers/merkle/merkle.md` + `index.ts`
## 5. Implementation — codecs (3)
- [x] 5.1 `src/codecs/tlv/tlv.ts` — `TlvChainCodec implements ChainCodec`; `[version=1][hasher][per level: algorithm + keys_len:u16 + keys + sig_len:u16 + sig]`; algorithm-id ↔ enum byte table; hasher-name ↔ enum byte table; raises `CHAIN_KEYS.INVALID_FORMAT` on bad version/truncation/length overrun
- [x] 5.2 `src/codecs/tlv/tlv.test.ts` — encode/decode round-trip for every algorithm; truncated buffer; bad version byte; length overrun; multi-level chain
- [x] 5.3 `src/codecs/tlv/tlv.md` + `index.ts`
- [x] 5.4 `src/codecs/recursive-ed25519/recursive-ed25519.ts` — `RecursiveEd25519Codec implements ChainCodec`; fixed `keys_size = 8 * KEY_SLOT_PUBLIC_BYTE_SIZE`; rejects non-Ed25519 at encode (`CHAIN_KEYS.UNSUPPORTED_ALGORITHM`)
- [x] 5.5 `src/codecs/recursive-ed25519/recursive-ed25519.test.ts` — Ed25519 round-trip; non-Ed25519 rejection; multi-level recursion; bad version
- [x] 5.6 `src/codecs/recursive-ed25519/recursive-ed25519.md` + `index.ts`
- [x] 5.7 `src/codecs/cbor/cbor.ts` — `CborChainCodec implements ChainCodec`; CBOR map `{ v, h, levels: [{ a, k, s }] }`; deterministic encoder; rejects unknown `h`/`a` strings
- [x] 5.8 `src/codecs/cbor/cbor.test.ts` — encode/decode round-trip; deterministic byte equality; bad CBOR; missing fields
- [x] 5.9 `src/codecs/cbor/cbor.md` + `index.ts`
## 6. Implementation — ChainedKeySigner
- [x] 6.1 `src/signer/signer.types.ts` — `ISigner` / `SignerOptions` / `SignerSignOptions` interfaces
- [x] 6.2 `src/signer/signer.ts` — `ChainedKeySigner` class:
- constructor `{ hasher?, codec? }` defaults `RawHasher` + `TlvChainCodec`
- `sign(publicKeys, signSlot, parent?)` — encodes public keychain, computes bytes-to-sign via hasher (passing parsed parent's leaf level if applicable), dispatches to `@suaveplan/crypto-curve` sign by `signSlot.algorithm`, prepends new level via codec
- `verify(chainBytes, trustedRoot)` — decodes via codec, walks every level computing the same bytes-to-sign, dispatches to `crypto-curve` verify, checks deepest signer matches `trustedRoot.public` via constant-time compare, returns `{ valid, depth, levels, errors }`
- `parse(chainBytes)` — decode-only
- `stripToDepth(chainBytes, depth)` — re-encode the leaf-most `depth` levels via codec
- Wraps any crypto-curve exception as `ChainKeysError` with `cause` preserved
- [x] 6.3 `src/signer/signer.test.ts` — every public method × every (hasher, codec) combination × every algorithm; tampering tests at every byte boundary; abort signal future-proofing (omitted intentionally; sign is in-memory)
- [x] 6.4 `src/signer/signer.md` + `index.ts`
## 7. Implementation — hierarchical factory
- [x] 7.1 `src/factory/factory.ts` — `createRootKeysWithChain`, `createServerKeysWithChain`, `rollServerKeysWithChain`; all default to `new ChainedKeySigner()` when omitted; raise `CHAIN_KEYS.INVALID_KEYCHAIN` when `rootKeys.master`/`rootKeys.sign` lacks private material
- [x] 7.2 `src/factory/factory.test.ts` — 3-level chain (root → server → user); verify against root; tamper at server level → fails; missing root.master / root.sign → INVALID_KEYCHAIN; injected signer with non-default hasher works end-to-end
- [x] 7.3 `src/factory/factory.md` + `index.ts`
## 8. Root barrel & public surface
- [x] 8.1 `src/index.ts` — re-export every public symbol via explicit named exports (no `export *` — rule §6a)
- [x] 8.2 Verify `bunx turbo build --filter=@suaveplan/crypto-keys` succeeds and dist contains every advertised subpath
## 9. Pre-archive gates
- [x] 9.1 `bunx turbo lint --filter=@suaveplan/crypto-keys` green; zero biome suppressions
- [x] 9.2 `bunx turbo typecheck --filter=@suaveplan/crypto-keys` green
- [x] 9.3 `bunx turbo test --filter=@suaveplan/crypto-keys` green; zero `.skip` / `.only` / `it.todo`
- [x] 9.4 `bunx turbo test:coverage --filter=@suaveplan/crypto-keys` green at 100% statement/branch/function/line (Istanbul)
- [x] 9.5 `bun run docs:check --filter=@suaveplan/crypto-keys` green
- [x] 9.6 `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/crypto/crypto-keys/src/` → zero matches (rule §7)
- [x] 9.7 `grep -rnE "^export \*" packages/crypto/crypto-keys/src/` → only the root barrel and exempt forms (rule §6a)
- [x] 9.8 `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http)['\"]" packages/crypto/crypto-keys/src/` → zero matches (universal tier)
- [x] 9.9 `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\\.skip\|\\.only\|\\bany\\b\|console\\.log" packages/crypto/crypto-keys/src/ --include="*.ts"` → zero matches
- [x] 9.10 Every `### Requirement` in `specs/crypto-keys/spec.md` checked off against the implementation
- [x] 9.11 `bun scripts/folder-reorg/generate-manifest.ts`; `MANIFEST.md` includes `@suaveplan/crypto-keys`
- [x] 9.12 `STATUS.md` row added or updated — entry under crypto plugin, status `✅`
## 10. Fold + archive
- [x] 10.1 Fold `specs/crypto-keys/spec.md` into `openspec/specs/crypto-keys/spec.md` (new canonical capability; convert `## ADDED Requirements` → `## Requirements` and prepend `## Purpose`)
- [x] 10.2 `openspec validate crypto-keys` clean
- [x] 10.3 Move `openspec/changes/add-crypto-keys/` → `openspec/archive/$(date +%Y-%m-%d)-add-crypto-keys/`
- [x] 10.4 Commit per CLAUDE.md commit-style; do not push without user approval