first commit
This commit is contained in:
188
docs/security-stack/crypto/crypto-keys/src/signer/signer.md
Normal file
188
docs/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.
|
||||
Reference in New Issue
Block a user