Files
skills/docs/security-stack/crypto/crypto-keys/src/signer/signer.md
2026-05-29 08:20:56 +01:00

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) 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

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

  • 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.