kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
385 lines (354 loc) • 23.4 kB
text/typescript
/**
* # adapters/broker/authority — the PRODUCTION human-signature authority + the arm-minting
* ceremony (kestrel-7o2, Wave A / A2).
*
* `armLive` (`src/adapters/broker.ts`) is the SOLE mint of a {@link LiveArm}, but it is an
* open/closed seam: it delegates the proof to an injected {@link AuthoritySigner} and, until this
* module, the only signer that existed was a mock in the test suite. This module supplies the
* PRODUCTION half — the canonical serialization, the signing input, the Ed25519 verifier, and the
* ceremony that composes them into `armLive`.
*
* ## The central invariant
*
* **A config flag can never arm live.** Not an env var, not a JSON field, not a CLI switch. The
* protocol already says so structurally — `live` is excluded from `WALLET_SIGNABLE_SCOPES`
* (`src/protocol/index.ts`), so no wallet/config authority reaches it; ADR-0034 §3 makes the
* human-signed Kestrel arm the load-bearing gate, with the broker account only the backstop. This
* module is where that becomes executable: the ONLY input that mints an arm is a genuine Ed25519
* signature, by a `"human"`-class key, over the CANONICAL bytes of the exact {@link RiskLimits}
* being armed.
*
* ## The thing signed IS the thing enforced
*
* The signature covers `klive1.<sha256(canonical(limits))>` and NOTHING else. Crucially, the
* ceremony does not carry the caller's `limits` OBJECT forward: {@link assertSignableLimits} returns
* a FROZEN, plain-number SNAPSHOT (each ceiling read exactly once, at signing time), and it is THAT
* snapshot that flows into `grant.limits`, the minted token, and `makeLiveClamp`'s stored limits. So
* verify, mint, and enforce all read the SAME immutable bytes. There is no gap between ceremony and
* enforcement in which a limit set could be substituted or widened: widen any ceiling by a cent and
* the digest changes and the signature stops verifying; mutate the caller's object AFTER minting
* (`limits.maxOrderQty = 500`), or hand in a divergent getter, and enforcement is unaffected — it
* reads the frozen snapshot, never the caller's object again.
*
* ## Reuse, not a parallel abstraction
*
* - the digest is `src/crypto/sha256.ts` (the repo's one portable, deterministic hash);
* - the key shape is `Ed25519PublicJwk` from `src/cli/keypair.ts` (the repo's existing Ed25519 face);
* - the signing-input convention (`<prefix>.<hex-digest>`, verified with node's `crypto.verify`
* against a published JWK) mirrors `src/cli/commands/verify.ts`'s `kgrade1.<root>` byte-for-byte
* in shape — a different domain prefix, the same discipline;
* - the authority class is the protocol's own {@link SignerClass}, not a new vocabulary.
*
* ## Determinism
*
* Canonicalization, the digest, and Ed25519 verification are pure: no wall clock, no RNG on the
* verify path. (The offline {@link signLiveArmGrant} half consumes the human's private key; it is
* ceremony tooling, not a runtime path.)
*
* ## Wave B contract
*
* The live driver calls {@link performLiveArmCeremony} and gets a {@link LiveArm} or a typed
* {@link LiveGateRefused}. It must NOT hand-roll an {@link AuthoritySigner}, and it must not accept
* an arm from anywhere else.
*/
import { createPublicKey, verify as ed25519Verify } from "node:crypto";
import { sha256 } from "../../crypto/sha256.ts";
import type { Ed25519PrivateJwk, Ed25519PublicJwk } from "../../cli/keypair.ts";
import type { SignerClass } from "../../protocol/index.ts";
import { armLive, LiveGateRefused, type AuthoritySigner, type LiveArm, type LiveAuthorityGrant, type RiskLimits } from "../broker.ts";
// ─────────────────────────────────────────────────────────────────────────────
// 1. The canonical serialization — the thing signed
// ─────────────────────────────────────────────────────────────────────────────
/**
* The live-arm signing-input prefix (`klive1`) — domain separation, so a signature minted for any
* OTHER Kestrel purpose (a grade proof's `kgrade1.<root>`, a request JWT) can never be replayed as
* live authority. An OPAQUE wire constant: the human's signing tool and this verifier must agree on
* it byte-for-byte. Versioned (`1`) so the canonical form can be revised without ambiguity.
*/
export const LIVE_ARM_SIGN_PREFIX = "klive1";
/** The canonical form's own version tag, inside the signed bytes — a v1 signature can never be
* replayed as a v2 grant even if the ceilings are identical. */
const CANONICAL_LIMITS_VERSION = "klive.limits.v1";
/**
* The three {@link RiskLimits} ceilings in a FIXED order — hard-coded, never `Object.keys`, so the
* canonical bytes cannot vary with property insertion order or with an extra field a caller bolted
* on. Ordering is part of the contract.
*/
const CANONICAL_LIMIT_FIELDS = ["maxNotionalUsd", "maxOrderQty", "maxPositionQty"] as const satisfies readonly (keyof RiskLimits)[];
/**
* FAIL-CLOSED validation of the {@link RiskLimits} a human is asked to sign / a grant presents.
* Every ceiling must be a FINITE, non-negative number. Refuses (typed {@link LiveGateRefused}):
*
* - **`Infinity` / unbounded** — unbounded live authority is the fail-open this whole epic exists
* to prevent (`UNBOUNDED_PAPER_LIMITS` is a PAPER convenience and must never be signable), and
* it is also a serialization trap: `JSON.stringify(Infinity)` is `"null"`, so a naive canonical
* form would make an unbounded ceiling indistinguishable from an absent one.
* - **`NaN`** — `NaN > limit` is `false`, so a NaN ceiling silently admits every order: a
* fail-open that looks like a limit.
* - **negative** — an incoherent ceiling; refuse rather than guess.
* - **non-number** (a string from a parsed JSON config) — never coerce; a silent coercion is a
* silent default.
*
* Returns a FROZEN, plain-number SNAPSHOT — NOT the caller's object. Each ceiling is read EXACTLY
* ONCE (here, at signing time) and stored as a primitive, then the snapshot is `Object.freeze`d.
* This is the substitution wall the module claims: threading THIS return value (not the caller's
* object) through the grant, the minted token, and the clamp means verify/mint/enforce all read the
* SAME immutable bytes, so neither a post-sign mutation of the caller's object (`limits.maxOrderQty
* = 500` after minting) nor a divergent getter (returns 5 at verify, 500 at enforce) can reach
* enforcement — the snapshot already captured the single, validated read.
*
* Exported so the ceremony can refuse EARLY with a legible reason, and so a caller can pre-check
* limits before asking a human to sign them.
*/
export function assertSignableLimits(limits: RiskLimits): RiskLimits {
if (limits === null || typeof limits !== "object") {
throw new LiveGateRefused(
`live authority requires a RiskLimits object to sign over (got ${JSON.stringify(limits)}) — fail-closed`,
);
}
const snapshot = {} as { -readonly [K in keyof RiskLimits]: number };
for (const field of CANONICAL_LIMIT_FIELDS) {
// Read the ceiling EXACTLY ONCE — a divergent getter cannot return one value here and another at
// enforcement, because the enforcement path reads the frozen snapshot, never this object again.
const v: unknown = limits[field];
if (typeof v !== "number") {
throw new LiveGateRefused(
`live authority: RiskLimits.${field} must be a number, got ${typeof v} (${JSON.stringify(v)}) — never coerced, fail-closed`,
);
}
if (!Number.isFinite(v)) {
throw new LiveGateRefused(
`live authority: RiskLimits.${field} must be FINITE, got ${String(v)} — an unbounded/NaN live ceiling is a fail-open (a NaN ceiling admits every order; Infinity is not signable — UNBOUNDED_PAPER_LIMITS is paper-only)`,
);
}
if (v < 0) {
throw new LiveGateRefused(`live authority: RiskLimits.${field} must be >= 0, got ${String(v)} — fail-closed`);
}
// Store the once-read, validated primitive. `+v` is a no-op here (v is already a number) but makes
// the "coerced to a plain number, no reference retained" intent explicit and total.
snapshot[field] = +v;
}
// FROZEN so a caller holding a reference to the returned snapshot still cannot widen it after signing.
return Object.freeze(snapshot);
}
/**
* The CANONICAL serialization of {@link RiskLimits} — the exact bytes a human signature attests
* over. Deterministic and total for any set {@link assertSignableLimits} accepts: fixed field
* order, fixed separators, a version tag, and JS's canonical shortest-round-trip number rendering.
* Refuses (never silently normalizes) anything unsignable.
*/
export function canonicalRiskLimits(limits: RiskLimits): string {
// Read from the FROZEN snapshot, never `limits` directly: the bytes we hash (and thus sign) are the
// exact once-read primitives enforcement will see — a getter cannot render 5 here and 500 downstream.
const signable = assertSignableLimits(limits);
const body = CANONICAL_LIMIT_FIELDS.map((f) => `${f}=${String(signable[f])}`).join("\n");
return `${CANONICAL_LIMITS_VERSION}\n${body}\n`;
}
/** The deterministic sha256 (64-char lowercase hex) of the {@link canonicalRiskLimits canonical bytes}. */
export function liveGrantDigest(limits: RiskLimits): string {
return sha256(canonicalRiskLimits(limits));
}
/**
* The exact message an Ed25519 human signature covers: `klive1.<digest>`. Both halves of the
* ceremony — {@link signLiveArmGrant} offline and {@link makeHumanAuthoritySigner} at arm time —
* build it through THIS function, so they cannot drift apart.
*/
export function liveArmSigningInput(limits: RiskLimits): string {
return `${LIVE_ARM_SIGN_PREFIX}.${liveGrantDigest(limits)}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. The key set + the signatureRef wire format
// ─────────────────────────────────────────────────────────────────────────────
/**
* One authority key in the verify-set the ceremony checks against. `signerClass` is the protocol's
* own {@link SignerClass}: only a `"human"` key can ever mint a live arm, because the protocol
* excludes `live` from `WALLET_SIGNABLE_SCOPES` (ADR-0034 §3). A `"wallet"` key may sit in the same
* set — it simply can never unlock live, and the ceremony says so out loud rather than ignoring it.
*/
export interface LiveAuthorityKey {
/** The key id the `signatureRef` names. Charset-restricted (see {@link KID_RE}) so it can never
* collide with the wire format's `.` separators. */
readonly kid: string;
/** The authority class this key holds. Live requires `"human"`. */
readonly signerClass: SignerClass;
/** The PUBLIC half only — the ceremony never holds a private key (`src/cli/keypair.ts`). */
readonly public_jwk: Ed25519PublicJwk;
}
/** kid/signature charset — base64url-safe, `.`-free, so `klive1.<kid>.<sig>` parses unambiguously. */
const KID_RE = /^[A-Za-z0-9_-]+$/;
const REF_RE = /^klive1\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/;
/** The parsed halves of a `signatureRef`. */
export interface LiveSignatureRef {
readonly kid: string;
/** The base64url Ed25519 signature over {@link liveArmSigningInput}. */
readonly signature: string;
}
/** Build the opaque `LiveAuthorityGrant.signatureRef` handle: `klive1.<kid>.<base64url-sig>`. */
export function formatLiveSignatureRef(kid: string, signature: string): string {
if (!KID_RE.test(kid)) {
throw new LiveGateRefused(`live authority: kid ${JSON.stringify(kid)} is not [A-Za-z0-9_-]+ — fail-closed`);
}
return `${LIVE_ARM_SIGN_PREFIX}.${kid}.${signature}`;
}
/**
* Parse a `signatureRef` — TOTAL and fail-closed: anything that is not exactly a well-formed
* `klive1.<kid>.<sig>` returns `undefined`, never a partial guess. This is the first place a config
* flag dies: `"true"`, `"1"`, `"live=true"` and `""` are not signatures and never parse.
*/
export function parseLiveSignatureRef(ref: string): LiveSignatureRef | undefined {
if (typeof ref !== "string") return undefined;
const m = REF_RE.exec(ref);
if (m === null) return undefined;
return { kid: m[1]!, signature: m[2]! };
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. The production AuthoritySigner
// ─────────────────────────────────────────────────────────────────────────────
/** Construction deps for {@link makeHumanAuthoritySigner}. */
export interface HumanAuthoritySignerDeps {
/** The verify-set: the PUBLIC halves of the keys whose signatures may arm live. An EMPTY set
* verifies nothing — it is not an open door (see {@link makeHumanAuthoritySigner}). */
readonly authorities: readonly LiveAuthorityKey[];
}
/**
* The PRODUCTION {@link AuthoritySigner} — re-verifies a {@link LiveAuthorityGrant}'s Ed25519 human
* signature over the CANONICAL limits, against a held public key selected by the ref's own kid.
*
* TOTAL and FAIL-CLOSED: `verify` returns a boolean and NEVER throws. Every path that is not "a
* held, `"human"`-class key verifies this exact signature over these exact limits" resolves to
* `false`, so `armLive` refuses:
*
* - the grant's scope is not `"live"`;
* - the ref does not parse as `klive1.<kid>.<sig>` (a config flag / env var / bare string);
* - no held key carries that kid (we cannot verify ⇒ we refuse — an unknown kid is not a pass);
* - the key is `"wallet"`-class (live is not wallet-signable, ADR-0034 §3);
* - the limits are unsignable (unbounded/NaN/negative — {@link assertSignableLimits});
* - the signature does not cover THESE limits (widened-after-signing, cross-grant replay);
* - any decoding/crypto error at all.
*/
export function makeHumanAuthoritySigner(deps: HumanAuthoritySignerDeps): AuthoritySigner {
const authorities = [...deps.authorities];
return {
verify(grant: LiveAuthorityGrant): boolean {
try {
// Scope: only the non-wallet-signable "live" scope is what this signer attests to.
if (grant?.scope !== "live") return false;
const parsed = parseLiveSignatureRef(grant.signatureRef);
if (parsed === undefined) return false;
const key = authorities.find((k) => k.kid === parsed.kid);
// Unknown kid: we hold no key, so we cannot re-verify, so we refuse. Never a pass.
if (key === undefined) return false;
// Class wall: a wallet signature — however cryptographically valid — can never arm live.
if (key.signerClass !== "human") return false;
// Rebuild the signed message from the limits IN THE GRANT (never from anything the caller
// asserts alongside them): this is what makes the thing signed the thing enforced.
const message = Buffer.from(liveArmSigningInput(grant.limits));
const publicKey = createPublicKey({ key: { ...key.public_jwk }, format: "jwk" });
return ed25519Verify(null, message, publicKey, Buffer.from(parsed.signature, "base64url"));
} catch {
// Fail closed: a malformed JWK, an undecodable signature, unsignable limits — all refuse.
return false;
}
},
};
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. The ceremony
// ─────────────────────────────────────────────────────────────────────────────
/** The inputs to {@link performLiveArmCeremony}. */
export interface LiveArmCeremony {
/** The EXACT risk ceilings to arm — the human signed over these and the clamp will enforce them. */
readonly limits: RiskLimits;
/** The human's signature handle (`klive1.<kid>.<sig>`), produced OFFLINE by
* {@link signLiveArmGrant} with a private key this process never holds. */
readonly signatureRef: string;
/** The verify-set — the PUBLIC halves of the authority keys. */
readonly authorities: readonly LiveAuthorityKey[];
}
/**
* **The arm-minting ceremony** — the ONE production path from a human signature to a
* {@link LiveArm}, and the entry point Wave B's live driver calls.
*
* It refuses EARLY and LEGIBLY (typed {@link LiveGateRefused}, one reason each) before delegating
* to `armLive`, so an operator gets "your signature was not a signature" rather than a generic
* "did not verify". Then it hands the grant to `armLive` with the PRODUCTION
* {@link makeHumanAuthoritySigner} — `armLive` remains the sole mint, and the signature wall is
* re-run there. Nothing here can substitute for the signature: every pre-check only ever REFUSES.
*/
export function performLiveArmCeremony(ceremony: LiveArmCeremony): LiveArm {
// Wall 1 — the ceilings must be signable at all (bounded, finite, non-negative). A legible
// refusal here beats a cryptic signature failure, and unbounded live authority never gets close.
// CAPTURE the frozen plain-number snapshot (each field read exactly once, HERE): this — not the
// caller's mutable object — is what flows into the grant, the minted token, and the clamp, so a
// post-sign `ceremony.limits.maxOrderQty = 500` (or a divergent getter) can never reach enforcement.
const signedLimits = assertSignableLimits(ceremony.limits);
// Wall 2 — there must BE a verify-set. An empty set is not an open door; with no held key there
// is nothing that could verify, so refuse loudly rather than fall through to a `false`.
if (ceremony.authorities.length === 0) {
throw new LiveGateRefused(
"live arm ceremony: no authority keys were supplied — with no held public key nothing can verify a human signature, so live can never be armed (fail-closed)",
);
}
// Wall 3 — at least one HUMAN-class key. A wallet-only verify-set can never arm live (the
// protocol excludes `live` from WALLET_SIGNABLE_SCOPES; ADR-0034 §3).
if (!ceremony.authorities.some((k) => k.signerClass === "human")) {
throw new LiveGateRefused(
"live arm ceremony: the authority key set holds no `human`-class key — live is NOT wallet-signable (protocol/index.ts WALLET_SIGNABLE_SCOPES, ADR-0034 §3), so a wallet/config authority can never arm it (fail-closed)",
);
}
// Wall 4 — THE CENTRAL INVARIANT, stated where an operator will read it: the thing presented must
// be a SIGNATURE. A config flag, an env var, a JSON `armed: true`, a CLI switch — none of them
// parse as `klive1.<kid>.<sig>`, and there is no other input to this function that could arm.
if (parseLiveSignatureRef(ceremony.signatureRef) === undefined) {
throw new LiveGateRefused(
`live arm ceremony: ${JSON.stringify(ceremony.signatureRef)} is not a human signature handle (expected ${LIVE_ARM_SIGN_PREFIX}.<kid>.<base64url-signature>) — a config flag / env var / CLI switch can NEVER arm live; only a human signature over the canonical risk limits can (fail-closed)`,
);
}
// Wall 5 — the real signature check, through the SOLE mint. `armLive` re-checks the scope and
// calls the production signer; if the signature does not cover exactly these canonical limits it
// throws and no arm exists.
const grant: LiveAuthorityGrant = { scope: "live", limits: signedLimits, signatureRef: ceremony.signatureRef };
return armLive(grant, makeHumanAuthoritySigner({ authorities: ceremony.authorities }));
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. The human's offline half
// ─────────────────────────────────────────────────────────────────────────────
/** Inputs to {@link signLiveArmGrant} — the HUMAN's side of the ceremony. */
export interface SignLiveArmGrantInput {
/** The exact ceilings being authorized. */
readonly limits: RiskLimits;
/** The kid the verify-set knows this key by. */
readonly kid: string;
/** The human's PRIVATE Ed25519 JWK. This never comes from config/env in a real ceremony — it is
* the operator's own key material, held offline (`src/cli/keypair.ts` shape). */
readonly privateJwk: Ed25519PrivateJwk;
}
/**
* Sign a live-arm grant — the ceremony's HUMAN half, and the tool a real operator (or a fixture)
* uses to produce a `signatureRef`. Returns the opaque handle {@link performLiveArmCeremony}
* consumes.
*
* Deliberately requires the caller to already hold a private key: there is no "generate and
* self-sign" path here, because a process that can mint its own authority has no authority. It
* validates the limits SYNCHRONOUSLY (before any await), so an unbounded/NaN ceiling throws a typed
* {@link LiveGateRefused} at the call rather than rejecting a promise — nothing unsignable can even
* be signed, so no such signature can exist for the verifier to be tricked by.
*/
export function signLiveArmGrant(input: SignLiveArmGrantInput): Promise<string> {
// SYNCHRONOUS validation (both walls) so the refusal is a throw, not a rejection.
const message = liveArmSigningInput(input.limits);
if (!KID_RE.test(input.kid)) {
throw new LiveGateRefused(`live authority: kid ${JSON.stringify(input.kid)} is not [A-Za-z0-9_-]+ — fail-closed`);
}
return signEd25519(message, input.privateJwk).then((sig) => formatLiveSignatureRef(input.kid, sig));
}
/** Ed25519 sign → base64url, via WebCrypto (node ≥18 / bun) — the same face `src/cli/keypair.ts` uses. */
async function signEd25519(message: string, privateJwk: Ed25519PrivateJwk): Promise<string> {
const c = (globalThis as { crypto?: Crypto }).crypto;
if (!c?.subtle) {
throw new LiveGateRefused("live authority: WebCrypto (crypto.subtle) unavailable — cannot sign a live arm grant (need node >= 18 or bun)");
}
const key = await c.subtle.importKey("jwk", { ...privateJwk }, { name: "Ed25519" }, false, ["sign"]);
const sig = await c.subtle.sign({ name: "Ed25519" }, key, Buffer.from(message));
return Buffer.from(new Uint8Array(sig)).toString("base64url");
}