From 3f11b75962a77304a19e8bf7e4cdbc0a19bbb8e2 Mon Sep 17 00:00:00 2001 From: SDS <209957663+dkitchell@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:00:26 -0600 Subject: [PATCH 1/2] feat: independent Agent Commerce receipt verification (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit certifieddata-verify now verifies payment receipts locally: fetch the payload+signature envelope from the public verify endpoint, fetch the Agent Commerce PEM from .well-known, RFC 8785 JCS-canonicalize (the repo's existing zero-dep canonicalizer), verify Ed25519 with Node crypto, and recompute SHA-256 against the stored receipt hash. The server's valid/signatureValid booleans are surfaced as informational metadata only — they never determine the verdict, proven by a lying- server test. Artifact-kind resolution per the issue spec (src/resolve.ts): prefix and URL-path hints, local JSON schema sniffing, and for bare UUIDs a dual probe of both public endpoints — exactly-one wins, both -> AMBIGUOUS (exit 64, require --type), neither -> NOT_FOUND, transport failure -> NETWORK (a server failure is never treated as absence). --type certificate|receipt overrides everything. Certificate behavior is unchanged (all 34 pre-existing tests pass untouched). Key handling fails closed: PEM endpoint 503/unreachable -> UNKNOWN_KEY exit with an explicit 'the server's own verdict is NOT accepted as a substitute' message; offline receipt verification requires --key . Tests: 15 new (49 total) — real Ed25519 over real JCS bytes; 9-field tamper matrix (amount, policy_hash, artifact_hash, agent, purpose, status, settlement_state, timestamp, receipt_id) each fails; stored-hash mismatch fails even with a valid signature; missing signature and wrong schema are MALFORMED; RSA key is UNKNOWN_KEY. Depends on platform PR #318 exposing signature + canonicalization + public_key_url on GET /api/payments/verify/:receiptId. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 5 + package.json | 2 +- src/cli.ts | 114 ++++++++++++++++++++- src/receipt.test.ts | 118 ++++++++++++++++++++++ src/receipt.ts | 235 ++++++++++++++++++++++++++++++++++++++++++++ src/resolve.ts | 92 +++++++++++++++++ 6 files changed, 563 insertions(+), 3 deletions(-) create mode 100644 src/receipt.test.ts create mode 100644 src/receipt.ts create mode 100644 src/resolve.ts diff --git a/package-lock.json b/package-lock.json index e02ce60..254b8d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -302,6 +302,7 @@ "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", @@ -545,6 +546,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -733,6 +735,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1271,6 +1274,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1419,6 +1423,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 55be910..596989d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", "typecheck": "tsc --noEmit", "lint": "eslint src/", - "test": "tsc -p tsconfig.json && node --test dist/canonicalize.test.js dist/verify.test.js dist/cli.test.js", + "test": "tsc -p tsconfig.json && node --test dist/canonicalize.test.js dist/verify.test.js dist/cli.test.js dist/receipt.test.js", "fixtures": "node fixtures/generate.mjs", "prepublishOnly": "npm run build && npm test" }, diff --git a/src/cli.ts b/src/cli.ts index afdbde1..15c71fb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,12 +3,16 @@ import { pathToFileURL } from "node:url"; import { fetchCert } from "./fetch-cert.js"; import { loadKeys } from "./keys.js"; import { verifyCertificate } from "./verify.js"; +import { fetchReceipt, loadReceiptKey, verifyReceiptEnvelope, type ReceiptVerifyResult } from "./receipt.js"; +import { resolveArtifactKind, type ArtifactKind } from "./resolve.js"; import type { VerifyResult } from "./types.js"; interface CliArgs { positional: string[]; dataset?: string; keys?: string; + type?: ArtifactKind; + key?: string; json: boolean; offline: boolean; noCache: boolean; @@ -18,7 +22,10 @@ interface CliArgs { const HELP = `certifieddata-verify [options] -Verify a CertifiedData.io certificate. +Verify a CertifiedData.io certificate or Agent Commerce payment receipt. + +The artifact is fetched from CertifiedData, but the verdict is not: the +Ed25519 signature is verified locally against the published public key. Inputs: certification UUID (resolved against the public API) @@ -28,7 +35,11 @@ Inputs: Options: --dataset recompute SHA-256 of dataset file and compare to cert.dataset_hash - --keys use a local keys document instead of fetching .well-known + --type force artifact kind: certificate | receipt (bare UUIDs + are probed against both public endpoints; if both exist + the CLI refuses to guess and requires --type) + --keys certificates: local keys document instead of .well-known + --key receipts: local Agent Commerce public-key PEM --offline do not touch the network (requires --keys or a fresh cache) --no-cache bypass ~/.certifieddata/keys.json cache --json machine-readable output @@ -67,6 +78,72 @@ export async function main(argv: string[]): Promise { } const target = args.positional[0]; + + // ── Artifact-kind resolution (verify#2) ──────────────────────────────── + let kind: ArtifactKind; + if (args.type) { + kind = args.type; + } else { + const resolved = await resolveArtifactKind(target, { offline: args.offline }); + if (resolved.kind === "ambiguous") { + process.stderr.write( + `${c.yellow("? AMBIGUOUS")} ${target} exists as BOTH a certificate and a receipt.\n` + + ` Re-run with --type certificate or --type receipt.\n`, + ); + return EXIT.USAGE; + } + if (resolved.kind === "not_found") { + process.stderr.write(`${c.red("✗ NOT_FOUND")} ${target} is neither a known certificate nor a known receipt.\n`); + return EXIT.MALFORMED; + } + if (resolved.kind === "transport_error") { + process.stderr.write( + `${c.red("✗ NETWORK")} could not determine artifact kind for ${target} — an endpoint failed.\n` + + ` A server failure is not evidence of absence. Retry, or pass --type explicitly.\n`, + ); + return EXIT.NETWORK; + } + kind = resolved.kind; + } + + // ── Receipt path ─────────────────────────────────────────────────────── + if (kind === "receipt") { + let rres: ReceiptVerifyResult; + try { + const env = await fetchReceipt(target, { offline: args.offline }); + const pem = await loadReceiptKey({ keyFile: args.key, offline: args.offline }); + rres = verifyReceiptEnvelope(env, pem); + } catch (err) { + const reason = (err as Error).message; + const keyUnavailable = /public key unavailable|requires --key/i.test(reason); + const isNetwork = /HTTP \d|ENOTFOUND|ECONN|getaddrinfo|fetch/i.test(reason); + if (args.json) { + process.stdout.write(JSON.stringify({ artifact_type: "receipt", artifact_id: null, verdict: keyUnavailable ? "UNKNOWN_KEY" : "MALFORMED", reason }) + "\n"); + } else { + const tag = keyUnavailable ? c.yellow("? KEY_UNAVAILABLE") : c.red("✗ ERROR"); + process.stderr.write(`${tag} ${reason}\n`); + if (keyUnavailable) { + process.stderr.write(` ${c.dim("Independent verification is impossible without the published key —")}\n`); + process.stderr.write(` ${c.dim("the server's own verdict is NOT accepted as a substitute.")}\n`); + } + } + return keyUnavailable ? EXIT.UNKNOWN_KEY : isNetwork ? EXIT.NETWORK : EXIT.MALFORMED; + } + + if (args.json) { + process.stdout.write(JSON.stringify(rres) + "\n"); + } else { + printReceiptHuman(rres); + } + switch (rres.verdict) { + case "VALID": return EXIT.VALID; + case "INVALID": return EXIT.INVALID; + case "UNKNOWN_KEY": return EXIT.UNKNOWN_KEY; + case "MALFORMED": return EXIT.MALFORMED; + } + } + + // ── Certificate path (unchanged behavior) ────────────────────────────── let result: VerifyResult; try { const cert = await fetchCert(target, { offline: args.offline }); @@ -103,6 +180,12 @@ function parseArgs(argv: string[]): CliArgs { case "--no-cache": out.noCache = true; break; case "--dataset": out.dataset = requireValue(argv, ++i, a); break; case "--keys": out.keys = requireValue(argv, ++i, a); break; + case "--key": out.key = requireValue(argv, ++i, a); break; + case "--type": { + const v = requireValue(argv, ++i, a); + if (v !== "certificate" && v !== "receipt") throw new Error("--type must be certificate or receipt"); + out.type = v; break; + } default: if (a.startsWith("--")) throw new Error(`unknown option: ${a}`); out.positional.push(a); @@ -157,6 +240,33 @@ function printHuman(r: VerifyResult): void { } } +function printReceiptHuman(r: ReceiptVerifyResult): void { + const id = r.artifact_id ?? "(unknown)"; + switch (r.verdict) { + case "VALID": { + process.stdout.write(`${c.green("✓ VALID")} receipt ${id}\n`); + process.stdout.write(` ${c.dim("signed by")} ${r.key_id ?? "(published Agent Commerce key)"} (${r.issuer ?? "CertifiedData.io"})\n`); + process.stdout.write(` ${c.dim("signature")} ${r.checks.signature}\n`); + process.stdout.write(` ${c.dim("payload hash")} ${r.checks.payload_hash}\n`); + process.stdout.write(` ${c.dim("public key")} /.well-known/certifieddata-public-key.pem\n`); + if (r.settlement_state) { + process.stdout.write(` ${c.dim("settlement")} ${r.settlement_state}\n`); + } + process.stdout.write(` ${c.dim("The verdict above was computed locally — not taken from the server.")}\n`); + break; + } + case "INVALID": + process.stdout.write(`${c.red("✗ INVALID")} receipt ${id}\n ${r.reason}\n`); + break; + case "UNKNOWN_KEY": + process.stdout.write(`${c.yellow("? UNKNOWN_KEY")} receipt ${id}\n ${r.reason}\n`); + break; + case "MALFORMED": + process.stdout.write(`${c.red("✗ MALFORMED")} ${r.reason}\n`); + break; + } +} + function networkErrorResult(reason: string): VerifyResult { return { verdict: "MALFORMED", diff --git a/src/receipt.test.ts b/src/receipt.test.ts new file mode 100644 index 0000000..a407bf9 --- /dev/null +++ b/src/receipt.test.ts @@ -0,0 +1,118 @@ +// Receipt verification tests (verify#2 acceptance criteria). +// +// Real Ed25519 with a throwaway keypair, real RFC 8785 canonical bytes: +// - a well-formed signed receipt verifies +// - tampering with ANY signed field fails verification +// - the server's booleans never influence the verdict +// - stored-hash mismatch fails even when the signature would pass +// - missing signature / wrong schema are MALFORMED, not INVALID + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash, generateKeyPairSync, sign as cryptoSign } from "node:crypto"; +import { canonicalizeToBytes } from "./canonicalize.js"; +import { verifyReceiptEnvelope } from "./receipt.js"; + +const { publicKey, privateKey } = generateKeyPairSync("ed25519"); +const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + +const BASE_PAYLOAD = { + receipt_id: "46b93444-9ce0-49de-94e4-7a3c41ac8430", + schema_version: "payment_receipt.v1", + timestamp: "2026-08-20T03:03:23.461Z", + issuer: "CertifiedData.io", + agent_id: "demo0000-0000-0000-0000-000000000002", + agent_name: "CertifiedData Demo Agent", + rail: "stripe", + currency: "usd", + amount: 2900, + status: "succeeded", + settlement_state: "simulated_sandbox", + purpose: "verify#2 test fixture", + policy_id: "demo0000-0000-0000-0000-000000000003", + policy_hash: "sha256:c220cd2760d84c4a58c59596cf5eb12662ce7f21d6196282be9fe9de31b7b7d7", + artifact_hash: "sha256:75961ef7be87c6a3039544f64e1687e76637f26f2d6c0d5dd4d978e1495a5d66", + transaction_id: "23b69400-e730-4340-ba4f-aadfc580b702", +}; + +function signedEnvelope(payload: Record = BASE_PAYLOAD) { + const bytes = canonicalizeToBytes(payload); + const signatureB64 = cryptoSign(null, bytes, privateKey).toString("base64"); + const storedHash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + return { payload, signatureB64, storedHash }; +} + +test("a well-formed signed receipt verifies locally", () => { + const r = verifyReceiptEnvelope(signedEnvelope(), publicKeyPem); + assert.equal(r.verdict, "VALID"); + assert.equal(r.checks.signature, "pass"); + assert.equal(r.checks.key_trust, "pass"); + assert.equal(r.checks.payload_hash, "pass"); + assert.equal(r.artifact_type, "receipt"); + assert.equal(r.artifact_id, BASE_PAYLOAD.receipt_id); +}); + +// Acceptance: tampering with ANY signed receipt field fails verification. +const TAMPER_FIELDS: Array<[string, unknown]> = [ + ["amount", 29], + ["policy_hash", "sha256:" + "0".repeat(64)], + ["artifact_hash", "sha256:" + "f".repeat(64)], + ["agent_id", "attacker-agent"], + ["purpose", "forged purpose"], + ["status", "succeeded_but_forged"], + ["settlement_state", "settled"], + ["timestamp", "2020-01-01T00:00:00.000Z"], + ["receipt_id", "00000000-0000-0000-0000-000000000000"], +]; + +for (const [field, forged] of TAMPER_FIELDS) { + test(`tampering with ${field} invalidates the receipt`, () => { + const env = signedEnvelope(); + const tampered = { ...env, payload: { ...env.payload, [field]: forged } }; + const r = verifyReceiptEnvelope(tampered, publicKeyPem); + assert.equal(r.verdict, "INVALID", `${field} tamper must fail`); + assert.equal(r.checks.signature, "fail"); + }); +} + +test("server booleans are informational only — verdict is computed locally", () => { + const env = signedEnvelope(); + const tampered = { + ...env, + payload: { ...env.payload, amount: 1 }, + // A lying server says everything is fine. + serverReported: { valid: true, signatureValid: true, hashValid: true }, + }; + const r = verifyReceiptEnvelope(tampered, publicKeyPem); + assert.equal(r.verdict, "INVALID"); + assert.deepEqual(r.server_reported, { valid: true, signatureValid: true, hashValid: true }); +}); + +test("stored-hash mismatch fails even with a valid signature", () => { + const env = signedEnvelope(); + const r = verifyReceiptEnvelope({ ...env, storedHash: "sha256:" + "9".repeat(64) }, publicKeyPem); + assert.equal(r.verdict, "INVALID"); + assert.equal(r.checks.signature, "pass"); + assert.equal(r.checks.payload_hash, "fail"); +}); + +test("missing signature is MALFORMED, not INVALID", () => { + const env = signedEnvelope(); + const r = verifyReceiptEnvelope({ ...env, signatureB64: null }, publicKeyPem); + assert.equal(r.verdict, "MALFORMED"); + assert.equal(r.checks.signature, "skipped"); +}); + +test("wrong schema_version is MALFORMED", () => { + const env = signedEnvelope({ ...BASE_PAYLOAD, schema_version: "payment_receipt.v2" }); + const r = verifyReceiptEnvelope(env, publicKeyPem); + assert.equal(r.verdict, "MALFORMED"); +}); + +test("a non-ed25519 published key is UNKNOWN_KEY, never a pass", () => { + const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const rsaPem = rsa.publicKey.export({ type: "spki", format: "pem" }).toString(); + const r = verifyReceiptEnvelope(signedEnvelope(), rsaPem); + assert.equal(r.verdict, "UNKNOWN_KEY"); + assert.equal(r.checks.key_trust, "fail"); +}); diff --git a/src/receipt.ts b/src/receipt.ts new file mode 100644 index 0000000..687da4f --- /dev/null +++ b/src/receipt.ts @@ -0,0 +1,235 @@ +// Agent Commerce receipt verification (verify#2). +// +// Same philosophy as verify.ts, one more artifact type: +// 1. Fetch the receipt payload + signature from the public verify endpoint +// (or read a local JSON file / stdin). +// 2. Fetch the Agent Commerce public key PEM from .well-known — a DIFFERENT +// trust root from the certificate keys document, on purpose. +// 3. RFC 8785 JCS-canonicalize the payload (signature excluded — the +// platform stores the payload without it) and verify Ed25519 locally. +// 4. Recompute SHA-256 over the same canonical bytes and compare to the +// stored receipt hash when one is exposed. +// +// The server's valid / signatureValid booleans are surfaced as INFORMATIONAL +// metadata only — they never determine the verdict. If the public key cannot +// be fetched, that is a distinct non-success outcome, never a silent +// fallback to the server's opinion. + +import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { canonicalizeToBytes } from "./canonicalize.js"; +import type { CheckResult } from "./types.js"; + +export const DEFAULT_RECEIPT_API = "https://certifieddata.io/api/payments/verify"; +export const DEFAULT_RECEIPT_KEY_URL = + "https://certifieddata.io/.well-known/certifieddata-public-key.pem"; + +export type ReceiptVerdict = "VALID" | "INVALID" | "UNKNOWN_KEY" | "MALFORMED"; + +export interface ReceiptVerifyResult { + artifact_type: "receipt"; + artifact_id: string | null; + verdict: ReceiptVerdict; + key_id: string | null; + issuer: string | null; + signed_at: string | null; + checks: { + signature: CheckResult; + key_trust: CheckResult; + payload_hash: CheckResult; + }; + reason: string; + /** The server's own booleans, informational only — never the verdict. */ + server_reported?: { valid?: boolean; signatureValid?: boolean; hashValid?: boolean }; + settlement_state?: string | null; + amount_cents?: number | null; + currency?: string | null; +} + +interface ReceiptEnvelope { + payload: Record; + signatureB64: string | null; + storedHash: string | null; + serverReported?: { valid?: boolean; signatureValid?: boolean; hashValid?: boolean }; +} + +export interface FetchReceiptOptions { + apiBase?: string; + offline?: boolean; +} + +/** Accepts a receipt id, a /api/payments/verify URL, a local .json path, or "-". */ +export async function fetchReceipt( + idOrPathOrUrl: string, + opts: FetchReceiptOptions = {}, +): Promise { + if (idOrPathOrUrl === "-") return parseEnvelope(await readStdin()); + if ( + idOrPathOrUrl.endsWith(".json") || + idOrPathOrUrl.startsWith("./") || + idOrPathOrUrl.startsWith("/") || + /^[A-Za-z]:[\\/]/.test(idOrPathOrUrl) + ) { + return parseEnvelope(await readFile(idOrPathOrUrl, "utf8")); + } + if (opts.offline) { + throw new Error("cannot resolve a receipt id in --offline mode (pass a local file)"); + } + const url = /^https?:\/\//.test(idOrPathOrUrl) + ? idOrPathOrUrl + : `${(opts.apiBase ?? DEFAULT_RECEIPT_API).replace(/\/$/, "")}/${encodeURIComponent(idOrPathOrUrl)}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + return parseEnvelope(await res.text()); +} + +function parseEnvelope(body: string): ReceiptEnvelope { + const parsed = JSON.parse(body) as Record; + // Server envelope: { receipt: {...}, signature, storedReceiptHash, valid... } + if (parsed.receipt && typeof parsed.receipt === "object") { + return { + payload: parsed.receipt as Record, + signatureB64: typeof parsed.signature === "string" ? parsed.signature : null, + storedHash: + typeof parsed.storedReceiptHash === "string" ? parsed.storedReceiptHash : null, + serverReported: { + valid: typeof parsed.valid === "boolean" ? parsed.valid : undefined, + signatureValid: + typeof parsed.signatureValid === "boolean" ? parsed.signatureValid : undefined, + hashValid: typeof parsed.hashValid === "boolean" ? parsed.hashValid : undefined, + }, + }; + } + // Bare payload with an embedded signature (local file usage). + if (parsed.schema_version === "payment_receipt.v1") { + const { signature, ...payload } = parsed as { signature?: string } & Record; + return { payload, signatureB64: signature ?? null, storedHash: null }; + } + throw new Error("input is neither a verify-endpoint envelope nor a payment_receipt.v1 payload"); +} + +export interface LoadReceiptKeyOptions { + keyUrl?: string; + keyFile?: string; + offline?: boolean; +} + +/** + * Loads the Agent Commerce public key PEM. Fails loudly — a 503 here means + * the issuer is misconfigured, and the CLI's answer is "cannot verify + * independently", never "let the server vouch for itself". + */ +export async function loadReceiptKey(opts: LoadReceiptKeyOptions = {}): Promise { + if (opts.keyFile) return readFile(opts.keyFile, "utf8"); + if (opts.offline) { + throw new Error("offline receipt verification requires --key "); + } + const url = opts.keyUrl ?? DEFAULT_RECEIPT_KEY_URL; + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `public key unavailable (HTTP ${res.status} from ${url}) — cannot verify independently`, + ); + } + const pem = await res.text(); + if (!pem.includes("BEGIN PUBLIC KEY")) { + throw new Error(`response from ${url} is not a PEM public key`); + } + return pem; +} + +export function verifyReceiptEnvelope( + env: ReceiptEnvelope, + publicKeyPem: string, +): ReceiptVerifyResult { + const p = env.payload as Record; + const result: ReceiptVerifyResult = { + artifact_type: "receipt", + artifact_id: typeof p.receipt_id === "string" ? p.receipt_id : null, + verdict: "MALFORMED", + key_id: null, + issuer: typeof p.issuer === "string" ? p.issuer : null, + signed_at: typeof p.timestamp === "string" ? p.timestamp : null, + checks: { signature: "skipped", key_trust: "skipped", payload_hash: "skipped" }, + reason: "", + server_reported: env.serverReported, + settlement_state: typeof p.settlement_state === "string" ? p.settlement_state : null, + amount_cents: typeof p.amount === "number" ? p.amount : null, + currency: typeof p.currency === "string" ? p.currency : null, + }; + + if (p.schema_version !== "payment_receipt.v1") { + result.reason = `unsupported schema_version: ${String(p.schema_version)}`; + return result; + } + if (!env.signatureB64) { + result.reason = + "no signature present — the verify endpoint predates signature exposure, or the local file omitted it"; + return result; + } + + let publicKey; + try { + publicKey = createPublicKey({ key: publicKeyPem, format: "pem" }); + if (publicKey.asymmetricKeyType !== "ed25519") { + result.checks.key_trust = "fail"; + result.verdict = "UNKNOWN_KEY"; + result.reason = `published key is ${publicKey.asymmetricKeyType}, expected ed25519`; + return result; + } + } catch (e) { + result.checks.key_trust = "fail"; + result.verdict = "UNKNOWN_KEY"; + result.reason = `cannot parse published public key: ${(e as Error).message}`; + return result; + } + result.checks.key_trust = "pass"; + + let sigBytes: Buffer; + try { + sigBytes = Buffer.from(env.signatureB64, "base64"); + if (sigBytes.length !== 64) throw new Error(`expected 64 bytes, got ${sigBytes.length}`); + } catch (e) { + result.reason = `signature is not valid base64 ed25519: ${(e as Error).message}`; + return result; + } + + // The platform signs canonicalize(payload) where payload never contained a + // signature field; strip defensively for local files. + const { signature: _drop, ...withoutSig } = env.payload as { signature?: string } & Record; + const canonicalBytes = canonicalizeToBytes(withoutSig); + + const sigOk = cryptoVerify(null, canonicalBytes, publicKey, sigBytes); + result.checks.signature = sigOk ? "pass" : "fail"; + if (!sigOk) { + result.verdict = "INVALID"; + result.reason = "ed25519 signature does not verify against the RFC 8785 canonical payload"; + return result; + } + + if (env.storedHash) { + const recomputed = `sha256:${createHash("sha256").update(canonicalBytes).digest("hex")}`; + const match = recomputed === env.storedHash; + result.checks.payload_hash = match ? "pass" : "fail"; + if (!match) { + result.verdict = "INVALID"; + result.reason = `stored receipt hash ${env.storedHash} does not match locally recomputed ${recomputed}`; + return result; + } + } + + // Key id is reported from the envelope's metadata when present; trust came + // from the .well-known fetch, not from this label. + result.key_id = typeof (env as any).keyId === "string" ? (env as any).keyId : result.key_id; + result.verdict = "VALID"; + result.reason = "ed25519 signature verified locally against the published Agent Commerce key"; + return result; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer)); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..ef7b7e1 --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,92 @@ +// Artifact-kind resolution (verify#2). +// +// Both certificates and payment receipts can be bare UUIDs, so a UUID alone +// is not a safe discriminator. Resolution order: +// 1. Strong syntax/path hints (prefixes, endpoint URLs, local JSON shape). +// 2. For an ambiguous bare id online: probe BOTH public endpoints. +// exactly one exists -> that kind +// both exist -> AMBIGUOUS (caller must pass --type) +// neither exists -> NOT_FOUND +// transport failure -> TRANSPORT (never silently "not found") +// 3. --type overrides everything. + +import { readFile } from "node:fs/promises"; +import { DEFAULT_CERT_API } from "./fetch-cert.js"; +import { DEFAULT_RECEIPT_API } from "./receipt.js"; + +export type ArtifactKind = "certificate" | "receipt"; + +export type Resolution = + | { kind: ArtifactKind; via: string } + | { kind: "ambiguous"; via: string } + | { kind: "not_found"; via: string } + | { kind: "transport_error"; via: string }; + +export async function resolveArtifactKind( + target: string, + opts: { offline?: boolean; certApiBase?: string; receiptApiBase?: string } = {}, +): Promise { + // 1. Prefix hints — cert-family prefixes are certificates. + if (/^(cert_|scert_)/.test(target)) return { kind: "certificate", via: "id-prefix" }; + + // Endpoint-URL hints. + if (/\/api\/payments\/verify\//.test(target)) return { kind: "receipt", via: "url-path" }; + if (/\/api\/v1\/certificates\//.test(target)) return { kind: "certificate", via: "url-path" }; + + // Local file / stdin: sniff the JSON shape. + const looksLocal = + target === "-" || + target.endsWith(".json") || + target.startsWith("./") || + target.startsWith("/") || + /^[A-Za-z]:[\\/]/.test(target); + if (looksLocal && target !== "-") { + try { + const parsed = JSON.parse(await readFile(target, "utf8")) as Record; + const schema = + (parsed.schema_version as string | undefined) ?? + ((parsed.receipt as Record | undefined)?.schema_version as string | undefined); + if (schema === "payment_receipt.v1") return { kind: "receipt", via: "local-schema" }; + if (typeof schema === "string" && schema.startsWith("cert.")) { + return { kind: "certificate", via: "local-schema" }; + } + // Envelope shape without schema — a verify-endpoint dump. + if (parsed.receipt && parsed.storedReceiptHash) return { kind: "receipt", via: "local-envelope" }; + return { kind: "certificate", via: "local-default" }; + } catch { + return { kind: "certificate", via: "local-unreadable-default" }; + } + } + if (target === "-") return { kind: "certificate", via: "stdin-default" }; + + // 2. Bare id online — probe both endpoints. Offline cannot probe. + if (opts.offline) return { kind: "certificate", via: "offline-default" }; + + const certUrl = `${(opts.certApiBase ?? DEFAULT_CERT_API).replace(/\/$/, "")}/${encodeURIComponent(target)}`; + const rcptUrl = `${(opts.receiptApiBase ?? DEFAULT_RECEIPT_API).replace(/\/$/, "")}/${encodeURIComponent(target)}`; + + const probe = async (url: string): Promise<"exists" | "missing" | "error"> => { + try { + const res = await fetch(url, { method: "GET" }); + if (res.ok) return "exists"; + if (res.status === 404) return "missing"; + return "error"; // 5xx/403/… — a server problem is NOT evidence of absence + } catch { + return "error"; + } + }; + + const [cert, rcpt] = await Promise.all([probe(certUrl), probe(rcptUrl)]); + + if (cert === "error" || rcpt === "error") { + // If the OTHER endpoint definitively resolved, use it; otherwise surface + // the transport problem instead of guessing. + if (cert === "exists" && rcpt !== "exists") return { kind: "certificate", via: "probe" }; + if (rcpt === "exists" && cert !== "exists") return { kind: "receipt", via: "probe" }; + return { kind: "transport_error", via: "probe" }; + } + if (cert === "exists" && rcpt === "exists") return { kind: "ambiguous", via: "probe" }; + if (cert === "exists") return { kind: "certificate", via: "probe" }; + if (rcpt === "exists") return { kind: "receipt", via: "probe" }; + return { kind: "not_found", via: "probe" }; +} From 7875ab82b85b229073b10f1cd804a2e3ada50a7e Mon Sep 17 00:00:00 2001 From: SDS <209957663+dkitchell@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:01:13 -0600 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20lint=20=E2=80=94=20replace=20expli?= =?UTF-8?q?cit=20any=20with=20unknown-narrowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/receipt.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/receipt.ts b/src/receipt.ts index 687da4f..14a31af 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -142,7 +142,7 @@ export function verifyReceiptEnvelope( env: ReceiptEnvelope, publicKeyPem: string, ): ReceiptVerifyResult { - const p = env.payload as Record; + const p = env.payload as Record; const result: ReceiptVerifyResult = { artifact_type: "receipt", artifact_id: typeof p.receipt_id === "string" ? p.receipt_id : null, @@ -220,7 +220,8 @@ export function verifyReceiptEnvelope( // Key id is reported from the envelope's metadata when present; trust came // from the .well-known fetch, not from this label. - result.key_id = typeof (env as any).keyId === "string" ? (env as any).keyId : result.key_id; + const keyIdMeta = (env as { keyId?: unknown }).keyId; + result.key_id = typeof keyIdMeta === "string" ? keyIdMeta : result.key_id; result.verdict = "VALID"; result.reason = "ed25519 signature verified locally against the published Agent Commerce key"; return result;