Initial Commit
This commit is contained in:
328
openspec/archive/2026-05-17-add-crypto-keys/design.md
Normal file
328
openspec/archive/2026-05-17-add-crypto-keys/design.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Design — @suaveplan/crypto-keys
|
||||
|
||||
Non-normative. Architecture, wire-format details, algorithm-dispatch rationale, and integration notes. The normative contract lives in `./specs/crypto-keys/spec.md`.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/crypto/crypto-keys/
|
||||
package.json # @suaveplan/crypto-keys v0.0.0, sideEffects: false
|
||||
tsconfig.json
|
||||
tsconfig.lib.json
|
||||
vite.config.ts
|
||||
vitest.config.ts # createTestConfig() + 100% Istanbul thresholds
|
||||
README.md
|
||||
src/
|
||||
index.ts # explicit named re-exports (rule §6a)
|
||||
error-codes/
|
||||
error-codes.ts # CHAIN_KEYS_CODES + ChainKeysError
|
||||
error-codes.test.ts
|
||||
error-codes.md
|
||||
index.ts
|
||||
types/
|
||||
chain-level.ts # ChainLevel, VerificationResult, ParsedChainLevel
|
||||
chain-level.md
|
||||
hasher.ts # ChainHasher interface
|
||||
hasher.md
|
||||
codec.ts # ChainCodec interface
|
||||
codec.md
|
||||
hashers/
|
||||
raw/ raw.ts raw.test.ts raw.md index.ts
|
||||
domain/ domain.ts domain.test.ts domain.md index.ts
|
||||
merkle/ merkle.ts merkle.test.ts merkle.md index.ts
|
||||
codecs/
|
||||
tlv/ tlv.ts tlv.test.ts tlv.md index.ts
|
||||
recursive-ed25519/ recursive-ed25519.ts recursive-ed25519.test.ts recursive-ed25519.md index.ts
|
||||
cbor/ cbor.ts cbor.test.ts cbor.md index.ts
|
||||
signer/
|
||||
signer.ts # ChainedKeySigner class
|
||||
signer.test.ts
|
||||
signer.types.ts # ISigner / SignerOptions
|
||||
signer.md
|
||||
index.ts
|
||||
factory/
|
||||
factory.ts # createRootKeysWithChain / createServerKeysWithChain / rollServerKeysWithChain
|
||||
factory.test.ts
|
||||
factory.md
|
||||
index.ts
|
||||
```
|
||||
|
||||
## Tier rationale: universal
|
||||
|
||||
The package consumes only `@suaveplan/crypto`, `@suaveplan/crypto-curve`, `@suaveplan/crypto-hash`, `@suaveplan/cbor`, `@suaveplan/error`, `@suaveplan/types`, and `tslib` — all universal-tier and free of `node:`/`bun:` imports. The chain-signer itself uses only `Uint8Array`, `DataView`, `performance.now()` (for optional timing in tests), and async/await. No filesystem, no streams, no node:crypto. Lands at `packages/crypto/crypto-keys/`.
|
||||
|
||||
## Algorithm dispatch
|
||||
|
||||
The package defines `SignAlgorithmId` — the string-literal union of the 5 signing curves exposed by `@suaveplan/crypto-curve` — and dispatches to the matching primitive per level:
|
||||
|
||||
| `SignAlgorithmId` | Wire byte | Sign function | Signature length |
|
||||
|---|---|---|---|
|
||||
| `"ed25519"` | `0` | `crypto-curve/ed25519::sign(digest, privateKey)` | 64 bytes |
|
||||
| `"secp256k1"` | `1` | `crypto-curve/secp256k1::sign(digest, privateKey)` | 64 bytes |
|
||||
| `"p256"` | `2` | `crypto-curve/p256::sign(digest, privateKey)` | DER-encoded; variable 64–72 bytes |
|
||||
| `"p384"` | `3` | `crypto-curve/p384::sign(digest, privateKey)` | DER-encoded; variable 96–104 bytes |
|
||||
| `"p521"` | `4` | `crypto-curve/p521::sign(digest, privateKey)` | DER-encoded; variable 132–139 bytes |
|
||||
|
||||
Verification mirrors the table with `verify(digest, signature, publicKey)`. Unknown wire bytes raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`.
|
||||
|
||||
Two ergonomic surfaces:
|
||||
|
||||
- **`signWithSlot(payload, signSlot, parent?)`** — convenience for sign-slots from the genesis `@suaveplan/crypto` keychain. Genesis crypto v1 only generates Ed25519 sign-slots (`ALGORITHMS.ED25519 = 0`; the other slots in v1 are X25519, AEADs, and KDFs — none signing-capable). Non-Ed25519 sign-slots raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`. When genesis crypto v2 adds the other curves to the keychain layer, this method's dispatch table extends with no API break.
|
||||
- **`signRaw({ payload, algorithm, privateKey, publicKey, parent? })`** — fully general. Caller supplies the algorithm tag, raw key material, and an arbitrary payload. Works today for any of the 5 curves. Use this when chain levels involve algorithms the genesis keychain doesn't yet generate (e.g. a secp256k1 root with Ed25519 children).
|
||||
|
||||
If `signWithSlot` is called with a slot that lacks private material, the signer raises `CHAIN_KEYS.INVALID_KEYCHAIN`. If `signRaw` is called with `privateKey.length` / `publicKey.length` mismatching the algorithm's expected sizes, the signer raises `CHAIN_KEYS.INVALID_KEYCHAIN`.
|
||||
|
||||
## Wire formats
|
||||
|
||||
### TLV codec (default)
|
||||
|
||||
```text
|
||||
[version: u8 = 1]
|
||||
[hasher: u8 — 0=raw, 1=domain, 2=merkle]
|
||||
per level (in order, leaf first; root last):
|
||||
[algorithm: u8 — 0=ed25519, 1=secp256k1, 2=p256, 3=p384, 4=p521]
|
||||
[pub_len: u8][signer_pub: pub_len bytes]
|
||||
[payload_len: u16 big-endian][payload: payload_len bytes]
|
||||
[sig_len: u16 big-endian][sig: sig_len bytes]
|
||||
```
|
||||
|
||||
- Per-level `signer_pub` records the public key that signed THIS level — the verifier reads it directly and dispatches `crypto-curve.verify(...)`. Self-contained: no external state required to walk the chain.
|
||||
- The parser walks the byte stream from offset 2 (past the two header bytes), consuming a level per iteration until the buffer is exhausted.
|
||||
- `version` and `hasher` are validated against known values; unknown → `CHAIN_KEYS.INVALID_FORMAT`.
|
||||
- `pub_len` bounds: 32 (Ed25519/secp256k1), 33 (compressed secp256k1), 65 (uncompressed), or 64/96/132 for P-curve coordinates depending on encoding choice — accepted up to 140 bytes (covers all 5 curves). `payload_len` and `sig_len` are bounded by `u16::max = 65535` for forward-compat (typical payloads are `encodePublicKeychain` output, well under 1 KB). Out-of-bounds reads → `CHAIN_KEYS.INVALID_FORMAT`.
|
||||
|
||||
### Recursive-Ed25519 codec
|
||||
|
||||
```text
|
||||
[version: u8 = 1]
|
||||
[hasher: u8]
|
||||
[signer_pub: 32 bytes — fixed Ed25519 public key]
|
||||
[payload_len: u16 big-endian][payload: payload_len bytes]
|
||||
[sig: 64 bytes]
|
||||
[parent_chain? — bytes [2..) of a parent recursive chain, optional]
|
||||
```
|
||||
|
||||
The recursion boundary is unambiguous: 32-byte fixed pub-key, u16-prefixed payload, 64-byte fixed signature. The parser slices off the header, peels one level per iteration. **Restricted to Ed25519** — `signRaw` or `signWithSlot` calls with any other algorithm raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` at encode time. Decoded levels carry `algorithm: "ed25519"` unconditionally.
|
||||
|
||||
### CBOR codec
|
||||
|
||||
```text
|
||||
[version: u8 = 1]
|
||||
[cbor: serialised CBOR map]
|
||||
```
|
||||
|
||||
CBOR payload shape:
|
||||
|
||||
```text
|
||||
{
|
||||
"v": 1, // matches the version byte; redundant safety check
|
||||
"h": "raw" | "domain" | "merkle", // hasher name (string)
|
||||
"levels": [
|
||||
{
|
||||
"a": "ed25519" | "secp256k1" | "p256" | "p384" | "p521",
|
||||
"p": <bytes>, // signer's public key
|
||||
"k": <bytes>, // payload (typically encodePublicKeychain output)
|
||||
"s": <bytes> // signature
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Uses `@suaveplan/cbor`'s deterministic encoder. Unknown `h` or `a` strings raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` (algorithm) or `CHAIN_KEYS.INVALID_FORMAT` (hasher). Levels are an ordered array; index 0 is the leaf, last index is the root, matching the TLV codec.
|
||||
|
||||
## Hash modes
|
||||
|
||||
### RawHasher
|
||||
|
||||
```ts
|
||||
hash(encodedKeys, _parent) → encodedKeys
|
||||
```
|
||||
|
||||
Signs the raw `encodePublicKeychain` output bytes. Mirrors upstream's `[keys][sig]` layout. Default.
|
||||
|
||||
### DomainHasher
|
||||
|
||||
```ts
|
||||
constructor({ domainTag: string }) // tag must be 1–64 ASCII bytes
|
||||
hash(encodedKeys, _parent) → sha256(utf8(domainTag) || encodedKeys)
|
||||
```
|
||||
|
||||
Adds a per-application domain separator so chain signatures cannot be replayed against other sign sites. Empty or oversize `domainTag` → `CHAIN_KEYS.INVALID_DOMAIN_TAG` at construction time. The tag is **not** included in the wire format — the verifier must construct the same `DomainHasher` with the same tag.
|
||||
|
||||
### MerkleHasher
|
||||
|
||||
```ts
|
||||
hash(encodedKeys, parent) →
|
||||
parent === undefined
|
||||
? sha256(encodedKeys) // root
|
||||
: sha256(encodedKeys || sha256(parent.encodedKeys || parent.signature))
|
||||
```
|
||||
|
||||
Each non-root level commits to its parent's content (encoded keys + signature). Tampering with a parent invalidates every descendant's signature. The verifier reproduces the parent root hash from the decoded chain — no external state required.
|
||||
|
||||
## ChainedKeySigner API
|
||||
|
||||
```ts
|
||||
class ChainedKeySigner {
|
||||
constructor(options?: {
|
||||
hasher?: ChainHasher; // default: new RawHasher()
|
||||
codec?: ChainCodec; // default: new TlvChainCodec()
|
||||
});
|
||||
|
||||
// Slot-based convenience: extracts (algorithm, publicKey, privateKey) from
|
||||
// a genesis-keychain sign-slot. Only Ed25519 sign-slots are supported in
|
||||
// v0.0.0 (matches genesis keychain v1's algorithm registry); other slot
|
||||
// algorithms raise CHAIN_KEYS.UNSUPPORTED_ALGORITHM.
|
||||
async signWithSlot(
|
||||
payload: Uint8Array,
|
||||
signSlot: KeySlotPrivate,
|
||||
parent?: Uint8Array,
|
||||
): Promise<Uint8Array>;
|
||||
|
||||
// Fully general: caller supplies (algorithm, privateKey, publicKey) for any
|
||||
// of the 5 signing algorithms exposed by @suaveplan/crypto-curve.
|
||||
async signRaw(options: {
|
||||
payload: Uint8Array;
|
||||
algorithm: SignAlgorithmId;
|
||||
privateKey: Uint8Array;
|
||||
publicKey: Uint8Array;
|
||||
parent?: Uint8Array;
|
||||
}): Promise<Uint8Array>;
|
||||
|
||||
// Verify against an expected root (algorithm + public key). The chain's
|
||||
// wire format records (algorithm, signerPublicKey) per level, so the
|
||||
// verifier dispatches to the right crypto-curve primitive automatically.
|
||||
async verify(
|
||||
chainBytes: Uint8Array,
|
||||
trustedRoot: { algorithm: SignAlgorithmId; publicKey: Uint8Array },
|
||||
): Promise<VerificationResult>;
|
||||
|
||||
// Decode-only (no signature verification).
|
||||
async parse(
|
||||
chainBytes: Uint8Array,
|
||||
): Promise<{ depth: number; levels: readonly ChainLevel[] }>;
|
||||
|
||||
// Re-encode the leaf-most `depth` levels only.
|
||||
async stripToDepth(
|
||||
chainBytes: Uint8Array,
|
||||
depth: number,
|
||||
): Promise<Uint8Array>;
|
||||
}
|
||||
```
|
||||
|
||||
`ChainLevel` is `{ algorithm: SignAlgorithmId; signerPublicKey: Uint8Array; payload: Uint8Array; signature: Uint8Array }`. Note `payload` is **opaque to the package** — typically `encodePublicKeychain(...)` output for keychain chains, but any byte sequence (canonical JSON, CBOR, manifest digest, etc.) is valid.
|
||||
|
||||
`VerificationResult` is `{ valid: boolean; depth: number; levels: readonly ChainLevel[]; errors: readonly string[] }`. On any failure (bad signature at any depth, root mismatch, codec parse error) `valid` is `false` and `errors` carries human-readable diagnostics; the structural `levels` array still reflects whatever the codec could decode.
|
||||
|
||||
`stripToDepth(chainBytes, 1)` returns just the leaf level (no parents). `stripToDepth(chainBytes, n)` for `n >= chain.length` is rejected with `CHAIN_KEYS.INVALID_DEPTH`. `n < 1` is also rejected.
|
||||
|
||||
## Hierarchical factory layer
|
||||
|
||||
```ts
|
||||
async function createRootKeysWithChain(options?: {
|
||||
signer?: ChainedKeySigner;
|
||||
generate?: GenerateKeychainOptions; // forwarded to crypto::generateKeychain
|
||||
}): Promise<{ keys: Keychain; chain: Uint8Array }>;
|
||||
|
||||
async function createServerKeysWithChain(options: {
|
||||
rootKeys: Keychain;
|
||||
rootChain: Uint8Array;
|
||||
signer?: ChainedKeySigner;
|
||||
generate?: GenerateKeychainOptions;
|
||||
}): Promise<{ keys: Keychain; chain: Uint8Array }>;
|
||||
|
||||
async function rollServerKeysWithChain(options: {
|
||||
rootKeys: Keychain;
|
||||
rootChain: Uint8Array;
|
||||
currentVersion: number;
|
||||
signer?: ChainedKeySigner;
|
||||
}): Promise<{ keys: Keychain; chain: Uint8Array }>;
|
||||
```
|
||||
|
||||
Implementation outline:
|
||||
|
||||
```ts
|
||||
async function createRootKeysWithChain(options = {}) {
|
||||
const signer = options.signer ?? new ChainedKeySigner();
|
||||
const keys = await generateKeychain(options.generate);
|
||||
const publicKeys = extractPublicKeys(keys);
|
||||
const payload = encodePublicKeychain(publicKeys);
|
||||
const chain = await signer.signWithSlot(payload, keys.sign.private);
|
||||
return { keys, chain };
|
||||
}
|
||||
|
||||
async function createServerKeysWithChain({ rootKeys, rootChain, signer, generate }) {
|
||||
const signerActual = signer ?? new ChainedKeySigner();
|
||||
const keys = await generateKeychain({
|
||||
...generate,
|
||||
masterKey: rootKeys.master.private,
|
||||
});
|
||||
const publicKeys = extractPublicKeys(keys);
|
||||
const payload = encodePublicKeychain(publicKeys);
|
||||
const chain = await signerActual.signWithSlot(payload, rootKeys.sign.private, rootChain);
|
||||
return { keys, chain };
|
||||
}
|
||||
|
||||
async function rollServerKeysWithChain({ rootKeys, rootChain, currentVersion, signer }) {
|
||||
const signerActual = signer ?? new ChainedKeySigner();
|
||||
const keys = await generateKeychain({
|
||||
masterKey: rootKeys.master.private,
|
||||
version: currentVersion + 1,
|
||||
});
|
||||
const publicKeys = extractPublicKeys(keys);
|
||||
const payload = encodePublicKeychain(publicKeys);
|
||||
const chain = await signerActual.signWithSlot(payload, rootKeys.sign.private, rootChain);
|
||||
return { keys, chain };
|
||||
}
|
||||
```
|
||||
|
||||
All three factory functions use `signWithSlot` (Ed25519-only path) since `generateKeychain` produces Ed25519 sign-slots. Consumers needing multi-curve chains use `signRaw` directly.
|
||||
|
||||
`rootKeys.master` / `rootKeys.sign` must have `private` material — otherwise `CHAIN_KEYS.INVALID_KEYCHAIN` with `seedName: undefined`, `algorithm: undefined` (the discriminants don't apply at this layer; the message itemises which slot was missing).
|
||||
|
||||
## Error code map
|
||||
|
||||
```text
|
||||
CHAIN_KEYS.UNSUPPORTED_ALGORITHM // sign-slot algorithm not handled by codec/signer combination
|
||||
CHAIN_KEYS.INVALID_FORMAT // codec parse failure (bad version, truncation, length overrun)
|
||||
CHAIN_KEYS.INVALID_SIGNATURE // signature failed verification at some depth
|
||||
CHAIN_KEYS.TRUST_MISMATCH // root signer public key !== supplied trustedRoot
|
||||
CHAIN_KEYS.INVALID_DEPTH // stripToDepth out of range (< 1 or > chain depth)
|
||||
CHAIN_KEYS.INVALID_KEYCHAIN // signSlot missing private bytes; rootKeys missing required slots
|
||||
CHAIN_KEYS.INVALID_DOMAIN_TAG // DomainHasher constructed with empty / oversize tag
|
||||
```
|
||||
|
||||
`ChainKeysError` discriminants:
|
||||
|
||||
```ts
|
||||
class ChainKeysError extends SuaveplanError<ChainKeysErrorCode> {
|
||||
readonly depth?: number; // populated for INVALID_SIGNATURE / INVALID_DEPTH
|
||||
readonly algorithm?: AlgorithmId; // populated for UNSUPPORTED_ALGORITHM
|
||||
}
|
||||
```
|
||||
|
||||
The class follows the same pattern as `SeedError` in `@suaveplan/seed`: extends `SuaveplanError<TCode>` literal-typed, threads `cause`, surfaces discriminants on the instance directly (not on the `context` map) for round-trip safety.
|
||||
|
||||
## Constant-time signature compare
|
||||
|
||||
The verifier compares the parsed-root public-key bytes with `trustedRoot.public` using a constant-time helper from `@suaveplan/crypto-utils` (`timingSafeEqual` if available; otherwise the package's own implementation as long as it's a single, audited byte-by-byte loop without short-circuit). This avoids leaking a timing side-channel on whether the root matches.
|
||||
|
||||
## Test plan
|
||||
|
||||
- **Unit per feature module:** every hasher / codec / signer / factory function has a `.test.ts` with full branch coverage.
|
||||
- **Round-trip property tests** (`fast-check`): for every (hasher × codec × algorithm) combination, signing then verifying with the same trusted root succeeds; tampering with any byte in the wire format causes verification to fail.
|
||||
- **Algorithm dispatch tests:** each of the 5 algorithms produces a recoverable chain through the TLV codec.
|
||||
- **RecursiveEd25519Codec restriction:** rejects non-Ed25519 algorithm at encode time.
|
||||
- **CborChainCodec interoperability:** decode-then-encode produces byte-identical output (deterministic CBOR encoder).
|
||||
- **Hierarchical factory:** 3-level chain (root → server → user); `verify` succeeds against root; tampering with the server level fails.
|
||||
- **`stripToDepth`:** stripping to a partial depth produces a chain that verifies as far as it goes when reverified with the right intermediate trust anchor.
|
||||
|
||||
Tests use `@suaveplan/testing/runner` (rule §11). `vitest.config.ts` uses `createTestConfig()` from `@suaveplan/testing/config`. Coverage gate is 100% statement/branch/function/line via Istanbul.
|
||||
|
||||
## Out-of-package decisions
|
||||
|
||||
- **No OTel.** Pure data transform; observability lives at the call site.
|
||||
- **No schema-registry.** The version byte in the wire format covers forward-compatibility for the foreseeable future.
|
||||
- **No vault helpers.** `packVault` / `unpackVault` / `VaultManager` / `VaultStreams` live in the deferred `crypto-vault` extension.
|
||||
- **No bitpacker-shape compatibility.** Upstream `BitPacker(publicKeychainSchema)` output is **incompatible** with genesis `encodePublicKeychain` output; the package will not accept upstream-encoded chains.
|
||||
98
openspec/archive/2026-05-17-add-crypto-keys/proposal.md
Normal file
98
openspec/archive/2026-05-17-add-crypto-keys/proposal.md
Normal 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.
|
||||
@@ -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
|
||||
108
openspec/archive/2026-05-17-add-crypto-keys/tasks.md
Normal file
108
openspec/archive/2026-05-17-add-crypto-keys/tasks.md
Normal 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
|
||||
Reference in New Issue
Block a user