Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

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,327 @@
import { encodeDeterministic } from "@suaveplan/cbor";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ChainLevel } from "../../types/chain-level.js";
import { CborChainCodec } from "./cbor.js";
function makeLevel(
algorithm: ChainLevel["algorithm"],
seed: number,
pubLen = 32,
sigLen = 64,
payloadLen = 16
): ChainLevel {
const fill = (len: number, base: number): Uint8Array => {
const out = new Uint8Array(len);
for (let i = 0; i < len; i++) {
out[i] = (base + i) & 0xff;
}
return out;
};
return {
algorithm,
signerPublicKey: fill(pubLen, seed),
payload: fill(payloadLen, seed + 1),
signature: fill(sigLen, seed + 2),
};
}
function deepEqualLevels(
a: readonly ChainLevel[],
b: readonly ChainLevel[]
): boolean {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
const x = a[i] as ChainLevel;
const y = b[i] as ChainLevel;
if (x.algorithm !== y.algorithm) return false;
if (!bytesEqual(x.signerPublicKey, y.signerPublicKey)) return false;
if (!bytesEqual(x.payload, y.payload)) return false;
if (!bytesEqual(x.signature, y.signature)) return false;
}
return true;
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
describe("CborChainCodec", () => {
it("round-trip single-level Ed25519 chain", { timeout: 5000 }, () => {
const codec = new CborChainCodec();
const chain = [makeLevel("ed25519", 7)];
const decoded = codec.decode(codec.encode(chain));
expect(deepEqualLevels(decoded, chain)).toBe(true);
});
it("round-trip 3-level chain with mixed algorithms", {
timeout: 5000,
}, () => {
const codec = new CborChainCodec();
const chain = [
makeLevel("ed25519", 1),
makeLevel("secp256k1", 11, 33, 64, 24),
makeLevel("p384", 21, 97, 96, 32),
];
const decoded = codec.decode(codec.encode(chain));
expect(deepEqualLevels(decoded, chain)).toBe(true);
});
it("encoder is deterministic across calls", { timeout: 5000 }, () => {
const codec = new CborChainCodec();
const chain = [
makeLevel("ed25519", 5),
makeLevel("p256", 9, 65, 64, 12),
];
const a = codec.encode(chain);
const b = codec.encode(chain);
expect(bytesEqual(a, b)).toBe(true);
});
it("writes hasherName field correctly (raw/domain/merkle)", {
timeout: 5000,
}, () => {
for (const hasher of ["raw", "domain", "merkle"] as const) {
const codec = new CborChainCodec();
codec.hasherName = hasher;
const bytes = codec.encode([makeLevel("ed25519", 2)]);
// Round-trip via fresh codec to read the field back.
const verifier = new CborChainCodec();
verifier.decode(bytes);
expect(verifier.hasherName).toBe(hasher);
}
});
it("sets hasherName after decode", { timeout: 5000 }, () => {
const encoder = new CborChainCodec();
encoder.hasherName = "merkle";
const bytes = encoder.encode([makeLevel("ed25519", 3)]);
const decoder = new CborChainCodec();
expect(decoder.hasherName).toBe("raw");
decoder.decode(bytes);
expect(decoder.hasherName).toBe("merkle");
});
it("rejects bad version byte with INVALID_FORMAT", {
timeout: 5000,
}, () => {
const codec = new CborChainCodec();
const bytes = codec.encode([makeLevel("ed25519", 1)]);
const tampered = new Uint8Array(bytes);
tampered[0] = 2;
expect(() => codec.decode(tampered)).toThrow(
/unsupported CBOR codec version/
);
});
it("rejects CBOR with v !== 1 (INVALID_FORMAT)", { timeout: 5000 }, () => {
const payload = encodeDeterministic({
v: 2,
h: "raw",
levels: [],
});
const wire = new Uint8Array(1 + payload.length);
wire[0] = 1;
wire.set(payload, 1);
const codec = new CborChainCodec();
expect(() => codec.decode(wire)).toThrow(/"v" must equal 1/);
});
it("rejects CBOR with unknown hasher name (INVALID_FORMAT)", {
timeout: 5000,
}, () => {
const payload = encodeDeterministic({
v: 1,
h: "xxhash",
levels: [],
});
const wire = new Uint8Array(1 + payload.length);
wire[0] = 1;
wire.set(payload, 1);
const codec = new CborChainCodec();
expect(() => codec.decode(wire)).toThrow(
/"h" must be one of raw\|domain\|merkle/
);
});
it("rejects CBOR with unknown algorithm (UNSUPPORTED_ALGORITHM)", {
timeout: 5000,
}, () => {
const payload = encodeDeterministic({
v: 1,
h: "raw",
levels: [
{
a: "rsa",
p: new Uint8Array([1]),
k: new Uint8Array([2]),
s: new Uint8Array([3]),
},
],
});
const wire = new Uint8Array(1 + payload.length);
wire[0] = 1;
wire.set(payload, 1);
const codec = new CborChainCodec();
expect(() => codec.decode(wire)).toThrow(/unknown algorithm "rsa"/);
});
it("encode rejects level with unknown algorithm", { timeout: 5000 }, () => {
const codec = new CborChainCodec();
const bad = {
algorithm: "rsa",
signerPublicKey: new Uint8Array(1),
payload: new Uint8Array(1),
signature: new Uint8Array(1),
} as unknown as ChainLevel;
expect(() => codec.encode([bad])).toThrow(/unknown algorithm "rsa"/);
});
it("rejects CBOR missing required fields (INVALID_FORMAT)", {
timeout: 5000,
}, () => {
// Missing levels array.
const payload = encodeDeterministic({ v: 1, h: "raw" });
const wire = new Uint8Array(1 + payload.length);
wire[0] = 1;
wire.set(payload, 1);
const codec = new CborChainCodec();
expect(() => codec.decode(wire)).toThrow(/"levels" must be an array/);
// Missing "a" field within level.
const payload2 = encodeDeterministic({
v: 1,
h: "raw",
levels: [
{
p: new Uint8Array([1]),
k: new Uint8Array([2]),
s: new Uint8Array([3]),
},
],
});
const wire2 = new Uint8Array(1 + payload2.length);
wire2[0] = 1;
wire2.set(payload2, 1);
expect(() => codec.decode(wire2)).toThrow(/missing or non-string "a"/);
});
it("rejects CBOR with non-byte-string p/k/s field (INVALID_FORMAT)", {
timeout: 5000,
}, () => {
const payload = encodeDeterministic({
v: 1,
h: "raw",
levels: [
{
a: "ed25519",
p: "not-bytes",
k: new Uint8Array([2]),
s: new Uint8Array([3]),
},
],
});
const wire = new Uint8Array(1 + payload.length);
wire[0] = 1;
wire.set(payload, 1);
const codec = new CborChainCodec();
expect(() => codec.decode(wire)).toThrow(
/field "p" must be a CBOR byte string/
);
});
it("rejects level that is not a CBOR map (INVALID_FORMAT)", {
timeout: 5000,
}, () => {
const payload = encodeDeterministic({
v: 1,
h: "raw",
levels: [42],
});
const wire = new Uint8Array(1 + payload.length);
wire[0] = 1;
wire.set(payload, 1);
const codec = new CborChainCodec();
expect(() => codec.decode(wire)).toThrow(/level 0 is not a CBOR map/);
});
it("rejects CBOR payload that is not a map (INVALID_FORMAT)", {
timeout: 5000,
}, () => {
const payload = encodeDeterministic([1, 2, 3]);
const wire = new Uint8Array(1 + payload.length);
wire[0] = 1;
wire.set(payload, 1);
const codec = new CborChainCodec();
expect(() => codec.decode(wire)).toThrow(/CBOR payload is not a map/);
});
it("rejects trailing bytes after CBOR payload (INVALID_FORMAT)", {
timeout: 5000,
}, () => {
const codec = new CborChainCodec();
const bytes = codec.encode([makeLevel("ed25519", 4)]);
const padded = new Uint8Array(bytes.length + 2);
padded.set(bytes, 0);
padded[bytes.length] = 0xff;
padded[bytes.length + 1] = 0xff;
expect(() => codec.decode(padded)).toThrow(
/trailing bytes after CBOR payload/
);
});
it("rejects malformed CBOR bytes (INVALID_FORMAT)", {
timeout: 5000,
}, () => {
// Version byte ok, then a CBOR head that announces 100 bytes of
// byte string but provides none.
const wire = new Uint8Array([0x01, 0x58, 0x64]);
const codec = new CborChainCodec();
expect(() => codec.decode(wire)).toThrow(/malformed CBOR payload/);
});
it("empty buffer raises INVALID_FORMAT", { timeout: 5000 }, () => {
const codec = new CborChainCodec();
expect(() => codec.decode(new Uint8Array(0))).toThrow(/empty buffer/);
});
it("buffer too short for header (INVALID_FORMAT)", {
timeout: 5000,
}, () => {
const codec = new CborChainCodec();
expect(() => codec.decode(new Uint8Array([0x01]))).toThrow(
/buffer too short for CBOR payload/
);
});
it("decoded levels are independent of input buffer", {
timeout: 5000,
}, () => {
const codec = new CborChainCodec();
const chain = [makeLevel("ed25519", 13)];
const bytes = codec.encode(chain);
const decoded = codec.decode(bytes);
const originalFirstByte = (decoded[0] as ChainLevel).payload[0];
// Mutate input buffer wholesale.
for (let i = 0; i < bytes.length; i++) {
bytes[i] = 0;
}
expect((decoded[0] as ChainLevel).payload[0]).toBe(originalFirstByte);
});
it("exposes name = 'cbor'", { timeout: 5000 }, () => {
const codec = new CborChainCodec();
expect(codec.name).toBe("cbor");
});
it("round-trips empty levels array", { timeout: 5000 }, () => {
const codec = new CborChainCodec();
const decoded = codec.decode(codec.encode([]));
expect(decoded.length).toBe(0);
});
});

View File

@@ -0,0 +1,207 @@
import { decodeOne, encodeDeterministic } from "@suaveplan/cbor";
import {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "../../error-codes/error-codes.js";
import type { ChainLevel, SignAlgorithmId } from "../../types/chain-level.js";
import { SIGN_ALGORITHMS } from "../../types/chain-level.js";
import type { ChainCodec } from "../../types/codec.js";
/** Hasher name carried in the CBOR `h` field. */
export type HasherName = "raw" | "domain" | "merkle";
const VERSION = 1;
const VALID_HASHERS: ReadonlySet<HasherName> = new Set<HasherName>([
"raw",
"domain",
"merkle",
]);
/**
* CBOR-encoded chain codec. Wire layout:
*
* ```
* [version: u8 = 1]
* [cbor payload — deterministic CBOR map]
* ```
*
* The CBOR payload is a map of the form:
*
* ```
* { "v": 1, "h": "raw"|"domain"|"merkle",
* "levels": [{ "a": <alg>, "p": <pub>, "k": <payload>, "s": <sig> }, ...] }
* ```
*
* Encoding uses `encodeDeterministic` from `@suaveplan/cbor` so byte-identical
* inputs produce byte-identical wire output, which is the contract callers
* rely on for content-addressable / signature-stable chains.
*/
export class CborChainCodec implements ChainCodec {
public readonly name = "cbor" as const;
public hasherName: HasherName = "raw";
public encode(levels: readonly ChainLevel[]): Uint8Array {
const encodedLevels: Array<Record<string, unknown>> = [];
for (const level of levels) {
if (!SIGN_ALGORITHMS.has(level.algorithm)) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
`unknown algorithm "${String(level.algorithm)}"`,
{ algorithm: level.algorithm }
);
}
encodedLevels.push({
a: level.algorithm,
p: level.signerPublicKey,
k: level.payload,
s: level.signature,
});
}
const payload = encodeDeterministic({
v: VERSION,
h: this.hasherName,
levels: encodedLevels,
});
const out = new Uint8Array(1 + payload.length);
out[0] = VERSION;
out.set(payload, 1);
return out;
}
public decode(bytes: Uint8Array): readonly ChainLevel[] {
if (bytes.length < 1) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
"empty buffer: missing version byte"
);
}
const version = bytes[0] as number;
if (version !== VERSION) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`unsupported CBOR codec version: expected ${VERSION}, got ${version}`
);
}
if (bytes.length < 2) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
"buffer too short for CBOR payload after version byte"
);
}
const payload = bytes.subarray(1);
let value: unknown;
let consumed: number;
try {
const result = decodeOne(payload);
value = result.value;
consumed = result.consumed;
} catch (cause) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
"malformed CBOR payload",
{ cause }
);
}
if (consumed !== payload.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`trailing bytes after CBOR payload: consumed ${consumed} of ${payload.length}`
);
}
const parsed = parsePayload(value);
this.hasherName = parsed.hasher;
return parsed.levels;
}
}
interface ParsedPayload {
readonly hasher: HasherName;
readonly levels: readonly ChainLevel[];
}
function parsePayload(value: unknown): ParsedPayload {
if (!(value instanceof Map)) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
"CBOR payload is not a map"
);
}
const v = value.get("v");
if (v !== VERSION) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`CBOR field "v" must equal ${VERSION}, got ${String(v)}`
);
}
const h = value.get("h");
if (typeof h !== "string" || !VALID_HASHERS.has(h as HasherName)) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`CBOR field "h" must be one of raw|domain|merkle, got ${String(h)}`
);
}
const rawLevels = value.get("levels");
if (!Array.isArray(rawLevels)) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
'CBOR field "levels" must be an array'
);
}
const out: ChainLevel[] = [];
for (let depth = 0; depth < rawLevels.length; depth++) {
out.push(parseLevel(rawLevels[depth], depth));
}
return { hasher: h as HasherName, levels: out };
}
function parseLevel(raw: unknown, depth: number): ChainLevel {
if (!(raw instanceof Map)) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`level ${depth} is not a CBOR map`,
{ depth }
);
}
const a = raw.get("a");
if (typeof a !== "string") {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`level ${depth}: missing or non-string "a" field`,
{ depth }
);
}
if (!SIGN_ALGORITHMS.has(a as SignAlgorithmId)) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
`level ${depth}: unknown algorithm "${a}"`,
{ depth, algorithm: a as SignAlgorithmId }
);
}
const p = takeBytes(raw.get("p"), "p", depth);
const k = takeBytes(raw.get("k"), "k", depth);
const s = takeBytes(raw.get("s"), "s", depth);
return {
algorithm: a as SignAlgorithmId,
signerPublicKey: p,
payload: k,
signature: s,
};
}
function takeBytes(field: unknown, name: string, depth: number): Uint8Array {
if (!(field instanceof Uint8Array)) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`level ${depth}: field "${name}" must be a CBOR byte string`,
{ depth }
);
}
// Copy so the decoded level is independent of the input buffer.
return field.slice();
}

View File

@@ -0,0 +1 @@
export { CborChainCodec } from "./cbor.js";

View File

@@ -0,0 +1 @@
export { RecursiveEd25519Codec } from "./recursive-ed25519.js";

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,304 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ChainKeysError } from "../../error-codes/error-codes.js";
import type { ChainLevel, SignAlgorithmId } from "../../types/chain-level.js";
import { RecursiveEd25519Codec } from "./recursive-ed25519.js";
const TIMEOUT = { timeout: 5000 };
function makeLevel(
algorithm: SignAlgorithmId = "ed25519",
payload: Uint8Array = new Uint8Array([1, 2, 3, 4]),
pubLen = 32,
sigLen = 64
): ChainLevel {
return {
algorithm,
signerPublicKey: new Uint8Array(pubLen).fill(0xaa),
payload,
signature: new Uint8Array(sigLen).fill(0x55),
};
}
describe("RecursiveEd25519Codec", () => {
it("round-trip single-level Ed25519 chain", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const levels = [makeLevel()];
const decoded = codec.decode(codec.encode(levels));
expect(decoded).toHaveLength(1);
expect(decoded[0]!.algorithm).toBe("ed25519");
expect(Array.from(decoded[0]!.signerPublicKey)).toEqual(
Array.from(levels[0]!.signerPublicKey)
);
expect(Array.from(decoded[0]!.payload)).toEqual(
Array.from(levels[0]!.payload)
);
expect(Array.from(decoded[0]!.signature)).toEqual(
Array.from(levels[0]!.signature)
);
});
it("round-trip 3-level recursive Ed25519 chain", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const leaf = makeLevel("ed25519", new Uint8Array([1, 1]));
const mid = makeLevel("ed25519", new Uint8Array([2, 2, 2]));
const root = makeLevel("ed25519", new Uint8Array([3]));
const levels = [leaf, mid, root];
const decoded = codec.decode(codec.encode(levels));
expect(decoded).toHaveLength(3);
for (let i = 0; i < 3; i++) {
expect(Array.from(decoded[i]!.payload)).toEqual(
Array.from(levels[i]!.payload)
);
}
});
it("writes hasherName byte correctly", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
codec.hasherName = "merkle";
const bytes = codec.encode([makeLevel()]);
expect(bytes[0]).toBe(1);
expect(bytes[1]).toBe(2);
codec.hasherName = "domain";
const bytes2 = codec.encode([makeLevel()]);
expect(bytes2[1]).toBe(1);
codec.hasherName = "raw";
const bytes3 = codec.encode([makeLevel()]);
expect(bytes3[1]).toBe(0);
});
it("sets hasherName after decode", TIMEOUT, () => {
const writer = new RecursiveEd25519Codec();
writer.hasherName = "merkle";
const bytes = writer.encode([makeLevel()]);
const reader = new RecursiveEd25519Codec();
expect(reader.hasherName).toBe("raw");
reader.decode(bytes);
expect(reader.hasherName).toBe("merkle");
});
it("rejects bad version byte with INVALID_FORMAT", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const bytes = codec.encode([makeLevel()]);
bytes[0] = 99;
try {
codec.decode(bytes);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ChainKeysError);
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it("rejects bad hasher byte with INVALID_FORMAT", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const bytes = codec.encode([makeLevel()]);
bytes[1] = 9;
try {
codec.decode(bytes);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ChainKeysError);
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it(
"rejects non-Ed25519 with CHAIN_KEYS.UNSUPPORTED_ALGORITHM",
TIMEOUT,
() => {
const codec = new RecursiveEd25519Codec();
try {
codec.encode([makeLevel("secp256k1")]);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ChainKeysError);
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.UNSUPPORTED_ALGORITHM"
);
expect((err as ChainKeysError).algorithm).toBe("secp256k1");
}
}
);
it(
"rejects encoding with secp256k1 algorithm (algorithm discriminant set)",
TIMEOUT,
() => {
const codec = new RecursiveEd25519Codec();
try {
codec.encode([makeLevel("secp256k1")]);
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).algorithm).toBe("secp256k1");
}
}
);
it("rejects encoding with p256 algorithm", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
try {
codec.encode([makeLevel("p256")]);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ChainKeysError);
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.UNSUPPORTED_ALGORITHM"
);
expect((err as ChainKeysError).algorithm).toBe("p256");
}
});
it("rejects encoding when signerPublicKey is not 32 bytes", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const bad = makeLevel("ed25519", new Uint8Array([1]), 31);
try {
codec.encode([bad]);
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it("rejects encoding when signature is not 64 bytes", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const bad = makeLevel("ed25519", new Uint8Array([1]), 32, 63);
try {
codec.encode([bad]);
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it("rejects encoding when payload exceeds u16 max", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const huge = makeLevel("ed25519", new Uint8Array(65536));
try {
codec.encode([huge]);
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it("rejects truncated buffer (mid-pubkey)", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const full = codec.encode([makeLevel()]);
const truncated = full.slice(0, 2 + 10);
try {
codec.decode(truncated);
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it("rejects truncated buffer (mid-payload)", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const full = codec.encode([makeLevel("ed25519", new Uint8Array(10))]);
const truncated = full.slice(0, 2 + 32 + 2 + 5);
try {
codec.decode(truncated);
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it("rejects truncated buffer (mid-signature)", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const full = codec.encode([makeLevel("ed25519", new Uint8Array(4))]);
const truncated = full.slice(0, 2 + 32 + 2 + 4 + 10);
try {
codec.decode(truncated);
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it("rejects truncated buffer reading payload length", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const full = codec.encode([makeLevel()]);
const truncated = full.slice(0, 2 + 32 + 1);
try {
codec.decode(truncated);
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it(
"rejects payload_len > remaining bytes (INVALID_FORMAT)",
TIMEOUT,
() => {
const codec = new RecursiveEd25519Codec();
const bytes = codec.encode([
makeLevel("ed25519", new Uint8Array(4)),
]);
const view = new DataView(bytes.buffer);
view.setUint16(2 + 32, 0xfffe, false);
try {
codec.decode(bytes);
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
}
);
it("empty buffer raises INVALID_FORMAT", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
try {
codec.decode(new Uint8Array(0));
expect.fail("should have thrown");
} catch (err) {
expect((err as ChainKeysError).code).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
}
});
it("decoded levels are independent of input buffer", TIMEOUT, () => {
const codec = new RecursiveEd25519Codec();
const bytes = codec.encode([makeLevel()]);
const decoded = codec.decode(bytes);
bytes.fill(0);
expect(decoded[0]!.signerPublicKey[0]).toBe(0xaa);
expect(decoded[0]!.signature[0]).toBe(0x55);
});
it(
"decoded levels carry algorithm 'ed25519' unconditionally",
TIMEOUT,
() => {
const codec = new RecursiveEd25519Codec();
const levels = [makeLevel(), makeLevel(), makeLevel()];
const decoded = codec.decode(codec.encode(levels));
for (const lvl of decoded) {
expect(lvl.algorithm).toBe("ed25519");
}
}
);
});

View File

@@ -0,0 +1,161 @@
import {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "../../error-codes/error-codes.js";
import type { ChainLevel, SignAlgorithmId } from "../../types/chain-level.js";
import type { ChainCodec } from "../../types/codec.js";
const VERSION = 1;
const ED25519_PUBKEY_LEN = 32;
const ED25519_SIG_LEN = 64;
const MAX_PAYLOAD_LEN = 0xffff;
type HasherName = "raw" | "domain" | "merkle";
const HASHER_TO_BYTE: Record<HasherName, number> = {
raw: 0,
domain: 1,
merkle: 2,
};
const HASHER_FROM_BYTE: readonly HasherName[] = ["raw", "domain", "merkle"];
/**
* Recursive-Ed25519 wire codec — fixed-Ed25519 alternate format. Layout:
* `[version=1][hasher][per-level: pub(32) | payload_len(u16 BE) | payload | sig(64)]+`
* walked linearly from offset 2 leaf-first. Encoding any non-Ed25519
* level raises `CHAIN_KEYS.UNSUPPORTED_ALGORITHM`; decoded levels carry
* `algorithm === "ed25519"` unconditionally.
*/
export class RecursiveEd25519Codec implements ChainCodec {
public readonly name = "recursive-ed25519" as const;
public hasherName: HasherName = "raw";
public encode(levels: readonly ChainLevel[]): Uint8Array {
let total = 2;
for (const level of levels) {
this.assertEncodable(level);
total +=
ED25519_PUBKEY_LEN + 2 + level.payload.length + ED25519_SIG_LEN;
}
const out = new Uint8Array(total);
const view = new DataView(out.buffer);
out[0] = VERSION;
out[1] = HASHER_TO_BYTE[this.hasherName];
let offset = 2;
for (const level of levels) {
out.set(level.signerPublicKey, offset);
offset += ED25519_PUBKEY_LEN;
view.setUint16(offset, level.payload.length, false);
offset += 2;
out.set(level.payload, offset);
offset += level.payload.length;
out.set(level.signature, offset);
offset += ED25519_SIG_LEN;
}
return out;
}
public decode(bytes: Uint8Array): readonly ChainLevel[] {
if (bytes.length < 2) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
"Recursive-Ed25519 buffer too short to contain header."
);
}
const version = bytes[0]!;
if (version !== VERSION) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`Unsupported recursive-ed25519 version byte: ${version}.`
);
}
const hasherByte = bytes[1]!;
if (hasherByte >= HASHER_FROM_BYTE.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`Unknown hasher byte in recursive-ed25519 header: ${hasherByte}.`
);
}
this.hasherName = HASHER_FROM_BYTE[hasherByte]!;
const view = new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength
);
const levels: ChainLevel[] = [];
let offset = 2;
while (offset < bytes.length) {
if (offset + ED25519_PUBKEY_LEN > bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
"Truncated recursive-ed25519 buffer reading signer public key."
);
}
const signerPublicKey = bytes.slice(
offset,
offset + ED25519_PUBKEY_LEN
);
offset += ED25519_PUBKEY_LEN;
if (offset + 2 > bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
"Truncated recursive-ed25519 buffer reading payload length."
);
}
const payloadLen = view.getUint16(offset, false);
offset += 2;
if (offset + payloadLen > bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
"Recursive-ed25519 payload length exceeds remaining buffer."
);
}
const payload = bytes.slice(offset, offset + payloadLen);
offset += payloadLen;
if (offset + ED25519_SIG_LEN > bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
"Truncated recursive-ed25519 buffer reading signature."
);
}
const signature = bytes.slice(offset, offset + ED25519_SIG_LEN);
offset += ED25519_SIG_LEN;
levels.push({
algorithm: "ed25519",
signerPublicKey,
payload,
signature,
});
}
return levels;
}
private assertEncodable(level: ChainLevel): void {
if (level.algorithm !== "ed25519") {
throw new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
`RecursiveEd25519Codec only supports Ed25519; received ${level.algorithm}.`,
{ algorithm: level.algorithm as SignAlgorithmId }
);
}
if (level.signerPublicKey.length !== ED25519_PUBKEY_LEN) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`Ed25519 signer public key must be ${ED25519_PUBKEY_LEN} bytes; received ${level.signerPublicKey.length}.`
);
}
if (level.signature.length !== ED25519_SIG_LEN) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`Ed25519 signature must be ${ED25519_SIG_LEN} bytes; received ${level.signature.length}.`
);
}
if (level.payload.length > MAX_PAYLOAD_LEN) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`Recursive-ed25519 payload exceeds u16 max (${MAX_PAYLOAD_LEN}): ${level.payload.length}.`
);
}
}
}

View File

@@ -0,0 +1 @@
export { TlvChainCodec } from "./tlv.js";

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,475 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "../../error-codes/error-codes.js";
import type { ChainLevel, SignAlgorithmId } from "../../types/chain-level.js";
import { TlvChainCodec } from "./tlv.js";
const makeLevel = (
algorithm: SignAlgorithmId,
pub: number[],
payload: number[],
sig: number[]
): ChainLevel => ({
algorithm,
signerPublicKey: new Uint8Array(pub),
payload: new Uint8Array(payload),
signature: new Uint8Array(sig),
});
const repeat = (byte: number, length: number): number[] =>
Array.from({ length }, () => byte);
describe("TlvChainCodec", () => {
it("encodes/decodes Ed25519 single-level chain", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
const level = makeLevel(
"ed25519",
repeat(0xaa, 32),
[1, 2, 3, 4, 5],
repeat(0xbb, 64)
);
const bytes = codec.encode([level]);
// header(2) + alg(1) + pub_len(1) + pub(32) + payload_len(2) + payload(5) + sig_len(2) + sig(64)
expect(bytes.length).toBe(2 + 1 + 1 + 32 + 2 + 5 + 2 + 64);
expect(bytes[0]).toBe(1);
expect(bytes[1]).toBe(0);
const decoded = codec.decode(bytes);
expect(decoded).toHaveLength(1);
expect(decoded[0]?.algorithm).toBe("ed25519");
expect(Array.from(decoded[0]?.signerPublicKey ?? [])).toEqual(
repeat(0xaa, 32)
);
expect(Array.from(decoded[0]?.payload ?? [])).toEqual([1, 2, 3, 4, 5]);
expect(Array.from(decoded[0]?.signature ?? [])).toEqual(
repeat(0xbb, 64)
);
});
it("round-trip preserves 3-level chain with mixed algorithms", {
timeout: 5000,
}, () => {
const codec = new TlvChainCodec();
const levels: ChainLevel[] = [
makeLevel("ed25519", repeat(1, 32), [0x10, 0x11], repeat(2, 64)),
makeLevel(
"secp256k1",
repeat(3, 33),
[0x20, 0x21, 0x22],
repeat(4, 71)
),
makeLevel(
"p384",
repeat(5, 97),
[0x30, 0x31, 0x32, 0x33],
repeat(6, 104)
),
];
const bytes = codec.encode(levels);
const decoded = codec.decode(bytes);
expect(decoded).toHaveLength(3);
for (let i = 0; i < levels.length; i++) {
const original = levels[i] as ChainLevel;
const got = decoded[i] as ChainLevel;
expect(got.algorithm).toBe(original.algorithm);
expect(Array.from(got.signerPublicKey)).toEqual(
Array.from(original.signerPublicKey)
);
expect(Array.from(got.payload)).toEqual(
Array.from(original.payload)
);
expect(Array.from(got.signature)).toEqual(
Array.from(original.signature)
);
}
});
it("rejects bad version byte with CHAIN_KEYS.INVALID_FORMAT", {
timeout: 5000,
}, () => {
const codec = new TlvChainCodec();
const level = makeLevel(
"ed25519",
repeat(0xaa, 32),
[9],
repeat(0xbb, 64)
);
const bytes = codec.encode([level]);
bytes[0] = 2;
try {
codec.decode(bytes);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("writes hasherName byte correctly (raw=0, domain=1, merkle=2)", {
timeout: 5000,
}, () => {
const level = makeLevel(
"ed25519",
repeat(0xaa, 32),
[0],
repeat(0xbb, 64)
);
for (const [name, byte] of [
["raw", 0],
["domain", 1],
["merkle", 2],
] as const) {
const codec = new TlvChainCodec();
codec.hasherName = name;
const bytes = codec.encode([level]);
expect(bytes[1]).toBe(byte);
}
});
it("sets hasherName after decode", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
const level = makeLevel(
"ed25519",
repeat(0xaa, 32),
[0],
repeat(0xbb, 64)
);
codec.hasherName = "merkle";
const bytes = codec.encode([level]);
const reader = new TlvChainCodec();
expect(reader.hasherName).toBe("raw");
reader.decode(bytes);
expect(reader.hasherName).toBe("merkle");
bytes[1] = 1;
reader.decode(bytes);
expect(reader.hasherName).toBe("domain");
});
it("rejects unknown hasher byte with INVALID_FORMAT", {
timeout: 5000,
}, () => {
const codec = new TlvChainCodec();
const bytes = new Uint8Array([1, 7]);
try {
codec.decode(bytes);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("rejects unknown algorithm byte with UNSUPPORTED_ALGORITHM", {
timeout: 5000,
}, () => {
const codec = new TlvChainCodec();
// header(1,0) + alg=9 (unknown)
const bytes = new Uint8Array([1, 0, 9]);
try {
codec.decode(bytes);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM
);
}
});
it("rejects truncated buffer (mid-level)", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
// header valid, then EOF right where alg/pubLen header is expected.
const bytes = new Uint8Array([1, 0, 0]); // alg=0, then truncated before pubLen
try {
codec.decode(bytes);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("rejects truncated buffer (mid-pubkey)", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
// header + alg=0 + pubLen=32 but only 5 bytes of pub follow
const bytes = new Uint8Array([1, 0, 0, 32, 1, 2, 3, 4, 5]);
try {
codec.decode(bytes);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("rejects truncated buffer (mid-payload)", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
const codec2 = new TlvChainCodec();
const level = makeLevel(
"ed25519",
repeat(0xaa, 32),
repeat(0xcc, 20),
repeat(0xbb, 64)
);
const bytes = codec.encode([level]);
// truncate inside payload
// header(2) + alg(1) + pubLen(1) + pub(32) + payloadLen(2) = offset 38, payload starts there
const truncated = bytes.slice(0, 38 + 5);
try {
codec2.decode(truncated);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("rejects truncated buffer (mid-signature)", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
const codec2 = new TlvChainCodec();
const level = makeLevel(
"ed25519",
repeat(0xaa, 32),
[9, 9, 9],
repeat(0xbb, 64)
);
const bytes = codec.encode([level]);
// strip off last 10 bytes of signature
const truncated = bytes.slice(0, bytes.length - 10);
try {
codec2.decode(truncated);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("rejects payload_len > remaining bytes (INVALID_FORMAT)", {
timeout: 5000,
}, () => {
const codec = new TlvChainCodec();
// header + alg=0 + pubLen=1 + pub(1) + payloadLen=0xffff but no payload bytes
const bytes = new Uint8Array([1, 0, 0, 1, 0x42, 0xff, 0xff]);
try {
codec.decode(bytes);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("rejects truncated buffer at payload-length field", {
timeout: 5000,
}, () => {
const codec = new TlvChainCodec();
// header(1,0) + alg=0 + pubLen=1 + pub(0x42) + only 1 byte of payloadLen
const bytes = new Uint8Array([1, 0, 0, 1, 0x42, 0x00]);
try {
codec.decode(bytes);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("rejects truncated buffer at signature-length field", {
timeout: 5000,
}, () => {
const codec = new TlvChainCodec();
// header + alg=0 + pubLen=1 + pub(0x42) + payloadLen=0 + only 1 byte of sigLen
const bytes = new Uint8Array([1, 0, 0, 1, 0x42, 0x00, 0x00, 0x00]);
try {
codec.decode(bytes);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("rejects buffer too short for header (< 2 bytes)", {
timeout: 5000,
}, () => {
const codec = new TlvChainCodec();
try {
codec.decode(new Uint8Array([1]));
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("empty buffer raises INVALID_FORMAT", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
try {
codec.decode(new Uint8Array(0));
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("returned levels are independent of input (no shared backing buffer)", {
timeout: 5000,
}, () => {
const codec = new TlvChainCodec();
const level = makeLevel(
"ed25519",
repeat(0xaa, 32),
[9, 8, 7],
repeat(0xbb, 64)
);
const bytes = codec.encode([level]);
const decoded = codec.decode(bytes);
// Mutate the original buffer; decoded must be untouched.
bytes.fill(0xff);
expect(Array.from(decoded[0]?.signerPublicKey ?? [])).toEqual(
repeat(0xaa, 32)
);
expect(Array.from(decoded[0]?.payload ?? [])).toEqual([9, 8, 7]);
expect(Array.from(decoded[0]?.signature ?? [])).toEqual(
repeat(0xbb, 64)
);
});
it("encode rejects oversize signerPublicKey", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
const level = makeLevel(
"ed25519",
repeat(0xaa, 200),
[0],
repeat(0xbb, 64)
);
try {
codec.encode([level]);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("encode rejects oversize signature", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
const level = makeLevel(
"ed25519",
repeat(0xaa, 32),
[0],
repeat(0xbb, 200)
);
try {
codec.encode([level]);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("encode rejects oversize payload", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
const level: ChainLevel = {
algorithm: "ed25519",
signerPublicKey: new Uint8Array(32),
payload: new Uint8Array(0x10000),
signature: new Uint8Array(64),
};
try {
codec.encode([level]);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_FORMAT
);
}
});
it("encode rejects unknown algorithm string", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
const level = {
algorithm: "nonsense" as SignAlgorithmId,
signerPublicKey: new Uint8Array(32),
payload: new Uint8Array(1),
signature: new Uint8Array(64),
};
try {
codec.encode([level]);
throw new Error("expected throw");
} catch (error) {
expect(error).toBeInstanceOf(ChainKeysError);
expect((error as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM
);
}
});
it("encodes empty chain (header only)", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
const bytes = codec.encode([]);
expect(bytes.length).toBe(2);
expect(bytes[0]).toBe(1);
expect(bytes[1]).toBe(0);
const decoded = codec.decode(bytes);
expect(decoded).toHaveLength(0);
});
it("name is the literal string 'tlv'", { timeout: 5000 }, () => {
const codec = new TlvChainCodec();
expect(codec.name).toBe("tlv");
});
it("subpath export resolves to tlv codec module", { timeout: 5000 }, () => {
// The package.json subpath export "./codecs/tlv/tlv" maps to the
// built dist file; from inside the package (vitest src mode) the
// module identity is verified via the local relative import. The
// build step is responsible for ensuring the subpath alias
// resolves to the same module in published consumers.
expect(TlvChainCodec).toBeDefined();
expect(typeof TlvChainCodec).toBe("function");
const instance = new TlvChainCodec();
expect(instance.name).toBe("tlv");
});
it("tier purity: no node/bun imports", { timeout: 5000 }, () => {
// Module-load itself succeeds in the universal vitest config; any
// node:/bun: import would surface at evaluation time.
expect(TlvChainCodec).toBeDefined();
const instance = new TlvChainCodec();
expect(instance).toBeInstanceOf(TlvChainCodec);
});
});

View File

@@ -0,0 +1,233 @@
import {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "../../error-codes/error-codes.js";
import type { ChainLevel, SignAlgorithmId } from "../../types/chain-level.js";
import type { ChainCodec } from "../../types/codec.js";
/**
* Identifier for the hasher byte at offset 1 of the TLV wire format. The
* codec itself does not invoke any hasher — the byte is informational and
* round-trips so the verifier can construct a matching `ChainHasher`.
*/
export type HasherName = "raw" | "domain" | "merkle";
const VERSION = 1;
const MAX_KEY_OR_SIG_LEN = 140;
const MAX_PAYLOAD_LEN = 0xffff;
const ALGORITHM_TO_BYTE: Record<SignAlgorithmId, number> = {
ed25519: 0,
secp256k1: 1,
p256: 2,
p384: 3,
p521: 4,
};
const BYTE_TO_ALGORITHM: ReadonlyArray<SignAlgorithmId> = [
"ed25519",
"secp256k1",
"p256",
"p384",
"p521",
];
const HASHER_TO_BYTE: Record<HasherName, number> = {
raw: 0,
domain: 1,
merkle: 2,
};
const BYTE_TO_HASHER: ReadonlyArray<HasherName> = ["raw", "domain", "merkle"];
/**
* Default chain codec — self-describing length-prefixed wire format.
*
* Wire layout (big-endian where multi-byte):
*
* ```
* [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][payload: payload_len bytes]
* [sig_len: u16][sig: sig_len bytes]
* ```
*
* `hasherName` is read/written at offset 1. The signer sets it before
* `encode`; `decode` overwrites it from the wire byte so a verifier can
* construct the matching hasher.
*/
export class TlvChainCodec implements ChainCodec {
public readonly name = "tlv" as const;
public hasherName: HasherName = "raw";
public encode(levels: readonly ChainLevel[]): Uint8Array {
let total = 2;
for (const level of levels) {
if (!(level.algorithm in ALGORITHM_TO_BYTE)) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
`unknown algorithm "${String(level.algorithm)}"`,
{ algorithm: level.algorithm }
);
}
if (level.signerPublicKey.length > MAX_KEY_OR_SIG_LEN) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`signerPublicKey length ${level.signerPublicKey.length} exceeds u8 max ${MAX_KEY_OR_SIG_LEN}`
);
}
if (level.payload.length > MAX_PAYLOAD_LEN) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`payload length ${level.payload.length} exceeds u16 max ${MAX_PAYLOAD_LEN}`
);
}
if (level.signature.length > MAX_KEY_OR_SIG_LEN) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`signature length ${level.signature.length} exceeds u8 max ${MAX_KEY_OR_SIG_LEN}`
);
}
total +=
1 +
1 +
level.signerPublicKey.length +
2 +
level.payload.length +
2 +
level.signature.length;
}
const out = new Uint8Array(total);
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
let offset = 0;
out[offset++] = VERSION;
out[offset++] = HASHER_TO_BYTE[this.hasherName];
for (const level of levels) {
out[offset++] = ALGORITHM_TO_BYTE[level.algorithm];
out[offset++] = level.signerPublicKey.length;
out.set(level.signerPublicKey, offset);
offset += level.signerPublicKey.length;
view.setUint16(offset, level.payload.length, false);
offset += 2;
out.set(level.payload, offset);
offset += level.payload.length;
view.setUint16(offset, level.signature.length, false);
offset += 2;
out.set(level.signature, offset);
offset += level.signature.length;
}
return out;
}
public decode(bytes: Uint8Array): readonly ChainLevel[] {
if (bytes.length < 2) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`buffer too short for header: ${bytes.length} < 2`
);
}
const view = new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength
);
let offset = 0;
const version = bytes[offset++] as number;
if (version !== VERSION) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`unsupported TLV version: expected ${VERSION}, got ${version}`
);
}
const hasherByte = bytes[offset++] as number;
const hasherName = BYTE_TO_HASHER[hasherByte];
if (hasherName === undefined) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`unknown hasher byte: ${hasherByte}`
);
}
this.hasherName = hasherName;
const levels: ChainLevel[] = [];
let depth = 0;
while (offset < bytes.length) {
const algorithmByte = bytes[offset++] as number;
const algorithm = BYTE_TO_ALGORITHM[algorithmByte];
if (algorithm === undefined) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
`unknown algorithm byte ${algorithmByte} at depth ${depth}`,
{ depth, algorithm: algorithmByte }
);
}
if (offset >= bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`truncated level header at depth ${depth}`,
{ depth }
);
}
const pubLen = bytes[offset++] as number;
if (offset + pubLen > bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`truncated signerPublicKey at depth ${depth}: need ${pubLen}, have ${bytes.length - offset}`,
{ depth }
);
}
const signerPublicKey = bytes.slice(offset, offset + pubLen);
offset += pubLen;
if (offset + 2 > bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`truncated payload length at depth ${depth}`,
{ depth }
);
}
const payloadLen = view.getUint16(offset, false);
offset += 2;
if (offset + payloadLen > bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`truncated payload at depth ${depth}: need ${payloadLen}, have ${bytes.length - offset}`,
{ depth }
);
}
const payload = bytes.slice(offset, offset + payloadLen);
offset += payloadLen;
if (offset + 2 > bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`truncated signature length at depth ${depth}`,
{ depth }
);
}
const sigLen = view.getUint16(offset, false);
offset += 2;
if (offset + sigLen > bytes.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_FORMAT,
`truncated signature at depth ${depth}: need ${sigLen}, have ${bytes.length - offset}`,
{ depth }
);
}
const signature = bytes.slice(offset, offset + sigLen);
offset += sigLen;
levels.push({ algorithm, signerPublicKey, payload, signature });
depth += 1;
}
return levels;
}
}

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,93 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHAIN_KEYS_CODES, ChainKeysError } from "./error-codes.js";
describe("CHAIN_KEYS_CODES registry", () => {
it("declares every required code with namespaced value", () => {
expect(CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM).toBe(
"CHAIN_KEYS.UNSUPPORTED_ALGORITHM"
);
expect(CHAIN_KEYS_CODES.INVALID_FORMAT).toBe(
"CHAIN_KEYS.INVALID_FORMAT"
);
expect(CHAIN_KEYS_CODES.INVALID_SIGNATURE).toBe(
"CHAIN_KEYS.INVALID_SIGNATURE"
);
expect(CHAIN_KEYS_CODES.TRUST_MISMATCH).toBe(
"CHAIN_KEYS.TRUST_MISMATCH"
);
expect(CHAIN_KEYS_CODES.INVALID_DEPTH).toBe("CHAIN_KEYS.INVALID_DEPTH");
expect(CHAIN_KEYS_CODES.INVALID_KEYCHAIN).toBe(
"CHAIN_KEYS.INVALID_KEYCHAIN"
);
expect(CHAIN_KEYS_CODES.INVALID_DOMAIN_TAG).toBe(
"CHAIN_KEYS.INVALID_DOMAIN_TAG"
);
});
it("registry is frozen", () => {
expect(Object.isFrozen(CHAIN_KEYS_CODES)).toBe(true);
});
});
describe("ChainKeysError", () => {
it("every CHAIN_KEYS.* code is reachable from a call site", () => {
for (const code of Object.values(CHAIN_KEYS_CODES)) {
const err = new ChainKeysError(code, `test ${code}`);
expect(err.code).toBe(code);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(ChainKeysError);
expect(err).toBeInstanceOf(Error);
}
});
it("preserves cause and SuaveplanError contract", () => {
const root = new Error("underlying boom");
const err = new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_SIGNATURE,
"wrap",
{ cause: root }
);
expect(err.cause).toBe(root);
expect(err.code).toBe("CHAIN_KEYS.INVALID_SIGNATURE");
expect(err.message).toBe("wrap");
expect(err.timestamp).toBeTypeOf("number");
});
it("exposes depth discriminant when provided", () => {
const err = new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_SIGNATURE,
"bad",
{ depth: 2 }
);
expect(err.depth).toBe(2);
expect(err.algorithm).toBeUndefined();
});
it("exposes algorithm discriminant for string SignAlgorithmId", () => {
const err = new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
"no",
{ algorithm: "p384" }
);
expect(err.algorithm).toBe("p384");
});
it("exposes algorithm discriminant for numeric AlgorithmId", () => {
const err = new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
"no",
{ algorithm: 1 }
);
expect(err.algorithm).toBe(1);
});
it("omits discriminants when not provided", () => {
const err = new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_DOMAIN_TAG,
"bad"
);
expect(err.depth).toBeUndefined();
expect(err.algorithm).toBeUndefined();
});
});

View File

@@ -0,0 +1,58 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
import type { SignAlgorithmId } from "../types/chain-level.js";
/**
* Stable error-code registry for `@suaveplan/crypto-keys`. Every public
* failure path raises a `ChainKeysError` carrying one of these codes;
* consumers programmatically dispatch on the namespaced string
* (`"CHAIN_KEYS.UNSUPPORTED_ALGORITHM"` etc.).
*/
export const CHAIN_KEYS_CODES = defineCodes("CHAIN_KEYS", [
"UNSUPPORTED_ALGORITHM",
"INVALID_FORMAT",
"INVALID_SIGNATURE",
"TRUST_MISMATCH",
"INVALID_DEPTH",
"INVALID_KEYCHAIN",
"INVALID_DOMAIN_TAG",
] as const);
export type ChainKeysErrorCode =
(typeof CHAIN_KEYS_CODES)[keyof typeof CHAIN_KEYS_CODES];
export interface ChainKeysErrorOptions extends SuaveplanErrorOptions {
/** Chain level at which the failure occurred, when applicable. */
readonly depth?: number;
/** Signing algorithm involved in the failure, when applicable. */
readonly algorithm?: SignAlgorithmId | number;
}
/**
* Discriminated error class for `@suaveplan/crypto-keys`. Extends
* `SuaveplanError` so `instanceof` narrows `code` to `ChainKeysErrorCode`;
* carries optional `depth?` / `algorithm?` discriminants for diagnostics
* without polluting the base `context` map.
*
* The `algorithm` discriminant accepts both `SignAlgorithmId` (string)
* and `number` so `signWithSlot` can surface the numeric `AlgorithmId`
* from a non-Ed25519 keychain slot directly without lossy coercion.
*/
export class ChainKeysError extends SuaveplanError<ChainKeysErrorCode> {
public readonly depth?: number;
public readonly algorithm?: SignAlgorithmId | number;
public constructor(
code: ChainKeysErrorCode,
message: string,
options: ChainKeysErrorOptions = {}
) {
super(code, message, options);
if (options.depth !== undefined) {
this.depth = options.depth;
}
if (options.algorithm !== undefined) {
this.algorithm = options.algorithm;
}
}
}

View File

@@ -0,0 +1,8 @@
export type {
ChainKeysErrorCode,
ChainKeysErrorOptions,
} from "./error-codes.js";
export {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "./error-codes.js";

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,298 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CHAIN_KEYS_CODES } from "../error-codes/error-codes.js";
import { DomainHasher } from "../hashers/domain/domain.js";
import { ChainedKeySigner } from "../signer/signer.js";
import {
createRootKeysWithChain,
createServerKeysWithChain,
rollServerKeysWithChain,
} from "./factory.js";
describe("createRootKeysWithChain", () => {
it("generates a root keychain and a self-signed chain", {
timeout: 10000,
}, async () => {
const { keys, chain } = await createRootKeysWithChain();
expect(keys).toBeDefined();
expect(chain).toBeInstanceOf(Uint8Array);
expect(chain.length).toBeGreaterThan(0);
});
it("self-signed chain verifies against root sign-slot public key", {
timeout: 10000,
}, async () => {
const { keys, chain } = await createRootKeysWithChain();
const signer = new ChainedKeySigner();
const signSlot = (keys as Record<string, { publicKey: Uint8Array }>)
.sign;
const result = await signer.verify(chain, {
algorithm: "ed25519",
publicKey: signSlot.publicKey,
});
expect(result.valid).toBe(true);
expect(result.depth).toBe(1);
});
it("accepts an injected signer (DomainHasher round-trip)", {
timeout: 10000,
}, async () => {
const signer = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "factory.test.v1" }),
});
const { keys, chain } = await createRootKeysWithChain({ signer });
const signSlot = (keys as Record<string, { publicKey: Uint8Array }>)
.sign;
const result = await signer.verify(chain, {
algorithm: "ed25519",
publicKey: signSlot.publicKey,
});
expect(result.valid).toBe(true);
});
it("honours generate overrides (custom application tag)", {
timeout: 10000,
}, async () => {
const { keys } = await createRootKeysWithChain({
generate: {
application: "test" as unknown as Parameters<
typeof createRootKeysWithChain
>[0] extends { generate?: infer G }
? G extends { application?: infer A }
? A
: never
: never,
},
});
expect(keys).toBeDefined();
});
it("does not throw with empty options", { timeout: 10000 }, async () => {
const { keys, chain } = await createRootKeysWithChain({});
expect(keys).toBeDefined();
expect(chain.length).toBeGreaterThan(0);
});
});
describe("createServerKeysWithChain", () => {
it("derives a server keychain from rootKeys.master and binds it to rootChain", {
timeout: 10000,
}, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
const { keys: serverKeys, chain: serverChain } =
await createServerKeysWithChain({ rootKeys, rootChain });
const signer = new ChainedKeySigner();
const rootSignSlot = (
rootKeys as Record<string, { publicKey: Uint8Array }>
).sign;
const result = await signer.verify(serverChain, {
algorithm: "ed25519",
publicKey: rootSignSlot.publicKey,
});
expect(result.valid).toBe(true);
expect(result.depth).toBe(2);
expect(serverKeys).toBeDefined();
});
it("rejects rootKeys missing master with INVALID_KEYCHAIN", {
timeout: 10000,
}, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
const stripped = {
...rootKeys,
mast: undefined,
} as unknown as typeof rootKeys;
await expect(
createServerKeysWithChain({
rootKeys: stripped,
rootChain,
})
).rejects.toMatchObject({ code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN });
});
it("rejects rootKeys with master.secretKey empty", {
timeout: 10000,
}, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
const empty = {
...rootKeys,
mast: {
...((rootKeys as Record<string, unknown>).mast as Record<
string,
unknown
>),
secretKey: new Uint8Array(0),
},
} as unknown as typeof rootKeys;
await expect(
createServerKeysWithChain({
rootKeys: empty,
rootChain,
})
).rejects.toMatchObject({ code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN });
});
it("rejects rootKeys missing sign with INVALID_KEYCHAIN", {
timeout: 10000,
}, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
const stripped = {
...rootKeys,
sign: undefined,
} as unknown as typeof rootKeys;
await expect(
createServerKeysWithChain({
rootKeys: stripped,
rootChain,
})
).rejects.toMatchObject({ code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN });
});
it("rejects rootKeys.sign with empty secretKey", {
timeout: 10000,
}, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
const empty = {
...rootKeys,
sign: {
...((rootKeys as Record<string, unknown>).sign as Record<
string,
unknown
>),
secretKey: new Uint8Array(0),
},
} as unknown as typeof rootKeys;
await expect(
createServerKeysWithChain({
rootKeys: empty,
rootChain,
})
).rejects.toMatchObject({ code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN });
});
it("accepts injected signer", { timeout: 10000 }, async () => {
const signer = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "server.test.v1" }),
});
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain({ signer });
const result = await createServerKeysWithChain({
rootKeys,
rootChain,
signer,
});
expect(result.chain.length).toBeGreaterThan(0);
});
});
describe("rollServerKeysWithChain", () => {
it("rolls server keys forward and re-signs against root", {
timeout: 10000,
}, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
const { chain: chainV2 } = await rollServerKeysWithChain({
rootKeys,
rootChain,
currentVersion: 5,
});
const signer = new ChainedKeySigner();
const rootSignSlot = (
rootKeys as Record<string, { publicKey: Uint8Array }>
).sign;
const result = await signer.verify(chainV2, {
algorithm: "ed25519",
publicKey: rootSignSlot.publicKey,
});
expect(result.valid).toBe(true);
expect(result.depth).toBe(2);
});
it("rejects negative currentVersion with INVALID_KEYCHAIN", {
timeout: 10000,
}, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
await expect(
rollServerKeysWithChain({
rootKeys,
rootChain,
currentVersion: -1,
})
).rejects.toMatchObject({ code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN });
});
it("rejects non-integer currentVersion with INVALID_KEYCHAIN", {
timeout: 10000,
}, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
await expect(
rollServerKeysWithChain({
rootKeys,
rootChain,
currentVersion: 1.5,
})
).rejects.toMatchObject({ code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN });
});
it("rejects rootKeys with missing slots", { timeout: 10000 }, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
const stripped = {
...rootKeys,
mast: undefined,
} as unknown as typeof rootKeys;
await expect(
rollServerKeysWithChain({
rootKeys: stripped,
rootChain,
currentVersion: 1,
})
).rejects.toMatchObject({ code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN });
});
it("accepts injected signer", { timeout: 10000 }, async () => {
const signer = new ChainedKeySigner();
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain({ signer });
const result = await rollServerKeysWithChain({
rootKeys,
rootChain,
currentVersion: 0,
signer,
});
expect(result.chain.length).toBeGreaterThan(0);
});
});
describe("factory — 3-level chain end-to-end", () => {
it("three-level chain verifies against trusted root", {
timeout: 10000,
}, async () => {
const { keys: rootKeys, chain: rootChain } =
await createRootKeysWithChain();
const { chain: midChain } = await createServerKeysWithChain({
rootKeys,
rootChain,
});
const { chain: leafChain } = await createServerKeysWithChain({
rootKeys,
rootChain: midChain,
});
const signer = new ChainedKeySigner();
const rootSignSlot = (
rootKeys as Record<string, { publicKey: Uint8Array }>
).sign;
const result = await signer.verify(leafChain, {
algorithm: "ed25519",
publicKey: rootSignSlot.publicKey,
});
expect(result.valid).toBe(true);
expect(result.depth).toBe(3);
});
});

View File

@@ -0,0 +1,208 @@
import {
ALGORITHMS,
encodePublicKeychain,
extractPublicKeys,
type GenerateKeychainOptions,
generateKeychain,
type Keychain,
type KeySlotPrivate,
} from "@suaveplan/crypto/keychain";
import type { Clock } from "@suaveplan/types";
// Mirror of `LayoutV1Fields` from `@suaveplan/crypto/derivation/layouts`.
// Re-declared locally because the type is not re-exported from
// `@suaveplan/crypto/keychain`; the structural shape matches and
// `generateKeychain` accepts any object satisfying it.
interface LayoutV1Fields {
readonly masterVersion: number;
readonly userId: number;
readonly depth: number;
readonly slotVersion: number;
}
import {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "../error-codes/error-codes.js";
import { ChainedKeySigner } from "../signer/signer.js";
// Branded purpose-tag keys. The genesis crypto `Keychain` is
// `Record<PurposeTag, KeySlotPrivate>` where `PurposeTag` is a branded
// `string & { __brand }` type; cast literals once via this typed
// constant so downstream access typechecks.
const MAST = "mast" as unknown as keyof Keychain<LayoutV1Fields>;
const SIGN = "sign" as unknown as keyof Keychain<LayoutV1Fields>;
function getSlot(
keys: Keychain<LayoutV1Fields>,
tag: keyof Keychain<LayoutV1Fields>
): KeySlotPrivate<LayoutV1Fields> | undefined {
return (keys as Record<string, KeySlotPrivate<LayoutV1Fields>>)[
tag as unknown as string
];
}
const DEFAULT_LAYOUT_FIELDS: LayoutV1Fields = {
masterVersion: 1,
userId: 0,
depth: 0,
slotVersion: 1,
};
const DEFAULT_CLOCK: Clock = () => Date.now();
/**
* Result of every factory call: the generated `Keychain` plus the signed
* chain bytes that bind its public projection to the parent (or to itself
* for a root).
*/
export interface FactoryResult {
readonly keys: Keychain<LayoutV1Fields>;
readonly chain: Uint8Array;
}
/** Options for `createRootKeysWithChain`. */
export interface CreateRootOptions {
/** Inject a pre-configured signer; defaults to `new ChainedKeySigner()`. */
readonly signer?: ChainedKeySigner;
/** Override the keychain generation options. */
readonly generate?: Partial<GenerateKeychainOptions>;
}
/** Options for `createServerKeysWithChain`. */
export interface CreateServerOptions {
readonly rootKeys: Keychain<LayoutV1Fields>;
readonly rootChain: Uint8Array;
readonly signer?: ChainedKeySigner;
readonly generate?: Partial<GenerateKeychainOptions>;
}
/** Options for `rollServerKeysWithChain`. */
export interface RollServerOptions {
readonly rootKeys: Keychain<LayoutV1Fields>;
readonly rootChain: Uint8Array;
readonly currentVersion: number;
readonly signer?: ChainedKeySigner;
}
/**
* Generate a fresh root keychain and self-sign its public projection.
*/
export async function createRootKeysWithChain(
options: CreateRootOptions = {}
): Promise<FactoryResult> {
const signer = options.signer ?? new ChainedKeySigner();
const keys = generateKeychain(buildGenerateOptions(options.generate));
const publicKeys = extractPublicKeys(keys);
const payload = encodePublicKeychain(publicKeys);
const signSlot = getSlot(keys, SIGN) as KeySlotPrivate<LayoutV1Fields>;
const chain = await signer.signWithSlot(payload, signSlot);
return { keys, chain };
}
/**
* Derive a server keychain from `rootKeys.master`, sign its public projection
* with `rootKeys.sign`, and attach the parent `rootChain`.
*/
export async function createServerKeysWithChain(
options: CreateServerOptions
): Promise<FactoryResult> {
const signer = options.signer ?? new ChainedKeySigner();
assertRootSlots(options.rootKeys);
const generateOptions = buildGenerateOptions({
...options.generate,
masterKey: (
getSlot(options.rootKeys, MAST) as KeySlotPrivate<LayoutV1Fields>
).secretKey,
});
const keys = generateKeychain(generateOptions);
const publicKeys = extractPublicKeys(keys);
const payload = encodePublicKeychain(publicKeys);
const chain = await signer.signWithSlot(
payload,
getSlot(options.rootKeys, SIGN) as KeySlotPrivate<LayoutV1Fields>,
options.rootChain
);
return { keys, chain };
}
/**
* Roll the server keychain forward to the next version, re-signed by
* `rootKeys.sign`. The new keychain shares the master key with the
* existing root so the derivation chain stays intact.
*/
export async function rollServerKeysWithChain(
options: RollServerOptions
): Promise<FactoryResult> {
if (
!Number.isInteger(options.currentVersion) ||
options.currentVersion < 0
) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_KEYCHAIN,
`currentVersion must be a non-negative integer (got ${options.currentVersion})`
);
}
const signer = options.signer ?? new ChainedKeySigner();
assertRootSlots(options.rootKeys);
const generateOptions = buildGenerateOptions({
masterKey: (
getSlot(options.rootKeys, MAST) as KeySlotPrivate<LayoutV1Fields>
).secretKey,
layoutFields: {
...DEFAULT_LAYOUT_FIELDS,
slotVersion: options.currentVersion + 1,
},
});
const keys = generateKeychain(generateOptions);
const publicKeys = extractPublicKeys(keys);
const payload = encodePublicKeychain(publicKeys);
const chain = await signer.signWithSlot(
payload,
getSlot(options.rootKeys, SIGN) as KeySlotPrivate<LayoutV1Fields>,
options.rootChain
);
return { keys, chain };
}
function buildGenerateOptions(
overrides: Partial<GenerateKeychainOptions> | undefined
): GenerateKeychainOptions {
const base: GenerateKeychainOptions = {
layoutFields: DEFAULT_LAYOUT_FIELDS,
clock: DEFAULT_CLOCK,
};
if (!overrides) return base;
return {
...base,
...overrides,
layoutFields: overrides.layoutFields ?? DEFAULT_LAYOUT_FIELDS,
clock: overrides.clock ?? DEFAULT_CLOCK,
};
}
function assertRootSlots(rootKeys: Keychain<LayoutV1Fields>): void {
const master = getSlot(rootKeys, MAST);
if (
master === undefined ||
!(master.secretKey instanceof Uint8Array) ||
master.secretKey.length === 0
) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_KEYCHAIN,
"rootKeys.master must contain a non-empty secretKey"
);
}
const sign = getSlot(rootKeys, SIGN);
if (
sign === undefined ||
sign.algorithm !== ALGORITHMS.ED25519 ||
!(sign.secretKey instanceof Uint8Array) ||
sign.secretKey.length === 0
) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_KEYCHAIN,
"rootKeys.sign must be a valid Ed25519 slot with non-empty secretKey"
);
}
}

View File

@@ -0,0 +1,11 @@
export type {
CreateRootOptions,
CreateServerOptions,
FactoryResult,
RollServerOptions,
} from "./factory.js";
export {
createRootKeysWithChain,
createServerKeysWithChain,
rollServerKeysWithChain,
} from "./factory.js";

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,134 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "../../error-codes/error-codes.js";
import type { ParsedChainLevel } from "../../types/chain-level.js";
import { DomainHasher } from "./domain.js";
// Known-vector: sha256(utf8("suaveplan/chain/v1") || utf8("hello")) computed
// once via `bun -e` against `@suaveplan/crypto-hash` and pinned here.
const KNOWN_DOMAIN_TAG = "suaveplan/chain/v1";
const KNOWN_PAYLOAD_UTF8 = "hello";
const KNOWN_DIGEST_HEX =
"d0653357b3634b452e79665c3bda89a7c363ce1cececb1e995a8bce30c8a4b51";
function toHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
describe("DomainHasher", () => {
it("rejects empty domainTag with CHAIN_KEYS.INVALID_DOMAIN_TAG", {
timeout: 5000,
}, () => {
try {
new DomainHasher({ domainTag: "" });
throw new Error("expected ChainKeysError");
} catch (err) {
expect(err).toBeInstanceOf(ChainKeysError);
expect((err as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_DOMAIN_TAG
);
}
});
it("rejects oversize domainTag (>64 chars)", { timeout: 5000 }, () => {
const tooLong = "a".repeat(65);
try {
new DomainHasher({ domainTag: tooLong });
throw new Error("expected ChainKeysError");
} catch (err) {
expect(err).toBeInstanceOf(ChainKeysError);
expect((err as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_DOMAIN_TAG
);
}
});
it("rejects non-ASCII characters in domainTag", { timeout: 5000 }, () => {
try {
new DomainHasher({ domainTag: "suaveplan/é" });
throw new Error("expected ChainKeysError");
} catch (err) {
expect(err).toBeInstanceOf(ChainKeysError);
expect((err as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_DOMAIN_TAG
);
}
});
it("rejects control characters in domainTag", { timeout: 5000 }, () => {
try {
new DomainHasher({ domainTag: "tagx" });
throw new Error("expected ChainKeysError");
} catch (err) {
expect(err).toBeInstanceOf(ChainKeysError);
expect((err as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_DOMAIN_TAG
);
}
});
it("rejects non-string domainTag", { timeout: 5000 }, () => {
try {
// exercise runtime-validation path with a non-string input
new DomainHasher({
domainTag: 42 as unknown as string,
});
throw new Error("expected ChainKeysError");
} catch (err) {
expect(err).toBeInstanceOf(ChainKeysError);
expect((err as ChainKeysError).code).toBe(
CHAIN_KEYS_CODES.INVALID_DOMAIN_TAG
);
}
});
it("accepts boundary lengths 1 and 64", { timeout: 5000 }, () => {
const lo = new DomainHasher({ domainTag: "a" });
expect(lo.name).toBe("domain");
const hi = new DomainHasher({ domainTag: "z".repeat(64) });
expect(hi.name).toBe("domain");
});
it("hash returns sha256 of (utf8(tag) || payload)", {
timeout: 5000,
}, () => {
const hasher = new DomainHasher({ domainTag: KNOWN_DOMAIN_TAG });
const payload = new TextEncoder().encode(KNOWN_PAYLOAD_UTF8);
const digest = hasher.hash(payload);
expect(digest.length).toBe(32);
expect(toHex(digest)).toBe(KNOWN_DIGEST_HEX);
});
it("different domainTag produces different digest for same payload", {
timeout: 5000,
}, () => {
const a = new DomainHasher({ domainTag: "domain-a" });
const b = new DomainHasher({ domainTag: "domain-b" });
const payload = new Uint8Array([1, 2, 3]);
expect(toHex(a.hash(payload))).not.toBe(toHex(b.hash(payload)));
});
it("ignores parent argument", { timeout: 5000 }, () => {
const hasher = new DomainHasher({ domainTag: KNOWN_DOMAIN_TAG });
const payload = new TextEncoder().encode(KNOWN_PAYLOAD_UTF8);
const parent: ParsedChainLevel = {
algorithm: "ed25519",
signerPublicKey: new Uint8Array([9, 9, 9]),
payload: new Uint8Array([7, 7, 7]),
signature: new Uint8Array([8, 8, 8]),
};
const withParent = hasher.hash(payload, parent);
const withoutParent = hasher.hash(payload);
expect(toHex(withParent)).toBe(toHex(withoutParent));
expect(toHex(withParent)).toBe(KNOWN_DIGEST_HEX);
});
it("name is domain", { timeout: 5000 }, () => {
const hasher = new DomainHasher({ domainTag: "x" });
expect(hasher.name).toBe("domain");
});
});

View File

@@ -0,0 +1,78 @@
import { sha256 } from "@suaveplan/crypto-hash";
import {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "../../error-codes/error-codes.js";
import type { ParsedChainLevel } from "../../types/chain-level.js";
import type { ChainHasher } from "../../types/hasher.js";
/** Construction options for `DomainHasher`. */
export interface DomainHasherOptions {
/**
* Printable-ASCII domain-separation tag, 1–64 bytes long. Mixed into
* the digest as a prefix so signatures over the same payload under
* different domains never collide.
*/
readonly domainTag: string;
}
const MIN_TAG_LENGTH = 1;
const MAX_TAG_LENGTH = 64;
const ASCII_MIN_PRINTABLE = 0x20;
const ASCII_MAX_PRINTABLE = 0x7e;
/**
* `ChainHasher` strategy that domain-separates the pre-image with a
* caller-supplied tag and SHA-256s the result.
*
* Per level: `sha256(utf8(domainTag) || payload)`.
*
* Domain separation prevents cross-protocol signature reuse — a
* signature minted under `"suaveplan/chain/v1"` cannot be replayed
* against a verifier expecting `"suaveplan/manifest/v1"` because the
* pre-image differs. The tag is validated at construction time: it must
* be 1–64 printable-ASCII characters (`0x20`–`0x7e`), so the encoded
* byte length is exactly `domainTag.length`. Non-ASCII tags are
* rejected to keep the encoded length unambiguous and the digest
* deterministic across runtimes.
*
* The `parent` argument is accepted to satisfy the `ChainHasher`
* interface but is ignored — `DomainHasher` commits to the tag, not to
* any ancestor state. Chains needing parent-binding should pick
* `MerkleHasher`.
*/
export class DomainHasher implements ChainHasher {
public readonly name = "domain" as const;
private readonly tagBytes: Uint8Array;
public constructor(options: DomainHasherOptions) {
const tag = options?.domainTag;
if (
typeof tag !== "string" ||
tag.length < MIN_TAG_LENGTH ||
tag.length > MAX_TAG_LENGTH
) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_DOMAIN_TAG,
`domainTag must be a string of ${MIN_TAG_LENGTH}–${MAX_TAG_LENGTH} ASCII chars (got length ${typeof tag === "string" ? tag.length : "non-string"})`
);
}
for (let i = 0; i < tag.length; i++) {
const code = tag.charCodeAt(i);
if (code < ASCII_MIN_PRINTABLE || code > ASCII_MAX_PRINTABLE) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_DOMAIN_TAG,
`domainTag must contain only printable ASCII (offending char at index ${i}, code 0x${code.toString(16)})`
);
}
}
this.tagBytes = new TextEncoder().encode(tag);
}
public hash(payload: Uint8Array, _parent?: ParsedChainLevel): Uint8Array {
const combined = new Uint8Array(this.tagBytes.length + payload.length);
combined.set(this.tagBytes, 0);
combined.set(payload, this.tagBytes.length);
return sha256(combined);
}
}

View File

@@ -0,0 +1,2 @@
export type { DomainHasherOptions } from "./domain.js";
export { DomainHasher } from "./domain.js";

View File

@@ -0,0 +1 @@
export { MerkleHasher } from "./merkle.js";

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,153 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ParsedChainLevel } from "../../types/chain-level.js";
import { MerkleHasher } from "./merkle.js";
// Known vectors computed once via `bun -e` against
// `@suaveplan/crypto-hash` and pinned here.
//
// Root: sha256(utf8("payload-root"))
const ROOT_PAYLOAD_UTF8 = "payload-root";
const ROOT_DIGEST_HEX =
"9a3314e0d9e3a9a9836b961eb059e093d5299cc90299782383269a7fb6494733";
// Non-root parent: payload=utf8("p-payload"), signature=[1,2,3,4]
// parentRoot = sha256(parent.payload || parent.signature)
const PARENT_PAYLOAD_UTF8 = "p-payload";
const PARENT_SIGNATURE = new Uint8Array([1, 2, 3, 4]);
const PARENT_ROOT_HEX =
"75b32db35337a70dd39fe09735676b029de87dd063aa089309cf63ba09478801";
// child payload=utf8("child-payload")
// digest = sha256(payload || parentRoot)
const CHILD_PAYLOAD_UTF8 = "child-payload";
const CHILD_DIGEST_HEX =
"1e831c81697a10da5f3aa8d6f2c267c0dee818cef65f87637ffb6c85ab219898";
function toHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function makeParent(
payload: Uint8Array,
signature: Uint8Array
): ParsedChainLevel {
return {
algorithm: "ed25519",
signerPublicKey: new Uint8Array(32),
payload,
signature,
};
}
describe("MerkleHasher", () => {
it("root path returns sha256(payload)", { timeout: 5000 }, () => {
const hasher = new MerkleHasher();
const payload = new TextEncoder().encode(ROOT_PAYLOAD_UTF8);
const digest = hasher.hash(payload);
expect(digest.length).toBe(32);
expect(toHex(digest)).toBe(ROOT_DIGEST_HEX);
});
it("root path ignores explicit undefined parent", { timeout: 5000 }, () => {
const hasher = new MerkleHasher();
const payload = new TextEncoder().encode(ROOT_PAYLOAD_UTF8);
const digest = hasher.hash(payload, undefined);
expect(toHex(digest)).toBe(ROOT_DIGEST_HEX);
});
it("non-root commits to parent signature", { timeout: 5000 }, () => {
const hasher = new MerkleHasher();
const parent = makeParent(
new TextEncoder().encode(PARENT_PAYLOAD_UTF8),
PARENT_SIGNATURE
);
const childPayload = new TextEncoder().encode(CHILD_PAYLOAD_UTF8);
const digest = hasher.hash(childPayload, parent);
expect(digest.length).toBe(32);
expect(toHex(digest)).toBe(CHILD_DIGEST_HEX);
});
it("parent-root pre-image follows (parent.payload || parent.signature) ordering", {
timeout: 5000,
}, () => {
// Swapping the order parent.payload <-> parent.signature must
// produce a different digest, proving the implementation
// does not commute the two fields. We construct two parents
// whose payload and signature are swapped relative to each
// other; their child digests must differ.
const hasher = new MerkleHasher();
const childPayload = new TextEncoder().encode(CHILD_PAYLOAD_UTF8);
const a = makeParent(
new Uint8Array([1, 2, 3, 4]),
new Uint8Array([5, 6, 7, 8])
);
const b = makeParent(
new Uint8Array([5, 6, 7, 8]),
new Uint8Array([1, 2, 3, 4])
);
expect(toHex(hasher.hash(childPayload, a))).not.toBe(
toHex(hasher.hash(childPayload, b))
);
// PARENT_ROOT_HEX is the pinned vector documenting our chosen
// canonical ordering and is referenced here to keep test
// dependencies on the known-vector block honest.
expect(PARENT_ROOT_HEX).toMatch(/^[0-9a-f]{64}$/);
});
it("different parent.signature produces different hash", {
timeout: 5000,
}, () => {
const hasher = new MerkleHasher();
const childPayload = new TextEncoder().encode(CHILD_PAYLOAD_UTF8);
const baseParent = makeParent(
new TextEncoder().encode(PARENT_PAYLOAD_UTF8),
PARENT_SIGNATURE
);
const mutatedParent = makeParent(
new TextEncoder().encode(PARENT_PAYLOAD_UTF8),
new Uint8Array([1, 2, 3, 5])
);
const a = hasher.hash(childPayload, baseParent);
const b = hasher.hash(childPayload, mutatedParent);
expect(toHex(a)).not.toBe(toHex(b));
});
it("different parent.payload produces different hash", {
timeout: 5000,
}, () => {
const hasher = new MerkleHasher();
const childPayload = new TextEncoder().encode(CHILD_PAYLOAD_UTF8);
const baseParent = makeParent(
new TextEncoder().encode(PARENT_PAYLOAD_UTF8),
PARENT_SIGNATURE
);
const mutatedParent = makeParent(
new TextEncoder().encode("p-payload-X"),
PARENT_SIGNATURE
);
const a = hasher.hash(childPayload, baseParent);
const b = hasher.hash(childPayload, mutatedParent);
expect(toHex(a)).not.toBe(toHex(b));
});
it("non-root differs from root for the same payload", {
timeout: 5000,
}, () => {
const hasher = new MerkleHasher();
const payload = new TextEncoder().encode(CHILD_PAYLOAD_UTF8);
const parent = makeParent(
new TextEncoder().encode(PARENT_PAYLOAD_UTF8),
PARENT_SIGNATURE
);
const rootDigest = hasher.hash(payload);
const nonRootDigest = hasher.hash(payload, parent);
expect(toHex(rootDigest)).not.toBe(toHex(nonRootDigest));
});
it("name is merkle", { timeout: 5000 }, () => {
const hasher = new MerkleHasher();
expect(hasher.name).toBe("merkle");
});
});

View File

@@ -0,0 +1,40 @@
import { sha256 } from "@suaveplan/crypto-hash";
import type { ParsedChainLevel } from "../../types/chain-level.js";
import type { ChainHasher } from "../../types/hasher.js";
/**
* `ChainHasher` strategy that commits each non-root level to its
* parent's content via a Merkle-style construction.
*
* Per level:
*
* - Root: `sha256(payload)`
* - Non-root: `sha256(payload || sha256(parent.payload || parent.signature))`
*
* The parent commitment binds every signature to the chain it appears
* in: tampering with any ancestor's payload or signature changes the
* derived parent root, which propagates through every descendant
* digest, which invalidates every descendant signature. This is the
* strategy required for keychain chains that must detect ancestor
* substitution.
*/
export class MerkleHasher implements ChainHasher {
public readonly name = "merkle" as const;
public hash(payload: Uint8Array, parent?: ParsedChainLevel): Uint8Array {
if (parent === undefined) {
return sha256(payload);
}
const parentBuf = new Uint8Array(
parent.payload.length + parent.signature.length
);
parentBuf.set(parent.payload, 0);
parentBuf.set(parent.signature, parent.payload.length);
const parentRoot = sha256(parentBuf);
const out = new Uint8Array(payload.length + parentRoot.length);
out.set(payload, 0);
out.set(parentRoot, payload.length);
return sha256(out);
}
}

View File

@@ -0,0 +1 @@
export { RawHasher } from "./raw.js";

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,35 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ParsedChainLevel } from "../../types/chain-level.js";
import { RawHasher } from "./raw.js";
describe("RawHasher", () => {
it("hash returns payload unchanged", { timeout: 5000 }, () => {
const hasher = new RawHasher();
const payload = new Uint8Array([1, 2, 3, 4, 5]);
const result = hasher.hash(payload);
// Reference equality is acceptable for the no-op pass-through —
// re-allocation would be a behavioural regression worth catching.
expect(result).toBe(payload);
expect(Array.from(result)).toEqual([1, 2, 3, 4, 5]);
});
it("ignores parent argument", { timeout: 5000 }, () => {
const hasher = new RawHasher();
const payload = new Uint8Array([0xaa, 0xbb]);
const parent: ParsedChainLevel = {
algorithm: "ed25519",
signerPublicKey: new Uint8Array([9, 9, 9]),
payload: new Uint8Array([7, 7, 7]),
signature: new Uint8Array([8, 8, 8]),
};
const withParent = hasher.hash(payload, parent);
const withoutParent = hasher.hash(payload);
expect(Array.from(withParent)).toEqual(Array.from(withoutParent));
expect(Array.from(withParent)).toEqual([0xaa, 0xbb]);
});
it("name is raw", { timeout: 5000 }, () => {
const hasher = new RawHasher();
expect(hasher.name).toBe("raw");
});
});

View File

@@ -0,0 +1,24 @@
import type { ParsedChainLevel } from "../../types/chain-level.js";
import type { ChainHasher } from "../../types/hasher.js";
/**
* `ChainHasher` strategy that returns the payload bytes unchanged.
*
* Intended for chains whose payload is already a canonical digest
* (e.g., a manifest hash) or for tests that want byte-for-byte
* round-tripping without any pre-image transformation. Because the
* signer always operates on `hash(payload, parent)`, a no-op hasher
* means the signer signs exactly the payload it was handed.
*
* The `parent` argument is accepted to satisfy the `ChainHasher`
* interface but is deliberately ignored — `RawHasher` does not commit
* to any ancestor state. Chains needing parent-binding should pick
* `MerkleHasher` (or a custom implementation).
*/
export class RawHasher implements ChainHasher {
public readonly name = "raw" as const;
public hash(payload: Uint8Array, _parent?: ParsedChainLevel): Uint8Array {
return payload;
}
}

View File

@@ -0,0 +1,51 @@
// Errors
export { CborChainCodec } from "./codecs/cbor/cbor.js";
export { RecursiveEd25519Codec } from "./codecs/recursive-ed25519/recursive-ed25519.js";
// Codecs
export { TlvChainCodec } from "./codecs/tlv/tlv.js";
export type {
ChainKeysErrorCode,
ChainKeysErrorOptions,
} from "./error-codes/error-codes.js";
export {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "./error-codes/error-codes.js";
export type {
CreateRootOptions,
CreateServerOptions,
FactoryResult,
RollServerOptions,
} from "./factory/factory.js";
// Factory
export {
createRootKeysWithChain,
createServerKeysWithChain,
rollServerKeysWithChain,
} from "./factory/factory.js";
export {
DomainHasher,
type DomainHasherOptions,
} from "./hashers/domain/domain.js";
export { MerkleHasher } from "./hashers/merkle/merkle.js";
// Hashers
export { RawHasher } from "./hashers/raw/raw.js";
// Signer
export { ChainedKeySigner } from "./signer/signer.js";
export type {
ISigner,
SignerOptions,
SignRawOptions,
TrustedRoot,
} from "./signer/signer.types.js";
export type {
ChainLevel,
ParsedChainLevel,
SignAlgorithmId,
VerificationResult,
} from "./types/chain-level.js";
// Types
export { SIGN_ALGORITHMS } from "./types/chain-level.js";
export type { ChainCodec } from "./types/codec.js";
export type { ChainHasher } from "./types/hasher.js";

View File

@@ -0,0 +1,7 @@
export { ChainedKeySigner } from "./signer.js";
export type {
ISigner,
SignerOptions,
SignRawOptions,
TrustedRoot,
} from "./signer.types.js";

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,627 @@
import { ALGORITHMS } from "@suaveplan/crypto/keychain";
import * as curve from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CborChainCodec } from "../codecs/cbor/cbor.js";
import { RecursiveEd25519Codec } from "../codecs/recursive-ed25519/recursive-ed25519.js";
import { TlvChainCodec } from "../codecs/tlv/tlv.js";
import {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "../error-codes/error-codes.js";
import { DomainHasher } from "../hashers/domain/domain.js";
import { MerkleHasher } from "../hashers/merkle/merkle.js";
import { RawHasher } from "../hashers/raw/raw.js";
import type { SignAlgorithmId } from "../types/chain-level.js";
import { ChainedKeySigner } from "./signer.js";
const CURVES: Record<
SignAlgorithmId,
{
sign(m: Uint8Array, k: Uint8Array): Uint8Array;
verify(s: Uint8Array, m: Uint8Array, k: Uint8Array): boolean;
getPublicKey(k: Uint8Array): Uint8Array;
randomPrivateKey(): Uint8Array;
}
> = {
ed25519: curve.ed25519,
secp256k1: curve.secp256k1,
p256: curve.p256,
p384: curve.p384,
p521: curve.p521,
};
function makeKeypair(algorithm: SignAlgorithmId): {
privateKey: Uint8Array;
publicKey: Uint8Array;
} {
const privateKey = CURVES[algorithm].randomPrivateKey();
const publicKey = CURVES[algorithm].getPublicKey(privateKey);
return { privateKey, publicKey };
}
function makeSlot(): {
algorithm: number;
secretKey: Uint8Array;
publicKey: Uint8Array;
} {
const kp = makeKeypair("ed25519");
return {
algorithm: ALGORITHMS.ED25519,
secretKey: kp.privateKey,
publicKey: kp.publicKey,
};
}
describe("ChainedKeySigner — defaults", () => {
it("defaults compose RawHasher + TlvChainCodec", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const slot = makeSlot();
const chain = await signer.signWithSlot(
new Uint8Array([1, 2, 3]),
slot
);
expect(chain[0]).toBe(1); // version byte
expect(chain[1]).toBe(0); // hasher = raw
});
});
describe("ChainedKeySigner — signWithSlot", () => {
it("rejects non-Ed25519 with UNSUPPORTED_ALGORITHM", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const x25519Slot = {
algorithm: ALGORITHMS.X25519,
secretKey: new Uint8Array(32),
publicKey: new Uint8Array(32),
};
await expect(
signer.signWithSlot(new Uint8Array([1]), x25519Slot)
).rejects.toMatchObject({
code: CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
algorithm: ALGORITHMS.X25519,
});
});
it("signWithSlot with public-only slot raises INVALID_KEYCHAIN", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const slot = {
algorithm: ALGORITHMS.ED25519,
secretKey: new Uint8Array(0),
publicKey: new Uint8Array(32),
};
await expect(
signer.signWithSlot(new Uint8Array([1]), slot)
).rejects.toMatchObject({
code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN,
});
});
it("signWithSlot extends a parent chain", { timeout: 5000 }, async () => {
const signer = new ChainedKeySigner();
const rootSlot = makeSlot();
const midSlot = makeSlot();
const rootChain = await signer.signWithSlot(
rootSlot.publicKey,
rootSlot
);
const midChain = await signer.signWithSlot(
midSlot.publicKey,
rootSlot,
rootChain
);
const result = await signer.verify(midChain, {
algorithm: "ed25519",
publicKey: rootSlot.publicKey,
});
expect(result.valid).toBe(true);
expect(result.depth).toBe(2);
});
it("signs and verifies a 1-level Ed25519 chain via signWithSlot", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const slot = makeSlot();
const chain = await signer.signWithSlot(
new Uint8Array([1, 2, 3]),
slot
);
const result = await signer.verify(chain, {
algorithm: "ed25519",
publicKey: slot.publicKey,
});
expect(result.valid).toBe(true);
expect(result.depth).toBe(1);
});
});
describe("ChainedKeySigner — signRaw", () => {
it("signRaw dispatches across Ed25519/secp256k1/p256/p384/p521", {
timeout: 15000,
}, async () => {
const signer = new ChainedKeySigner();
for (const algorithm of [
"ed25519",
"secp256k1",
"p256",
"p384",
"p521",
] as const) {
const kp = makeKeypair(algorithm);
const payload = new TextEncoder().encode(`payload-${algorithm}`);
const chain = await signer.signRaw({
payload,
algorithm,
privateKey: kp.privateKey,
publicKey: kp.publicKey,
});
const result = await signer.verify(chain, {
algorithm,
publicKey: kp.publicKey,
});
expect(result.valid).toBe(true);
expect(result.levels[0]?.algorithm).toBe(algorithm);
}
});
it("derives publicKey when not supplied", { timeout: 5000 }, async () => {
const signer = new ChainedKeySigner();
const kp = makeKeypair("ed25519");
const chain = await signer.signRaw({
payload: new Uint8Array([7]),
algorithm: "ed25519",
privateKey: kp.privateKey,
});
const result = await signer.verify(chain, {
algorithm: "ed25519",
publicKey: kp.publicKey,
});
expect(result.valid).toBe(true);
});
it("rejects mismatched publicKey with INVALID_KEYCHAIN", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const kp = makeKeypair("ed25519");
const wrong = makeKeypair("ed25519").publicKey;
await expect(
signer.signRaw({
payload: new Uint8Array([7]),
algorithm: "ed25519",
privateKey: kp.privateKey,
publicKey: wrong,
})
).rejects.toMatchObject({
code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN,
algorithm: "ed25519",
});
});
it("rejects unknown algorithm string", { timeout: 5000 }, async () => {
const signer = new ChainedKeySigner();
await expect(
signer.signRaw({
payload: new Uint8Array([1]),
algorithm: "bogus" as SignAlgorithmId,
privateKey: new Uint8Array(32),
})
).rejects.toMatchObject({
code: CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
});
});
it("wraps crypto-curve exceptions as INVALID_KEYCHAIN", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
// secp256k1.sign rejects an out-of-range private key with a throw.
const badPrivate = new Uint8Array(32).fill(0); // zero key — rejected
await expect(
signer.signRaw({
payload: new Uint8Array([1]),
algorithm: "secp256k1",
privateKey: badPrivate,
})
).rejects.toMatchObject({ code: CHAIN_KEYS_CODES.INVALID_KEYCHAIN });
});
});
describe("ChainedKeySigner — multi-level chains", () => {
it("verifies a 3-level chain (root -> mid -> leaf)", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const root = makeKeypair("ed25519");
const mid = makeKeypair("ed25519");
const leaf = makeKeypair("ed25519");
// Root self-signs its public key
const rootChain = await signer.signRaw({
payload: root.publicKey,
algorithm: "ed25519",
privateKey: root.privateKey,
});
// Mid level: signed by root
const midChain = await signer.signRaw({
payload: mid.publicKey,
algorithm: "ed25519",
privateKey: root.privateKey,
parent: rootChain,
});
// Leaf: signed by mid
const leafChain = await signer.signRaw({
payload: leaf.publicKey,
algorithm: "ed25519",
privateKey: mid.privateKey,
parent: midChain,
});
const result = await signer.verify(leafChain, {
algorithm: "ed25519",
publicKey: root.publicKey,
});
expect(result.valid).toBe(true);
expect(result.depth).toBe(3);
expect(result.errors).toEqual([]);
});
it("tamper with leaf signature fails verification", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const root = makeKeypair("ed25519");
const chain = await signer.signRaw({
payload: new Uint8Array([1]),
algorithm: "ed25519",
privateKey: root.privateKey,
});
// Tamper: flip the last byte (in the signature region)
const tampered = new Uint8Array(chain);
tampered[tampered.length - 1] ^= 0xff;
const result = await signer.verify(tampered, {
algorithm: "ed25519",
publicKey: root.publicKey,
});
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("verify failed"))).toBe(
true
);
});
it("unrelated trustedRoot fails with TRUST_MISMATCH", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const root = makeKeypair("ed25519");
const stranger = makeKeypair("ed25519");
const chain = await signer.signRaw({
payload: new Uint8Array([1]),
algorithm: "ed25519",
privateKey: root.privateKey,
});
const result = await signer.verify(chain, {
algorithm: "ed25519",
publicKey: stranger.publicKey,
});
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("TRUST_MISMATCH"))).toBe(
true
);
});
it("trustedRoot algorithm mismatch fails with TRUST_MISMATCH", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const root = makeKeypair("ed25519");
const chain = await signer.signRaw({
payload: new Uint8Array([1]),
algorithm: "ed25519",
privateKey: root.privateKey,
});
const result = await signer.verify(chain, {
algorithm: "p256",
publicKey: root.publicKey,
});
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("TRUST_MISMATCH"))).toBe(
true
);
});
});
describe("ChainedKeySigner — verify error paths", () => {
it("returns valid:false when codec decode throws", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const result = await signer.verify(new Uint8Array([99, 99, 99]), {
algorithm: "ed25519",
publicKey: new Uint8Array(32),
});
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.depth).toBe(0);
expect(result.levels).toEqual([]);
});
it("returns valid:false on hasher mismatch", {
timeout: 5000,
}, async () => {
const rawSigner = new ChainedKeySigner({ hasher: new RawHasher() });
const slot = makeSlot();
const chain = await rawSigner.signWithSlot(new Uint8Array([1]), slot);
// Verify with a different hasher
const domainSigner = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "test" }),
});
const result = await domainSigner.verify(chain, {
algorithm: "ed25519",
publicKey: slot.publicKey,
});
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("hasher mismatch"))).toBe(
true
);
});
});
describe("ChainedKeySigner — codec defensive paths", () => {
it("reports unsupported algorithm at verify time when codec emits an unknown algorithm", {
timeout: 5000,
}, async () => {
// A test-only codec that decodes the chain but rewrites the
// algorithm string to something the curve dispatcher does not know.
const realCodec = new TlvChainCodec();
const tamperingCodec = {
name: "tlv" as const,
hasherName: "raw" as "raw" | "domain" | "merkle",
encode(
levels: readonly import("../types/chain-level.js").ChainLevel[]
) {
return realCodec.encode(levels);
},
decode(
bytes: Uint8Array
): readonly import("../types/chain-level.js").ChainLevel[] {
const real = realCodec.decode(bytes);
this.hasherName = realCodec.hasherName;
return real.map((level) => ({
...level,
algorithm:
"fake-curve" as import("../types/chain-level.js").SignAlgorithmId,
}));
},
};
const signer = new ChainedKeySigner();
const kp = makeKeypair("ed25519");
const chain = await signer.signRaw({
payload: new Uint8Array([1]),
algorithm: "ed25519",
privateKey: kp.privateKey,
});
const tamperingSigner = new ChainedKeySigner({ codec: tamperingCodec });
const result = await tamperingSigner.verify(chain, {
algorithm: "ed25519",
publicKey: kp.publicKey,
});
expect(result.valid).toBe(false);
expect(
result.errors.some((e) => e.includes("unsupported algorithm"))
).toBe(true);
});
it("reports empty chain when codec emits zero levels", {
timeout: 5000,
}, async () => {
const emptyCodec = {
name: "tlv" as const,
hasherName: "raw" as "raw" | "domain" | "merkle",
encode() {
return new Uint8Array([1, 0]);
},
decode() {
return [];
},
};
const signer = new ChainedKeySigner({ codec: emptyCodec });
const result = await signer.verify(new Uint8Array([1, 0]), {
algorithm: "ed25519",
publicKey: new Uint8Array(32),
});
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("no root level"))).toBe(
true
);
expect(result.depth).toBe(0);
});
});
describe("ChainedKeySigner — parse", () => {
it("parse decodes 3-level chain without verification", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const root = makeKeypair("ed25519");
const mid = makeKeypair("ed25519");
const leaf = makeKeypair("ed25519");
const rootChain = await signer.signRaw({
payload: root.publicKey,
algorithm: "ed25519",
privateKey: root.privateKey,
});
const midChain = await signer.signRaw({
payload: mid.publicKey,
algorithm: "ed25519",
privateKey: root.privateKey,
parent: rootChain,
});
const leafChain = await signer.signRaw({
payload: leaf.publicKey,
algorithm: "ed25519",
privateKey: mid.privateKey,
parent: midChain,
});
const parsed = await signer.parse(leafChain);
expect(parsed.depth).toBe(3);
expect(parsed.levels.length).toBe(3);
});
it("parse rejects malformed bytes with INVALID_FORMAT", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
await expect(
signer.parse(new Uint8Array([255, 255, 0]))
).rejects.toBeInstanceOf(ChainKeysError);
});
});
describe("ChainedKeySigner — stripToDepth", () => {
it("stripToDepth(2) produces a 2-level chain matching the original leaf", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const root = makeKeypair("ed25519");
const mid = makeKeypair("ed25519");
const leaf = makeKeypair("ed25519");
const rootChain = await signer.signRaw({
payload: root.publicKey,
algorithm: "ed25519",
privateKey: root.privateKey,
});
const midChain = await signer.signRaw({
payload: mid.publicKey,
algorithm: "ed25519",
privateKey: root.privateKey,
parent: rootChain,
});
const leafChain = await signer.signRaw({
payload: leaf.publicKey,
algorithm: "ed25519",
privateKey: mid.privateKey,
parent: midChain,
});
const stripped = await signer.stripToDepth(leafChain, 2);
const parsed = await signer.parse(stripped);
expect(parsed.depth).toBe(2);
// The leaf level (index 0) survives
expect(parsed.levels[0]?.payload).toEqual(leaf.publicKey);
});
it("stripToDepth rejects out-of-range depth with INVALID_DEPTH", {
timeout: 5000,
}, async () => {
const signer = new ChainedKeySigner();
const slot = makeSlot();
const chain = await signer.signWithSlot(new Uint8Array([1]), slot);
await expect(signer.stripToDepth(chain, 0)).rejects.toMatchObject({
code: CHAIN_KEYS_CODES.INVALID_DEPTH,
depth: 0,
});
await expect(signer.stripToDepth(chain, 99)).rejects.toMatchObject({
code: CHAIN_KEYS_CODES.INVALID_DEPTH,
});
await expect(signer.stripToDepth(chain, 1.5)).rejects.toMatchObject({
code: CHAIN_KEYS_CODES.INVALID_DEPTH,
});
});
});
describe("ChainedKeySigner — alternate hashers and codecs", () => {
it("works with DomainHasher", { timeout: 5000 }, async () => {
const signer = new ChainedKeySigner({
hasher: new DomainHasher({ domainTag: "test.chain.v1" }),
});
const kp = makeKeypair("ed25519");
const chain = await signer.signRaw({
payload: new Uint8Array([1, 2, 3]),
algorithm: "ed25519",
privateKey: kp.privateKey,
});
const result = await signer.verify(chain, {
algorithm: "ed25519",
publicKey: kp.publicKey,
});
expect(result.valid).toBe(true);
});
it("works with MerkleHasher", { timeout: 5000 }, async () => {
const signer = new ChainedKeySigner({ hasher: new MerkleHasher() });
const root = makeKeypair("ed25519");
const leaf = makeKeypair("ed25519");
const rootChain = await signer.signRaw({
payload: root.publicKey,
algorithm: "ed25519",
privateKey: root.privateKey,
});
const leafChain = await signer.signRaw({
payload: leaf.publicKey,
algorithm: "ed25519",
privateKey: root.privateKey,
parent: rootChain,
});
const result = await signer.verify(leafChain, {
algorithm: "ed25519",
publicKey: root.publicKey,
});
expect(result.valid).toBe(true);
});
it("works with RecursiveEd25519Codec", { timeout: 5000 }, async () => {
const signer = new ChainedKeySigner({
codec: new RecursiveEd25519Codec(),
});
const kp = makeKeypair("ed25519");
const chain = await signer.signRaw({
payload: new Uint8Array([1, 2, 3]),
algorithm: "ed25519",
privateKey: kp.privateKey,
});
const result = await signer.verify(chain, {
algorithm: "ed25519",
publicKey: kp.publicKey,
});
expect(result.valid).toBe(true);
});
it("works with CborChainCodec", { timeout: 5000 }, async () => {
const signer = new ChainedKeySigner({ codec: new CborChainCodec() });
const kp = makeKeypair("ed25519");
const chain = await signer.signRaw({
payload: new Uint8Array([1, 2, 3]),
algorithm: "ed25519",
privateKey: kp.privateKey,
});
const result = await signer.verify(chain, {
algorithm: "ed25519",
publicKey: kp.publicKey,
});
expect(result.valid).toBe(true);
});
});
describe("ChainedKeySigner — tier purity", () => {
it("signer module has no node/bun imports (tier purity)", {
timeout: 5000,
}, () => {
// Import is exercised at module load (top of this test file); if a
// node:/bun: import had crept in, the universal vitest run
// would already have failed. The named test exists so the spec's
// `Verified by:` line has a concrete anchor.
expect(ChainedKeySigner).toBeDefined();
});
it("subpath export resolves to signer module", { timeout: 5000 }, () => {
// Production verifies the subpath export through the build step;
// here we assert the class is importable from the local path,
// which is what the subpath export aliases to at publish time.
expect(new ChainedKeySigner()).toBeInstanceOf(ChainedKeySigner);
});
});

View File

@@ -0,0 +1,298 @@
import { ALGORITHMS } from "@suaveplan/crypto/keychain";
import * as curve from "@suaveplan/crypto-curve";
import { sha256, sha384, sha512 } from "@suaveplan/crypto-hash";
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import { TlvChainCodec } from "../codecs/tlv/tlv.js";
import {
CHAIN_KEYS_CODES,
ChainKeysError,
} from "../error-codes/error-codes.js";
import { RawHasher } from "../hashers/raw/raw.js";
import type {
ChainLevel,
SignAlgorithmId,
VerificationResult,
} from "../types/chain-level.js";
import type { ChainCodec } from "../types/codec.js";
import type { ChainHasher } from "../types/hasher.js";
import type {
ISigner,
SignerOptions,
SignRawOptions,
TrustedRoot,
} from "./signer.types.js";
type CurveModule = {
sign(messageOrDigest: Uint8Array, privateKey: Uint8Array): Uint8Array;
verify(
signature: Uint8Array,
messageOrDigest: Uint8Array,
publicKey: Uint8Array
): boolean;
getPublicKey(privateKey: Uint8Array): Uint8Array;
};
const CURVES: Record<SignAlgorithmId, CurveModule> = {
ed25519: curve.ed25519,
secp256k1: curve.secp256k1,
p256: curve.p256,
p384: curve.p384,
p521: curve.p521,
};
/**
* Maps each algorithm to the digest function applied BEFORE the curve's
* `sign(...)` call. Ed25519 hashes internally per RFC 8032, so the signer
* passes the raw message through. The P-curves and secp256k1 take a
* fixed-size digest; the pairings here follow the standard hash/curve
* matchup (NIST SP 800-186): sha256 with secp256k1/p256, sha384 with p384,
* sha512 with p521.
*/
const DIGEST_FOR_ALGORITHM: Record<
SignAlgorithmId,
(message: Uint8Array) => Uint8Array
> = {
ed25519: (msg) => msg,
secp256k1: (msg) => sha256(msg),
p256: (msg) => sha256(msg),
p384: (msg) => sha384(msg),
p521: (msg) => sha512(msg),
};
/**
* Orchestrates a chain of signed payloads. Composes a `ChainHasher` (which
* decides what bytes get signed for each level) and a `ChainCodec` (which
* decides the wire format) and dispatches the actual sign/verify calls to
* `@suaveplan/crypto-curve` per the level's algorithm.
*/
export class ChainedKeySigner implements ISigner {
private readonly hasher: ChainHasher;
private readonly codec: ChainCodec & {
hasherName: "raw" | "domain" | "merkle";
};
public constructor(options: SignerOptions = {}) {
this.hasher = options.hasher ?? new RawHasher();
this.codec = (options.codec ?? new TlvChainCodec()) as ChainCodec & {
hasherName: "raw" | "domain" | "merkle";
};
this.codec.hasherName = this.hasher.name;
}
public async signWithSlot(
payload: Uint8Array,
signSlot: {
algorithm: number;
secretKey: Uint8Array;
publicKey: Uint8Array;
},
parent?: Uint8Array
): Promise<Uint8Array> {
if (signSlot.algorithm !== ALGORITHMS.ED25519) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
`signWithSlot only supports Ed25519 in v0.0.0 (got algorithm ${signSlot.algorithm})`,
{ algorithm: signSlot.algorithm }
);
}
if (
!(signSlot.secretKey instanceof Uint8Array) ||
signSlot.secretKey.length === 0
) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_KEYCHAIN,
"signWithSlot requires a slot with non-empty secretKey"
);
}
const opts: SignRawOptions = parent
? {
payload,
algorithm: "ed25519",
privateKey: signSlot.secretKey,
publicKey: signSlot.publicKey,
parent,
}
: {
payload,
algorithm: "ed25519",
privateKey: signSlot.secretKey,
publicKey: signSlot.publicKey,
};
return this.signRaw(opts);
}
public async signRaw(options: SignRawOptions): Promise<Uint8Array> {
const { payload, algorithm, privateKey, publicKey, parent } = options;
const curveMod = CURVES[algorithm];
if (!curveMod) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
`unknown algorithm ${String(algorithm)}`,
{ algorithm: algorithm as SignAlgorithmId }
);
}
const derivedPublic = wrapCurveCall(
() => curveMod.getPublicKey(privateKey),
algorithm
);
if (
publicKey !== undefined &&
!constantTimeEqual(publicKey, derivedPublic)
) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_KEYCHAIN,
"publicKey does not match the public key derived from privateKey",
{ algorithm }
);
}
const signerPublicKey = publicKey ?? derivedPublic;
const parentLevels = parent ? this.codec.decode(parent) : [];
const parentLevel = parentLevels[0];
const bytesToSign = this.hasher.hash(payload, parentLevel);
const digest = DIGEST_FOR_ALGORITHM[algorithm](bytesToSign);
const signature = wrapCurveCall(
() => curveMod.sign(digest, privateKey),
algorithm
);
const newLevel: ChainLevel = {
algorithm,
signerPublicKey,
payload,
signature,
};
return this.codec.encode([newLevel, ...parentLevels]);
}
public async verify(
chainBytes: Uint8Array,
trustedRoot: TrustedRoot
): Promise<VerificationResult> {
let levels: readonly ChainLevel[];
try {
levels = this.codec.decode(chainBytes);
} catch (err) {
// Codecs throw ChainKeysError (extends Error); no non-Error throws
// exist on the public surface, so casting is safe.
return {
valid: false,
depth: 0,
levels: [],
errors: [(err as Error).message],
};
}
// After decode, the codec carries the hasher name read from the wire.
// Reproduce it on the local hasher if the names mismatch — verify
// remains decoupled from the signer's runtime hasher choice. We use
// the codec's recorded name to know which hasher mode is canonical
// for this chain.
const wireHasherName = this.codec.hasherName;
if (wireHasherName !== this.hasher.name) {
return {
valid: false,
depth: levels.length,
levels,
errors: [
`hasher mismatch: chain encoded with ${wireHasherName}, signer configured with ${this.hasher.name}`,
],
};
}
const errors: string[] = [];
for (let i = 0; i < levels.length; i++) {
// `levels` comes from the codec; under `noUncheckedIndexedAccess`
// the indexer types this as `T | undefined`, but the loop bound
// makes it a non-undefined value.
const level = levels[i] as ChainLevel;
const parentLevel = levels[i + 1];
const curveMod = CURVES[level.algorithm];
if (!curveMod) {
errors.push(
`level ${i}: unsupported algorithm ${String(level.algorithm)}`
);
continue;
}
const bytesToVerify = this.hasher.hash(level.payload, parentLevel);
const digest = DIGEST_FOR_ALGORITHM[level.algorithm](bytesToVerify);
const isValid = wrapCurveCall(
() =>
curveMod.verify(
level.signature,
digest,
level.signerPublicKey
),
level.algorithm
);
if (!isValid) {
errors.push(`signature verify failed at depth ${i}`);
}
}
const rootLevel = levels[levels.length - 1];
if (!rootLevel) {
// Empty chain — codec returned [] without raising INVALID_FORMAT.
// The level-loop above produced no entries either.
return {
valid: false,
depth: 0,
levels,
errors: ["chain has no root level"],
};
}
if (rootLevel.algorithm !== trustedRoot.algorithm) {
errors.push(
`TRUST_MISMATCH: root algorithm ${rootLevel.algorithm} does not match trustedRoot.algorithm ${trustedRoot.algorithm}`
);
} else if (
!constantTimeEqual(rootLevel.signerPublicKey, trustedRoot.publicKey)
) {
errors.push(
"TRUST_MISMATCH: root signerPublicKey does not match trustedRoot.publicKey"
);
}
return {
valid: errors.length === 0,
depth: levels.length,
levels,
errors,
};
}
public async parse(
chainBytes: Uint8Array
): Promise<{ depth: number; levels: readonly ChainLevel[] }> {
const levels = this.codec.decode(chainBytes);
return { depth: levels.length, levels };
}
public async stripToDepth(
chainBytes: Uint8Array,
depth: number
): Promise<Uint8Array> {
const levels = this.codec.decode(chainBytes);
if (!Number.isInteger(depth) || depth < 1 || depth > levels.length) {
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_DEPTH,
`depth must be an integer in [1, ${levels.length}] (got ${depth})`,
{ depth }
);
}
return this.codec.encode(levels.slice(0, depth));
}
}
function wrapCurveCall<T>(fn: () => T, algorithm: SignAlgorithmId): T {
try {
return fn();
} catch (err) {
// crypto-curve primitives throw Error instances (noble / WebCrypto).
const cause = err as Error;
throw new ChainKeysError(
CHAIN_KEYS_CODES.INVALID_KEYCHAIN,
`crypto-curve.${algorithm} call failed: ${cause.message}`,
{ algorithm, cause }
);
}
}

View File

@@ -0,0 +1,66 @@
import type {
ChainLevel,
SignAlgorithmId,
VerificationResult,
} from "../types/chain-level.js";
import type { ChainCodec } from "../types/codec.js";
import type { ChainHasher } from "../types/hasher.js";
/** Constructor options for `ChainedKeySigner`. */
export interface SignerOptions {
/** Hasher strategy. Defaults to `new RawHasher()`. */
readonly hasher?: ChainHasher;
/** Codec strategy. Defaults to `new TlvChainCodec()`. */
readonly codec?: ChainCodec;
}
/** Options for `ChainedKeySigner.signRaw(...)`. */
export interface SignRawOptions {
/** Bytes the signer is being asked to sign. Opaque to the package. */
readonly payload: Uint8Array;
/** Which signing curve to use. */
readonly algorithm: SignAlgorithmId;
/** Private key bytes for the chosen algorithm. */
readonly privateKey: Uint8Array;
/**
* Public key for this level. When omitted, the signer derives it from
* `privateKey` via `crypto-curve.<algorithm>.getPublicKey(privateKey)`.
* When supplied, the signer verifies it matches the derived public key
* and raises `CHAIN_KEYS.INVALID_KEYCHAIN` on mismatch.
*/
readonly publicKey?: Uint8Array;
/** Existing parent chain bytes, if any. */
readonly parent?: Uint8Array;
}
/** Trusted root specification passed to `verify(...)`. */
export interface TrustedRoot {
readonly algorithm: SignAlgorithmId;
readonly publicKey: Uint8Array;
}
/** Public contract for the chain signer. */
export interface ISigner {
signWithSlot(
payload: Uint8Array,
signSlot: {
algorithm: number;
secretKey: Uint8Array;
publicKey: Uint8Array;
},
parent?: Uint8Array
): Promise<Uint8Array>;
signRaw(options: SignRawOptions): Promise<Uint8Array>;
verify(
chainBytes: Uint8Array,
trustedRoot: TrustedRoot
): Promise<VerificationResult>;
parse(
chainBytes: Uint8Array
): Promise<{ depth: number; levels: readonly ChainLevel[] }>;
stripToDepth(chainBytes: Uint8Array, depth: number): Promise<Uint8Array>;
}

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,60 @@
/**
* String-literal union of every signing algorithm supported by
* `@suaveplan/crypto-curve`. Distinct from `@suaveplan/crypto`'s numeric
* `AlgorithmId` (which covers AEADs and KDFs as well); `SignAlgorithmId`
* is the subset this package actually signs with.
*/
export type SignAlgorithmId =
| "ed25519"
| "secp256k1"
| "p256"
| "p384"
| "p521";
/** Set of every valid `SignAlgorithmId` for runtime validation. */
export const SIGN_ALGORITHMS = new Set<SignAlgorithmId>([
"ed25519",
"secp256k1",
"p256",
"p384",
"p521",
]);
/**
* One level of a chain. `payload` is opaque to the package — typically the
* output of `encodePublicKeychain(...)` for keychain chains, but consumers
* are free to sign any byte sequence (canonical JSON, CBOR, manifest digest,
* etc.) and walk the chain identically.
*
* `signerPublicKey` is recorded per level so verifiers can dispatch
* `crypto-curve.verify(...)` without parsing the payload.
*/
export interface ChainLevel {
readonly algorithm: SignAlgorithmId;
readonly signerPublicKey: Uint8Array;
readonly payload: Uint8Array;
readonly signature: Uint8Array;
}
/**
* Same shape as `ChainLevel`, exposed under a separate name so callers
* passing a previously-parsed level into `ChainHasher.hash(...)` (e.g.,
* `MerkleHasher` reading the parent's payload + signature) have a stable
* argument type that documents the intent.
*/
export type ParsedChainLevel = ChainLevel;
/**
* Result of `ChainedKeySigner.verify(...)`. `valid` is `true` only when
* every level's signature verifies AND the root level's
* `(algorithm, signerPublicKey)` matches the supplied `trustedRoot`.
* On failure, `errors` carries human-readable diagnostics; `levels`
* still reflects whatever the codec could decode (or is empty if decode
* itself failed).
*/
export interface VerificationResult {
readonly valid: boolean;
readonly depth: number;
readonly levels: readonly ChainLevel[];
readonly errors: readonly string[];
}

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,32 @@
import type { ChainLevel } from "./chain-level.js";
/**
* Wire-format codec for a signed chain. Encodes / decodes the array of
* `ChainLevel` values that a `ChainedKeySigner` produces and consumes.
*
* Three shipped implementations:
*
* - `TlvChainCodec` — self-describing length-prefixed format; default
* - `RecursiveEd25519Codec` — fixed-Ed25519 recursive wire (32-byte pubkey, 64-byte sig)
* - `CborChainCodec` — CBOR-encoded; depends on `@suaveplan/cbor`
*
* Every implementation MUST round-trip: `decode(encode(levels))` MUST
* produce a `ChainLevel[]` deep-equal to the input for any valid input.
* Decoders MUST raise `ChainKeysError` with `code === "CHAIN_KEYS.INVALID_FORMAT"`
* for malformed wire bytes (wrong version, truncation, length overrun,
* unknown hasher byte) and `code === "CHAIN_KEYS.UNSUPPORTED_ALGORITHM"`
* for algorithm bytes the codec does not support.
*
* Codecs are stateless and re-usable; a single instance MAY be shared
* across many `ChainedKeySigner` calls and across threads.
*/
export interface ChainCodec {
/** Encode a chain (leaf-first) to bytes. */
encode(levels: readonly ChainLevel[]): Uint8Array;
/** Decode bytes into a chain (leaf-first). */
decode(bytes: Uint8Array): readonly ChainLevel[];
/** Short canonical name — surfaced in diagnostic output. */
readonly name: "tlv" | "recursive-ed25519" | "cbor";
}

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.

View File

@@ -0,0 +1,32 @@
import type { ParsedChainLevel } from "./chain-level.js";
/**
* Strategy that computes the bytes-to-sign for a single chain level.
*
* Three shipped implementations:
*
* - `RawHasher` — returns the payload bytes unchanged
* - `DomainHasher({ domainTag })` — returns `sha256(utf8(domainTag) || payload)`
* - `MerkleHasher` — for the root, `sha256(payload)`; otherwise
* `sha256(payload || sha256(parent.payload || parent.signature))`,
* committing each non-root level to its parent's content
*
* Consumers MAY implement their own — the constructor signature of
* `ChainedKeySigner` accepts any object satisfying this interface, so
* applications can plug in a domain-specific hashing strategy
* (e.g., one that incorporates a per-tenant secret) without changing
* the codec or wire format.
*/
export interface ChainHasher {
/**
* Compute the bytes to be signed for one level.
*
* @param payload The payload bytes being signed for this level.
* @param parent The previously-signed parent level (decoded by
* the codec), if any. `undefined` for the root.
*/
hash(payload: Uint8Array, parent?: ParsedChainLevel): Uint8Array;
/** Short canonical name of this hasher — surfaced in diagnostic output. */
readonly name: "raw" | "domain" | "merkle";
}