Initial Commit

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

View File

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