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

244 lines
17 KiB
Markdown
Raw Permalink Blame History

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