Initial Commit
This commit is contained in:
192
packages/security-stack/crypto/crypto-keys/README.md
Normal file
192
packages/security-stack/crypto/crypto-keys/README.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# @suaveplan/crypto-keys - Hierarchical keychain chain-signing for the crypto ecosystem: pluggable ChainHasher + ChainCodec interfaces with three shipped implementations each (RawHasher / DomainHasher / MerkleHasher and TlvChainCodec / RecursiveEd25519Codec / CborChainCodec); ChainedKeySigner class with per-slot algorithm dispatch across Ed25519/secp256k1/p256/p384/p521; hierarchical key factory (createRootKeysWithChain / createServerKeysWithChain / rollServerKeysWithChain). Universal.
|
||||
|
||||
Hierarchical keychain chain-signing for the crypto ecosystem.
|
||||
Universal, store-agnostic, free of `node:` / `bun:` imports.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [cbor](./src/codecs/cbor/cbor.md) — `CborChainCodec` is the CBOR-based wire-format `ChainCodec` for `@suaveplan/crypto-keys`. It serialises an ordered list…
|
||||
- [cbor](./src/codecs/cbor/cbor.md) — `CborChainCodec` is the CBOR-based wire-format `ChainCodec` for `@suaveplan/crypto-keys`. It serialises an ordered list…
|
||||
- [recursive-ed25519](./src/codecs/recursive-ed25519/recursive-ed25519.md) — `RecursiveEd25519Codec` is a fixed-Ed25519 alternate wire format for signed key chains in `@suaveplan/crypto-keys`. It…
|
||||
- [recursive-ed25519](./src/codecs/recursive-ed25519/recursive-ed25519.md) — `RecursiveEd25519Codec` is a fixed-Ed25519 alternate wire format for signed key chains in `@suaveplan/crypto-keys`. It…
|
||||
- [tlv](./src/codecs/tlv/tlv.md) — `TlvChainCodec` is the default wire-format codec for `@suaveplan/crypto-keys`. It encodes and decodes the `ChainLevel[]…
|
||||
- [tlv](./src/codecs/tlv/tlv.md) — `TlvChainCodec` is the default wire-format codec for `@suaveplan/crypto-keys`. It encodes and decodes the `ChainLevel[]…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/crypto-keys` package centralises every failure mode it can produce behind a single registry built with…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/crypto-keys` package centralises every failure mode it can produce behind a single registry built with…
|
||||
- [factory](./src/factory/factory.md) — Hierarchical-keychain factory helpers that compose `generateKeychain` from `@suaveplan/crypto` with `ChainedKeySigner.s…
|
||||
- [factory](./src/factory/factory.md) — Hierarchical-keychain factory helpers that compose `generateKeychain` from `@suaveplan/crypto` with `ChainedKeySigner.s…
|
||||
- [domain](./src/hashers/domain/domain.md) — `DomainHasher` is the `ChainHasher` strategy that domain-separates the signed pre-image with a caller-supplied tag and…
|
||||
- [domain](./src/hashers/domain/domain.md) — `DomainHasher` is the `ChainHasher` strategy that domain-separates the signed pre-image with a caller-supplied tag and…
|
||||
- [merkle](./src/hashers/merkle/merkle.md) — `MerkleHasher` is the `ChainHasher` strategy that binds each non-root level to its parent's content via a Merkle-style…
|
||||
- [merkle](./src/hashers/merkle/merkle.md) — `MerkleHasher` is the `ChainHasher` strategy that binds each non-root level to its parent's content via a Merkle-style…
|
||||
- [raw](./src/hashers/raw/raw.md) — `RawHasher` is the trivial `ChainHasher` strategy: it returns the payload bytes verbatim, performing no transformation.…
|
||||
- [raw](./src/hashers/raw/raw.md) — `RawHasher` is the trivial `ChainHasher` strategy: it returns the payload bytes verbatim, performing no transformation.…
|
||||
- [signer](./src/signer/signer.md) — `ChainedKeySigner` composes a `ChainHasher` (bytes-to-sign strategy) and a `ChainCodec` (wire-format strategy) to produ…
|
||||
- [signer](./src/signer/signer.md) — `ChainedKeySigner` composes a `ChainHasher` (bytes-to-sign strategy) and a `ChainCodec` (wire-format strategy) to produ…
|
||||
- [chain-level](./src/types/chain-level.md) — Shared type module declaring the value-shapes that every other module in `@suaveplan/crypto-keys` references. Defining…
|
||||
- [codec](./src/types/codec.md) — `ChainCodec` is the wire-format strategy that turns an array of `ChainLevel` values into bytes and back. Decoupling the…
|
||||
- [hasher](./src/types/hasher.md) — `ChainHasher` is the strategy interface that decides what bytes a `ChainedKeySigner` actually signs for one chain level…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Ships strong primitives in `@suaveplan/crypto`,
|
||||
`@suaveplan/crypto-curve`, and `@suaveplan/crypto-hash`, plus a
|
||||
keychain layer in `@suaveplan/crypto` itself (`encodePublicKeychain`,
|
||||
`generateKeychain`, per-slot `AlgorithmId`). What it does not ship is
|
||||
the *pattern* of binding a chain of public keychains together with
|
||||
signatures — root signs server, server signs user, and so on — with
|
||||
end-to-end verification back to a trusted root. This package fills
|
||||
that gap.
|
||||
|
||||
The design is pluggable. Two interfaces (`ChainHasher` for the
|
||||
bytes-to-sign and `ChainCodec` for the wire format) decouple the three
|
||||
shipped hash modes (raw / domain-tagged / merkle) from the three
|
||||
shipped codecs (TLV / recursive-Ed25519 / CBOR). The `ChainedKeySigner`
|
||||
class composes a hasher + codec and dispatches signing per-algorithm
|
||||
to the matching `@suaveplan/crypto-curve` primitive — so a single
|
||||
chain can mix Ed25519, secp256k1, and the P-curves across its levels.
|
||||
|
||||
## Features
|
||||
|
||||
- Three shipped hashers — `RawHasher`, `DomainHasher({ domainTag })`, `MerkleHasher`
|
||||
- Three shipped codecs — `TlvChainCodec` (default), `RecursiveEd25519Codec`, `CborChainCodec`
|
||||
- `ChainedKeySigner` class — `sign` / `verify` / `parse` / `stripToDepth`
|
||||
- Per-slot algorithm dispatch across Ed25519 / secp256k1 / P-256 / P-384 / P-521
|
||||
- Hierarchical factory — `createRootKeysWithChain`, `createServerKeysWithChain`, `rollServerKeysWithChain`
|
||||
- `ChainKeysError` extending `SuaveplanError` with `depth?` / `algorithm?` discriminants and 7 stable `CHAIN_KEYS.*` codes
|
||||
- Constant-time root-key compare via `@suaveplan/crypto-utils`
|
||||
- 13 tree-shakeable subpath exports — import the codec you want without dragging in the rest
|
||||
- 100% statement / branch / function / line coverage via Istanbul
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChainedKeySigner,
|
||||
createRootKeysWithChain,
|
||||
createServerKeysWithChain,
|
||||
} from "@suaveplan/crypto-keys";
|
||||
|
||||
// Build a 2-level chain: root → server
|
||||
const { keys: rootKeys, chain: rootChain } = await createRootKeysWithChain();
|
||||
const { keys: serverKeys, chain: serverChain } = await createServerKeysWithChain({
|
||||
rootKeys,
|
||||
rootChain,
|
||||
});
|
||||
|
||||
// Verify end-to-end against the trusted root sign-slot
|
||||
const signer = new ChainedKeySigner();
|
||||
const result = await signer.verify(serverChain, rootKeys.sign.public);
|
||||
if (!result.valid) throw new Error(result.errors.join("; "));
|
||||
console.log("Chain depth:", result.depth); // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChainedKeySigner,
|
||||
DomainHasher,
|
||||
CborChainCodec,
|
||||
createRootKeysWithChain,
|
||||
createServerKeysWithChain,
|
||||
rollServerKeysWithChain,
|
||||
} from "@suaveplan/crypto-keys";
|
||||
|
||||
// Domain-tagged signing inside CBOR-encoded chains
|
||||
const signer = new ChainedKeySigner({
|
||||
hasher: new DomainHasher({ domainTag: "acme/key-chain-v1" }),
|
||||
codec: new CborChainCodec(),
|
||||
});
|
||||
|
||||
const { keys: rootKeys, chain: rootChain } = await createRootKeysWithChain({ signer });
|
||||
const { keys: serverV1, chain: chainV1 } = await createServerKeysWithChain({
|
||||
rootKeys,
|
||||
rootChain,
|
||||
signer,
|
||||
});
|
||||
|
||||
// Six months later, roll the server keychain forward
|
||||
const { keys: serverV2, chain: chainV2 } = await rollServerKeysWithChain({
|
||||
rootKeys,
|
||||
rootChain,
|
||||
currentVersion: serverV1.master.private.layout.fields.version,
|
||||
signer,
|
||||
});
|
||||
|
||||
// Hand a verifier a partial chain (just the leaf + its signature)
|
||||
const leafOnly = await signer.stripToDepth(chainV2, 1);
|
||||
|
||||
// Verifiers reproduce the same hasher + codec to verify
|
||||
const verifier = new ChainedKeySigner({
|
||||
hasher: new DomainHasher({ domainTag: "acme/key-chain-v1" }),
|
||||
codec: new CborChainCodec(),
|
||||
});
|
||||
const result = await verifier.verify(chainV2, rootKeys.sign.public);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Class `ChainedKeySigner`
|
||||
|
||||
| Method | Signature |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `constructor` | `(options?: { hasher?: ChainHasher; codec?: ChainCodec })` |
|
||||
| `sign` | `(publicKeys: PublicKeychain, signSlot: KeySlotPrivate, parent?: Uint8Array) => Promise<Uint8Array>` |
|
||||
| `verify` | `(chainBytes: Uint8Array, trustedRoot: KeySlotPublic) => Promise<VerificationResult>` |
|
||||
| `parse` | `(chainBytes: Uint8Array) => Promise<{ depth: number; levels: readonly ChainLevel[] }>` |
|
||||
| `stripToDepth` | `(chainBytes: Uint8Array, depth: number) => Promise<Uint8Array>` |
|
||||
|
||||
### Hashers
|
||||
|
||||
- `class RawHasher implements ChainHasher`
|
||||
- `class DomainHasher implements ChainHasher` — constructor `({ domainTag: string })`, tag must be 1–64 ASCII bytes
|
||||
- `class MerkleHasher implements ChainHasher`
|
||||
|
||||
### Codecs
|
||||
|
||||
- `class TlvChainCodec implements ChainCodec` (default)
|
||||
- `class RecursiveEd25519Codec implements ChainCodec` — Ed25519 only
|
||||
- `class CborChainCodec implements ChainCodec`
|
||||
|
||||
### Factory functions
|
||||
|
||||
- `async function createRootKeysWithChain(options?: { signer?: ChainedKeySigner; generate?: GenerateKeychainOptions }): Promise<{ keys: Keychain; chain: Uint8Array }>`
|
||||
- `async function createServerKeysWithChain(options: { rootKeys: Keychain; rootChain: Uint8Array; signer?: ChainedKeySigner; generate?: GenerateKeychainOptions }): Promise<{ keys: Keychain; chain: Uint8Array }>`
|
||||
- `async function rollServerKeysWithChain(options: { rootKeys: Keychain; rootChain: Uint8Array; currentVersion: number; signer?: ChainedKeySigner }): Promise<{ keys: Keychain; chain: Uint8Array }>`
|
||||
|
||||
### Errors
|
||||
|
||||
- `class ChainKeysError extends SuaveplanError<ChainKeysErrorCode>` with optional `depth?: number` and `algorithm?: AlgorithmId` discriminants
|
||||
- `CHAIN_KEYS_CODES` — frozen registry of 7 codes: `UNSUPPORTED_ALGORITHM`, `INVALID_FORMAT`, `INVALID_SIGNATURE`, `TRUST_MISMATCH`, `INVALID_DEPTH`, `INVALID_KEYCHAIN`, `INVALID_DOMAIN_TAG`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The package is universal — no `node:` / `bun:` imports anywhere
|
||||
in `src/`. All cryptographic primitives come from
|
||||
`@suaveplan/crypto-curve` (per-algorithm `sign(digest, privateKey)` /
|
||||
`verify(...)`) and `@suaveplan/crypto-hash` (`sha256` for the
|
||||
`DomainHasher` / `MerkleHasher`). Constant-time byte compare comes
|
||||
from `@suaveplan/crypto-utils`.
|
||||
|
||||
The wire format is **self-describing**. Every codec prepends
|
||||
`[version: u8][hasher: u8]` so a verifier reproduces the exact hash
|
||||
that was signed. The TLV codec also tags each level with its
|
||||
algorithm byte, letting a chain mix algorithms across levels — useful
|
||||
when the root uses Ed25519 for speed and intermediate authorities
|
||||
use P-256 for compliance.
|
||||
|
||||
Signing dispatches at the slot level: the signer reads
|
||||
`signSlot.algorithm` and calls the matching `crypto-curve` primitive.
|
||||
This means upstream pre-suaveplan chain bytes are **not** byte-compatible
|
||||
with this package — they were emitted by an older bitpacker-based
|
||||
scheme. Migration is one-way: re-sign chains in the new format.
|
||||
|
||||
Errors are never raw `Error` — every failure goes through `ChainKeysError`
|
||||
with a stable `CHAIN_KEYS.*` code. Verification failures don't throw;
|
||||
they return `{ valid: false, errors: [...] }` so callers can chain
|
||||
multiple verification attempts without try/catch. Only construction-time
|
||||
errors (e.g. an invalid `domainTag`) throw synchronously.
|
||||
|
||||
182
packages/security-stack/crypto/crypto-keys/package.json
Normal file
182
packages/security-stack/crypto/crypto-keys/package.json
Normal file
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"name": "@suaveplan/crypto-keys",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Hierarchical keychain chain-signing for the genesis crypto ecosystem: pluggable ChainHasher + ChainCodec interfaces with three shipped implementations each (RawHasher / DomainHasher / MerkleHasher and TlvChainCodec / RecursiveEd25519Codec / CborChainCodec); ChainedKeySigner class with per-slot algorithm dispatch across Ed25519/secp256k1/p256/p384/p521; hierarchical key factory (createRootKeysWithChain / createServerKeysWithChain / rollServerKeysWithChain). Universal.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./types/chain-level": {
|
||||
"import": {
|
||||
"types": "./dist/types/chain-level.d.ts",
|
||||
"default": "./dist/types/chain-level.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/chain-level.d.ts",
|
||||
"default": "./dist/types/chain-level.js"
|
||||
}
|
||||
},
|
||||
"./types/hasher": {
|
||||
"import": {
|
||||
"types": "./dist/types/hasher.d.ts",
|
||||
"default": "./dist/types/hasher.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/hasher.d.ts",
|
||||
"default": "./dist/types/hasher.js"
|
||||
}
|
||||
},
|
||||
"./types/codec": {
|
||||
"import": {
|
||||
"types": "./dist/types/codec.d.ts",
|
||||
"default": "./dist/types/codec.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/codec.d.ts",
|
||||
"default": "./dist/types/codec.js"
|
||||
}
|
||||
},
|
||||
"./hashers/raw/raw": {
|
||||
"import": {
|
||||
"types": "./dist/hashers/raw/raw.d.ts",
|
||||
"default": "./dist/hashers/raw/raw.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/hashers/raw/raw.d.ts",
|
||||
"default": "./dist/hashers/raw/raw.js"
|
||||
}
|
||||
},
|
||||
"./hashers/domain/domain": {
|
||||
"import": {
|
||||
"types": "./dist/hashers/domain/domain.d.ts",
|
||||
"default": "./dist/hashers/domain/domain.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/hashers/domain/domain.d.ts",
|
||||
"default": "./dist/hashers/domain/domain.js"
|
||||
}
|
||||
},
|
||||
"./hashers/merkle/merkle": {
|
||||
"import": {
|
||||
"types": "./dist/hashers/merkle/merkle.d.ts",
|
||||
"default": "./dist/hashers/merkle/merkle.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/hashers/merkle/merkle.d.ts",
|
||||
"default": "./dist/hashers/merkle/merkle.js"
|
||||
}
|
||||
},
|
||||
"./codecs/tlv/tlv": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/tlv/tlv.d.ts",
|
||||
"default": "./dist/codecs/tlv/tlv.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/codecs/tlv/tlv.d.ts",
|
||||
"default": "./dist/codecs/tlv/tlv.js"
|
||||
}
|
||||
},
|
||||
"./codecs/recursive-ed25519/recursive-ed25519": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/recursive-ed25519/recursive-ed25519.d.ts",
|
||||
"default": "./dist/codecs/recursive-ed25519/recursive-ed25519.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/codecs/recursive-ed25519/recursive-ed25519.d.ts",
|
||||
"default": "./dist/codecs/recursive-ed25519/recursive-ed25519.js"
|
||||
}
|
||||
},
|
||||
"./codecs/cbor/cbor": {
|
||||
"import": {
|
||||
"types": "./dist/codecs/cbor/cbor.d.ts",
|
||||
"default": "./dist/codecs/cbor/cbor.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/codecs/cbor/cbor.d.ts",
|
||||
"default": "./dist/codecs/cbor/cbor.js"
|
||||
}
|
||||
},
|
||||
"./signer/signer": {
|
||||
"import": {
|
||||
"types": "./dist/signer/signer.d.ts",
|
||||
"default": "./dist/signer/signer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/signer/signer.d.ts",
|
||||
"default": "./dist/signer/signer.js"
|
||||
}
|
||||
},
|
||||
"./factory/factory": {
|
||||
"import": {
|
||||
"types": "./dist/factory/factory.d.ts",
|
||||
"default": "./dist/factory/factory.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/factory/factory.d.ts",
|
||||
"default": "./dist/factory/factory.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/cbor": "workspace:^",
|
||||
"@suaveplan/crypto": "workspace:^",
|
||||
"@suaveplan/crypto-curve": "workspace:^",
|
||||
"@suaveplan/crypto-hash": "workspace:^",
|
||||
"@suaveplan/crypto-utils": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# CborChainCodec
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`CborChainCodec` is the CBOR-based wire-format `ChainCodec` for `@suaveplan/crypto-keys`. It serialises an ordered list of `ChainLevel` values (leaf-first, root-last) to a single `Uint8Array` and decodes the same bytes back to a deep-equal list of levels. Where the default `TlvChainCodec` is a tight self-describing length-prefixed binary, `CborChainCodec` trades a few bytes of overhead for a fully RFC 8949 compliant payload that can be inspected, diffed, and re-emitted with any other CBOR tool in the ecosystem.
|
||||
|
||||
The wire layout is a single version byte followed by a deterministic CBOR map. Deterministic CBOR encoding (RFC 8949 §4.2) guarantees that byte-identical inputs produce byte-identical wire bytes regardless of host, runtime, or call order — which is the contract callers rely on when chains are content-addressable, hashed, or re-signed by an outer layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Single version-byte header (`0x01`) gates future wire revisions without an in-band CBOR migration.
|
||||
- Payload is a deterministic CBOR map `{ "v": 1, "h": <hasher>, "levels": [...] }` with one nested map per level using short keys `a` / `p` / `k` / `s`.
|
||||
- Hasher name (`raw` | `domain` | `merkle`) round-trips on the `h` field so a downstream verifier can reconstruct a matching `ChainHasher` without out-of-band hints.
|
||||
- Decoder rejects unknown versions, unknown hasher names, unknown algorithms, missing fields, non-byte-string `p`/`k`/`s` values, malformed CBOR, and any trailing bytes after the CBOR top-level item.
|
||||
- Decoded `Uint8Array` fields are copies, so mutating the input buffer after decode never aliases into the returned levels.
|
||||
- Stateless and reusable: a single instance MAY be shared across calls and across threads. The only mutable field is `hasherName`, which is read at encode time and written at decode time.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CborChainCodec } from "@suaveplan/crypto-keys/codecs/cbor/cbor";
|
||||
import type { ChainLevel } from "@suaveplan/crypto-keys/types/chain-level";
|
||||
|
||||
const codec = new CborChainCodec();
|
||||
const levels: ChainLevel[] = [
|
||||
{
|
||||
algorithm: "ed25519",
|
||||
signerPublicKey: new Uint8Array(32),
|
||||
payload: new Uint8Array([0x01, 0x02, 0x03]),
|
||||
signature: new Uint8Array(64),
|
||||
},
|
||||
];
|
||||
|
||||
const bytes = codec.encode(levels);
|
||||
const decoded = codec.decode(bytes);
|
||||
// decoded is deep-equal to levels; codec.hasherName === "raw"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pair the codec with a non-`raw` hasher so the wire bytes carry the hasher tag a verifier needs:
|
||||
|
||||
```ts
|
||||
import { CborChainCodec } from "@suaveplan/crypto-keys/codecs/cbor/cbor";
|
||||
import type { ChainLevel } from "@suaveplan/crypto-keys/types/chain-level";
|
||||
|
||||
const encoder = new CborChainCodec();
|
||||
encoder.hasherName = "merkle";
|
||||
const wireBytes = encoder.encode(chain);
|
||||
|
||||
// On the verifier side: a fresh codec decodes the bytes and reports the
|
||||
// hasher used at encode time. The verifier then constructs a matching
|
||||
// `MerkleHasher` and walks the chain.
|
||||
const verifier = new CborChainCodec();
|
||||
const levels = verifier.decode(wireBytes);
|
||||
if (verifier.hasherName !== "merkle") {
|
||||
throw new Error("hasher mismatch — refusing to verify");
|
||||
}
|
||||
|
||||
// Deterministic encoding: re-encoding the decoded levels with the same
|
||||
// hasher must reproduce the original wire bytes byte-for-byte.
|
||||
const reEncoder = new CborChainCodec();
|
||||
reEncoder.hasherName = verifier.hasherName;
|
||||
const reEncoded = reEncoder.encode(levels);
|
||||
// reEncoded equals wireBytes.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class CborChainCodec implements ChainCodec`
|
||||
|
||||
- `readonly name: "cbor"` — codec identifier surfaced in diagnostics.
|
||||
- `hasherName: "raw" | "domain" | "merkle"` — read at `encode` time, written by `decode`. Defaults to `"raw"`.
|
||||
- `encode(levels: readonly ChainLevel[]): Uint8Array` — emits `[0x01][cbor-map]`. Raises `ChainKeysError(UNSUPPORTED_ALGORITHM)` when any level's `algorithm` is not a `SignAlgorithmId`.
|
||||
- `decode(bytes: Uint8Array): readonly ChainLevel[]` — validates header and CBOR shape; sets `this.hasherName` only after the full payload validates. Raises `ChainKeysError(INVALID_FORMAT)` for wrong version, missing/wrong-typed fields, malformed CBOR, or trailing bytes; `ChainKeysError(UNSUPPORTED_ALGORITHM)` for unknown algorithm strings.
|
||||
|
||||
### Error codes
|
||||
|
||||
- `CHAIN_KEYS.INVALID_FORMAT` — bad version byte, malformed CBOR, missing/wrong-typed map fields, unknown hasher, trailing bytes.
|
||||
- `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` — algorithm string outside `SIGN_ALGORITHMS`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codec delegates raw bytes to `@suaveplan/cbor`'s `encodeDeterministic` so the canonical-encoding requirement is upheld by a vetted implementation rather than re-derived locally. The deterministic encoder sorts map keys by canonical encoded bytes, emits shortest-form integers, forbids indefinite-length items, and rejects `undefined` — exactly the constraints a chain signature needs.
|
||||
|
||||
Trailing-byte detection uses `decodeOne(payload)` and compares its returned `consumed` against `payload.length`. `decode` from `@suaveplan/cbor` would also flag trailing bytes via `CBOR.MALFORMED` in strict mode, but `decodeOne` gives a clean integer comparison that maps directly to `INVALID_FORMAT` with a precise message ("consumed X of Y") for diagnostics.
|
||||
|
||||
Decoded byte fields are returned via `Uint8Array.prototype.slice()` so the chain is independent of whatever buffer the caller hands in. This keeps the codec safe for callers that reuse input buffers across decode calls.
|
||||
|
||||
State management is intentionally minimal: `hasherName` is the only mutable field and it is set only after the entire payload validates, so a failed decode never leaves a stale hasher tag on the instance.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CborChainCodec } from "./cbor.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { RecursiveEd25519Codec } from "./recursive-ed25519.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# RecursiveEd25519Codec
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`RecursiveEd25519Codec` is a fixed-Ed25519 alternate wire format for signed key chains in `@suaveplan/crypto-keys`. It trades the algorithm-flexibility of `TlvChainCodec` for a denser, predictable byte layout suitable for embedded systems, replicated logs, and any context where chains are known to be purely Ed25519. The codec walks bytes linearly leaf-first using the fixed 32-byte public key and 64-byte signature lengths defined by Ed25519, with a single u16 length prefix for each variable-length payload.
|
||||
|
||||
## Features
|
||||
|
||||
- Fixed 32-byte Ed25519 public keys and 64-byte Ed25519 signatures — no per-level algorithm byte.
|
||||
- Compact 2-byte header carrying `version=1` plus the active `hasherName` byte (`raw`/`domain`/`merkle`).
|
||||
- u16 big-endian payload length (up to 65,535 bytes per level).
|
||||
- Leaf-first emission; the parent chain is appended back-to-back with no inter-level separator.
|
||||
- Strict validation: bad version byte, unknown hasher byte, truncated buffers, and out-of-bound payload lengths all raise `CHAIN_KEYS.INVALID_FORMAT`.
|
||||
- Non-Ed25519 algorithms raise `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` with the `algorithm` discriminant populated, mirroring the `signWithSlot` failure surface.
|
||||
- After `decode(...)`, `hasherName` is set from the wire byte so a downstream `ChainedKeySigner` can reconfigure its hasher without an out-of-band channel.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RecursiveEd25519Codec } from "@suaveplan/crypto-keys/codecs/recursive-ed25519/recursive-ed25519";
|
||||
|
||||
const codec = new RecursiveEd25519Codec();
|
||||
const level = {
|
||||
algorithm: "ed25519" as const,
|
||||
signerPublicKey: new Uint8Array(32),
|
||||
payload: new Uint8Array([1, 2, 3, 4]),
|
||||
signature: new Uint8Array(64),
|
||||
};
|
||||
|
||||
const bytes = codec.encode([level]);
|
||||
const decoded = codec.decode(bytes);
|
||||
// decoded[0].algorithm === "ed25519"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Round-tripping a multi-level chain and surfacing hasher state:
|
||||
|
||||
```ts
|
||||
import { RecursiveEd25519Codec } from "@suaveplan/crypto-keys/codecs/recursive-ed25519/recursive-ed25519";
|
||||
import { ChainKeysError } from "@suaveplan/crypto-keys/error-codes/error-codes";
|
||||
|
||||
const writer = new RecursiveEd25519Codec();
|
||||
writer.hasherName = "merkle";
|
||||
const wire = writer.encode([leafLevel, midLevel, rootLevel]);
|
||||
|
||||
const reader = new RecursiveEd25519Codec();
|
||||
try {
|
||||
const levels = reader.decode(wire);
|
||||
// reader.hasherName is now "merkle" — propagate to the ChainedKeySigner
|
||||
// before verifying signatures.
|
||||
handleChain(levels, reader.hasherName);
|
||||
} catch (err) {
|
||||
if (err instanceof ChainKeysError && err.code === "CHAIN_KEYS.UNSUPPORTED_ALGORITHM") {
|
||||
// Routing decision: switch to TlvChainCodec or CborChainCodec.
|
||||
fallback(err.algorithm);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class RecursiveEd25519Codec implements ChainCodec`
|
||||
|
||||
- `readonly name: "recursive-ed25519"` — codec identifier surfaced in diagnostics.
|
||||
- `hasherName: "raw" | "domain" | "merkle"` — hasher byte written on encode and overwritten on decode. Default `"raw"`.
|
||||
- `encode(levels: readonly ChainLevel[]): Uint8Array` — emits `[version=1, hasher, (pub32, payload_len_u16_be, payload, sig64)+]` leaf-first. Throws `ChainKeysError` with code `CHAIN_KEYS.UNSUPPORTED_ALGORITHM` for any non-Ed25519 level (with `algorithm` discriminant set), or `CHAIN_KEYS.INVALID_FORMAT` for wrong public-key/signature sizes or oversized payloads.
|
||||
- `decode(bytes: Uint8Array): readonly ChainLevel[]` — walks the buffer from offset 2 until end-of-buffer. Every decoded level carries `algorithm: "ed25519"` unconditionally. Throws `CHAIN_KEYS.INVALID_FORMAT` on a wrong version byte, unknown hasher byte, truncated read, or an in-stream `payload_len` exceeding the remaining bytes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The encoder pre-computes total length and writes into a single `Uint8Array`, avoiding per-level allocations and intermediate concatenations.
|
||||
- `payload_len` uses u16 big-endian via `DataView.setUint16(offset, len, false)` — keeping a network-byte-order convention consistent with TLV.
|
||||
- `decode(...)` calls `bytes.slice(...)` for each field so the returned `Uint8Array`s do not alias the input buffer; consumers may safely mutate or zero the wire bytes after decoding.
|
||||
- The hasher byte is validated as `< HASHER_FROM_BYTE.length` rather than a hard-coded `<= 2` so adding a fourth hasher only requires extending the lookup table.
|
||||
- Encoding a non-Ed25519 level raises `UNSUPPORTED_ALGORITHM` even when the supplied key/signature happen to be 32/64 bytes — the algorithm check fires first to keep the error surface predictable.
|
||||
- Decoded levels are returned `readonly` at the type level but the underlying `Uint8Array`s are fresh copies; callers can freeze them at a higher layer if mutation must be statically forbidden.
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TlvChainCodec } from "./tlv.js";
|
||||
118
packages/security-stack/crypto/crypto-keys/src/codecs/tlv/tlv.md
Normal file
118
packages/security-stack/crypto/crypto-keys/src/codecs/tlv/tlv.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
233
packages/security-stack/crypto/crypto-keys/src/codecs/tlv/tlv.ts
Normal file
233
packages/security-stack/crypto/crypto-keys/src/codecs/tlv/tlv.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `@suaveplan/crypto-keys` package centralises every failure mode it
|
||||
can produce behind a single registry built with
|
||||
`defineCodes("CHAIN_KEYS", [...])` from `@suaveplan/error`. The
|
||||
registry is the stable contract callers depend on: code strings are
|
||||
documented, frozen at module load, and never renamed without an
|
||||
explicit migration. This module exports the `CHAIN_KEYS_CODES`
|
||||
registry, the derived `ChainKeysErrorCode` type, and the
|
||||
`ChainKeysError` class that every public failure path raises.
|
||||
|
||||
`ChainKeysError` extends `SuaveplanError<ChainKeysErrorCode>` so
|
||||
`instanceof ChainKeysError` narrows `code` to the exact union of
|
||||
`CHAIN_KEYS.*` strings. It adds optional `depth?: number` and
|
||||
`algorithm?: SignAlgorithmId | number` discriminants that diagnose
|
||||
which chain level (or which algorithm) triggered the error, while
|
||||
leaving the base `context` map free for caller-supplied structured
|
||||
data. The class threads `cause` straight through `SuaveplanError`,
|
||||
so primitive-layer exceptions from `@suaveplan/crypto-curve` remain
|
||||
inspectable through `error.cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `CHAIN_KEYS_CODES` registry with seven namespaced codes
|
||||
- `ChainKeysErrorCode` literal-union type derived from the registry
|
||||
- `ChainKeysError` class extending `SuaveplanError<ChainKeysErrorCode>`
|
||||
- Optional `depth` and `algorithm` diagnostic discriminants
|
||||
- `algorithm` accepts both `SignAlgorithmId` (string) and `number` to surface a numeric `AlgorithmId` from a non-Ed25519 keychain slot directly
|
||||
- Full `cause`-chain preservation via `SuaveplanError`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CHAIN_KEYS_CODES, ChainKeysError } from "@suaveplan/crypto-keys";
|
||||
|
||||
if (slot.algorithm !== ED25519) {
|
||||
throw new ChainKeysError(
|
||||
CHAIN_KEYS_CODES.UNSUPPORTED_ALGORITHM,
|
||||
`signWithSlot supports only Ed25519 in v0.0.0 (got algorithm ${slot.algorithm})`,
|
||||
{ algorithm: slot.algorithm }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CHAIN_KEYS_CODES, ChainKeysError } from "@suaveplan/crypto-keys";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
try {
|
||||
await crypto.curve.ed25519.verify(digest, signature, publicKey);
|
||||
} catch (cause) {
|
||||
throw new ChainKeysError(
|
||||
CHAIN_KEYS_CODES.INVALID_SIGNATURE,
|
||||
`signature verify failed at depth ${depth}`,
|
||||
{
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
depth,
|
||||
algorithm: "ed25519",
|
||||
context: { signatureLength: signature.length },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Downstream:
|
||||
catch (err) {
|
||||
if (err instanceof ChainKeysError && err.code === CHAIN_KEYS_CODES.INVALID_SIGNATURE) {
|
||||
metrics.increment("chain.verify.failure", {
|
||||
depth: err.depth,
|
||||
algorithm: err.algorithm,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CHAIN_KEYS_CODES`
|
||||
|
||||
A frozen object literal of seven namespaced codes:
|
||||
|
||||
| Property | Value |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `UNSUPPORTED_ALGORITHM` | `"CHAIN_KEYS.UNSUPPORTED_ALGORITHM"` |
|
||||
| `INVALID_FORMAT` | `"CHAIN_KEYS.INVALID_FORMAT"` |
|
||||
| `INVALID_SIGNATURE` | `"CHAIN_KEYS.INVALID_SIGNATURE"` |
|
||||
| `TRUST_MISMATCH` | `"CHAIN_KEYS.TRUST_MISMATCH"` |
|
||||
| `INVALID_DEPTH` | `"CHAIN_KEYS.INVALID_DEPTH"` |
|
||||
| `INVALID_KEYCHAIN` | `"CHAIN_KEYS.INVALID_KEYCHAIN"` |
|
||||
| `INVALID_DOMAIN_TAG` | `"CHAIN_KEYS.INVALID_DOMAIN_TAG"` |
|
||||
|
||||
### `type ChainKeysErrorCode`
|
||||
|
||||
The literal union of every value in `CHAIN_KEYS_CODES`.
|
||||
|
||||
### `class ChainKeysError extends SuaveplanError<ChainKeysErrorCode>`
|
||||
|
||||
| Member | Type |
|
||||
| ------------------ | ----------------------------------------------------------------- |
|
||||
| `code` | `ChainKeysErrorCode` (narrowed) |
|
||||
| `depth?` | `number` |
|
||||
| `algorithm?` | `SignAlgorithmId \| number` |
|
||||
| `cause?` | `unknown` (inherited) |
|
||||
| `timestamp` | `number` (inherited; `Date.now()` at construction) |
|
||||
| `constructor(...)` | `(code, message, options?: ChainKeysErrorOptions)` |
|
||||
|
||||
`ChainKeysErrorOptions` extends `SuaveplanErrorOptions` (`cause`,
|
||||
`context`, `clock`, `traceId`) with the additional optional `depth`
|
||||
and `algorithm` fields.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns an `Object.freeze`d record with `null` prototype,
|
||||
so the registry cannot be mutated, reassigned, or extended at runtime.
|
||||
The literal type machinery preserves the exact `${Namespace}.${Reason}`
|
||||
string template across every consumer, so `switch (err.code)` enjoys
|
||||
exhaustive narrowing without runtime cost.
|
||||
|
||||
The `algorithm` discriminant is a union of `SignAlgorithmId` (this
|
||||
package's string union) and `number` (the `AlgorithmId`
|
||||
numeric enum). The numeric form lets `signWithSlot` surface the
|
||||
actual non-Ed25519 algorithm id from a keychain slot without lossy
|
||||
coercion to a string. Most call sites populate the string form
|
||||
because they know which algorithm they tried.
|
||||
|
||||
`ChainKeysError`'s constructor passes `message` straight through to
|
||||
`SuaveplanError`'s `messageKey` parameter. The base class resolves
|
||||
the key via the i18n resolver if one has been registered with
|
||||
`setMessageResolver`, falling back to the raw string otherwise — so
|
||||
this package works without i18n wiring and lights up automatically
|
||||
when `@suaveplan/i18n` is present.
|
||||
|
||||
Optional `depth` / `algorithm` fields are stored on the error
|
||||
instance directly (not on `context`) so they survive structured-cloning
|
||||
and serialisation via the `toJSON` helper from `@suaveplan/error`.
|
||||
Consumers that programmatically dispatch on a failing depth or
|
||||
algorithm read `err.depth` / `err.algorithm` without parsing
|
||||
`err.context`.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
ChainKeysErrorCode,
|
||||
ChainKeysErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
CHAIN_KEYS_CODES,
|
||||
ChainKeysError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,147 @@
|
||||
# factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Hierarchical-keychain factory helpers that compose `generateKeychain`
|
||||
from `@suaveplan/crypto` with `ChainedKeySigner.signWithSlot` to
|
||||
produce signed chains in one call. Three functions cover the common
|
||||
pattern: bootstrap a root keychain, derive a server keychain bound to
|
||||
the root, and roll a server keychain forward to the next version
|
||||
without changing the root.
|
||||
|
||||
These functions are Ed25519-only in v0.0.0 because the crypto
|
||||
keychain v1 grammar only generates Ed25519 sign-slots. Callers that
|
||||
need a different curve at any chain level should reach for
|
||||
`ChainedKeySigner.signRaw` directly instead.
|
||||
|
||||
## Features
|
||||
|
||||
- `createRootKeysWithChain` — fresh keychain + self-signed chain
|
||||
- `createServerKeysWithChain` — derived keychain + chain bound to root
|
||||
- `rollServerKeysWithChain` — re-derive at next slot version, re-sign
|
||||
- All three accept an injected `signer?` for custom hasher/codec choices
|
||||
- Strict `INVALID_KEYCHAIN` errors when root slots are missing or empty
|
||||
- Sensible `LayoutV1Fields` defaults (`masterVersion: 1, userId: 0, depth: 0, slotVersion: 1`)
|
||||
- Default `Clock` is `Date.now` — caller can override via `generate.clock`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChainedKeySigner,
|
||||
createRootKeysWithChain,
|
||||
createServerKeysWithChain,
|
||||
} from "@suaveplan/crypto-keys";
|
||||
|
||||
// Bootstrap a root keychain + self-signed chain
|
||||
const { keys: rootKeys, chain: rootChain } = await createRootKeysWithChain();
|
||||
|
||||
// Derive a server keychain whose chain anchors to the root
|
||||
const { keys: serverKeys, chain: serverChain } = await createServerKeysWithChain({
|
||||
rootKeys,
|
||||
rootChain,
|
||||
});
|
||||
|
||||
// Verify end-to-end
|
||||
const verifier = new ChainedKeySigner();
|
||||
const result = await verifier.verify(serverChain, {
|
||||
algorithm: "ed25519",
|
||||
publicKey: (rootKeys as Record<string, { publicKey: Uint8Array }>).sign.publicKey,
|
||||
});
|
||||
console.log(result.valid, result.depth); // true 2
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChainedKeySigner,
|
||||
DomainHasher,
|
||||
createRootKeysWithChain,
|
||||
createServerKeysWithChain,
|
||||
rollServerKeysWithChain,
|
||||
} from "@suaveplan/crypto-keys";
|
||||
|
||||
const signer = new ChainedKeySigner({
|
||||
hasher: new DomainHasher({ domainTag: "tenant.acme.v1" }),
|
||||
});
|
||||
|
||||
// Tagged root
|
||||
const { keys: rootKeys, chain: rootChain } = await createRootKeysWithChain({
|
||||
signer,
|
||||
generate: { userId: 42 as never }, // custom layout fields per app
|
||||
});
|
||||
|
||||
// First server keychain (slot version 1)
|
||||
const v1 = await createServerKeysWithChain({ rootKeys, rootChain, signer });
|
||||
|
||||
// Six months later: roll to slot version 2, same root binding
|
||||
const v2 = await rollServerKeysWithChain({
|
||||
rootKeys,
|
||||
rootChain,
|
||||
currentVersion: 1,
|
||||
signer,
|
||||
});
|
||||
|
||||
// Issuance chain: root -> v1 -> v2 -> leaf user
|
||||
const leaf = await createServerKeysWithChain({
|
||||
rootKeys,
|
||||
rootChain: v2.chain,
|
||||
signer,
|
||||
});
|
||||
|
||||
const verifier = new ChainedKeySigner({
|
||||
hasher: new DomainHasher({ domainTag: "tenant.acme.v1" }),
|
||||
});
|
||||
const result = await verifier.verify(leaf.chain, {
|
||||
algorithm: "ed25519",
|
||||
publicKey: (rootKeys as Record<string, { publicKey: Uint8Array }>).sign.publicKey,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `async function createRootKeysWithChain(options?)`
|
||||
|
||||
| Field | Type | Default |
|
||||
| ----------------- | ------------------------------------- | ----------------------- |
|
||||
| `signer?` | `ChainedKeySigner` | `new ChainedKeySigner()` |
|
||||
| `generate?` | `Partial<GenerateKeychainOptions>` | sensible defaults |
|
||||
|
||||
Returns `{ keys: Keychain<LayoutV1Fields>; chain: Uint8Array }`.
|
||||
|
||||
### `async function createServerKeysWithChain(options)`
|
||||
|
||||
| Field | Type | Required |
|
||||
| ------------- | ------------------------------------- | -------- |
|
||||
| `rootKeys` | `Keychain<LayoutV1Fields>` | yes |
|
||||
| `rootChain` | `Uint8Array` | yes |
|
||||
| `signer?` | `ChainedKeySigner` | no |
|
||||
| `generate?` | `Partial<GenerateKeychainOptions>` | no |
|
||||
|
||||
Raises `CHAIN_KEYS.INVALID_KEYCHAIN` if `rootKeys.master` or `rootKeys.sign` is missing, has an empty `secretKey`, or has a non-Ed25519 `algorithm`.
|
||||
|
||||
### `async function rollServerKeysWithChain(options)`
|
||||
|
||||
| Field | Type | Required |
|
||||
| ----------------- | -------------------------- | -------- |
|
||||
| `rootKeys` | `Keychain<LayoutV1Fields>` | yes |
|
||||
| `rootChain` | `Uint8Array` | yes |
|
||||
| `currentVersion` | `number` (≥ 0, integer) | yes |
|
||||
| `signer?` | `ChainedKeySigner` | no |
|
||||
|
||||
Generates a new keychain with `slotVersion: currentVersion + 1` and re-signs against the same root. Raises `CHAIN_KEYS.INVALID_KEYCHAIN` when `currentVersion` is negative or non-integer, or when the root slots are missing/invalid.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
crypto's `Keychain` is `Record<PurposeTag, KeySlotPrivate>` where `PurposeTag` is a branded string type. The factory module uses a small `getSlot(keys, tag)` helper that casts through `Record<string, KeySlotPrivate>` to access individual slots; this keeps the TypeScript narrowing clean without pulling in the `PurposeTag` brand machinery.
|
||||
|
||||
`LayoutV1Fields` is re-declared locally because it's not currently re-exported from `@suaveplan/crypto/keychain`. The shape is identical to the upstream definition and `generateKeychain` accepts any object satisfying it structurally. If a future crypto release adds a `./derivation` subpath export, this declaration can be replaced with the canonical import.
|
||||
|
||||
The factory functions trust `generateKeychain`'s contract — that every generated keychain contains all 8 default-purpose slots with valid Ed25519 sign-slots — and do not re-validate the freshly-generated output. Validation is concentrated at the boundaries: `signWithSlot` rejects non-Ed25519 slots, and `assertRootSlots` checks every caller-supplied root before use. This keeps the happy path lean and pushes the cost of bad input onto the caller's stack frame.
|
||||
|
||||
`signWithSlot` takes an optional `parent: Uint8Array`; the factory functions pass `rootChain` when extending and omit it for the self-signed root. `ChainedKeySigner` distinguishes a root signature (no parent) from a non-root signature (parent supplied) by the presence of the argument — there's no separate "root" API.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
CreateRootOptions,
|
||||
CreateServerOptions,
|
||||
FactoryResult,
|
||||
RollServerOptions,
|
||||
} from "./factory.js";
|
||||
export {
|
||||
createRootKeysWithChain,
|
||||
createServerKeysWithChain,
|
||||
rollServerKeysWithChain,
|
||||
} from "./factory.js";
|
||||
@@ -0,0 +1,88 @@
|
||||
# DomainHasher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DomainHasher` is the `ChainHasher` strategy that domain-separates the
|
||||
signed pre-image with a caller-supplied tag and then SHA-256s the
|
||||
result. For every level it returns `sha256(utf8(domainTag) || payload)`.
|
||||
|
||||
Domain separation is the reason this strategy exists: by mixing a stable
|
||||
ASCII tag into the pre-image, signatures minted under one protocol
|
||||
identifier (e.g. `"suaveplan/chain/v1"`) cannot be replayed against a
|
||||
verifier expecting a different identifier. This is the recommended
|
||||
default for new chains where the payload is application data rather
|
||||
than an already-canonical digest.
|
||||
|
||||
## Features
|
||||
|
||||
- 32-byte SHA-256 digest output per call, identical across all
|
||||
supported runtimes (Bun, Node, browser).
|
||||
- Strict construction-time validation of the tag: 1–64 printable-ASCII
|
||||
characters (`0x20`–`0x7e`). Anything else raises
|
||||
`ChainKeysError` with code `CHAIN_KEYS.INVALID_DOMAIN_TAG`.
|
||||
- ASCII-only constraint guarantees `tag.length === utf8(tag).byteLength`
|
||||
so the encoded length is unambiguous across platforms.
|
||||
- `parent` argument is accepted (interface compatibility) but ignored:
|
||||
`DomainHasher` commits to the tag, not to any ancestor.
|
||||
- Stateless after construction; safe to share a single instance.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DomainHasher } from "@suaveplan/crypto-keys/hashers/domain/domain";
|
||||
|
||||
const hasher = new DomainHasher({ domainTag: "suaveplan/chain/v1" });
|
||||
const digest = hasher.hash(new TextEncoder().encode("hello"));
|
||||
// digest.length === 32
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Bind two related protocols to different domains so a signature in one
|
||||
cannot be replayed in the other, even when the payload happens to be
|
||||
byte-identical:
|
||||
|
||||
```ts
|
||||
import { DomainHasher } from "@suaveplan/crypto-keys/hashers/domain/domain";
|
||||
|
||||
const chainHasher = new DomainHasher({ domainTag: "suaveplan/chain/v1" });
|
||||
const manifestHasher = new DomainHasher({
|
||||
domainTag: "suaveplan/manifest/v1",
|
||||
});
|
||||
|
||||
const payload = new TextEncoder().encode("identical bytes");
|
||||
const chainDigest = chainHasher.hash(payload);
|
||||
const manifestDigest = manifestHasher.hash(payload);
|
||||
// chainDigest !== manifestDigest — cross-protocol replay defeated
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface DomainHasherOptions`
|
||||
|
||||
- `domainTag: string` — 1–64 printable-ASCII characters. Invalid tags
|
||||
raise `ChainKeysError` with code `CHAIN_KEYS.INVALID_DOMAIN_TAG`.
|
||||
|
||||
### `class DomainHasher implements ChainHasher`
|
||||
|
||||
- `readonly name: "domain"`
|
||||
- `hash(payload: Uint8Array, parent?: ParsedChainLevel): Uint8Array` —
|
||||
returns `sha256(utf8(domainTag) || payload)`, 32 bytes. The `parent`
|
||||
argument is accepted for interface compatibility and is ignored.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tag is encoded to UTF-8 once at construction time and cached as
|
||||
`tagBytes`. Each `hash` call allocates a fresh `Uint8Array` exactly
|
||||
sized to `tagBytes.length + payload.length`, copies the tag at offset
|
||||
zero and the payload immediately after, then feeds the buffer to
|
||||
`sha256` from `@suaveplan/crypto-hash`. The ASCII-only constraint
|
||||
matters: without it, a tag containing multi-byte UTF-8 codepoints would
|
||||
have a runtime-dependent byte length, breaking interoperability. By
|
||||
rejecting non-ASCII at construction we guarantee a stable, predictable
|
||||
pre-image format that every conforming implementation produces
|
||||
identically.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { DomainHasherOptions } from "./domain.js";
|
||||
export { DomainHasher } from "./domain.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { MerkleHasher } from "./merkle.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# MerkleHasher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MerkleHasher` is the `ChainHasher` strategy that binds each non-root
|
||||
level to its parent's content via a Merkle-style construction. For the
|
||||
root it returns `sha256(payload)`; for every non-root level it returns
|
||||
`sha256(payload || sha256(parent.payload || parent.signature))`.
|
||||
|
||||
The parent commitment is the whole point: by mixing
|
||||
`sha256(parent.payload || parent.signature)` into every descendant
|
||||
pre-image, tampering with any ancestor invalidates every signature
|
||||
beneath it. This is the strategy required for keychain chains that must
|
||||
detect ancestor substitution (e.g., an attacker swapping in a rolled
|
||||
server key whose signature was minted under a different root).
|
||||
|
||||
## Features
|
||||
|
||||
- Self-detecting root: when `parent` is `undefined` the implementation
|
||||
short-circuits to `sha256(payload)`. No depth bookkeeping required at
|
||||
the call site.
|
||||
- 32-byte SHA-256 output at every level.
|
||||
- Deterministic and stateless — safe to share one instance across
|
||||
arbitrary parallel chain operations.
|
||||
- Canonical pre-image ordering: parent.payload always precedes
|
||||
parent.signature, so the digest is portable across implementations.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MerkleHasher } from "@suaveplan/crypto-keys/hashers/merkle/merkle";
|
||||
|
||||
const hasher = new MerkleHasher();
|
||||
const rootDigest = hasher.hash(new TextEncoder().encode("payload-root"));
|
||||
// rootDigest.length === 32
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Building a non-root level requires a previously-decoded
|
||||
`ParsedChainLevel` for the parent. In production the verifier hands you
|
||||
the parent it just decoded; in tests or simulation you can construct one
|
||||
directly:
|
||||
|
||||
```ts
|
||||
import { MerkleHasher } from "@suaveplan/crypto-keys/hashers/merkle/merkle";
|
||||
import type { ParsedChainLevel } from "@suaveplan/crypto-keys/types/chain-level";
|
||||
|
||||
const hasher = new MerkleHasher();
|
||||
const parent: ParsedChainLevel = {
|
||||
algorithm: "ed25519",
|
||||
signerPublicKey: new Uint8Array(32),
|
||||
payload: new TextEncoder().encode("p-payload"),
|
||||
signature: new Uint8Array([1, 2, 3, 4]),
|
||||
};
|
||||
const child = hasher.hash(
|
||||
new TextEncoder().encode("child-payload"),
|
||||
parent
|
||||
);
|
||||
// child commits to both parent.payload AND parent.signature
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MerkleHasher implements ChainHasher`
|
||||
|
||||
- `readonly name: "merkle"`
|
||||
- `hash(payload: Uint8Array, parent?: ParsedChainLevel): Uint8Array`
|
||||
- When `parent === undefined`: returns `sha256(payload)`.
|
||||
- Otherwise: returns
|
||||
`sha256(payload || sha256(parent.payload || parent.signature))`.
|
||||
- Output is always 32 bytes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The construction is `H(x || H(p_payload || p_sig))` rather than
|
||||
`H(x || p_payload || p_sig)` so the pre-image at every level is a
|
||||
fixed-width `payload.length + 32` bytes. This makes the digest cheap to
|
||||
verify with streaming primitives that only have to hash one fresh copy
|
||||
of the variable-length payload alongside a constant 32-byte parent
|
||||
root. Concatenation order is fixed by spec: parent.payload always
|
||||
precedes parent.signature so swapping the two fields yields a
|
||||
different digest. The test suite pins both the root digest and a
|
||||
non-root digest as known-vector hex strings so any change to byte
|
||||
layout — including endianness or order — fails the build immediately.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { RawHasher } from "./raw.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# RawHasher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`RawHasher` is the trivial `ChainHasher` strategy: it returns the payload
|
||||
bytes verbatim, performing no transformation. It exists so that consumers
|
||||
whose payload is already a canonical digest (a manifest hash, a CBOR
|
||||
fingerprint, a CID) can plug a chain signer in without paying for a
|
||||
redundant SHA-256 pass.
|
||||
|
||||
Because `ChainedKeySigner` always invokes `hash(payload, parent)` before
|
||||
delegating to `crypto-curve.sign(...)`, choosing `RawHasher` means the
|
||||
signer signs exactly the payload it was handed — useful for tests, for
|
||||
audit chains over pre-digested manifests, and for cases where the
|
||||
application wants full control over the pre-image format.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero allocation: returns the input `Uint8Array` reference unchanged.
|
||||
- Stateless: safe to share a single instance across many signers and
|
||||
threads. No instance fields, no mutable state.
|
||||
- Ignores the optional `parent` argument by design. Chains needing
|
||||
parent-binding should pick `MerkleHasher` (or a custom hasher).
|
||||
- Stable `name: "raw"` for diagnostics and audit logs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RawHasher } from "@suaveplan/crypto-keys/hashers/raw/raw";
|
||||
|
||||
const hasher = new RawHasher();
|
||||
const payload = new Uint8Array([1, 2, 3]);
|
||||
const bytesToSign = hasher.hash(payload);
|
||||
// bytesToSign === payload (same reference)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Swap `RawHasher` in to verify that an externally computed manifest
|
||||
digest round-trips through the signer unchanged — useful for cross-stack
|
||||
interoperability with consumers that have already computed their own
|
||||
canonical digest:
|
||||
|
||||
```ts
|
||||
import { RawHasher } from "@suaveplan/crypto-keys/hashers/raw/raw";
|
||||
import type { ParsedChainLevel } from "@suaveplan/crypto-keys/types/chain-level";
|
||||
|
||||
const hasher = new RawHasher();
|
||||
const manifestDigest = new Uint8Array(32); // already-canonical 32-byte digest
|
||||
const parent: ParsedChainLevel | undefined = undefined;
|
||||
const signedPreImage = hasher.hash(manifestDigest, parent);
|
||||
// signedPreImage is bit-identical to manifestDigest
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class RawHasher implements ChainHasher`
|
||||
|
||||
- `readonly name: "raw"` — canonical short name surfaced in diagnostics.
|
||||
- `hash(payload: Uint8Array, parent?: ParsedChainLevel): Uint8Array`
|
||||
— returns `payload` unchanged. The `parent` argument is accepted to
|
||||
satisfy the `ChainHasher` interface and is deliberately ignored.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation returns the input reference directly rather than
|
||||
copying. Callers that need ownership of the returned bytes must clone
|
||||
explicitly. Reference-return is deliberate: it makes `RawHasher` a true
|
||||
no-op and ensures the cost of pre-image hashing is exactly zero — which
|
||||
matters for high-throughput audit logs where the payload is already a
|
||||
fixed-width digest. The test suite asserts both reference equality and
|
||||
content equality so any future refactor that switches to a copy would be
|
||||
caught immediately.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
51
packages/security-stack/crypto/crypto-keys/src/index.ts
Normal file
51
packages/security-stack/crypto/crypto-keys/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
export { ChainedKeySigner } from "./signer.js";
|
||||
export type {
|
||||
ISigner,
|
||||
SignerOptions,
|
||||
SignRawOptions,
|
||||
TrustedRoot,
|
||||
} from "./signer.types.js";
|
||||
188
packages/security-stack/crypto/crypto-keys/src/signer/signer.md
Normal file
188
packages/security-stack/crypto/crypto-keys/src/signer/signer.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
298
packages/security-stack/crypto/crypto-keys/src/signer/signer.ts
Normal file
298
packages/security-stack/crypto/crypto-keys/src/signer/signer.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# types/chain-level
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type module declaring the value-shapes that every other module in
|
||||
`@suaveplan/crypto-keys` references. Defining `SignAlgorithmId`,
|
||||
`ChainLevel`, `ParsedChainLevel`, and `VerificationResult` here keeps
|
||||
the public surface coherent and lets consumers import the types they
|
||||
need from a single path. The types are intentionally framework-free —
|
||||
no Zod schemas, no runtime validators — because the package's
|
||||
developer-facing API is constructed in-process and validated via
|
||||
TypeScript's structural type system plus per-feature runtime checks
|
||||
that raise stable `CHAIN_KEYS.*` errors.
|
||||
|
||||
## Features
|
||||
|
||||
- `SignAlgorithmId` — string-literal union for the 5 signing algorithms
|
||||
- `SIGN_ALGORITHMS` — `Set<SignAlgorithmId>` for `O(1)` runtime membership tests
|
||||
- `ChainLevel` — `{ algorithm, signerPublicKey, payload, signature }` shape produced by codecs
|
||||
- `ParsedChainLevel` — alias for `ChainLevel`, semantic name when handing a level to a hasher
|
||||
- `VerificationResult` — `{ valid, depth, levels, errors }` returned by `ChainedKeySigner.verify`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ChainLevel, SignAlgorithmId } from "@suaveplan/crypto-keys";
|
||||
|
||||
const level: ChainLevel = {
|
||||
algorithm: "ed25519",
|
||||
signerPublicKey: new Uint8Array(32),
|
||||
payload: new Uint8Array([1, 2, 3]),
|
||||
signature: new Uint8Array(64),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ChainLevel,
|
||||
ParsedChainLevel,
|
||||
SignAlgorithmId,
|
||||
VerificationResult,
|
||||
} from "@suaveplan/crypto-keys";
|
||||
import { SIGN_ALGORITHMS } from "@suaveplan/crypto-keys";
|
||||
|
||||
function isSignAlgorithm(value: string): value is SignAlgorithmId {
|
||||
return SIGN_ALGORITHMS.has(value as SignAlgorithmId);
|
||||
}
|
||||
|
||||
function reportRoot(result: VerificationResult): void {
|
||||
if (!result.valid) {
|
||||
console.error("chain invalid", result.errors);
|
||||
return;
|
||||
}
|
||||
const root = result.levels[result.levels.length - 1];
|
||||
console.log(`root algorithm: ${root.algorithm}`);
|
||||
}
|
||||
|
||||
// `ParsedChainLevel` is the right type when a hasher needs to look at the
|
||||
// parent that came out of the codec — same shape as `ChainLevel`, separate
|
||||
// name signals "parsed by codec" intent.
|
||||
function hashWithParent(payload: Uint8Array, parent: ParsedChainLevel): Uint8Array {
|
||||
return sha256(concat(payload, parent.payload, parent.signature));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type SignAlgorithmId`
|
||||
|
||||
```ts
|
||||
type SignAlgorithmId = "ed25519" | "secp256k1" | "p256" | "p384" | "p521";
|
||||
```
|
||||
|
||||
### `const SIGN_ALGORITHMS: Set<SignAlgorithmId>`
|
||||
|
||||
Set of every value in `SignAlgorithmId`. Use `SIGN_ALGORITHMS.has(value)` for runtime checks.
|
||||
|
||||
### `interface ChainLevel`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| ----------------- | ------------------- | ------------------------------------------------------------- |
|
||||
| `algorithm` | `SignAlgorithmId` | Which signing curve produced `signature` |
|
||||
| `signerPublicKey` | `Uint8Array` | Public key that signed this level (recorded on-wire per level) |
|
||||
| `payload` | `Uint8Array` | Opaque bytes — typically `encodePublicKeychain(...)` output |
|
||||
| `signature` | `Uint8Array` | Signature over `hasher.hash(payload, parent?)` |
|
||||
|
||||
### `type ParsedChainLevel`
|
||||
|
||||
Type alias for `ChainLevel`. Use this name when the level value originates from a codec decode (e.g., when passing it to `ChainHasher.hash(...)` as the `parent` argument).
|
||||
|
||||
### `interface VerificationResult`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------- | --------------------- | ---------------------------------------------------------- |
|
||||
| `valid` | `boolean` | `true` iff every signature verifies and root matches trust |
|
||||
| `depth` | `number` | Number of levels in the chain (≥ 1 for any valid chain) |
|
||||
| `levels` | `readonly ChainLevel[]` | Decoded levels, leaf-first; empty if decode failed |
|
||||
| `errors` | `readonly string[]` | Human-readable diagnostics; empty when `valid` is `true` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SignAlgorithmId` is a string-literal union rather than the numeric
|
||||
`AlgorithmId` from `@suaveplan/crypto` because:
|
||||
|
||||
- The crypto-curve primitives are organised by string name (one folder
|
||||
per algorithm), so the string maps directly to the import target.
|
||||
- `@suaveplan/crypto`'s numeric `AlgorithmId` covers AEADs and KDFs as
|
||||
well; this package signs with exactly 5 of those, so a smaller union
|
||||
documents the surface accurately.
|
||||
- String unions exhaust-check well in `switch` statements without
|
||||
pulling in the upstream registry.
|
||||
|
||||
The TLV codec maps `SignAlgorithmId` ↔ `u8` enum bytes (0–4) at the
|
||||
wire boundary; consumers never see the numeric form.
|
||||
|
||||
`ChainLevel.payload` is intentionally untyped beyond `Uint8Array`. The
|
||||
package treats it as opaque — verification re-hashes the bytes and
|
||||
checks the signature; it never parses the payload. Keychain chains
|
||||
will typically pass `encodePublicKeychain(...)` output here, but
|
||||
attestation or manifest chains can pass any byte sequence.
|
||||
|
||||
`signerPublicKey` is on every level because the wire format is
|
||||
self-contained: the verifier reads each level's signer pub-key
|
||||
directly. Without that field, the verifier would need to peer into
|
||||
the next-deeper level's `payload` and parse a keychain out of it —
|
||||
fine for keychain chains, broken for chains carrying anything else.
|
||||
|
||||
`VerificationResult` never throws on bad input — `valid: false` plus
|
||||
`errors` is the failure shape. The only synchronous throws come from
|
||||
construction-time argument validation (e.g., an empty `domainTag`).
|
||||
@@ -0,0 +1,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[];
|
||||
}
|
||||
128
packages/security-stack/crypto/crypto-keys/src/types/codec.md
Normal file
128
packages/security-stack/crypto/crypto-keys/src/types/codec.md
Normal 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).
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Polyfills for test environment — none required. The package is universal
|
||||
// and consumes only @suaveplan/* internal deps (which themselves use only
|
||||
// browser-and-server safe APIs). This file exists to mirror sibling package
|
||||
// layout and to give vitest a stable setup file path.
|
||||
export { };
|
||||
12
packages/security-stack/crypto/crypto-keys/tsconfig.json
Normal file
12
packages/security-stack/crypto/crypto-keys/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/security-stack/crypto/crypto-keys/tsconfig.lib.json
Normal file
12
packages/security-stack/crypto/crypto-keys/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
36
packages/security-stack/crypto/crypto-keys/vite.config.ts
Normal file
36
packages/security-stack/crypto/crypto-keys/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/crypto-keys",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\/cbor/,
|
||||
/^@suaveplan\/crypto/,
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/security-stack/crypto/crypto-keys/vitest.config.ts
Normal file
32
packages/security-stack/crypto/crypto-keys/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user