Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
114 changes: 112 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,7 +22,10 @@ interface CliArgs {

const HELP = `certifieddata-verify <id|path|url|-> [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:
<id> certification UUID (resolved against the public API)
Expand All @@ -28,7 +35,11 @@ Inputs:

Options:
--dataset <path> recompute SHA-256 of dataset file and compare to cert.dataset_hash
--keys <path> use a local keys document instead of fetching .well-known
--type <t> 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 <path> certificates: local keys document instead of .well-known
--key <pem> 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
Expand Down Expand Up @@ -67,6 +78,72 @@ export async function main(argv: string[]): Promise<number> {
}

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 });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
118 changes: 118 additions & 0 deletions src/receipt.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = 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");
});
Loading
Loading