UNPKG

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
/** * # 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"); }