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