first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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