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.

265 lines (247 loc) 13.7 kB
/** * # cli/commands/verify — `verify <url>` (LIGHT, node-runnable): the zero-trust proof check (n04e.1). * * A PURE, zero-trust signature check of a PUBLISHED proof. It accepts a full proof URL * (`https://kestrel.markets/proof/<id>`) or a bare id, then: * * 1. `GET {api}/proof/<id>` → the proof's signature envelope (root + signature + kid); * 2. `GET {api}/.well-known/kestrel-markets` → the PUBLISHED verify-set, fetched INDEPENDENTLY; * 3. re-verifies the Ed25519 signature over `kgrade1.<bare-hex-root>` LOCALLY with node's * crypto — it does NOT trust the proof body's own `verification.verified` self-assertion. * * WIRE-ROOT PREFIX (bead kestrel-markets-ha87): the proof's `root` is served with a `sha256:` * DISPLAY prefix (platform bead 7ea, so it equals the Grade artifact's `content_hash`), but the * platform signs the BARE lowercase-hex root — `kgrade1.<hex>`, never `kgrade1.sha256:<hex>`. The * verifier MUST strip the prefix before rebuilding the signed message; not doing so falsely * reported UNVERIFIED on every genuine prod proof (incl. fresh mints) — the flagship evidence * loop failing at the exact step a skeptical agent runs. * * Verdicts: `VERIFIED` (a held key verifies the signature) · `UNVERIFIED` (a held key does * NOT — genuine tampering/corruption) · `KEY_RETIRED` (the proof's kid is not in the published * set — we hold no key; honest, not tampering) · `NOT_FOUND` (no such proof). Mints NO trial — * two GETs, no funnel touch. * * BOUNDARY: `verify` re-checks the CRYPTOGRAPHIC signature against independently-published public * keys AND — when the proof carries the signature-covered grade fields — binds the published NUMBERS * to the signed root by recomputing `certifiedRoot(coveredFields)` locally (OSS-ADR-0051). The * grade-root derivation is now PUBLIC dependency-free protocol (`protocol/attestation.ts`), so * reproducing the root no longer drags any control-plane grade schema into OSS — this supersedes the * prior boundary note that `verify` "deliberately does NOT reproduce the canonical grade-root * derivation." The recompute is ADDITIVE-OPTIONAL: a proof without the covered fields leaves the leg * UNKNOWN and the signature-over-root check stands alone (never a silent false). LIGHT: node built-ins * (`node:crypto`) + WebCrypto-via-protocol only, no bun/chdb, no platform code. */ import { createPublicKey, verify as ed25519Verify } from "node:crypto"; // The signed-root WIRE constants + the pure `certifiedRoot` derivation live in the dependency-free // protocol package (OSS-ADR-0046/0051), the ONE home both this LIGHT verifier and the platform signer // import so the two can never drift. PLAIN consts + a pure function — importing them keeps verify's // import graph node-only (no engine/frame; `certifiedRoot` hashes via WebCrypto globals). import { GRADE_SIGN_PREFIX, ROOT_HASH_PREFIX, certifiedRoot } from "../../protocol/attestation.ts"; import { HostedFunnel, type PublishedProof, type PublishedVerifyKey } from "../backend/hosted.ts"; import type { OutputCtx, GlobalFlags } from "../context.ts"; import { parseArgs } from "../args.ts"; import { CliError, EXIT } from "../errors.ts"; import { resolveSimBase, resolveCopyToken } from "./sim.ts"; type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; interface Env { readonly [k: string]: string | undefined; } /** The four honest verdicts a zero-trust check resolves to. */ export type VerifyVerdict = "VERIFIED" | "UNVERIFIED" | "KEY_RETIRED" | "NOT_FOUND"; /** Verdict → CLI exit: VERIFIED passes; every other outcome is a nonzero, machine-legible code. */ const VERDICT_EXIT: Readonly<Record<VerifyVerdict, number>> = { VERIFIED: 0, UNVERIFIED: EXIT.GENERIC, // a held key does NOT verify — genuine tampering/corruption KEY_RETIRED: EXIT.GENERIC, // we hold no key for this kid — honest "cannot re-verify" NOT_FOUND: EXIT.NOT_FOUND, }; /** * Extract the bare proof id from a full/partial proof URL or a `/proof/<id>` path; a bare id * passes through. A URL that is NOT a proof URL is a loud USAGE error (never silently treated * as an id). Pure — no network. */ export function parseProofRef(input: string): string { const trimmed = input.trim(); if (trimmed === "") throw new CliError({ code: "USAGE", exit: EXIT.USAGE, message: "verify needs a proof URL or id" }); const m = /\/proof\/([^/?#]+)/.exec(trimmed); if (m !== null) return decodeURIComponent(m[1]!); if (/^https?:\/\//i.test(trimmed)) throw new CliError({ code: "USAGE", exit: EXIT.USAGE, message: `not a proof URL: ${JSON.stringify(trimmed)}`, hint: "pass a proof URL (https://kestrel.markets/proof/<id>) or a bare proof id", }); return trimmed; } /** * The PURE zero-trust check: does a held key verify the proof's Ed25519 signature over * `kgrade1.<root>`? Selects the key by the proof's OWN kid (trusting active + accept-only). A * kid absent from the published set is `KEY_RETIRED` (honest); a held key that fails the * signature is `UNVERIFIED` (tampering) — never conflated. Fail-closed: any crypto/decoding * error resolves AWAY from VERIFIED. */ export function verifyProofSignature(proof: PublishedProof, keys: readonly PublishedVerifyKey[]): VerifyVerdict { const key = keys.find((k) => k.kid === proof.kid); if (key === undefined) return "KEY_RETIRED"; try { const publicKey = createPublicKey({ key: { ...key.public_jwk }, format: "jwk" }); const signature = Buffer.from(proof.signature, "base64url"); // The wire `root` carries a `sha256:` DISPLAY prefix (platform bead 7ea); the Ed25519 // signature covers the BARE hex root (`kgrade1.<hex>`). Strip the prefix before rebuilding // the signed message, or every genuine prod proof falsely reports UNVERIFIED (bead ha87). const bareRoot = proof.root.startsWith(ROOT_HASH_PREFIX) ? proof.root.slice(ROOT_HASH_PREFIX.length) : proof.root; const message = Buffer.from(`${GRADE_SIGN_PREFIX}.${bareRoot}`); const ok = ed25519Verify(null, message, publicKey, signature); return ok ? "VERIFIED" : "UNVERIFIED"; } catch { return "UNVERIFIED"; } } /** * The numbers↔root binding leg (OSS-ADR-0051): does the proof's PUBLISHED covered-field set rebuild * the signed root? `"yes"` — `certifiedRoot(coveredFields)` equals the bare signed root, so the * published numbers ARE what was signed. `"no"` — they do NOT rebuild it: a tampered published number * whose old signature still validates over the old root (the attack this leg closes). `"unknown"` — the * proof carries no covered-field set (additive-optional / absent = UNKNOWN), so the leg is not run. */ export type NumbersBindRoot = "yes" | "no" | "unknown"; /** * Recompute the numbers↔root binding of a published proof (pure): strip the `sha256:` display prefix * from the wire `root` and compare it to `certifiedRoot(coveredFields)`. Returns `"unknown"` when the * proof carries no covered fields (the leg is additive). Fail-safe: a derivation error (e.g. WebCrypto * unavailable) resolves to `"unknown"` — an inability to adjudicate the binding, never a fabricated * `"yes"` (the independent signature check remains the standing guarantee). */ export async function recomputeNumbersBindRoot(proof: PublishedProof): Promise<NumbersBindRoot> { if (proof.coveredFields === undefined) return "unknown"; try { const bareRoot = proof.root.startsWith(ROOT_HASH_PREFIX) ? proof.root.slice(ROOT_HASH_PREFIX.length) : proof.root; const recomputed = await certifiedRoot(proof.coveredFields); return recomputed === bareRoot ? "yes" : "no"; } catch { return "unknown"; } } export interface VerifyDeps { readonly fetch?: FetchLike; readonly env?: Env; } /** The structured verdict `verify` emits (the `--json` twin + the source of the text line). */ export interface VerifyReport { readonly verdict: VerifyVerdict; readonly proof_id: string; readonly kid?: string; readonly epoch?: number; readonly proof_url?: string; /** The proof body's OWN self-asserted verdict — carried to CONTRAST with the independent one. */ readonly claimed_verified?: boolean; /** * The numbers↔root binding leg (OSS-ADR-0051): `yes` — the published covered-field numbers rebuild * the signed root; `no` — they do NOT (a tampered published number is the cause; the verdict is * UNVERIFIED); `unknown` — the proof carried no covered fields, so only the signature was checked. */ readonly numbers_bind_root?: NumbersBindRoot; /** * WHERE the published verify-set the check ran against lives (bead kestrel-markets-5bjn/fjq7). * Named on a non-VERIFIED verdict so a reader can go re-adjudicate the signature against the * SAME key material independently, instead of guessing where the key is published. */ readonly verify_key_url?: string; } /** Verdicts where the reader needs to know WHERE the key lives to independently re-adjudicate. */ function needsVerifyKeyUrl(verdict: VerifyVerdict): boolean { return verdict === "UNVERIFIED" || verdict === "KEY_RETIRED"; } function emit(ctx: OutputCtx, report: VerifyReport, notice?: string): void { if (ctx.mode === "json") { process.stdout.write(JSON.stringify({ schema: "kestrel.verify/v1", ...(notice !== undefined ? { notice } : {}), ...report }) + "\n"); return; } if (notice !== undefined) process.stdout.write(`note: ${notice}\n`); const parts = [report.verdict, `proof=${report.proof_id}`]; if (report.kid !== undefined) parts.push(`kid=${report.kid}`); if (report.epoch !== undefined) parts.push(`epoch=${report.epoch}`); if (report.verdict === "UNVERIFIED" && report.numbers_bind_root === "no") parts.push("note=published-numbers-do-not-rebuild-the-signed-root"); else if (report.verdict === "UNVERIFIED" && report.claimed_verified === true) parts.push("note=the-proof-claimed-verified-but-its-signature-did-not-re-verify"); if (report.numbers_bind_root !== undefined) parts.push(`numbers-bind-root=${report.numbers_bind_root}`); // Point a skeptical reader AT the published key material so they can re-check independently // (bead 5bjn/fjq7): "against the published verify key" is not an instruction until it names WHERE. if (needsVerifyKeyUrl(report.verdict) && report.verify_key_url !== undefined) parts.push(`verify-key=${report.verify_key_url}`); process.stdout.write(parts.join("\t") + "\n"); } /** * The SHARED zero-trust verify path (reused by `verify` and `replay`'s degrade branch): fetch * the published proof, fetch the verify-set INDEPENDENTLY, re-check the signature locally, emit * the verdict as data, and return the verdict's exit code. `notice` prefixes an honest one-line * explanation (replay uses it when it has no local strategy to reproduce). */ export async function runVerify( client: HostedFunnel, id: string, ctx: OutputCtx, forward: { readonly copyToken?: string }, notice?: string, ): Promise<number> { const proof = await client.publishedProof(id, forward); if (proof === null) { emit(ctx, { verdict: "NOT_FOUND", proof_id: id }, notice); return VERDICT_EXIT.NOT_FOUND; } // Fetch the verify-set INDEPENDENTLY — the zero-trust input (never the proof's own verdict). const keys = await client.verifyKeys(forward); const signatureVerdict = verifyProofSignature(proof, keys); // The numbers↔root binding leg (OSS-ADR-0051): recompute the signed root from the PUBLISHED covered // fields. `"no"` — the numbers do not rebuild the signed root (a tampered published number the // signature-over-root check alone cannot catch) — is genuine tampering, so it forces UNVERIFIED even // when the signature validates. `"unknown"` (no covered fields published) leaves the signature verdict. const numbersBind = await recomputeNumbersBindRoot(proof); const verdict: VerifyVerdict = numbersBind === "no" ? "UNVERIFIED" : signatureVerdict; emit( ctx, { verdict, proof_id: proof.proof_id, kid: proof.kid, epoch: proof.epoch, claimed_verified: proof.verificationClaim, numbers_bind_root: numbersBind, // Name the published key set on a non-VERIFIED verdict (bead 5bjn/fjq7) — the same // `/.well-known/kestrel-markets` the verify-set was fetched from, resolved at the API base. ...(needsVerifyKeyUrl(verdict) ? { verify_key_url: client.verifyKeysUrl } : {}), }, notice, ); return VERDICT_EXIT[verdict]; } /** * `verify` — zero-trust re-verification of a published proof. The verdict is DATA on stdout * (VERIFIED/UNVERIFIED/KEY_RETIRED/NOT_FOUND) and the exit code reflects it (0 only for * VERIFIED). Never trusts the proof body's own `verification.verified`. */ export async function verifyCommand( argv: readonly string[], ctx: OutputCtx, globals: GlobalFlags, deps: VerifyDeps = {}, ): Promise<number> { const { positionals, flags } = parseArgs(argv, new Set(), new Set(["copy-token"])); if (positionals.length === 0) throw new CliError({ code: "USAGE", exit: EXIT.USAGE, message: "verify needs a proof URL or id", hint: "usage: kestrel verify <https://kestrel.markets/proof/<id> | <id>>", }); const env = deps.env ?? (process.env as Env); const id = parseProofRef(positionals[0]!); const copyToken = resolveCopyToken(flags, env); const forward = copyToken !== undefined ? { copyToken } : {}; const base = resolveSimBase(globals, env); const client = new HostedFunnel({ baseUrl: base, ...(deps.fetch !== undefined ? { fetch: deps.fetch } : {}) }); return runVerify(client, id, ctx, forward); }