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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user