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,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