8.4 KiB
signer
(no submodules)
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) andsignRaw(5-algorithm, raw-key) verifyreturns{ valid, depth, levels, errors }— never throws on bad inputparsedecode-only when verification isn't neededstripToDepthproduces 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
publicKeyis supplied tosignRaw, the signer derives it fromprivateKeyand checks they match - Constant-time root-key compare via
@suaveplan/crypto-utils::constantTimeEqual - All crypto-curve exceptions wrapped as
ChainKeysErrorwithalgorithmdiscriminant + preservedcause
Usage: Basic
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
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
signWithSlotthrowsChainKeysError(UNSUPPORTED_ALGORITHM)for non-Ed25519 slots in v0.0.0signWithSlot/signRawthrowChainKeysError(INVALID_KEYCHAIN)for missing/empty private key or pub/priv mismatchsignRawthrowsChainKeysError(UNSUPPORTED_ALGORITHM)for unknown algorithm strings- Any
crypto-curveexception is wrapped asChainKeysError(INVALID_KEYCHAIN)withcausepreserved verifynever throws — returns{ valid: false, errors }on every failure modeparsethrowsChainKeysError(INVALID_FORMAT)if codec decoding failsstripToDepththrowsChainKeysError(INVALID_DEPTH)fordepth < 1ordepth > 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.