Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
# @suaveplan/crypto-keys - Hierarchical keychain chain-signing for the crypto ecosystem: pluggable ChainHasher + ChainCodec interfaces with three shipped implementations each (RawHasher / DomainHasher / MerkleHasher and TlvChainCodec / RecursiveEd25519Codec / CborChainCodec); ChainedKeySigner class with per-slot algorithm dispatch across Ed25519/secp256k1/p256/p384/p521; hierarchical key factory (createRootKeysWithChain / createServerKeysWithChain / rollServerKeysWithChain). Universal.
Hierarchical keychain chain-signing for the crypto ecosystem.
Store-agnostic, free of `node:` / `bun:` imports.
<!-- begin:auto-docs -->
## Documentation tree
- [cbor](./src/codecs/cbor/cbor.md) — `CborChainCodec` is the CBOR-based wire-format `ChainCodec` for `@suaveplan/crypto-keys`. It serialises an ordered list…
- [cbor](./src/codecs/cbor/cbor.md) — `CborChainCodec` is the CBOR-based wire-format `ChainCodec` for `@suaveplan/crypto-keys`. It serialises an ordered list…
- [recursive-ed25519](./src/codecs/recursive-ed25519/recursive-ed25519.md) — `RecursiveEd25519Codec` is a fixed-Ed25519 alternate wire format for signed key chains in `@suaveplan/crypto-keys`. It…
- [recursive-ed25519](./src/codecs/recursive-ed25519/recursive-ed25519.md) — `RecursiveEd25519Codec` is a fixed-Ed25519 alternate wire format for signed key chains in `@suaveplan/crypto-keys`. It…
- [tlv](./src/codecs/tlv/tlv.md) — `TlvChainCodec` is the default wire-format codec for `@suaveplan/crypto-keys`. It encodes and decodes the `ChainLevel[]…
- [tlv](./src/codecs/tlv/tlv.md) — `TlvChainCodec` is the default wire-format codec for `@suaveplan/crypto-keys`. It encodes and decodes the `ChainLevel[]…
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/crypto-keys` package centralises every failure mode it can produce behind a single registry built with…
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/crypto-keys` package centralises every failure mode it can produce behind a single registry built with…
- [factory](./src/factory/factory.md) — Hierarchical-keychain factory helpers that compose `generateKeychain` from `@suaveplan/crypto` with `ChainedKeySigner.s…
- [factory](./src/factory/factory.md) — Hierarchical-keychain factory helpers that compose `generateKeychain` from `@suaveplan/crypto` with `ChainedKeySigner.s…
- [domain](./src/hashers/domain/domain.md) — `DomainHasher` is the `ChainHasher` strategy that domain-separates the signed pre-image with a caller-supplied tag and…
- [domain](./src/hashers/domain/domain.md) — `DomainHasher` is the `ChainHasher` strategy that domain-separates the signed pre-image with a caller-supplied tag and…
- [merkle](./src/hashers/merkle/merkle.md) — `MerkleHasher` is the `ChainHasher` strategy that binds each non-root level to its parent's content via a Merkle-style…
- [merkle](./src/hashers/merkle/merkle.md) — `MerkleHasher` is the `ChainHasher` strategy that binds each non-root level to its parent's content via a Merkle-style…
- [raw](./src/hashers/raw/raw.md) — `RawHasher` is the trivial `ChainHasher` strategy: it returns the payload bytes verbatim, performing no transformation.…
- [raw](./src/hashers/raw/raw.md) — `RawHasher` is the trivial `ChainHasher` strategy: it returns the payload bytes verbatim, performing no transformation.…
- [signer](./src/signer/signer.md) — `ChainedKeySigner` composes a `ChainHasher` (bytes-to-sign strategy) and a `ChainCodec` (wire-format strategy) to produ…
- [signer](./src/signer/signer.md) — `ChainedKeySigner` composes a `ChainHasher` (bytes-to-sign strategy) and a `ChainCodec` (wire-format strategy) to produ…
- [chain-level](./src/types/chain-level.md) — Shared type module declaring the value-shapes that every other module in `@suaveplan/crypto-keys` references. Defining…
- [codec](./src/types/codec.md) — `ChainCodec` is the wire-format strategy that turns an array of `ChainLevel` values into bytes and back. Decoupling the…
- [hasher](./src/types/hasher.md) — `ChainHasher` is the strategy interface that decides what bytes a `ChainedKeySigner` actually signs for one chain level…
<!-- end:auto-docs -->
## Purpose
Ships strong primitives in `@suaveplan/crypto`,
`@suaveplan/crypto-curve`, and `@suaveplan/crypto-hash`, plus a
keychain layer in `@suaveplan/crypto` itself (`encodePublicKeychain`,
`generateKeychain`, per-slot `AlgorithmId`). What it does not ship is
the *pattern* of binding a chain of public keychains together with
signatures — root signs server, server signs user, and so on — with
end-to-end verification back to a trusted root. This package fills
that gap.
The design is pluggable. Two interfaces (`ChainHasher` for the
bytes-to-sign and `ChainCodec` for the wire format) decouple the three
shipped hash modes (raw / domain-tagged / merkle) from the three
shipped codecs (TLV / recursive-Ed25519 / CBOR). The `ChainedKeySigner`
class composes a hasher + codec and dispatches signing per-algorithm
to the matching `@suaveplan/crypto-curve` primitive — so a single
chain can mix Ed25519, secp256k1, and the P-curves across its levels.
## Features
- Three shipped hashers — `RawHasher`, `DomainHasher({ domainTag })`, `MerkleHasher`
- Three shipped codecs — `TlvChainCodec` (default), `RecursiveEd25519Codec`, `CborChainCodec`
- `ChainedKeySigner` class — `sign` / `verify` / `parse` / `stripToDepth`
- Per-slot algorithm dispatch across Ed25519 / secp256k1 / P-256 / P-384 / P-521
- Hierarchical factory — `createRootKeysWithChain`, `createServerKeysWithChain`, `rollServerKeysWithChain`
- `ChainKeysError` extending `SuaveplanError` with `depth?` / `algorithm?` discriminants and 7 stable `CHAIN_KEYS.*` codes
- Constant-time root-key compare via `@suaveplan/crypto-utils`
- 13 tree-shakeable subpath exports — import the codec you want without dragging in the rest
- 100% statement / branch / function / line coverage via Istanbul
## Usage: Basic
```ts
import {
ChainedKeySigner,
createRootKeysWithChain,
createServerKeysWithChain,
} from "@suaveplan/crypto-keys";
// Build a 2-level chain: root → server
const { keys: rootKeys, chain: rootChain } = await createRootKeysWithChain();
const { keys: serverKeys, chain: serverChain } = await createServerKeysWithChain({
rootKeys,
rootChain,
});
// Verify end-to-end against the trusted root sign-slot
const signer = new ChainedKeySigner();
const result = await signer.verify(serverChain, rootKeys.sign.public);
if (!result.valid) throw new Error(result.errors.join("; "));
console.log("Chain depth:", result.depth); // 2
```
## Usage: Advanced
```ts
import {
ChainedKeySigner,
DomainHasher,
CborChainCodec,
createRootKeysWithChain,
createServerKeysWithChain,
rollServerKeysWithChain,
} from "@suaveplan/crypto-keys";
// Domain-tagged signing inside CBOR-encoded chains
const signer = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "acme/key-chain-v1" }),
codec: new CborChainCodec(),
});
const { keys: rootKeys, chain: rootChain } = await createRootKeysWithChain({ signer });
const { keys: serverV1, chain: chainV1 } = await createServerKeysWithChain({
rootKeys,
rootChain,
signer,
});
// Six months later, roll the server keychain forward
const { keys: serverV2, chain: chainV2 } = await rollServerKeysWithChain({
rootKeys,
rootChain,
currentVersion: serverV1.master.private.layout.fields.version,
signer,
});
// Hand a verifier a partial chain (just the leaf + its signature)
const leafOnly = await signer.stripToDepth(chainV2, 1);
// Verifiers reproduce the same hasher + codec to verify
const verifier = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "acme/key-chain-v1" }),
codec: new CborChainCodec(),
});
const result = await verifier.verify(chainV2, rootKeys.sign.public);
```
## API Reference
### Class `ChainedKeySigner`
| Method | Signature |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| `constructor` | `(options?: { hasher?: ChainHasher; codec?: ChainCodec })` |
| `sign` | `(publicKeys: PublicKeychain, signSlot: KeySlotPrivate, parent?: Uint8Array) => Promise<Uint8Array>` |
| `verify` | `(chainBytes: Uint8Array, trustedRoot: KeySlotPublic) => Promise<VerificationResult>` |
| `parse` | `(chainBytes: Uint8Array) => Promise<{ depth: number; levels: readonly ChainLevel[] }>` |
| `stripToDepth` | `(chainBytes: Uint8Array, depth: number) => Promise<Uint8Array>` |
### Hashers
- `class RawHasher implements ChainHasher`
- `class DomainHasher implements ChainHasher` — constructor `({ domainTag: string })`, tag must be 1–64 ASCII bytes
- `class MerkleHasher implements ChainHasher`
### Codecs
- `class TlvChainCodec implements ChainCodec` (default)
- `class RecursiveEd25519Codec implements ChainCodec` — Ed25519 only
- `class CborChainCodec implements ChainCodec`
### Factory functions
- `async function createRootKeysWithChain(options?: { signer?: ChainedKeySigner; generate?: GenerateKeychainOptions }): 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 }>`
### Errors
- `class ChainKeysError extends SuaveplanError<ChainKeysErrorCode>` with optional `depth?: number` and `algorithm?: AlgorithmId` discriminants
- `CHAIN_KEYS_CODES` — frozen registry of 7 codes: `UNSUPPORTED_ALGORITHM`, `INVALID_FORMAT`, `INVALID_SIGNATURE`, `TRUST_MISMATCH`, `INVALID_DEPTH`, `INVALID_KEYCHAIN`, `INVALID_DOMAIN_TAG`
## Implementation Notes
The package is universal — no `node:` / `bun:` imports anywhere
in `src/`. All cryptographic primitives come from
`@suaveplan/crypto-curve` (per-algorithm `sign(digest, privateKey)` /
`verify(...)`) and `@suaveplan/crypto-hash` (`sha256` for the
`DomainHasher` / `MerkleHasher`). Constant-time byte compare comes
from `@suaveplan/crypto-utils`.
The wire format is **self-describing**. Every codec prepends
`[version: u8][hasher: u8]` so a verifier reproduces the exact hash
that was signed. The TLV codec also tags each level with its
algorithm byte, letting a chain mix algorithms across levels — useful
when the root uses Ed25519 for speed and intermediate authorities
use P-256 for compliance.
Signing dispatches at the slot level: the signer reads
`signSlot.algorithm` and calls the matching `crypto-curve` primitive.
This means upstream pre-suaveplan chain bytes are **not** byte-compatible
with this package — they were emitted by an older bitpacker-based
scheme. Migration is one-way: re-sign chains in the new format.
Errors are never raw `Error` — every failure goes through `ChainKeysError`
with a stable `CHAIN_KEYS.*` code. Verification failures don't throw;
they return `{ valid: false, errors: [...] }` so callers can chain
multiple verification attempts without try/catch. Only construction-time
errors (e.g. an invalid `domainTag`) throw synchronously.

View File

@@ -0,0 +1,94 @@
# CborChainCodec
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CborChainCodec` is the CBOR-based wire-format `ChainCodec` for `@suaveplan/crypto-keys`. It serialises an ordered list of `ChainLevel` values (leaf-first, root-last) to a single `Uint8Array` and decodes the same bytes back to a deep-equal list of levels. Where the default `TlvChainCodec` is a tight self-describing length-prefixed binary, `CborChainCodec` trades a few bytes of overhead for a fully RFC 8949 compliant payload that can be inspected, diffed, and re-emitted with any other CBOR tool in the ecosystem.
The wire layout is a single version byte followed by a deterministic CBOR map. Deterministic CBOR encoding (RFC 8949 §4.2) guarantees that byte-identical inputs produce byte-identical wire bytes regardless of host, runtime, or call order — which is the contract callers rely on when chains are content-addressable, hashed, or re-signed by an outer layer.
## Features
- Single version-byte header (`0x01`) gates future wire revisions without an in-band CBOR migration.
- Payload is a deterministic CBOR map `{ "v": 1, "h": <hasher>, "levels": [...] }` with one nested map per level using short keys `a` / `p` / `k` / `s`.
- Hasher name (`raw` | `domain` | `merkle`) round-trips on the `h` field so a downstream verifier can reconstruct a matching `ChainHasher` without out-of-band hints.
- Decoder rejects unknown versions, unknown hasher names, unknown algorithms, missing fields, non-byte-string `p`/`k`/`s` values, malformed CBOR, and any trailing bytes after the CBOR top-level item.
- Decoded `Uint8Array` fields are copies, so mutating the input buffer after decode never aliases into the returned levels.
- Stateless and reusable: a single instance MAY be shared across calls and across threads. The only mutable field is `hasherName`, which is read at encode time and written at decode time.
## Usage: Basic
```ts
import { CborChainCodec } from "@suaveplan/crypto-keys/codecs/cbor/cbor";
import type { ChainLevel } from "@suaveplan/crypto-keys/types/chain-level";
const codec = new CborChainCodec();
const levels: ChainLevel[] = [
{
algorithm: "ed25519",
signerPublicKey: new Uint8Array(32),
payload: new Uint8Array([0x01, 0x02, 0x03]),
signature: new Uint8Array(64),
},
];
const bytes = codec.encode(levels);
const decoded = codec.decode(bytes);
// decoded is deep-equal to levels; codec.hasherName === "raw"
```
## Usage: Advanced
Pair the codec with a non-`raw` hasher so the wire bytes carry the hasher tag a verifier needs:
```ts
import { CborChainCodec } from "@suaveplan/crypto-keys/codecs/cbor/cbor";
import type { ChainLevel } from "@suaveplan/crypto-keys/types/chain-level";
const encoder = new CborChainCodec();
encoder.hasherName = "merkle";
const wireBytes = encoder.encode(chain);
// On the verifier side: a fresh codec decodes the bytes and reports the
// hasher used at encode time. The verifier then constructs a matching
// `MerkleHasher` and walks the chain.
const verifier = new CborChainCodec();
const levels = verifier.decode(wireBytes);
if (verifier.hasherName !== "merkle") {
throw new Error("hasher mismatch — refusing to verify");
}
// Deterministic encoding: re-encoding the decoded levels with the same
// hasher must reproduce the original wire bytes byte-for-byte.
const reEncoder = new CborChainCodec();
reEncoder.hasherName = verifier.hasherName;
const reEncoded = reEncoder.encode(levels);
// reEncoded equals wireBytes.
```
## API Reference
### `class CborChainCodec implements ChainCodec`
- `readonly name: "cbor"` — codec identifier surfaced in diagnostics.
- `hasherName: "raw" | "domain" | "merkle"` — read at `encode` time, written by `decode`. Defaults to `"raw"`.
- `encode(levels: readonly ChainLevel[]): Uint8Array` — emits `[0x01][cbor-map]`. Raises `ChainKeysError(UNSUPPORTED_ALGORITHM)` when any level's `algorithm` is not a `SignAlgorithmId`.
- `decode(bytes: Uint8Array): readonly ChainLevel[]` — validates header and CBOR shape; sets `this.hasherName` only after the full payload validates. Raises `ChainKeysError(INVALID_FORMAT)` for wrong version, missing/wrong-typed fields, malformed CBOR, or trailing bytes; `ChainKeysError(UNSUPPORTED_ALGORITHM)` for unknown algorithm strings.
### Error codes
- `CHAIN_KEYS.INVALID_FORMAT` — bad version byte, malformed CBOR, missing/wrong-typed map fields, unknown hasher, trailing bytes.
- `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` — algorithm string outside `SIGN_ALGORITHMS`.
## Implementation Notes
The codec delegates raw bytes to `@suaveplan/cbor`'s `encodeDeterministic` so the canonical-encoding requirement is upheld by a vetted implementation rather than re-derived locally. The deterministic encoder sorts map keys by canonical encoded bytes, emits shortest-form integers, forbids indefinite-length items, and rejects `undefined` — exactly the constraints a chain signature needs.
Trailing-byte detection uses `decodeOne(payload)` and compares its returned `consumed` against `payload.length`. `decode` from `@suaveplan/cbor` would also flag trailing bytes via `CBOR.MALFORMED` in strict mode, but `decodeOne` gives a clean integer comparison that maps directly to `INVALID_FORMAT` with a precise message ("consumed X of Y") for diagnostics.
Decoded byte fields are returned via `Uint8Array.prototype.slice()` so the chain is independent of whatever buffer the caller hands in. This keeps the codec safe for callers that reuse input buffers across decode calls.
State management is intentionally minimal: `hasherName` is the only mutable field and it is set only after the entire payload validates, so a failed decode never leaves a stale hasher tag on the instance.

View File

@@ -0,0 +1,83 @@
# RecursiveEd25519Codec
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RecursiveEd25519Codec` is a fixed-Ed25519 alternate wire format for signed key chains in `@suaveplan/crypto-keys`. It trades the algorithm-flexibility of `TlvChainCodec` for a denser, predictable byte layout suitable for embedded systems, replicated logs, and any context where chains are known to be purely Ed25519. The codec walks bytes linearly leaf-first using the fixed 32-byte public key and 64-byte signature lengths defined by Ed25519, with a single u16 length prefix for each variable-length payload.
## Features
- Fixed 32-byte Ed25519 public keys and 64-byte Ed25519 signatures — no per-level algorithm byte.
- Compact 2-byte header carrying `version=1` plus the active `hasherName` byte (`raw`/`domain`/`merkle`).
- u16 big-endian payload length (up to 65,535 bytes per level).
- Leaf-first emission; the parent chain is appended back-to-back with no inter-level separator.
- Strict validation: bad version byte, unknown hasher byte, truncated buffers, and out-of-bound payload lengths all raise `CHAIN_KEYS.INVALID_FORMAT`.
- Non-Ed25519 algorithms raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` with the `algorithm` discriminant populated, mirroring the `signWithSlot` failure surface.
- After `decode(...)`, `hasherName` is set from the wire byte so a downstream `ChainedKeySigner` can reconfigure its hasher without an out-of-band channel.
## Usage: Basic
```ts
import { RecursiveEd25519Codec } from "@suaveplan/crypto-keys/codecs/recursive-ed25519/recursive-ed25519";
const codec = new RecursiveEd25519Codec();
const level = {
algorithm: "ed25519" as const,
signerPublicKey: new Uint8Array(32),
payload: new Uint8Array([1, 2, 3, 4]),
signature: new Uint8Array(64),
};
const bytes = codec.encode([level]);
const decoded = codec.decode(bytes);
// decoded[0].algorithm === "ed25519"
```
## Usage: Advanced
Round-tripping a multi-level chain and surfacing hasher state:
```ts
import { RecursiveEd25519Codec } from "@suaveplan/crypto-keys/codecs/recursive-ed25519/recursive-ed25519";
import { ChainKeysError } from "@suaveplan/crypto-keys/error-codes/error-codes";
const writer = new RecursiveEd25519Codec();
writer.hasherName = "merkle";
const wire = writer.encode([leafLevel, midLevel, rootLevel]);
const reader = new RecursiveEd25519Codec();
try {
const levels = reader.decode(wire);
// reader.hasherName is now "merkle" — propagate to the ChainedKeySigner
// before verifying signatures.
handleChain(levels, reader.hasherName);
} catch (err) {
if (err instanceof ChainKeysError && err.code === "CHAIN_KEYS.UNSUPPORTED_ALGORITHM") {
// Routing decision: switch to TlvChainCodec or CborChainCodec.
fallback(err.algorithm);
} else {
throw err;
}
}
```
## API Reference
### `class RecursiveEd25519Codec implements ChainCodec`
- `readonly name: "recursive-ed25519"` — codec identifier surfaced in diagnostics.
- `hasherName: "raw" | "domain" | "merkle"` — hasher byte written on encode and overwritten on decode. Default `"raw"`.
- `encode(levels: readonly ChainLevel[]): Uint8Array` — emits `[version=1, hasher, (pub32, payload_len_u16_be, payload, sig64)+]` leaf-first. Throws `ChainKeysError` with code `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` for any non-Ed25519 level (with `algorithm` discriminant set), or `CHAIN_KEYS.INVALID_FORMAT` for wrong public-key/signature sizes or oversized payloads.
- `decode(bytes: Uint8Array): readonly ChainLevel[]` — walks the buffer from offset 2 until end-of-buffer. Every decoded level carries `algorithm: "ed25519"` unconditionally. Throws `CHAIN_KEYS.INVALID_FORMAT` on a wrong version byte, unknown hasher byte, truncated read, or an in-stream `payload_len` exceeding the remaining bytes.
## Implementation Notes
- The encoder pre-computes total length and writes into a single `Uint8Array`, avoiding per-level allocations and intermediate concatenations.
- `payload_len` uses u16 big-endian via `DataView.setUint16(offset, len, false)` — keeping a network-byte-order convention consistent with TLV.
- `decode(...)` calls `bytes.slice(...)` for each field so the returned `Uint8Array`s do not alias the input buffer; consumers may safely mutate or zero the wire bytes after decoding.
- The hasher byte is validated as `< HASHER_FROM_BYTE.length` rather than a hard-coded `<= 2` so adding a fourth hasher only requires extending the lookup table.
- Encoding a non-Ed25519 level raises `UNSUPPORTED_ALGORITHM` even when the supplied key/signature happen to be 32/64 bytes — the algorithm check fires first to keep the error surface predictable.
- Decoded levels are returned `readonly` at the type level but the underlying `Uint8Array`s are fresh copies; callers can freeze them at a higher layer if mutation must be statically forbidden.

View File

@@ -0,0 +1,118 @@
# `TlvChainCodec`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TlvChainCodec` is the default wire-format codec for
`@suaveplan/crypto-keys`. It encodes and decodes the `ChainLevel[]` that a
`ChainedKeySigner` emits, using a self-describing length-prefixed binary
format. Because every level carries its own algorithm byte, public key,
payload, and signature lengths, the codec round-trips chains that mix
Ed25519, secp256k1, p256, p384, and p521 signers without external schema
information.
The codec also carries a single byte identifying which `ChainHasher`
produced the per-level payloads (`raw`, `domain`, or `merkle`). The codec
itself never invokes a hasher — the byte is informational and lets a
verifier construct a matching hasher implementation.
## Features
- Self-describing wire format: version + hasher byte + per-level TLV
records (algorithm, signerPublicKey, payload, signature).
- Five supported sign algorithms, dispatched via the algorithm byte:
`0=ed25519`, `1=secp256k1`, `2=p256`, `3=p384`, `4=p521`.
- u8 length fields for keys/signatures (max 140 bytes — covers every
curve), u16 big-endian length for payloads (max 65535).
- Strict input validation: unknown version, unknown hasher byte,
truncated buffers, and overrunning length fields all raise
`ChainKeysError` with the appropriate `CHAIN_KEYS.*` code.
- Decoded `ChainLevel` objects own their byte buffers — mutating the
source buffer never affects decoded levels.
- Stateless across calls except for the public `hasherName` property,
which round-trips the hasher byte for signer / verifier configuration.
## Usage: Basic
```ts
import { TlvChainCodec } from "@suaveplan/crypto-keys/codecs/tlv/tlv";
const codec = new TlvChainCodec();
const wire = codec.encode(levels); // levels: ChainLevel[]
const decoded = codec.decode(wire);
```
## Usage: Advanced
The `hasherName` property tells `encode` which hasher byte to write and is
overwritten by `decode` with the byte it just read. A typical
signer/verifier pair:
```ts
import { TlvChainCodec } from "@suaveplan/crypto-keys/codecs/tlv/tlv";
import type { ChainLevel } from "@suaveplan/crypto-keys/types/chain-level";
// Signer side — declare which hasher produced the payloads.
const signerCodec = new TlvChainCodec();
signerCodec.hasherName = "merkle";
const wire = signerCodec.encode(levels);
// Verifier side — read the byte back and construct a matching hasher.
const verifierCodec = new TlvChainCodec();
const decoded: readonly ChainLevel[] = verifierCodec.decode(wire);
const hasherName = verifierCodec.hasherName; // "merkle"
// hasherName ∈ {"raw", "domain", "merkle"} — caller picks a hasher impl.
```
## API Reference
### `class TlvChainCodec implements ChainCodec`
- `readonly name: "tlv"` — surfaced in diagnostics.
- `hasherName: "raw" | "domain" | "merkle"` — default `"raw"`. Set
before `encode` to record which hasher produced the payloads; set by
`decode` to whatever byte the input declared.
- `encode(levels: readonly ChainLevel[]): Uint8Array` — produces the
wire bytes. Throws `ChainKeysError(INVALID_FORMAT)` if any field
exceeds its on-wire length budget;
`ChainKeysError(UNSUPPORTED_ALGORITHM)` for unknown `algorithm`
strings.
- `decode(bytes: Uint8Array): readonly ChainLevel[]` — parses the wire
bytes. Throws `ChainKeysError(INVALID_FORMAT)` for any structural
failure (bad version, bad hasher byte, truncation, length overrun);
`ChainKeysError(UNSUPPORTED_ALGORITHM)` for unknown algorithm bytes.
### Wire format
```text
[version: u8 = 1]
[hasher: u8 — 0=raw, 1=domain, 2=merkle]
per level (leaf first, root last):
[algorithm: u8]
[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]
```
## Implementation Notes
- Big-endian u16 writes/reads use `DataView` against the underlying
`ArrayBuffer` (respecting `byteOffset`); single-byte fields use direct
index access.
- Decoded `signerPublicKey`, `payload`, and `signature` are produced via
`Uint8Array.prototype.slice`, ensuring each output is a fresh,
owned buffer. Callers may safely retain decoded fields after the input
buffer is mutated or freed.
- `encode` performs a single pre-pass to compute the total output length
(and validate every field's length budget) before allocating the
output buffer, so a partially-written buffer is never returned to the
caller.
- The 140-byte upper bound for `pub_len` / `sig_len` covers every shipped
curve's longest representation (p521: 133-byte uncompressed pubkey,
132-byte raw signature) with margin for envelope encoding.
- The codec is `ChainHasher`-agnostic: the `hasher` byte is opaque
metadata that the codec faithfully round-trips. The signer / verifier
are responsible for choosing and constructing the hasher.

View File

@@ -0,0 +1,145 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `@suaveplan/crypto-keys` package centralises every failure mode it
can produce behind a single registry built with
`defineCodes("CHAIN_KEYS", [...])` from `@suaveplan/error`. The
registry is the stable contract callers depend on: code strings are
documented, frozen at module load, and never renamed without an
explicit migration. This module exports the `CHAIN_KEYS_CODES`
registry, the derived `ChainKeysErrorCode` type, and the
`ChainKeysError` class that every public failure path raises.
`ChainKeysError` extends `SuaveplanError<ChainKeysErrorCode>` so
`instanceof ChainKeysError` narrows `code` to the exact union of
`CHAIN_KEYS.*` strings. It adds optional `depth?: number` and
`algorithm?: SignAlgorithmId | number` discriminants that diagnose
which chain level (or which algorithm) triggered the error, while
leaving the base `context` map free for caller-supplied structured
data. The class threads `cause` straight through `SuaveplanError`,
so primitive-layer exceptions from `@suaveplan/crypto-curve` remain
inspectable through `error.cause`.
## Features
- Frozen `CHAIN_KEYS_CODES` registry with seven namespaced codes
- `ChainKeysErrorCode` literal-union type derived from the registry
- `ChainKeysError` class extending `SuaveplanError<ChainKeysErrorCode>`
- Optional `depth` and `algorithm` diagnostic discriminants
- `algorithm` accepts both `SignAlgorithmId` (string) and `number` to surface a numeric `AlgorithmId` from a non-Ed25519 keychain slot directly
- Full `cause`-chain preservation via `SuaveplanError`
## Usage: Basic
```ts
import { CHAIN_KEYS_CODES, ChainKeysError } from "@suaveplan/crypto-keys";
if (slot.algorithm !== ED25519) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
`signWithSlot supports only Ed25519 in v0.0.0 (got algorithm ${slot.algorithm})`,
{ algorithm: slot.algorithm }
);
}
```
## Usage: Advanced
```ts
import { CHAIN_KEYS_CODES, ChainKeysError } from "@suaveplan/crypto-keys";
import { SuaveplanError } from "@suaveplan/error";
try {
await crypto.curve.ed25519.verify(digest, signature, publicKey);
} catch (cause) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_SIGNATURE,
`signature verify failed at depth ${depth}`,
{
cause: cause instanceof Error ? cause : undefined,
depth,
algorithm: "ed25519",
context: { signatureLength: signature.length },
}
);
}
// Downstream:
catch (err) {
if (err instanceof ChainKeysError && err.code === CHAIN_KEYS_CODES.INVALID_SIGNATURE) {
metrics.increment("chain.verify.failure", {
depth: err.depth,
algorithm: err.algorithm,
});
}
}
```
## API Reference
### `CHAIN_KEYS_CODES`
A frozen object literal of seven namespaced codes:
| Property | Value |
| ------------------------- | ---------------------------------- |
| `UNSUPPORTED_ALGORITHM` | `"CHAIN_KEYS.UNSUPPORTED_ALGORITHM"` |
| `INVALID_FORMAT` | `"CHAIN_KEYS.INVALID_FORMAT"` |
| `INVALID_SIGNATURE` | `"CHAIN_KEYS.INVALID_SIGNATURE"` |
| `TRUST_MISMATCH` | `"CHAIN_KEYS.TRUST_MISMATCH"` |
| `INVALID_DEPTH` | `"CHAIN_KEYS.INVALID_DEPTH"` |
| `INVALID_KEYCHAIN` | `"CHAIN_KEYS.INVALID_KEYCHAIN"` |
| `INVALID_DOMAIN_TAG` | `"CHAIN_KEYS.INVALID_DOMAIN_TAG"` |
### `type ChainKeysErrorCode`
The literal union of every value in `CHAIN_KEYS_CODES`.
### `class ChainKeysError extends SuaveplanError<ChainKeysErrorCode>`
| Member | Type |
| ------------------ | ----------------------------------------------------------------- |
| `code` | `ChainKeysErrorCode` (narrowed) |
| `depth?` | `number` |
| `algorithm?` | `SignAlgorithmId \| number` |
| `cause?` | `unknown` (inherited) |
| `timestamp` | `number` (inherited; `Date.now()` at construction) |
| `constructor(...)` | `(code, message, options?: ChainKeysErrorOptions)` |
`ChainKeysErrorOptions` extends `SuaveplanErrorOptions` (`cause`,
`context`, `clock`, `traceId`) with the additional optional `depth`
and `algorithm` fields.
## Implementation Notes
`defineCodes` returns an `Object.freeze`d record with `null` prototype,
so the registry cannot be mutated, reassigned, or extended at runtime.
The literal type machinery preserves the exact `${Namespace}.${Reason}`
string template across every consumer, so `switch (err.code)` enjoys
exhaustive narrowing without runtime cost.
The `algorithm` discriminant is a union of `SignAlgorithmId` (this
package's string union) and `number` (the `AlgorithmId`
numeric enum). The numeric form lets `signWithSlot` surface the
actual non-Ed25519 algorithm id from a keychain slot without lossy
coercion to a string. Most call sites populate the string form
because they know which algorithm they tried.
`ChainKeysError`'s constructor passes `message` straight through to
`SuaveplanError`'s `messageKey` parameter. The base class resolves
the key via the i18n resolver if one has been registered with
`setMessageResolver`, falling back to the raw string otherwise — so
this package works without i18n wiring and lights up automatically
when `@suaveplan/i18n` is present.
Optional `depth` / `algorithm` fields are stored on the error
instance directly (not on `context`) so they survive structured-cloning
and serialisation via the `toJSON` helper from `@suaveplan/error`.
Consumers that programmatically dispatch on a failing depth or
algorithm read `err.depth` / `err.algorithm` without parsing
`err.context`.

View File

@@ -0,0 +1,147 @@
# factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Hierarchical-keychain factory helpers that compose `generateKeychain`
from `@suaveplan/crypto` with `ChainedKeySigner.signWithSlot` to
produce signed chains in one call. Three functions cover the common
pattern: bootstrap a root keychain, derive a server keychain bound to
the root, and roll a server keychain forward to the next version
without changing the root.
These functions are Ed25519-only in v0.0.0 because the crypto
keychain v1 grammar only generates Ed25519 sign-slots. Callers that
need a different curve at any chain level should reach for
`ChainedKeySigner.signRaw` directly instead.
## Features
- `createRootKeysWithChain` — fresh keychain + self-signed chain
- `createServerKeysWithChain` — derived keychain + chain bound to root
- `rollServerKeysWithChain` — re-derive at next slot version, re-sign
- All three accept an injected `signer?` for custom hasher/codec choices
- Strict `INVALID_KEYCHAIN` errors when root slots are missing or empty
- Sensible `LayoutV1Fields` defaults (`masterVersion: 1, userId: 0, depth: 0, slotVersion: 1`)
- Default `Clock` is `Date.now` — caller can override via `generate.clock`
## Usage: Basic
```ts
import {
ChainedKeySigner,
createRootKeysWithChain,
createServerKeysWithChain,
} from "@suaveplan/crypto-keys";
// Bootstrap a root keychain + self-signed chain
const { keys: rootKeys, chain: rootChain } = await createRootKeysWithChain();
// Derive a server keychain whose chain anchors to the root
const { keys: serverKeys, chain: serverChain } = await createServerKeysWithChain({
rootKeys,
rootChain,
});
// Verify end-to-end
const verifier = new ChainedKeySigner();
const result = await verifier.verify(serverChain, {
algorithm: "ed25519",
publicKey: (rootKeys as Record<string, { publicKey: Uint8Array }>).sign.publicKey,
});
console.log(result.valid, result.depth); // true 2
```
## Usage: Advanced
```ts
import {
ChainedKeySigner,
DomainHasher,
createRootKeysWithChain,
createServerKeysWithChain,
rollServerKeysWithChain,
} from "@suaveplan/crypto-keys";
const signer = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "tenant.acme.v1" }),
});
// Tagged root
const { keys: rootKeys, chain: rootChain } = await createRootKeysWithChain({
signer,
generate: { userId: 42 as never }, // custom layout fields per app
});
// First server keychain (slot version 1)
const v1 = await createServerKeysWithChain({ rootKeys, rootChain, signer });
// Six months later: roll to slot version 2, same root binding
const v2 = await rollServerKeysWithChain({
rootKeys,
rootChain,
currentVersion: 1,
signer,
});
// Issuance chain: root -> v1 -> v2 -> leaf user
const leaf = await createServerKeysWithChain({
rootKeys,
rootChain: v2.chain,
signer,
});
const verifier = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "tenant.acme.v1" }),
});
const result = await verifier.verify(leaf.chain, {
algorithm: "ed25519",
publicKey: (rootKeys as Record<string, { publicKey: Uint8Array }>).sign.publicKey,
});
```
## API Reference
### `async function createRootKeysWithChain(options?)`
| Field | Type | Default |
| ----------------- | ------------------------------------- | ----------------------- |
| `signer?` | `ChainedKeySigner` | `new ChainedKeySigner()` |
| `generate?` | `Partial<GenerateKeychainOptions>` | sensible defaults |
Returns `{ keys: Keychain<LayoutV1Fields>; chain: Uint8Array }`.
### `async function createServerKeysWithChain(options)`
| Field | Type | Required |
| ------------- | ------------------------------------- | -------- |
| `rootKeys` | `Keychain<LayoutV1Fields>` | yes |
| `rootChain` | `Uint8Array` | yes |
| `signer?` | `ChainedKeySigner` | no |
| `generate?` | `Partial<GenerateKeychainOptions>` | no |
Raises `CHAIN_KEYS.INVALID_KEYCHAIN` if `rootKeys.master` or `rootKeys.sign` is missing, has an empty `secretKey`, or has a non-Ed25519 `algorithm`.
### `async function rollServerKeysWithChain(options)`
| Field | Type | Required |
| ----------------- | -------------------------- | -------- |
| `rootKeys` | `Keychain<LayoutV1Fields>` | yes |
| `rootChain` | `Uint8Array` | yes |
| `currentVersion` | `number` (≥ 0, integer) | yes |
| `signer?` | `ChainedKeySigner` | no |
Generates a new keychain with `slotVersion: currentVersion + 1` and re-signs against the same root. Raises `CHAIN_KEYS.INVALID_KEYCHAIN` when `currentVersion` is negative or non-integer, or when the root slots are missing/invalid.
## Implementation Notes
crypto's `Keychain` is `Record<PurposeTag, KeySlotPrivate>` where `PurposeTag` is a branded string type. The factory module uses a small `getSlot(keys, tag)` helper that casts through `Record<string, KeySlotPrivate>` to access individual slots; this keeps the TypeScript narrowing clean without pulling in the `PurposeTag` brand machinery.
`LayoutV1Fields` is re-declared locally because it's not currently re-exported from `@suaveplan/crypto/keychain`. The shape is identical to the upstream definition and `generateKeychain` accepts any object satisfying it structurally. If a future crypto release adds a `./derivation` subpath export, this declaration can be replaced with the canonical import.
The factory functions trust `generateKeychain`'s contract — that every generated keychain contains all 8 default-purpose slots with valid Ed25519 sign-slots — and do not re-validate the freshly-generated output. Validation is concentrated at the boundaries: `signWithSlot` rejects non-Ed25519 slots, and `assertRootSlots` checks every caller-supplied root before use. This keeps the happy path lean and pushes the cost of bad input onto the caller's stack frame.
`signWithSlot` takes an optional `parent: Uint8Array`; the factory functions pass `rootChain` when extending and omit it for the self-signed root. `ChainedKeySigner` distinguishes a root signature (no parent) from a non-root signature (parent supplied) by the presence of the argument — there's no separate "root" API.

View File

@@ -0,0 +1,88 @@
# DomainHasher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DomainHasher` is the `ChainHasher` strategy that domain-separates the
signed pre-image with a caller-supplied tag and then SHA-256s the
result. For every level it returns `sha256(utf8(domainTag) || payload)`.
Domain separation is the reason this strategy exists: by mixing a stable
ASCII tag into the pre-image, signatures minted under one protocol
identifier (e.g. `"suaveplan/chain/v1"`) cannot be replayed against a
verifier expecting a different identifier. This is the recommended
default for new chains where the payload is application data rather
than an already-canonical digest.
## Features
- 32-byte SHA-256 digest output per call, identical across all
supported runtimes (Bun, Node, browser).
- Strict construction-time validation of the tag: 1–64 printable-ASCII
characters (`0x20`–`0x7e`). Anything else raises
`ChainKeysError` with code `CHAIN_KEYS.INVALID_DOMAIN_TAG`.
- ASCII-only constraint guarantees `tag.length === utf8(tag).byteLength`
so the encoded length is unambiguous across platforms.
- `parent` argument is accepted (interface compatibility) but ignored:
`DomainHasher` commits to the tag, not to any ancestor.
- Stateless after construction; safe to share a single instance.
## Usage: Basic
```ts
import { DomainHasher } from "@suaveplan/crypto-keys/hashers/domain/domain";
const hasher = new DomainHasher({ domainTag: "suaveplan/chain/v1" });
const digest = hasher.hash(new TextEncoder().encode("hello"));
// digest.length === 32
```
## Usage: Advanced
Bind two related protocols to different domains so a signature in one
cannot be replayed in the other, even when the payload happens to be
byte-identical:
```ts
import { DomainHasher } from "@suaveplan/crypto-keys/hashers/domain/domain";
const chainHasher = new DomainHasher({ domainTag: "suaveplan/chain/v1" });
const manifestHasher = new DomainHasher({
domainTag: "suaveplan/manifest/v1",
});
const payload = new TextEncoder().encode("identical bytes");
const chainDigest = chainHasher.hash(payload);
const manifestDigest = manifestHasher.hash(payload);
// chainDigest !== manifestDigest — cross-protocol replay defeated
```
## API Reference
### `interface DomainHasherOptions`
- `domainTag: string` — 1–64 printable-ASCII characters. Invalid tags
raise `ChainKeysError` with code `CHAIN_KEYS.INVALID_DOMAIN_TAG`.
### `class DomainHasher implements ChainHasher`
- `readonly name: "domain"`
- `hash(payload: Uint8Array, parent?: ParsedChainLevel): Uint8Array` —
returns `sha256(utf8(domainTag) || payload)`, 32 bytes. The `parent`
argument is accepted for interface compatibility and is ignored.
## Implementation Notes
The tag is encoded to UTF-8 once at construction time and cached as
`tagBytes`. Each `hash` call allocates a fresh `Uint8Array` exactly
sized to `tagBytes.length + payload.length`, copies the tag at offset
zero and the payload immediately after, then feeds the buffer to
`sha256` from `@suaveplan/crypto-hash`. The ASCII-only constraint
matters: without it, a tag containing multi-byte UTF-8 codepoints would
have a runtime-dependent byte length, breaking interoperability. By
rejecting non-ASCII at construction we guarantee a stable, predictable
pre-image format that every conforming implementation produces
identically.

View File

@@ -0,0 +1,89 @@
# MerkleHasher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MerkleHasher` is the `ChainHasher` strategy that binds each non-root
level to its parent's content via a Merkle-style construction. For the
root it returns `sha256(payload)`; for every non-root level it returns
`sha256(payload || sha256(parent.payload || parent.signature))`.
The parent commitment is the whole point: by mixing
`sha256(parent.payload || parent.signature)` into every descendant
pre-image, tampering with any ancestor invalidates every signature
beneath it. This is the strategy required for keychain chains that must
detect ancestor substitution (e.g., an attacker swapping in a rolled
server key whose signature was minted under a different root).
## Features
- Self-detecting root: when `parent` is `undefined` the implementation
short-circuits to `sha256(payload)`. No depth bookkeeping required at
the call site.
- 32-byte SHA-256 output at every level.
- Deterministic and stateless — safe to share one instance across
arbitrary parallel chain operations.
- Canonical pre-image ordering: parent.payload always precedes
parent.signature, so the digest is portable across implementations.
## Usage: Basic
```ts
import { MerkleHasher } from "@suaveplan/crypto-keys/hashers/merkle/merkle";
const hasher = new MerkleHasher();
const rootDigest = hasher.hash(new TextEncoder().encode("payload-root"));
// rootDigest.length === 32
```
## Usage: Advanced
Building a non-root level requires a previously-decoded
`ParsedChainLevel` for the parent. In production the verifier hands you
the parent it just decoded; in tests or simulation you can construct one
directly:
```ts
import { MerkleHasher } from "@suaveplan/crypto-keys/hashers/merkle/merkle";
import type { ParsedChainLevel } from "@suaveplan/crypto-keys/types/chain-level";
const hasher = new MerkleHasher();
const parent: ParsedChainLevel = {
algorithm: "ed25519",
signerPublicKey: new Uint8Array(32),
payload: new TextEncoder().encode("p-payload"),
signature: new Uint8Array([1, 2, 3, 4]),
};
const child = hasher.hash(
new TextEncoder().encode("child-payload"),
parent
);
// child commits to both parent.payload AND parent.signature
```
## API Reference
### `class MerkleHasher implements ChainHasher`
- `readonly name: "merkle"`
- `hash(payload: Uint8Array, parent?: ParsedChainLevel): Uint8Array`
- When `parent === undefined`: returns `sha256(payload)`.
- Otherwise: returns
`sha256(payload || sha256(parent.payload || parent.signature))`.
- Output is always 32 bytes.
## Implementation Notes
The construction is `H(x || H(p_payload || p_sig))` rather than
`H(x || p_payload || p_sig)` so the pre-image at every level is a
fixed-width `payload.length + 32` bytes. This makes the digest cheap to
verify with streaming primitives that only have to hash one fresh copy
of the variable-length payload alongside a constant 32-byte parent
root. Concatenation order is fixed by spec: parent.payload always
precedes parent.signature so swapping the two fields yields a
different digest. The test suite pins both the root digest and a
non-root digest as known-vector hex strings so any change to byte
layout — including endianness or order — fails the build immediately.

View File

@@ -0,0 +1,77 @@
# RawHasher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RawHasher` is the trivial `ChainHasher` strategy: it returns the payload
bytes verbatim, performing no transformation. It exists so that consumers
whose payload is already a canonical digest (a manifest hash, a CBOR
fingerprint, a CID) can plug a chain signer in without paying for a
redundant SHA-256 pass.
Because `ChainedKeySigner` always invokes `hash(payload, parent)` before
delegating to `crypto-curve.sign(...)`, choosing `RawHasher` means the
signer signs exactly the payload it was handed — useful for tests, for
audit chains over pre-digested manifests, and for cases where the
application wants full control over the pre-image format.
## Features
- Zero allocation: returns the input `Uint8Array` reference unchanged.
- Stateless: safe to share a single instance across many signers and
threads. No instance fields, no mutable state.
- Ignores the optional `parent` argument by design. Chains needing
parent-binding should pick `MerkleHasher` (or a custom hasher).
- Stable `name: "raw"` for diagnostics and audit logs.
## Usage: Basic
```ts
import { RawHasher } from "@suaveplan/crypto-keys/hashers/raw/raw";
const hasher = new RawHasher();
const payload = new Uint8Array([1, 2, 3]);
const bytesToSign = hasher.hash(payload);
// bytesToSign === payload (same reference)
```
## Usage: Advanced
Swap `RawHasher` in to verify that an externally computed manifest
digest round-trips through the signer unchanged — useful for cross-stack
interoperability with consumers that have already computed their own
canonical digest:
```ts
import { RawHasher } from "@suaveplan/crypto-keys/hashers/raw/raw";
import type { ParsedChainLevel } from "@suaveplan/crypto-keys/types/chain-level";
const hasher = new RawHasher();
const manifestDigest = new Uint8Array(32); // already-canonical 32-byte digest
const parent: ParsedChainLevel | undefined = undefined;
const signedPreImage = hasher.hash(manifestDigest, parent);
// signedPreImage is bit-identical to manifestDigest
```
## API Reference
### `class RawHasher implements ChainHasher`
- `readonly name: "raw"` — canonical short name surfaced in diagnostics.
- `hash(payload: Uint8Array, parent?: ParsedChainLevel): Uint8Array`
— returns `payload` unchanged. The `parent` argument is accepted to
satisfy the `ChainHasher` interface and is deliberately ignored.
## Implementation Notes
The implementation returns the input reference directly rather than
copying. Callers that need ownership of the returned bytes must clone
explicitly. Reference-return is deliberate: it makes `RawHasher` a true
no-op and ensures the cost of pre-image hashing is exactly zero — which
matters for high-throughput audit logs where the payload is already a
fixed-width digest. The test suite asserts both reference equality and
content equality so any future refactor that switches to a copy would be
caught immediately.

View File

@@ -0,0 +1,188 @@
# signer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ChainedKeySigner` composes a `ChainHasher` (bytes-to-sign strategy)
and a `ChainCodec` (wire-format strategy) to produce and verify signed
chains of arbitrary payloads. It dispatches the actual cryptographic
work to `@suaveplan/crypto-curve` primitives per the level's algorithm:
Ed25519, secp256k1, P-256, P-384, or P-521. Two ergonomic signing
surfaces are exposed: `signWithSlot` is a convenience layer over the
`@suaveplan/crypto` keychain (Ed25519-only in v0.0.0,
extending automatically when the v2 keychain adds the other curves),
and `signRaw` is the fully general primitive that takes raw key
material plus an algorithm tag.
The signer is the package's main entry point. Most consumers
construct a `ChainedKeySigner()` with default options
(`RawHasher` + `TlvChainCodec`), then call `signWithSlot` or
`signRaw` to build chains and `verify` to check them.
## Features
- Two signing surfaces: `signWithSlot` (Ed25519-only, slot-based) and `signRaw` (5-algorithm, raw-key)
- `verify` returns `{ valid, depth, levels, errors }` — never throws on bad input
- `parse` decode-only when verification isn't needed
- `stripToDepth` produces a partial-proof chain bounded by depth
- Per-algorithm digest dispatch: Ed25519 raw, secp256k1/P-256 with sha256, P-384 with sha384, P-521 with sha512
- Public-key validation: if a `publicKey` is supplied to `signRaw`, the signer derives it from `privateKey` and checks they match
- Constant-time root-key compare via `@suaveplan/crypto-utils::constantTimeEqual`
- All crypto-curve exceptions wrapped as `ChainKeysError` with `algorithm` discriminant + preserved `cause`
## Usage: Basic
```ts
import {
ChainedKeySigner,
type ChainLevel,
} from "@suaveplan/crypto-keys";
import * as curve from "@suaveplan/crypto-curve";
const signer = new ChainedKeySigner();
const privateKey = curve.ed25519.randomPrivateKey();
const publicKey = curve.ed25519.getPublicKey(privateKey);
const chain = await signer.signRaw({
payload: new TextEncoder().encode("hello"),
algorithm: "ed25519",
privateKey,
});
const result = await signer.verify(chain, {
algorithm: "ed25519",
publicKey,
});
console.log(result.valid); // true
```
## Usage: Advanced
```ts
import {
ChainedKeySigner,
DomainHasher,
CborChainCodec,
} from "@suaveplan/crypto-keys";
import * as curve from "@suaveplan/crypto-curve";
const signer = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "acme/chain/v1" }),
codec: new CborChainCodec(),
});
const root = {
privateKey: curve.secp256k1.randomPrivateKey(),
publicKey: curve.secp256k1.getPublicKey(curve.secp256k1.randomPrivateKey()),
};
const intermediate = {
privateKey: curve.p256.randomPrivateKey(),
publicKey: curve.p256.getPublicKey(curve.p256.randomPrivateKey()),
};
// Root self-signs (secp256k1)
const rootChain = await signer.signRaw({
payload: root.publicKey,
algorithm: "secp256k1",
privateKey: root.privateKey,
});
// Intermediate (P-256) signed by root
const interChain = await signer.signRaw({
payload: intermediate.publicKey,
algorithm: "p256",
privateKey: root.privateKey,
parent: rootChain,
});
// Strip to depth 1 for distribution
const leafOnly = await signer.stripToDepth(interChain, 1);
// Recipient verifies (must reproduce same hasher + codec)
const verifier = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "acme/chain/v1" }),
codec: new CborChainCodec(),
});
const result = await verifier.verify(interChain, {
algorithm: "secp256k1",
publicKey: root.publicKey,
});
if (!result.valid) {
throw new Error(`chain invalid: ${result.errors.join("; ")}`);
}
```
## API Reference
### Class `ChainedKeySigner`
| Method | Signature |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `constructor` | `(options?: { hasher?: ChainHasher; codec?: ChainCodec })` |
| `signWithSlot` | `(payload: Uint8Array, signSlot: { algorithm: number; secretKey: Uint8Array; publicKey: Uint8Array }, parent?: Uint8Array) => Promise<Uint8Array>` |
| `signRaw` | `(options: SignRawOptions) => Promise<Uint8Array>` |
| `verify` | `(chainBytes: Uint8Array, trustedRoot: { algorithm: SignAlgorithmId; publicKey: Uint8Array }) => Promise<VerificationResult>` |
| `parse` | `(chainBytes: Uint8Array) => Promise<{ depth: number; levels: readonly ChainLevel[] }>` |
| `stripToDepth` | `(chainBytes: Uint8Array, depth: number) => Promise<Uint8Array>` |
### `SignRawOptions`
| Field | Type | Required | Meaning |
| ------------ | ------------------- | -------- | ---------------------------------------------------------------- |
| `payload` | `Uint8Array` | yes | Bytes to sign — opaque to the package |
| `algorithm` | `SignAlgorithmId` | yes | Which signing curve |
| `privateKey` | `Uint8Array` | yes | Private key bytes for the chosen algorithm |
| `publicKey` | `Uint8Array` | no | If supplied, validated against derived; if omitted, derived |
| `parent` | `Uint8Array` | no | Existing parent chain bytes to extend |
### Error contract
- `signWithSlot` throws `ChainKeysError(UNSUPPORTED_ALGORITHM)` for non-Ed25519 slots in v0.0.0
- `signWithSlot` / `signRaw` throw `ChainKeysError(INVALID_KEYCHAIN)` for missing/empty private key or pub/priv mismatch
- `signRaw` throws `ChainKeysError(UNSUPPORTED_ALGORITHM)` for unknown algorithm strings
- Any `crypto-curve` exception is wrapped as `ChainKeysError(INVALID_KEYCHAIN)` with `cause` preserved
- `verify` never throws — returns `{ valid: false, errors }` on every failure mode
- `parse` throws `ChainKeysError(INVALID_FORMAT)` if codec decoding fails
- `stripToDepth` throws `ChainKeysError(INVALID_DEPTH)` for `depth < 1` or `depth > chain.length`
## Implementation Notes
The signer keeps a private reference to the configured `ChainHasher`
and `ChainCodec`. On construction, it sets `codec.hasherName` to the
hasher's `name` so the wire format records which mode is in use; on
`verify`, the signer reads `codec.hasherName` after decode to detect
hasher mismatches between sender and receiver. Mismatch produces
`{ valid: false, errors: ["hasher mismatch: ..."] }` rather than
silently verifying with the wrong hash.
Per-curve digest pre-hashing is intentional. Ed25519 hashes its input
internally (RFC 8032 PureEdDSA), so the signer passes the hasher
output straight through. The P-curves and secp256k1 take a
fixed-size digest; the package pairs them with the standard hash
(`sha256` for secp256k1/P-256, `sha384` for P-384, `sha512` for
P-521) per NIST SP 800-186.
`signRaw`'s `publicKey` field is optional but recommended. When
supplied, the signer derives the expected public key from
`privateKey` and rejects with `INVALID_KEYCHAIN` if they differ.
This catches caller bugs (mismatched key material) at sign time
rather than at downstream verify time. When omitted, the signer
derives the public key itself — one extra `getPublicKey` call per
sign, which is fast on every supported curve.
`verify` walks levels leaf-to-root. For each level it recomputes
`hasher.hash(payload, parentLevel)` — passing the codec-parsed parent
level if any — then digests via the curve's standard hash and calls
`crypto-curve.verify(...)`. The deepest level's
`(algorithm, signerPublicKey)` is compared against `trustedRoot`
with algorithm equality plus constant-time byte equality on the
public key.
`stripToDepth(chain, n)` returns the leaf-most `n` levels re-encoded
via the configured codec. Useful for distributing partial proofs —
e.g. a leaf-only chain whose verifier trusts the immediate parent's
public key directly rather than walking back to the root.

View File

@@ -0,0 +1,132 @@
# types/chain-level
## Purpose
Shared type module declaring the value-shapes that every other module in
`@suaveplan/crypto-keys` references. Defining `SignAlgorithmId`,
`ChainLevel`, `ParsedChainLevel`, and `VerificationResult` here keeps
the public surface coherent and lets consumers import the types they
need from a single path. The types are intentionally framework-free —
no Zod schemas, no runtime validators — because the package's
developer-facing API is constructed in-process and validated via
TypeScript's structural type system plus per-feature runtime checks
that raise stable `CHAIN_KEYS.*` errors.
## Features
- `SignAlgorithmId` — string-literal union for the 5 signing algorithms
- `SIGN_ALGORITHMS` — `Set<SignAlgorithmId>` for `O(1)` runtime membership tests
- `ChainLevel` — `{ algorithm, signerPublicKey, payload, signature }` shape produced by codecs
- `ParsedChainLevel` — alias for `ChainLevel`, semantic name when handing a level to a hasher
- `VerificationResult` — `{ valid, depth, levels, errors }` returned by `ChainedKeySigner.verify`
## Usage: Basic
```ts
import type { ChainLevel, SignAlgorithmId } from "@suaveplan/crypto-keys";
const level: ChainLevel = {
algorithm: "ed25519",
signerPublicKey: new Uint8Array(32),
payload: new Uint8Array([1, 2, 3]),
signature: new Uint8Array(64),
};
```
## Usage: Advanced
```ts
import type {
ChainLevel,
ParsedChainLevel,
SignAlgorithmId,
VerificationResult,
} from "@suaveplan/crypto-keys";
import { SIGN_ALGORITHMS } from "@suaveplan/crypto-keys";
function isSignAlgorithm(value: string): value is SignAlgorithmId {
return SIGN_ALGORITHMS.has(value as SignAlgorithmId);
}
function reportRoot(result: VerificationResult): void {
if (!result.valid) {
console.error("chain invalid", result.errors);
return;
}
const root = result.levels[result.levels.length - 1];
console.log(`root algorithm: ${root.algorithm}`);
}
// `ParsedChainLevel` is the right type when a hasher needs to look at the
// parent that came out of the codec — same shape as `ChainLevel`, separate
// name signals "parsed by codec" intent.
function hashWithParent(payload: Uint8Array, parent: ParsedChainLevel): Uint8Array {
return sha256(concat(payload, parent.payload, parent.signature));
}
```
## API Reference
### `type SignAlgorithmId`
```ts
type SignAlgorithmId = "ed25519" | "secp256k1" | "p256" | "p384" | "p521";
```
### `const SIGN_ALGORITHMS: Set<SignAlgorithmId>`
Set of every value in `SignAlgorithmId`. Use `SIGN_ALGORITHMS.has(value)` for runtime checks.
### `interface ChainLevel`
| Field | Type | Meaning |
| ----------------- | ------------------- | ------------------------------------------------------------- |
| `algorithm` | `SignAlgorithmId` | Which signing curve produced `signature` |
| `signerPublicKey` | `Uint8Array` | Public key that signed this level (recorded on-wire per level) |
| `payload` | `Uint8Array` | Opaque bytes — typically `encodePublicKeychain(...)` output |
| `signature` | `Uint8Array` | Signature over `hasher.hash(payload, parent?)` |
### `type ParsedChainLevel`
Type alias for `ChainLevel`. Use this name when the level value originates from a codec decode (e.g., when passing it to `ChainHasher.hash(...)` as the `parent` argument).
### `interface VerificationResult`
| Field | Type | Meaning |
| -------- | --------------------- | ---------------------------------------------------------- |
| `valid` | `boolean` | `true` iff every signature verifies and root matches trust |
| `depth` | `number` | Number of levels in the chain (≥ 1 for any valid chain) |
| `levels` | `readonly ChainLevel[]` | Decoded levels, leaf-first; empty if decode failed |
| `errors` | `readonly string[]` | Human-readable diagnostics; empty when `valid` is `true` |
## Implementation Notes
`SignAlgorithmId` is a string-literal union rather than the numeric
`AlgorithmId` from `@suaveplan/crypto` because:
- The crypto-curve primitives are organised by string name (one folder
per algorithm), so the string maps directly to the import target.
- `@suaveplan/crypto`'s numeric `AlgorithmId` covers AEADs and KDFs as
well; this package signs with exactly 5 of those, so a smaller union
documents the surface accurately.
- String unions exhaust-check well in `switch` statements without
pulling in the upstream registry.
The TLV codec maps `SignAlgorithmId` ↔ `u8` enum bytes (0–4) at the
wire boundary; consumers never see the numeric form.
`ChainLevel.payload` is intentionally untyped beyond `Uint8Array`. The
package treats it as opaque — verification re-hashes the bytes and
checks the signature; it never parses the payload. Keychain chains
will typically pass `encodePublicKeychain(...)` output here, but
attestation or manifest chains can pass any byte sequence.
`signerPublicKey` is on every level because the wire format is
self-contained: the verifier reads each level's signer pub-key
directly. Without that field, the verifier would need to peer into
the next-deeper level's `payload` and parse a keychain out of it —
fine for keychain chains, broken for chains carrying anything else.
`VerificationResult` never throws on bad input — `valid: false` plus
`errors` is the failure shape. The only synchronous throws come from
construction-time argument validation (e.g., an empty `domainTag`).

View File

@@ -0,0 +1,128 @@
# types/codec
## Purpose
`ChainCodec` is the wire-format strategy that turns an array of
`ChainLevel` values into bytes and back. Decoupling the wire format
from the hash strategy lets the same signed-chain payload travel under
three different encodings (TLV, recursive-Ed25519, CBOR) without the
signer or verifier knowing or caring. Consumers can write their own
codec — for example one that wraps the chain in a JOSE-style JWS or
embeds it inside an X.509 certificate extension — and plug it into the
signer without touching the package's source.
The interface is intentionally tiny: `encode`, `decode`, and a `name`
constant. Codecs are pure data-transform: stateless, idempotent,
and thread-safe. A single instance can be shared across many signers
and many verify calls.
## Features
- Two methods, `encode(levels) → bytes` and `decode(bytes) → levels`
- `name` constant for diagnostics and wire-format byte mapping
- Round-trip contract enforced by per-codec property tests
- Stateless and re-usable — no need to construct one per call
## Usage: Basic
```ts
import { TlvChainCodec, type ChainLevel } from "@suaveplan/crypto-keys";
const codec = new TlvChainCodec();
const levels: ChainLevel[] = [
{
algorithm: "ed25519",
signerPublicKey: new Uint8Array(32),
payload: new Uint8Array([1, 2, 3]),
signature: new Uint8Array(64),
},
];
const bytes = codec.encode(levels);
const restored = codec.decode(bytes);
console.log(restored.length === 1); // true
```
## Usage: Advanced
```ts
import type { ChainCodec, ChainLevel } from "@suaveplan/crypto-keys";
import { ChainedKeySigner, CborChainCodec } from "@suaveplan/crypto-keys";
class TaggedCodec implements ChainCodec {
readonly name = "cbor" as const;
private readonly inner = new CborChainCodec();
encode(levels: readonly ChainLevel[]): Uint8Array {
const body = this.inner.encode(levels);
const tagged = new Uint8Array(body.length + 4);
tagged.set([0x53, 0x47, 0x4e, 0x31]); // "SGN1"
tagged.set(body, 4);
return tagged;
}
decode(bytes: Uint8Array): readonly ChainLevel[] {
return this.inner.decode(bytes.subarray(4));
}
}
const signer = new ChainedKeySigner({ codec: new TaggedCodec() });
```
## API Reference
### `interface ChainCodec`
| Member | Type |
| -------- | ---------------------------------------------------------- |
| `encode` | `(levels: readonly ChainLevel[]) => Uint8Array` |
| `decode` | `(bytes: Uint8Array) => readonly ChainLevel[]` |
| `name` | `"tlv" \| "recursive-ed25519" \| "cbor"` |
`encode(levels)` MUST emit levels in leaf-first order — index 0 is the
leaf, last index is the root. `decode(bytes)` MUST return levels in the
same order. Codecs MAY reject inputs they cannot represent (e.g.
`RecursiveEd25519Codec` rejects any algorithm other than `"ed25519"`),
raising `ChainKeysError` synchronously.
### Round-trip contract
Every shipped codec satisfies:
```ts
deepEqual(codec.decode(codec.encode(levels)), levels)
```
for any well-formed `ChainLevel[]` input. The package's test suite
exercises this property with `fast-check` over generators that produce
chains of varying depth, mixed algorithms (where supported), and
randomised payloads.
### Error contract
Codecs MUST raise `ChainKeysError` (never raw `Error`) on:
- Unknown version byte → `CHAIN_KEYS.INVALID_FORMAT`
- Unknown hasher byte → `CHAIN_KEYS.INVALID_FORMAT`
- Length overrun (e.g. claimed payload extends past buffer) → `CHAIN_KEYS.INVALID_FORMAT`
- Truncation (e.g. EOF mid-level) → `CHAIN_KEYS.INVALID_FORMAT`
- Unsupported algorithm byte for this codec → `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`
## Implementation Notes
Codecs do not know how the payload was produced — they treat
`ChainLevel.payload` as opaque bytes. Keychain-shaped chains pass
`encodePublicKeychain(...)` output; attestation chains might pass
a manifest digest; the codec's only job is to round-trip the bytes.
The interface stays sync. Codecs that genuinely need async work
(e.g., remote serialisation services) should expose a separate
async interface and be wired into a future async signer; this
version keeps the simple case fast and lock-free.
`name` is a string-literal union — the TLV codec writes the hasher
name's byte equivalent, but reads the codec's `name` only for
diagnostic output. The wire format does NOT record which codec
produced the bytes (because the parser is already chosen by the
verifier); it only records which hasher was used (so the verifier
reproduces the right hash).

View File

@@ -0,0 +1,96 @@
# types/hasher
## Purpose
`ChainHasher` is the strategy interface that decides what bytes a
`ChainedKeySigner` actually signs for one chain level. Decoupling the
hash strategy from the codec lets the same wire format carry chains
under different cryptographic commitments: raw bytes, domain-tagged
hashes, or Merkle-style parent-committed hashes. Consumers can also
plug in their own implementation for application-specific commitments
(e.g., incorporating a per-tenant secret) without changing the codec
or the signer class.
The interface is intentionally tiny — a single `hash(payload, parent?)`
method plus a `name` constant for diagnostics — so the contract is
trivial to reason about and trivial to test exhaustively.
## Features
- One method, `hash(payload, parent?)`, returning the bytes the signer signs
- Optional `parent` argument passes the previously-signed parent level — only Merkle uses it
- `name` constant for human-readable diagnostics in error messages
- Three shipped implementations under `src/hashers/`
## Usage: Basic
```ts
import type { ChainHasher } from "@suaveplan/crypto-keys";
import { RawHasher } from "@suaveplan/crypto-keys";
const hasher: ChainHasher = new RawHasher();
const bytes = hasher.hash(new Uint8Array([1, 2, 3]));
console.log(bytes); // Uint8Array(3) [ 1, 2, 3 ]
```
## Usage: Advanced
```ts
import type { ChainHasher, ParsedChainLevel } from "@suaveplan/crypto-keys";
import { sha256 } from "@suaveplan/crypto-hash";
class TenantHasher implements ChainHasher {
readonly name = "domain" as const; // reuse domain-mode wire byte
constructor(private readonly tenantSecret: Uint8Array) {}
hash(payload: Uint8Array, _parent?: ParsedChainLevel): Uint8Array {
const buf = new Uint8Array(this.tenantSecret.length + payload.length);
buf.set(this.tenantSecret);
buf.set(payload, this.tenantSecret.length);
return sha256(buf);
}
}
// Pass it to a signer:
const signer = new ChainedKeySigner({
hasher: new TenantHasher(secrets.fetch("acme.signing.salt")),
});
```
## API Reference
### `interface ChainHasher`
| Member | Type |
| ------- | --------------------------------------------------------------- |
| `hash` | `(payload: Uint8Array, parent?: ParsedChainLevel) => Uint8Array` |
| `name` | `"raw" \| "domain" \| "merkle"` |
The `name` field must be one of the three canonical values because
the TLV codec writes it as a single byte (0/1/2). Consumers implementing
their own hasher reuse the closest canonical name; the wire-format byte
records the mode but the verifier reproduces the actual hasher object
by constructing the matching `ChainedKeySigner({ hasher: ... })`.
## Implementation Notes
The optional `parent` argument is only meaningful for `MerkleHasher` —
`RawHasher` and `DomainHasher` ignore it. That asymmetry is documented
on each implementation rather than enforced at the interface level
because forcing every consumer to handle `parent` (e.g. branding the
type as `RawHasher.hash: (payload) => Uint8Array`) would prevent the
signer from holding a `ChainHasher` reference uniformly.
Hash output length is **not** constrained by the interface — `RawHasher`
returns the payload unchanged (variable length), while the sha256-based
hashers always return 32 bytes. The signer then feeds the result into
the matching `crypto-curve` sign primitive (each primitive accepts a
variable-length message because the curves all hash internally to fit
their field size).
The interface stays sync-only (no `Promise<Uint8Array>`) because all
three shipped implementations are fast pure functions. If a future
hasher needs async work (e.g., HSM-backed hashing), it should expose a
separate `AsyncChainHasher` interface and the signer can be extended
to accept either — this version intentionally keeps the simple case
simple.