diff --git a/.gitignore b/.gitignore index c2203f0..fcf2f22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -dist/ *.log .DS_Store .env diff --git a/README.md b/README.md index f61e7c7..745b538 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,25 @@ certifieddata-verify ./received-cert.json --keys ./certifieddata-keys.json --off `--offline` refuses to make any network call. Combined with `--keys`, it produces a fully reproducible audit you can replay months later. +## Verifying payment receipts + +Receipts are a different artifact from certificates, with a different trust root +(`/.well-known/certifieddata-public-key.pem` rather than the keys document). + +```bash +npx @certifieddata/verify --type receipt +``` + +The canonicalization, the exact bytes that are signed, and the test vectors are +specified normatively in **[RECEIPT-VERIFICATION.md](./RECEIPT-VERIFICATION.md)**. +It is RFC 8785 (JCS) — not `json-stable-stringify`; the two produce different +bytes for the same document. `fixtures/valid-receipt.json` is a real production +receipt and `src/receipt-vectors.test.ts` pins its digest, so an outside +implementation can check itself against a known-good value. + +The server's `signatureValid` boolean is never used to decide the verdict. It is +CertifiedData's opinion about CertifiedData's own signature. + ## How CertifiedData certificates work CertifiedData.io issues `cert.v1` documents that bind together: diff --git a/RECEIPT-VERIFICATION.md b/RECEIPT-VERIFICATION.md new file mode 100644 index 0000000..d3dc1ea --- /dev/null +++ b/RECEIPT-VERIFICATION.md @@ -0,0 +1,234 @@ +# Receipt verification — normative specification + +This document specifies how to verify a CertifiedData Agent Commerce payment +receipt **without trusting CertifiedData**. It exists because the platform docs +referred to "SHA-256 of the canonical receipt payload" in several places without +ever defining *canonical*, leaving an outside implementer unable to reproduce +the hash and with no vectors to check an attempt against. + +Everything here is exercised by `src/receipt.test.ts` against the fixtures in +`fixtures/`, and the worked example below is a real live receipt. + +--- + +## 1. The trust boundary + +A receipt is verifiable from two inputs: + +| Input | Where from | Trust required | +|---|---|---| +| The receipt envelope | `GET /api/payments/verify/{id}` | none — tampering is detected by the signature | +| The Ed25519 public key | `GET /.well-known/certifieddata-public-key.pem` | that this key belongs to CertifiedData | + +The endpoint also returns `valid`, `hashValid` and `signatureValid`. **These are +the server's opinion about its own signature and are not evidence.** A verifier +must ignore them and compute its own verdict. They are useful only as a +cross-check: if your verdict disagrees with the server's, one of you has a bug. + +If the public key cannot be fetched, that is a distinct outcome (`UNKNOWN_KEY` +/ `NETWORK`). It must never degrade into accepting the server's booleans. + +--- + +## 2. The canonical payload + +The signed object is the value of the envelope's `receipt` field, exactly as +returned, with **no** fields added or removed. + +Three fields are commonly mistaken for part of it: + +- `signature` — sits at the envelope level, not inside `receipt`. It cannot be + part of the payload it signs. +- `sha256_hash`, `ed25519_sig` — appended by `POST /v1/transactions/{id}/capture` + to its inline receipt object for convenience. They are **not** part of the + canonical payload. If you are verifying a capture response rather than the + verify endpoint, remove them first. The verify endpoint does not include them. + +Producer-side note: the platform applies a `stripUndefined()` pass before +canonicalizing. This has no effect on a consumer, because JSON has no +`undefined` — a key is either present or absent. It is documented only so the +two implementations can be compared line by line. + +--- + +## 3. Canonicalization: RFC 8785 (JCS) + +**Serialize the canonical payload per [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785), +the JSON Canonicalization Scheme**, then encode as UTF-8. + +This settles an ambiguity that existed in the public docs. It is RFC 8785 JCS — +*not* `json-stable-stringify`. They agree on key ordering for simple documents +and disagree on string escaping and number formatting, so they can produce +different bytes and therefore different hashes. + +The rules that matter for receipt payloads: + +- Object keys sorted ascending by **UTF-16 code unit** sequence. +- Array order preserved. +- No insignificant whitespace. +- Strings use the minimal RFC 8259 §7 escapes (`"`, `\`, `\b`, `\f`, `\n`, + `\r`, `\t`), and `\u00XX` for other control characters `U+0000`–`U+001F`. + Non-ASCII characters are emitted literally, not `\u`-escaped. +- Numbers use the ECMAScript `Number::toString` algorithm — what + `JSON.stringify` already emits for finite numbers. `NaN` and `±Infinity` must + not appear. + +`src/canonicalize.ts` is a dependency-free implementation, written by hand so a +reviewer can confirm there is no surprising behavior. + +--- + +## 4. The two checks + +Let `C` be the canonical UTF-8 bytes from §3. + +**Hash.** `sha256(C)`, hex-encoded, prefixed `sha256:`, must equal the +envelope's `storedReceiptHash`. + +**Signature.** The envelope's `signature` is base64. Decoded it is exactly 64 +bytes. It is an Ed25519 signature over `C` — over the canonical bytes directly, +with no pre-hashing (Ed25519 hashes internally; do not pass the digest). + +Verify against the SPKI public key from the PEM. A receipt is `VALID` only if +both checks pass. + +--- + +## 5. Worked example — a real live receipt + +Receipt `2492a060-8fbc-40ae-beab-7258aefb0608`, a $0.99 certificate-linked +dataset purchase on the live rail, captured 2026-08-20: + +``` +signing key ed25519-prod-2025-02 +canonicalization RFC8785-JCS +storedReceiptHash sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22 +settlement_state succeeded_live +artifact_hash sha256:bd48985485c9a3e19838e29795bb89ddedd7f7e5c706b57c190cc6c46119a660 +certificate_id fb914a90-b1b3-4355-8147-cc0194160e23 +``` + +The `artifact_hash` is the SHA-256 of the delivered ZIP, and it is also the +digest inside certificate `fb914a90-…`. A buyer can therefore chain: +downloaded bytes → hash → certificate → receipt, with no step requiring +CertifiedData's word. + +Note the key id: **`ed25519-prod-2025-02`**. Some older documentation shows +`cd_root_2026`, which is not the key live receipts are signed with. + +### Reproduce it + +```bash +npx @certifieddata/verify 2492a060-8fbc-40ae-beab-7258aefb0608 --type receipt +``` + +Expected: + +``` +✓ VALID receipt 2492a060-8fbc-40ae-beab-7258aefb0608 + signature pass + payload hash pass + settlement succeeded_live + The verdict above was computed locally — not taken from the server. +``` + +--- + +## 6. Test vectors + +In `fixtures/`: + +| File | Expected verdict | What it exercises | +|---|---|---| +| `valid-receipt.json` | `VALID` | the happy path, captured from production | +| `tampered-receipt.json` | `INVALID` | `receipt.amount` altered, signature untouched — proves the signature actually covers the payload | +| `malformed-receipt.json` | `MALFORMED` | signature is not a 64-byte Ed25519 value | + +With the repo checked out: + +```bash +npx github:certifieddata/verify fixtures/valid-receipt.json --type receipt # VALID +npx github:certifieddata/verify fixtures/tampered-receipt.json --type receipt # INVALID +``` + +Without checking anything out — pipe the fixture in on stdin: + +```bash +curl -s https://raw.githubusercontent.com/certifieddata/verify/main/fixtures/tampered-receipt.json \ + | npx github:certifieddata/verify - --type receipt +# → ✗ INVALID ed25519 signature does not verify against the RFC 8785 canonical payload +``` + +The tampered vector is the important one: an implementation that reports `VALID` +for it is not verifying anything. + +### Accepted inputs + +| Form | Receipts | Certificates | +|---|---|---| +| bare UUID | yes — fetched from the public verify endpoint | yes | +| local file path | yes | yes | +| `-` (stdin) | yes | yes | +| `https://…` URL | **no** — treated as a file path | yes | + +URL input is certificate-only today. For a remote receipt, use the UUID form or +pipe it in on stdin. + +--- + +## 7. Minimal implementation + +Roughly 90 lines, Node built-ins only, for implementers who would rather not +take a dependency — including on this package: + +```js +const BASE = "https://certifieddata.io"; + +function jcs(v) { + if (v === null || typeof v === "boolean" || typeof v === "number") return JSON.stringify(v); + if (typeof v === "string") return JSON.stringify(v); + if (Array.isArray(v)) return "[" + v.map(jcs).join(",") + "]"; + const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort(); + return "{" + keys.map((k) => JSON.stringify(k) + ":" + jcs(v[k])).join(",") + "}"; +} + +const env = await (await fetch(`${BASE}/api/payments/verify/${id}`)).json(); +const bytes = Buffer.from(jcs(env.receipt), "utf8"); + +const hash = "sha256:" + Buffer.from( + await crypto.subtle.digest("SHA-256", bytes)).toString("hex"); +const hashOk = hash === env.storedReceiptHash; + +const pem = (await (await fetch(`${BASE}/.well-known/certifieddata-public-key.pem`)).text()).trim(); +const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""), "base64"); +const key = await crypto.subtle.importKey("spki", der, { name: "Ed25519" }, false, ["verify"]); +const sigOk = await crypto.subtle.verify( + "Ed25519", key, Buffer.from(env.signature, "base64"), bytes); + +console.log(hashOk && sigOk ? "VALID" : "INVALID"); +``` + +The string-escaping shortcut above (`JSON.stringify` for strings) is JCS-correct +for the ASCII content receipts carry today. Use `src/canonicalize.ts` for a +fully general implementation. + +--- + +## 8. What a receipt does and does not prove + +**Proves.** A specific agent was authorized under a named policy +(`policy_hash`, `policy_version`) to spend a specific amount on a specific +rail; that the charge reached a terminal settlement state +(`settlement_state`, `settled_at`, `external_payment_intent_id`, +`external_charge_id`); and, when `artifact_hash` is present, precisely which +artifact the payment was for. + +**Does not prove.** That the artifact was delivered, or that the buyer received +it. Delivery is a separate signed record. + +**`integrity_notes`.** When present, the issuer is stating that a binding this +receipt would normally carry is absent, and why. Its absence means the +pre-signature gate found nothing to declare — not that no check ran. Receipts +signed before the gate existed carry neither notes nor the bindings, and are +annotated by separate append-only records rather than edited: receipts are +immutable, and corrections are new records. diff --git a/dist/canonicalize.d.ts b/dist/canonicalize.d.ts new file mode 100644 index 0000000..733d654 --- /dev/null +++ b/dist/canonicalize.d.ts @@ -0,0 +1,6 @@ +export type JsonValue = null | boolean | number | string | JsonValue[] | { + [k: string]: JsonValue; +}; +export declare function canonicalize(value: unknown): string; +export declare function canonicalizeToBytes(value: unknown): Uint8Array; +//# sourceMappingURL=canonicalize.d.ts.map \ No newline at end of file diff --git a/dist/canonicalize.d.ts.map b/dist/canonicalize.d.ts.map new file mode 100644 index 0000000..5035c38 --- /dev/null +++ b/dist/canonicalize.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"canonicalize.d.ts","sourceRoot":"","sources":["../src/canonicalize.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,SAAS,GACjB,IAAI,GACJ,OAAO,GACP,MAAM,GACN,MAAM,GACN,SAAS,EAAE,GACX;IAAE,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AAE/B,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEnD;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CAE9D"} \ No newline at end of file diff --git a/dist/canonicalize.js b/dist/canonicalize.js new file mode 100644 index 0000000..3e7ef75 --- /dev/null +++ b/dist/canonicalize.js @@ -0,0 +1,100 @@ +// RFC 8785 — JSON Canonicalization Scheme (JCS). +// Hand-written so reviewers can confirm there is no surprise behavior. +// +// Rules summarized: +// - Object keys are sorted lexicographically by their UTF-16 code-unit sequence. +// - Strings are escaped using the minimal RFC 8259 §7 escapes (",\,\b,\f,\n,\r,\t) +// plus \u00XX for any other control character (U+0000..U+001F). +// - Numbers are emitted via the ECMAScript Number-to-String algorithm (ES2020 +// §7.1.12.1), which is what JSON.stringify already produces for finite numbers. +// Non-finite numbers (NaN, ±Infinity) MUST NOT appear in canonical JSON. +// - No insignificant whitespace anywhere. +// - Arrays preserve insertion order; null/true/false serialize as their literals. +export function canonicalize(value) { + return serialize(value); +} +export function canonicalizeToBytes(value) { + return new TextEncoder().encode(canonicalize(value)); +} +function serialize(value) { + if (value === null) + return "null"; + if (value === true) + return "true"; + if (value === false) + return "false"; + if (typeof value === "string") + return serializeString(value); + if (typeof value === "number") + return serializeNumber(value); + if (Array.isArray(value)) + return serializeArray(value); + if (typeof value === "object") + return serializeObject(value); + throw new TypeError(`canonicalize: unsupported value of type ${typeof value}`); +} +function serializeArray(arr) { + const parts = []; + for (const item of arr) + parts.push(serialize(item)); + return "[" + parts.join(",") + "]"; +} +function serializeObject(obj) { + // RFC 8785 §3.2.3: sort by UTF-16 code units. JS strings are UTF-16, and the + // default Array#sort comparator on strings compares code-unit-by-code-unit, which + // is exactly the JCS requirement. + const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort(); + const parts = []; + for (const k of keys) { + parts.push(serializeString(k) + ":" + serialize(obj[k])); + } + return "{" + parts.join(",") + "}"; +} +function serializeNumber(n) { + if (!Number.isFinite(n)) { + throw new RangeError(`canonicalize: non-finite number ${n}`); + } + // ECMAScript Number-to-String, which JSON.stringify already invokes for finite + // numbers. JCS aligns with this exact serialization. + if (Object.is(n, -0)) + return "0"; + return JSON.stringify(n); +} +function serializeString(s) { + let out = '"'; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + switch (c) { + case 0x22: + out += '\\"'; + break; + case 0x5c: + out += "\\\\"; + break; + case 0x08: + out += "\\b"; + break; + case 0x09: + out += "\\t"; + break; + case 0x0a: + out += "\\n"; + break; + case 0x0c: + out += "\\f"; + break; + case 0x0d: + out += "\\r"; + break; + default: + if (c < 0x20) { + out += "\\u" + c.toString(16).padStart(4, "0"); + } + else { + out += s[i]; + } + } + } + return out + '"'; +} +//# sourceMappingURL=canonicalize.js.map \ No newline at end of file diff --git a/dist/canonicalize.js.map b/dist/canonicalize.js.map new file mode 100644 index 0000000..44fbbc6 --- /dev/null +++ b/dist/canonicalize.js.map @@ -0,0 +1 @@ +{"version":3,"file":"canonicalize.js","sourceRoot":"","sources":["../src/canonicalize.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/canonicalize.test.d.ts b/dist/canonicalize.test.d.ts new file mode 100644 index 0000000..088db96 --- /dev/null +++ b/dist/canonicalize.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=canonicalize.test.d.ts.map \ No newline at end of file diff --git a/dist/canonicalize.test.d.ts.map b/dist/canonicalize.test.d.ts.map new file mode 100644 index 0000000..cc6bbea --- /dev/null +++ b/dist/canonicalize.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"canonicalize.test.d.ts","sourceRoot":"","sources":["../src/canonicalize.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/canonicalize.test.js b/dist/canonicalize.test.js new file mode 100644 index 0000000..0ee04b3 --- /dev/null +++ b/dist/canonicalize.test.js @@ -0,0 +1,109 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { canonicalize, canonicalizeToBytes } from "./canonicalize.js"; +test("primitives", () => { + assert.equal(canonicalize(null), "null"); + assert.equal(canonicalize(true), "true"); + assert.equal(canonicalize(false), "false"); + assert.equal(canonicalize(0), "0"); + assert.equal(canonicalize(-0), "0"); + assert.equal(canonicalize(1), "1"); + assert.equal(canonicalize(1.5), "1.5"); +}); +test("strings — minimal RFC 8259 escapes", () => { + assert.equal(canonicalize(""), '""'); + assert.equal(canonicalize("hello"), '"hello"'); + assert.equal(canonicalize('a"b'), '"a\\"b"'); + assert.equal(canonicalize("a\\b"), '"a\\\\b"'); + assert.equal(canonicalize("a\nb"), '"a\\nb"'); + assert.equal(canonicalize("a\tb"), '"a\\tb"'); + assert.equal(canonicalize("a\rb"), '"a\\rb"'); + assert.equal(canonicalize("\b\f"), '"\\b\\f"'); + // Other control chars use \u00XX. + assert.equal(canonicalize(""), '"\\u0001"'); + assert.equal(canonicalize(""), '"\\u001f"'); + // Non-ASCII characters pass through unescaped (UTF-8 in the byte form). + assert.equal(canonicalize("ä"), '"ä"'); +}); +test("arrays — preserve order", () => { + assert.equal(canonicalize([]), "[]"); + assert.equal(canonicalize([1, 2, 3]), "[1,2,3]"); + assert.equal(canonicalize(["b", "a"]), '["b","a"]'); +}); +test("objects — keys sorted by UTF-16 code units", () => { + assert.equal(canonicalize({}), "{}"); + assert.equal(canonicalize({ b: 1, a: 2 }), '{"a":2,"b":1}'); + // RFC 8785 §3.2.3 sorting example (code-unit order, NOT Unicode codepoint order). + // The keys "ä" (ä) and "\" (\) and "€" (€) sort by UTF-16 units. + const input = { "€": "Euro", "ä": "a-umlaut", a: "ascii" }; + // Code-unit values: 0x61 (a), 0xe4 (ä), 0x20ac (€). + assert.equal(canonicalize(input), '"a":"ascii","ä":"a-umlaut","€":"Euro"'.replace(/^/, "{") + "}"); +}); +test("RFC 8785 §3.2.3 sample (sorting nested objects)", () => { + // Adapted from RFC 8785 example: keys including non-ASCII chars and surrogate pairs + // must compare as UTF-16 code-unit sequences. + const input = { + peach: "This sorting order", + péché: "is wrong according to French", + pêche: "but canonicalization MUST", + sin: "ignore locale", + }; + // Sort by UTF-16 code units of the keys. + // peach (p,e,a,c,h) — 0x70 0x65 ... + // péché (p,é=0xe9,c,h,é) + // pêche (p,ê=0xea,c,h,e) + // sin (s=0x73, ...) + // Order by first differing unit: peach < péché < pêche < sin. + const out = canonicalize(input); + assert.match(out, /^\{"peach":/); + // Confirm the four keys appear in the expected order. + const order = ["peach", "péché", "pêche", "sin"]; + let idx = -1; + for (const k of order) { + const next = out.indexOf(`"${k}":`); + assert.ok(next > idx, `expected ${k} after position ${idx}, got ${next}`); + idx = next; + } +}); +test("nested objects + arrays", () => { + const input = { z: [3, 2, 1], a: { y: 1, x: 2 } }; + assert.equal(canonicalize(input), '{"a":{"x":2,"y":1},"z":[3,2,1]}'); +}); +test("undefined keys are dropped", () => { + // RFC 8785 inputs come from JSON; JS undefined has no JSON encoding, so we drop. + const input = { a: 1, b: undefined, c: 3 }; + assert.equal(canonicalize(input), '{"a":1,"c":3}'); +}); +test("non-finite numbers throw", () => { + assert.throws(() => canonicalize(NaN), RangeError); + assert.throws(() => canonicalize(Infinity), RangeError); + assert.throws(() => canonicalize(-Infinity), RangeError); +}); +test("canonicalizeToBytes round-trips through UTF-8", () => { + const bytes = canonicalizeToBytes({ a: "ä" }); + assert.equal(new TextDecoder().decode(bytes), '{"a":"ä"}'); +}); +test("idempotent: canonicalize(JSON.parse(canonicalize(x))) === canonicalize(x)", () => { + const x = { z: 1, a: { c: [1, 2], b: "x" } }; + const once = canonicalize(x); + const twice = canonicalize(JSON.parse(once)); + assert.equal(once, twice); +}); +test("number formatting matches JSON.stringify for finite numbers", () => { + for (const n of [0, 1, -1, 1.5, -1.5, 1e20, 1e-7, 0.1 + 0.2]) { + assert.equal(canonicalize(n), JSON.stringify(n)); + } +}); +test("removes only the named field — signature stripping pattern", () => { + const cert = { a: 1, signature: "AAA", z: 9 }; + const { signature: _s, ...rest } = cert; + assert.equal(canonicalize(rest), '{"a":1,"z":9}'); +}); +test("array of objects sorts each object's keys independently", () => { + const input = [{ b: 1, a: 2 }, { d: 4, c: 3 }]; + assert.equal(canonicalize(input), '[{"a":2,"b":1},{"c":3,"d":4}]'); +}); +test("empty key string", () => { + assert.equal(canonicalize({ "": "x", a: "y" }), '{"":"x","a":"y"}'); +}); +//# sourceMappingURL=canonicalize.test.js.map \ No newline at end of file diff --git a/dist/canonicalize.test.js.map b/dist/canonicalize.test.js.map new file mode 100644 index 0000000..93087b2 --- /dev/null +++ b/dist/canonicalize.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"canonicalize.test.js","sourceRoot":"","sources":["../src/canonicalize.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/cli.d.ts b/dist/cli.d.ts new file mode 100644 index 0000000..f8456c4 --- /dev/null +++ b/dist/cli.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export declare function main(argv: string[]): Promise; +//# sourceMappingURL=cli.d.ts.map \ No newline at end of file diff --git a/dist/cli.d.ts.map b/dist/cli.d.ts.map new file mode 100644 index 0000000..07e0e28 --- /dev/null +++ b/dist/cli.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAgEA,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAwG1D"} \ No newline at end of file diff --git a/dist/cli.js b/dist/cli.js new file mode 100644 index 0000000..5adce80 --- /dev/null +++ b/dist/cli.js @@ -0,0 +1,298 @@ +#!/usr/bin/env node +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 } from "./receipt.js"; +import { resolveArtifactKind } from "./resolve.js"; +const HELP = `certifieddata-verify [options] + +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) + local certificate file + direct URL to a certificate JSON + - read certificate JSON from stdin + +Options: + --dataset recompute SHA-256 of dataset file and compare to cert.dataset_hash + --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 + --version print version + --help this message + +Exit codes: + 0 VALID 1 INVALID 2 UNKNOWN_KEY 3 MALFORMED + 4 NETWORK 64 USAGE`; +const EXIT = { + VALID: 0, INVALID: 1, UNKNOWN_KEY: 2, MALFORMED: 3, NETWORK: 4, USAGE: 64, +}; +const COLOR = process.stdout.isTTY && !process.env.NO_COLOR; +const c = { + green: (s) => COLOR ? `\x1b[32m${s}\x1b[0m` : s, + red: (s) => COLOR ? `\x1b[31m${s}\x1b[0m` : s, + yellow: (s) => COLOR ? `\x1b[33m${s}\x1b[0m` : s, + dim: (s) => COLOR ? `\x1b[2m${s}\x1b[0m` : s, +}; +export async function main(argv) { + let args; + try { + args = parseArgs(argv); + } + catch (e) { + process.stderr.write(`error: ${e.message}\n${HELP}\n`); + return EXIT.USAGE; + } + if (args.help) { + process.stdout.write(HELP + "\n"); + return EXIT.VALID; + } + if (args.version) { + process.stdout.write(await readVersion() + "\n"); + return EXIT.VALID; + } + if (args.positional.length !== 1) { + process.stderr.write(`error: expected exactly one certificate argument\n${HELP}\n`); + return EXIT.USAGE; + } + const target = args.positional[0]; + // ── Artifact-kind resolution (verify#2) ──────────────────────────────── + let kind; + 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; + 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.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; + try { + const cert = await fetchCert(target, { offline: args.offline }); + const keys = await loadKeys({ keysFile: args.keys, offline: args.offline, noCache: args.noCache }); + result = await verifyCertificate(cert, keys, args.dataset); + } + catch (err) { + const reason = err.message; + const isNetwork = /failed to fetch|HTTP \d|ENOTFOUND|ECONN|getaddrinfo/i.test(reason); + if (args.json) { + process.stdout.write(JSON.stringify(networkErrorResult(reason)) + "\n"); + } + else { + process.stderr.write(`${c.red("✗ ERROR")} ${reason}\n`); + } + return isNetwork ? EXIT.NETWORK : EXIT.MALFORMED; + } + if (args.json) { + process.stdout.write(JSON.stringify(result) + "\n"); + } + else { + printHuman(result); + } + return verdictToExit(result); +} +function parseArgs(argv) { + const out = { positional: [], json: false, offline: false, noCache: false, help: false, version: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case "--help": + case "-h": + out.help = true; + break; + case "--version": + case "-v": + out.version = true; + break; + case "--json": + out.json = true; + break; + case "--offline": + out.offline = true; + break; + 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); + } + } + return out; +} +function requireValue(argv, i, flag) { + const v = argv[i]; + if (v === undefined) + throw new Error(`${flag} requires a value`); + return v; +} +function verdictToExit(r) { + switch (r.verdict) { + case "VALID": return EXIT.VALID; + case "INVALID": + case "DATASET_MISMATCH": return EXIT.INVALID; + case "UNKNOWN_KEY": return EXIT.UNKNOWN_KEY; + case "MALFORMED": return EXIT.MALFORMED; + } +} +function printHuman(r) { + const id = r.certification_id ?? "(unknown)"; + switch (r.verdict) { + case "VALID": { + process.stdout.write(`${c.green("✓ VALID")} certification_id ${id}\n`); + const label = r.key_label ? `${r.key_id} (${r.issuer}, ${r.key_label})` : `${r.key_id} (${r.issuer})`; + process.stdout.write(` ${c.dim("signed by")} ${label}\n`); + const rows = (r.rows ?? 0).toLocaleString("en-US"); + const cols = (r.columns ?? 0).toLocaleString("en-US"); + process.stdout.write(` ${c.dim("algorithm")} ${r.algorithm} · ${rows} rows × ${cols} cols · signed ${r.signed_at}\n`); + if (r.checks.dataset_match === "pass") { + process.stdout.write(` ${c.dim("dataset")} ${r.dataset_hash_actual} ${c.green("matches")}\n`); + } + break; + } + case "INVALID": + process.stdout.write(`${c.red("✗ INVALID")} certification_id ${id}\n ${r.reason}\n`); + break; + case "DATASET_MISMATCH": + process.stdout.write(`${c.red("✗ DATASET_MISMATCH")} certification_id ${id}\n`); + process.stdout.write(` expected ${r.dataset_hash_expected}\n actual ${r.dataset_hash_actual}\n`); + break; + case "UNKNOWN_KEY": + process.stdout.write(`${c.yellow("? UNKNOWN_KEY")} certification_id ${id}\n ${r.reason}\n`); + break; + case "MALFORMED": + process.stdout.write(`${c.red("✗ MALFORMED")} ${r.reason}\n`); + break; + } +} +function printReceiptHuman(r) { + 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) { + return { + verdict: "MALFORMED", + certification_id: null, key_id: null, issuer: null, algorithm: null, signed_at: null, + dataset_hash_expected: null, dataset_hash_actual: null, + checks: { signature: "skipped", key_trust: "skipped", dataset_match: "skipped" }, + reason, + }; +} +async function readVersion() { + try { + const { readFile } = await import("node:fs/promises"); + const { fileURLToPath } = await import("node:url"); + const { dirname, join } = await import("node:path"); + const here = dirname(fileURLToPath(import.meta.url)); + const pkg = JSON.parse(await readFile(join(here, "..", "package.json"), "utf8")); + return `@certifieddata/verify ${pkg.version}`; + } + catch { + return "@certifieddata/verify (unknown version)"; + } +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then((code) => process.exit(code)); +} +//# sourceMappingURL=cli.js.map \ No newline at end of file diff --git a/dist/cli.js.map b/dist/cli.js.map new file mode 100644 index 0000000..a1f2714 --- /dev/null +++ b/dist/cli.js.map @@ -0,0 +1 @@ +{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/cli.test.d.ts b/dist/cli.test.d.ts new file mode 100644 index 0000000..9e8ffec --- /dev/null +++ b/dist/cli.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=cli.test.d.ts.map \ No newline at end of file diff --git a/dist/cli.test.d.ts.map b/dist/cli.test.d.ts.map new file mode 100644 index 0000000..3498769 --- /dev/null +++ b/dist/cli.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"cli.test.d.ts","sourceRoot":"","sources":["../src/cli.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/cli.test.js b/dist/cli.test.js new file mode 100644 index 0000000..fc0d2bd --- /dev/null +++ b/dist/cli.test.js @@ -0,0 +1,142 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +const here = dirname(fileURLToPath(import.meta.url)); +const cliPath = join(here, "cli.js"); +const fixturesDir = join(here, "..", "fixtures"); +const keysPath = join(fixturesDir, "keys.json"); +function run(args, input) { + return new Promise((resolve) => { + const env = { ...process.env, NO_COLOR: "1" }; + const child = spawn(process.execPath, [cliPath, ...args], { env }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => (stdout += d.toString())); + child.stderr.on("data", (d) => (stderr += d.toString())); + if (input !== undefined) { + child.stdin.write(input); + child.stdin.end(); + } + child.on("close", (code) => resolve({ stdout, stderr, code: code ?? -1 })); + }); +} +test("VALID — exits 0 against a clean cert + dataset", async () => { + const r = await run([ + join(fixturesDir, "valid-cert.json"), + "--keys", keysPath, + "--offline", + "--dataset", join(fixturesDir, "valid-dataset.csv"), + ]); + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.match(r.stdout, /VALID/); +}); +test("INVALID — exits 1 against a tampered cert", async () => { + const r = await run([ + join(fixturesDir, "tampered-cert.json"), + "--keys", keysPath, + "--offline", + ]); + assert.equal(r.code, 1); + assert.match(r.stdout, /INVALID/); +}); +test("UNKNOWN_KEY — exits 2 when key_id is not in trusted set", async () => { + const r = await run([ + join(fixturesDir, "unknown-key-cert.json"), + "--keys", keysPath, + "--offline", + ]); + assert.equal(r.code, 2); + assert.match(r.stdout, /UNKNOWN_KEY/); +}); +test("MALFORMED — exits 3 when required fields are missing", async () => { + const r = await run([ + join(fixturesDir, "malformed-cert.json"), + "--keys", keysPath, + "--offline", + ]); + assert.equal(r.code, 3); + assert.match(r.stdout, /MALFORMED/); +}); +test("DATASET_MISMATCH — exits 1 with a clear reason", async () => { + const r = await run([ + join(fixturesDir, "valid-cert.json"), + "--keys", keysPath, + "--offline", + "--dataset", keysPath, // wrong file -> wrong hash + ]); + assert.equal(r.code, 1); + assert.match(r.stdout, /DATASET_MISMATCH/); +}); +test("--json — emits a structured result with all expected fields", async () => { + const r = await run([ + join(fixturesDir, "valid-cert.json"), + "--keys", keysPath, + "--offline", + "--json", + ]); + assert.equal(r.code, 0); + const parsed = JSON.parse(r.stdout); + assert.equal(parsed.verdict, "VALID"); + assert.equal(parsed.issuer, "CertifiedData.io"); + assert.equal(parsed.checks.signature, "pass"); + assert.equal(parsed.checks.key_trust, "pass"); + assert.equal(parsed.checks.dataset_match, "skipped"); + assert.ok(typeof parsed.certification_id === "string"); +}); +test("--json on tampered cert returns verdict INVALID with signature=fail", async () => { + const r = await run([ + join(fixturesDir, "tampered-cert.json"), + "--keys", keysPath, + "--offline", + "--json", + ]); + assert.equal(r.code, 1); + const parsed = JSON.parse(r.stdout); + assert.equal(parsed.verdict, "INVALID"); + assert.equal(parsed.checks.signature, "fail"); +}); +test("--help — exits 0 and prints usage", async () => { + const r = await run(["--help"]); + assert.equal(r.code, 0); + assert.match(r.stdout, /certifieddata-verify/); + assert.match(r.stdout, /Exit codes/); +}); +test("--version — exits 0 and prints package version", async () => { + const r = await run(["--version"]); + assert.equal(r.code, 0); + assert.match(r.stdout, /@certifieddata\/verify/); +}); +test("USAGE — exits 64 on unknown flag", async () => { + const r = await run(["--made-up-flag"]); + assert.equal(r.code, 64); + assert.match(r.stderr, /unknown option/); +}); +test("USAGE — exits 64 when no positional argument is given", async () => { + const r = await run(["--keys", keysPath, "--offline"]); + assert.equal(r.code, 64); +}); +test("stdin input via '-' — exits 0 against a piped valid cert", async () => { + const { readFile } = await import("node:fs/promises"); + const certBody = await readFile(join(fixturesDir, "valid-cert.json"), "utf8"); + const r = await run(["-", "--keys", keysPath, "--offline"], certBody); + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.match(r.stdout, /VALID/); +}); +test("checks pass/fail/skipped for each verdict in --json mode", async () => { + const cases = [ + ["valid-cert.json", "VALID", { signature: "pass", key_trust: "pass", dataset_match: "skipped" }], + ["tampered-cert.json", "INVALID", { signature: "fail", key_trust: "pass", dataset_match: "skipped" }], + ["unknown-key-cert.json", "UNKNOWN_KEY", { signature: "skipped", key_trust: "fail", dataset_match: "skipped" }], + ]; + for (const [file, expected, checks] of cases) { + const r = await run([join(fixturesDir, file), "--keys", keysPath, "--offline", "--json"]); + const parsed = JSON.parse(r.stdout); + assert.equal(parsed.verdict, expected, `case ${file}`); + for (const [k, v] of Object.entries(checks)) { + assert.equal(parsed.checks[k], v, `${file}.checks.${k}`); + } + } +}); +//# sourceMappingURL=cli.test.js.map \ No newline at end of file diff --git a/dist/cli.test.js.map b/dist/cli.test.js.map new file mode 100644 index 0000000..1840cba --- /dev/null +++ b/dist/cli.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"cli.test.js","sourceRoot":"","sources":["../src/cli.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/fetch-cert.d.ts b/dist/fetch-cert.d.ts new file mode 100644 index 0000000..a658b62 --- /dev/null +++ b/dist/fetch-cert.d.ts @@ -0,0 +1,8 @@ +import type { Certificate } from "./types.js"; +export declare const DEFAULT_CERT_API = "https://certifieddata.io/api/v1/certificates"; +export interface FetchCertOptions { + apiBase?: string; + offline?: boolean; +} +export declare function fetchCert(idOrPathOrUrl: string, opts?: FetchCertOptions): Promise; +//# sourceMappingURL=fetch-cert.d.ts.map \ No newline at end of file diff --git a/dist/fetch-cert.d.ts.map b/dist/fetch-cert.d.ts.map new file mode 100644 index 0000000..fe49df0 --- /dev/null +++ b/dist/fetch-cert.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"fetch-cert.d.ts","sourceRoot":"","sources":["../src/fetch-cert.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,eAAO,MAAM,gBAAgB,iDAAiD,CAAC;AAE/E,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,wBAAsB,SAAS,CAAC,aAAa,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,WAAW,CAAC,CAiBxG"} \ No newline at end of file diff --git a/dist/fetch-cert.js b/dist/fetch-cert.js new file mode 100644 index 0000000..beeeb4c --- /dev/null +++ b/dist/fetch-cert.js @@ -0,0 +1,38 @@ +import { readFile } from "node:fs/promises"; +export const DEFAULT_CERT_API = "https://certifieddata.io/api/v1/certificates"; +export async function fetchCert(idOrPathOrUrl, opts = {}) { + if (idOrPathOrUrl === "-") { + return parseCertJson(await readStdin()); + } + if (idOrPathOrUrl.endsWith(".json") || idOrPathOrUrl.startsWith("./") || idOrPathOrUrl.startsWith("/")) { + return parseCertJson(await readFile(idOrPathOrUrl, "utf8")); + } + if (/^https?:\/\//.test(idOrPathOrUrl)) { + if (opts.offline) + throw new Error("cannot fetch URL in --offline mode"); + return parseCertJson(await fetchText(idOrPathOrUrl)); + } + if (opts.offline) { + throw new Error("cannot resolve certification id in --offline mode (pass a local file)"); + } + const base = opts.apiBase ?? DEFAULT_CERT_API; + const url = `${base.replace(/\/$/, "")}/${encodeURIComponent(idOrPathOrUrl)}`; + return parseCertJson(await fetchText(url)); +} +async function fetchText(url) { + const res = await fetch(url); + if (!res.ok) + throw new Error(`HTTP ${res.status} fetching ${url}`); + return res.text(); +} +function parseCertJson(body) { + return JSON.parse(body); +} +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} +//# sourceMappingURL=fetch-cert.js.map \ No newline at end of file diff --git a/dist/fetch-cert.js.map b/dist/fetch-cert.js.map new file mode 100644 index 0000000..519366c --- /dev/null +++ b/dist/fetch-cert.js.map @@ -0,0 +1 @@ +{"version":3,"file":"fetch-cert.js","sourceRoot":"","sources":["../src/fetch-cert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAG5C,MAAM,CAAC,MAAM,gBAAgB,GAAG,8CAA8C,CAAC;AAO/E,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,aAAqB,EAAE,OAAyB,EAAE;IAChF,IAAI,aAAa,KAAK,GAAG,EAAE,CAAC;QAC1B,OAAO,aAAa,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC;IAC1C,CAAC;IACD,IAAI,aAAa,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,aAAa,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,aAAa,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACvG,OAAO,aAAa,CAAC,MAAM,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9D,CAAC;IACD,IAAI,cAAc,CAAC,IAAI,CAAC,aAAa,CAAC,EAAE,CAAC;QACvC,IAAI,IAAI,CAAC,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;QACxE,OAAO,aAAa,CAAC,MAAM,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC;IACvD,CAAC;IACD,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,uEAAuE,CAAC,CAAC;IAC3F,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,IAAI,gBAAgB,CAAC;IAC9C,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,kBAAkB,CAAC,aAAa,CAAC,EAAE,CAAC;IAC9E,OAAO,aAAa,CAAC,MAAM,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED,KAAK,UAAU,SAAS,CAAC,GAAW;IAClC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,CAAC;IAC7B,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,aAAa,GAAG,EAAE,CAAC,CAAC;IACnE,OAAO,GAAG,CAAC,IAAI,EAAE,CAAC;AACpB,CAAC;AAED,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAgB,CAAC;AACzC,CAAC;AAED,KAAK,UAAU,SAAS;IACtB,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACxC,MAAM,CAAC,IAAI,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAe,CAAC,CAAC;IAChF,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC"} \ No newline at end of file diff --git a/dist/hash.d.ts b/dist/hash.d.ts new file mode 100644 index 0000000..0002922 --- /dev/null +++ b/dist/hash.d.ts @@ -0,0 +1,8 @@ +export declare function sha256Hex(bytes: Uint8Array | string): string; +export declare function sha256File(path: string): Promise; +export declare function formatDigest(hex: string): string; +export declare function parseDigest(value: string): { + algo: string; + hex: string; +}; +//# sourceMappingURL=hash.d.ts.map \ No newline at end of file diff --git a/dist/hash.d.ts.map b/dist/hash.d.ts.map new file mode 100644 index 0000000..ad6b8f5 --- /dev/null +++ b/dist/hash.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAGA,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,MAAM,CAE5D;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ9D;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAIxE"} \ No newline at end of file diff --git a/dist/hash.js b/dist/hash.js new file mode 100644 index 0000000..65d4f1e --- /dev/null +++ b/dist/hash.js @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +export function sha256Hex(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} +export async function sha256File(path) { + return new Promise((resolve, reject) => { + const h = createHash("sha256"); + const s = createReadStream(path); + s.on("error", reject); + s.on("data", (chunk) => h.update(chunk)); + s.on("end", () => resolve(h.digest("hex"))); + }); +} +export function formatDigest(hex) { + return `sha256:${hex}`; +} +export function parseDigest(value) { + const m = /^([a-z0-9-]+):([0-9a-f]+)$/i.exec(value); + if (!m) + throw new Error(`malformed digest: ${value}`); + return { algo: m[1].toLowerCase(), hex: m[2].toLowerCase() }; +} +//# sourceMappingURL=hash.js.map \ No newline at end of file diff --git a/dist/hash.js.map b/dist/hash.js.map new file mode 100644 index 0000000..56d85c2 --- /dev/null +++ b/dist/hash.js.map @@ -0,0 +1 @@ +{"version":3,"file":"hash.js","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAE3C,MAAM,UAAU,SAAS,CAAC,KAA0B;IAClD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC1D,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY;IAC3C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;QAC/B,MAAM,CAAC,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;QACjC,CAAC,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QACtB,CAAC,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QACzC,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,OAAO,UAAU,GAAG,EAAE,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,MAAM,CAAC,GAAG,6BAA6B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACpD,IAAI,CAAC,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,qBAAqB,KAAK,EAAE,CAAC,CAAC;IACtD,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;AAC/D,CAAC"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..9630093 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,7 @@ +export { verifyCertificate } from "./verify.js"; +export { canonicalize, canonicalizeToBytes } from "./canonicalize.js"; +export { sha256Hex, sha256File, formatDigest, parseDigest } from "./hash.js"; +export { loadKeys, findKey, DEFAULT_KEYS_URL } from "./keys.js"; +export { fetchCert, DEFAULT_CERT_API } from "./fetch-cert.js"; +export type { Certificate, KeyDoc, KeyEntry, VerifyResult, Verdict, CheckResult, } from "./types.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map new file mode 100644 index 0000000..77a1e8a --- /dev/null +++ b/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAC9D,YAAY,EACV,WAAW,EACX,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,OAAO,EACP,WAAW,GACZ,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..21c58f6 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,6 @@ +export { verifyCertificate } from "./verify.js"; +export { canonicalize, canonicalizeToBytes } from "./canonicalize.js"; +export { sha256Hex, sha256File, formatDigest, parseDigest } from "./hash.js"; +export { loadKeys, findKey, DEFAULT_KEYS_URL } from "./keys.js"; +export { fetchCert, DEFAULT_CERT_API } from "./fetch-cert.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map new file mode 100644 index 0000000..2b665bc --- /dev/null +++ b/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC"} \ No newline at end of file diff --git a/dist/keys.d.ts b/dist/keys.d.ts new file mode 100644 index 0000000..2d97bf2 --- /dev/null +++ b/dist/keys.d.ts @@ -0,0 +1,14 @@ +import type { KeyDoc, KeyEntry } from "./types.js"; +export declare const DEFAULT_KEYS_URL = "https://certifieddata.io/.well-known/certifieddata-keys.json"; +export declare const CACHE_PATH: string; +export declare const CACHE_TTL_MS: number; +export interface LoadKeysOptions { + url?: string; + keysFile?: string; + noCache?: boolean; + offline?: boolean; + cachePath?: string; +} +export declare function loadKeys(opts?: LoadKeysOptions): Promise; +export declare function findKey(doc: KeyDoc, keyId: string): KeyEntry | undefined; +//# sourceMappingURL=keys.d.ts.map \ No newline at end of file diff --git a/dist/keys.d.ts.map b/dist/keys.d.ts.map new file mode 100644 index 0000000..6fc6d06 --- /dev/null +++ b/dist/keys.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEnD,eAAO,MAAM,gBAAgB,iEAAiE,CAAC;AAC/F,eAAO,MAAM,UAAU,QAAiD,CAAC;AACzE,eAAO,MAAM,YAAY,QAAsB,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,QAAQ,CAAC,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,MAAM,CAAC,CAoC1E;AAED,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAExE"} \ No newline at end of file diff --git a/dist/keys.js b/dist/keys.js new file mode 100644 index 0000000..7be5938 --- /dev/null +++ b/dist/keys.js @@ -0,0 +1,69 @@ +import { readFile, writeFile, mkdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +export const DEFAULT_KEYS_URL = "https://certifieddata.io/.well-known/certifieddata-keys.json"; +export const CACHE_PATH = join(homedir(), ".certifieddata", "keys.json"); +export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +export async function loadKeys(opts = {}) { + if (opts.keysFile) { + return parseKeyDoc(await readFile(opts.keysFile, "utf8")); + } + const cachePath = opts.cachePath ?? CACHE_PATH; + if (opts.offline) { + if (opts.noCache) { + throw new Error("offline mode requires --keys when --no-cache is set"); + } + return parseKeyDoc(await readFile(cachePath, "utf8")); + } + if (!opts.noCache) { + const fresh = await readCacheIfFresh(cachePath); + if (fresh) + return fresh; + } + const url = opts.url ?? DEFAULT_KEYS_URL; + let body; + try { + const res = await fetch(url); + if (!res.ok) + throw new Error(`HTTP ${res.status}`); + body = await res.text(); + } + catch (err) { + if (!opts.noCache) { + const stale = await readFile(cachePath, "utf8").catch(() => null); + if (stale) + return parseKeyDoc(stale); + } + throw new Error(`failed to fetch keys from ${url}: ${err.message}`); + } + const doc = parseKeyDoc(body); + if (!opts.noCache) + await writeCache(cachePath, body); + return doc; +} +export function findKey(doc, keyId) { + return doc.keys.find((k) => k.key_id === keyId); +} +function parseKeyDoc(body) { + const parsed = JSON.parse(body); + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.keys)) { + throw new Error("invalid key document: missing keys[]"); + } + return parsed; +} +async function readCacheIfFresh(path) { + try { + const s = await stat(path); + if (Date.now() - s.mtimeMs > CACHE_TTL_MS) + return null; + return parseKeyDoc(await readFile(path, "utf8")); + } + catch { + return null; + } +} +async function writeCache(path, body) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, body, "utf8"); +} +//# sourceMappingURL=keys.js.map \ No newline at end of file diff --git a/dist/keys.js.map b/dist/keys.js.map new file mode 100644 index 0000000..621ac04 --- /dev/null +++ b/dist/keys.js.map @@ -0,0 +1 @@ +{"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/receipt-vectors.test.d.ts b/dist/receipt-vectors.test.d.ts new file mode 100644 index 0000000..f5d48a9 --- /dev/null +++ b/dist/receipt-vectors.test.d.ts @@ -0,0 +1,18 @@ +/** + * Receipt signature test vectors. + * + * These existed for webhook-signature, idempotency, provenance and events, but + * not for receipts — so an outside implementer had nothing to check their + * attempt against, and the canonicalization was never pinned to a concrete + * expected hash anywhere in the repo. + * + * The tampered vector is the one that matters. An implementation that reports + * VALID for it is not verifying anything: it has an Ed25519 signature that is + * genuine, over a payload that has been altered. + * + * Fixtures are captured from production receipt + * 2492a060-8fbc-40ae-beab-7258aefb0608 — a $0.99 certificate-linked dataset + * purchase on the live rail. + */ +export {}; +//# sourceMappingURL=receipt-vectors.test.d.ts.map \ No newline at end of file diff --git a/dist/receipt-vectors.test.d.ts.map b/dist/receipt-vectors.test.d.ts.map new file mode 100644 index 0000000..54ff67f --- /dev/null +++ b/dist/receipt-vectors.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"receipt-vectors.test.d.ts","sourceRoot":"","sources":["../src/receipt-vectors.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG"} \ No newline at end of file diff --git a/dist/receipt-vectors.test.js b/dist/receipt-vectors.test.js new file mode 100644 index 0000000..31c7c39 --- /dev/null +++ b/dist/receipt-vectors.test.js @@ -0,0 +1,96 @@ +/** + * Receipt signature test vectors. + * + * These existed for webhook-signature, idempotency, provenance and events, but + * not for receipts — so an outside implementer had nothing to check their + * attempt against, and the canonicalization was never pinned to a concrete + * expected hash anywhere in the repo. + * + * The tampered vector is the one that matters. An implementation that reports + * VALID for it is not verifying anything: it has an Ed25519 signature that is + * genuine, over a payload that has been altered. + * + * Fixtures are captured from production receipt + * 2492a060-8fbc-40ae-beab-7258aefb0608 — a $0.99 certificate-linked dataset + * purchase on the live rail. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { canonicalizeToBytes } from "./canonicalize.js"; +const FIXTURES = join(import.meta.dirname ?? __dirname, "..", "fixtures"); +function envelope(name) { + return JSON.parse(readFileSync(join(FIXTURES, name), "utf8")); +} +const EXPECTED_HASH = "sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22"; +// ── canonicalization is pinned to a concrete expected hash ── +test("JCS(receipt) hashes to the published storedReceiptHash", () => { + const env = envelope("valid-receipt.json"); + const bytes = canonicalizeToBytes(env.receipt); + const hash = "sha256:" + createHash("sha256").update(bytes).digest("hex"); + assert.equal(hash, EXPECTED_HASH); + assert.equal(hash, env.storedReceiptHash); +}); +test("the canonical payload excludes signature and the appended crypto fields", () => { + // Documented in RECEIPT-VERIFICATION.md §2. If any of these ever appear + // inside `receipt`, the hash above stops reproducing and every external + // verifier breaks at once. + const env = envelope("valid-receipt.json"); + for (const k of ["signature", "sha256_hash", "ed25519_sig"]) { + assert.ok(!Object.keys(env.receipt).includes(k)); + } +}); +test("key ordering is what makes it canonical, not insertion order", () => { + // Re-serialise with keys deliberately reversed; the digest must not move. + const env = envelope("valid-receipt.json"); + const reversed = {}; + for (const k of Object.keys(env.receipt).reverse()) + reversed[k] = env.receipt[k]; + const a = createHash("sha256").update(canonicalizeToBytes(env.receipt)).digest("hex"); + const b = createHash("sha256").update(canonicalizeToBytes(reversed)).digest("hex"); + assert.equal(b, a); +}); +// ── the tampered vector must not verify ── +test("altering amount changes the canonical hash", () => { + const good = envelope("valid-receipt.json"); + const bad = envelope("tampered-receipt.json"); + // The signature is byte-identical — only the payload differs. + assert.equal(bad.signature, good.signature); + assert.notEqual(bad.receipt.amount, good.receipt.amount); + const hash = "sha256:" + createHash("sha256") + .update(canonicalizeToBytes(bad.receipt)) + .digest("hex"); + assert.notEqual(hash, EXPECTED_HASH); +}); +// ── the live receipt carries the bindings the category depends on ── +const r = envelope("valid-receipt.json").receipt; +test("binds the artifact — the half authorization-only proofs scope out", () => { + assert.match(r.artifact_hash, /^sha256:[0-9a-f]{64}$/); + assert.ok(r.certificate_id); +}); +test("binds the governing policy", () => { + assert.match(r.policy_hash, /^sha256:[0-9a-f]{64}$/); + assert.ok(r.policy_version); +}); +test("states settlement rather than implying it", () => { + assert.equal(r.settlement_state, "succeeded_live"); + assert.ok(r.settled_at); +}); +test("keeps typed reference fields distinct and correctly prefixed", () => { + // Receipt c2e70d98 held one PaymentIntent id in four fields, one of them a + // charge field. Distinct, correctly-prefixed values are the fix. + assert.match(r.external_payment_intent_id, /^pi_/); + assert.match(r.external_charge_id, /^ch_/); + assert.notEqual(r.external_charge_id, r.external_payment_intent_id); + assert.equal(r.external_reference_type, "stripe_payment_intent"); +}); +test("carries no U+FFFD anywhere", () => { + assert.ok(!JSON.stringify(r).includes("�")); +}); +test("declares no integrity caveats", () => { + // Absence means the pre-signature gate found nothing to declare. + assert.equal(r.integrity_notes ?? null, null); +}); +//# sourceMappingURL=receipt-vectors.test.js.map \ No newline at end of file diff --git a/dist/receipt-vectors.test.js.map b/dist/receipt-vectors.test.js.map new file mode 100644 index 0000000..088ba79 --- /dev/null +++ b/dist/receipt-vectors.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"receipt-vectors.test.js","sourceRoot":"","sources":["../src/receipt-vectors.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/receipt.d.ts b/dist/receipt.d.ts new file mode 100644 index 0000000..3e15c0b --- /dev/null +++ b/dist/receipt.d.ts @@ -0,0 +1,57 @@ +import type { CheckResult } from "./types.js"; +export declare const DEFAULT_RECEIPT_API = "https://certifieddata.io/api/payments/verify"; +export declare 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 declare function fetchReceipt(idOrPathOrUrl: string, opts?: FetchReceiptOptions): Promise; +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 declare function loadReceiptKey(opts?: LoadReceiptKeyOptions): Promise; +export declare function verifyReceiptEnvelope(env: ReceiptEnvelope, publicKeyPem: string): ReceiptVerifyResult; +export {}; +//# sourceMappingURL=receipt.d.ts.map \ No newline at end of file diff --git a/dist/receipt.d.ts.map b/dist/receipt.d.ts.map new file mode 100644 index 0000000..b40cdf5 --- /dev/null +++ b/dist/receipt.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"receipt.d.ts","sourceRoot":"","sources":["../src/receipt.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,eAAO,MAAM,mBAAmB,iDAAiD,CAAC;AAClF,eAAO,MAAM,uBAAuB,sEACiC,CAAC;AAEtE,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,SAAS,GAAG,aAAa,GAAG,WAAW,CAAC;AAE/E,MAAM,WAAW,mBAAmB;IAClC,aAAa,EAAE,SAAS,CAAC;IACzB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,OAAO,EAAE,cAAc,CAAC;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE;QACN,SAAS,EAAE,WAAW,CAAC;QACvB,SAAS,EAAE,WAAW,CAAC;QACvB,YAAY,EAAE,WAAW,CAAC;KAC3B,CAAC;IACF,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,eAAe,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,cAAc,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACrF,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,UAAU,eAAe;IACvB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,cAAc,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,cAAc,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CACrF;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,oFAAoF;AACpF,wBAAsB,YAAY,CAChC,aAAa,EAAE,MAAM,EACrB,IAAI,GAAE,mBAAwB,GAC7B,OAAO,CAAC,eAAe,CAAC,CAmB1B;AA2BD,MAAM,WAAW,qBAAqB;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,IAAI,GAAE,qBAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAiBtF;AAED,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,eAAe,EACpB,YAAY,EAAE,MAAM,GACnB,mBAAmB,CAoFrB"} \ No newline at end of file diff --git a/dist/receipt.js b/dist/receipt.js new file mode 100644 index 0000000..8caf6a8 --- /dev/null +++ b/dist/receipt.js @@ -0,0 +1,175 @@ +// 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"; +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"; +/** Accepts a receipt id, a /api/payments/verify URL, a local .json path, or "-". */ +export async function fetchReceipt(idOrPathOrUrl, opts = {}) { + 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) { + const parsed = JSON.parse(body); + // Server envelope: { receipt: {...}, signature, storedReceiptHash, valid... } + if (parsed.receipt && typeof parsed.receipt === "object") { + return { + payload: parsed.receipt, + 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; + return { payload, signatureB64: signature ?? null, storedHash: null }; + } + throw new Error("input is neither a verify-endpoint envelope nor a payment_receipt.v1 payload"); +} +/** + * 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 = {}) { + 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, publicKeyPem) { + const p = env.payload; + const result = { + 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.message}`; + return result; + } + result.checks.key_trust = "pass"; + let sigBytes; + 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.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; + 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. + const keyIdMeta = env.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; +} +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} +//# sourceMappingURL=receipt.js.map \ No newline at end of file diff --git a/dist/receipt.js.map b/dist/receipt.js.map new file mode 100644 index 0000000..3e2d42f --- /dev/null +++ b/dist/receipt.js.map @@ -0,0 +1 @@ +{"version":3,"file":"receipt.js","sourceRoot":"","sources":["../src/receipt.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/receipt.test.d.ts b/dist/receipt.test.d.ts new file mode 100644 index 0000000..6e23322 --- /dev/null +++ b/dist/receipt.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=receipt.test.d.ts.map \ No newline at end of file diff --git a/dist/receipt.test.d.ts.map b/dist/receipt.test.d.ts.map new file mode 100644 index 0000000..c84f77a --- /dev/null +++ b/dist/receipt.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"receipt.test.d.ts","sourceRoot":"","sources":["../src/receipt.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/receipt.test.js b/dist/receipt.test.js new file mode 100644 index 0000000..21df51a --- /dev/null +++ b/dist/receipt.test.js @@ -0,0 +1,107 @@ +// 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 = 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 = [ + ["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"); +}); +//# sourceMappingURL=receipt.test.js.map \ No newline at end of file diff --git a/dist/receipt.test.js.map b/dist/receipt.test.js.map new file mode 100644 index 0000000..06711e5 --- /dev/null +++ b/dist/receipt.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"receipt.test.js","sourceRoot":"","sources":["../src/receipt.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/resolve.d.ts b/dist/resolve.d.ts new file mode 100644 index 0000000..399e29d --- /dev/null +++ b/dist/resolve.d.ts @@ -0,0 +1,20 @@ +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 declare function resolveArtifactKind(target: string, opts?: { + offline?: boolean; + certApiBase?: string; + receiptApiBase?: string; +}): Promise; +//# sourceMappingURL=resolve.d.ts.map \ No newline at end of file diff --git a/dist/resolve.d.ts.map b/dist/resolve.d.ts.map new file mode 100644 index 0000000..1ecc699 --- /dev/null +++ b/dist/resolve.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAgBA,MAAM,MAAM,YAAY,GAAG,aAAa,GAAG,SAAS,CAAC;AAErD,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7C,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9E,OAAO,CAAC,UAAU,CAAC,CAgErB"} \ No newline at end of file diff --git a/dist/resolve.js b/dist/resolve.js new file mode 100644 index 0000000..86b9575 --- /dev/null +++ b/dist/resolve.js @@ -0,0 +1,87 @@ +// 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 async function resolveArtifactKind(target, opts = {}) { + // 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")); + const schema = parsed.schema_version ?? + parsed.receipt?.schema_version; + 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) => { + 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" }; +} +//# sourceMappingURL=resolve.js.map \ No newline at end of file diff --git a/dist/resolve.js.map b/dist/resolve.js.map new file mode 100644 index 0000000..5dee692 --- /dev/null +++ b/dist/resolve.js.map @@ -0,0 +1 @@ +{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/types.d.ts b/dist/types.d.ts new file mode 100644 index 0000000..a990413 --- /dev/null +++ b/dist/types.d.ts @@ -0,0 +1,51 @@ +export interface Certificate { + certification_id: string; + timestamp: string; + issuer: string; + dataset_hash: string; + algorithm: "CTGAN" | "GaussianCopula" | "DP-CTGAN" | string; + rows: number; + columns: number; + schema_version: "cert.v1"; + signature: string; + key_id: string; + metadata?: Record & { + epsilon?: number | null; + }; +} +export interface KeyEntry { + key_id: string; + public_key: string; + algorithm: "ed25519"; + created_at: string; + revoked_at?: string | null; + label?: string; +} +export interface KeyDoc { + issuer: string; + keys: KeyEntry[]; + fetched_at?: string; +} +export type Verdict = "VALID" | "INVALID" | "UNKNOWN_KEY" | "DATASET_MISMATCH" | "MALFORMED"; +export type CheckResult = "pass" | "fail" | "skipped"; +export interface VerifyResult { + verdict: Verdict; + certification_id: string | null; + key_id: string | null; + issuer: string | null; + algorithm: string | null; + signed_at: string | null; + dataset_hash_expected: string | null; + dataset_hash_actual: string | null; + checks: { + signature: CheckResult; + key_trust: CheckResult; + dataset_match: CheckResult; + }; + reason: string; + rows?: number; + columns?: number; + key_label?: string; +} +export declare const REQUIRED_CERT_FIELDS: ReadonlyArray; +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/dist/types.d.ts.map b/dist/types.d.ts.map new file mode 100644 index 0000000..294bdd1 --- /dev/null +++ b/dist/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,OAAO,GAAG,gBAAgB,GAAG,UAAU,GAAG,MAAM,CAAC;IAC5D,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,SAAS,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CAClE;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,SAAS,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,MAAM;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,OAAO,GACf,OAAO,GACP,SAAS,GACT,aAAa,GACb,kBAAkB,GAClB,WAAW,CAAC;AAEhB,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAEtD,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,MAAM,EAAE;QACN,SAAS,EAAE,WAAW,CAAC;QACvB,SAAS,EAAE,WAAW,CAAC;QACvB,aAAa,EAAE,WAAW,CAAC;KAC5B,CAAC;IACF,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,oBAAoB,EAAE,aAAa,CAAC,MAAM,WAAW,CAWjE,CAAC"} \ No newline at end of file diff --git a/dist/types.js b/dist/types.js new file mode 100644 index 0000000..3c9e1a0 --- /dev/null +++ b/dist/types.js @@ -0,0 +1,13 @@ +export const REQUIRED_CERT_FIELDS = [ + "certification_id", + "timestamp", + "issuer", + "dataset_hash", + "algorithm", + "rows", + "columns", + "schema_version", + "signature", + "key_id", +]; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/types.js.map b/dist/types.js.map new file mode 100644 index 0000000..0da57a1 --- /dev/null +++ b/dist/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA0DA,MAAM,CAAC,MAAM,oBAAoB,GAAqC;IACpE,kBAAkB;IAClB,WAAW;IACX,QAAQ;IACR,cAAc;IACd,WAAW;IACX,MAAM;IACN,SAAS;IACT,gBAAgB;IAChB,WAAW;IACX,QAAQ;CACT,CAAC"} \ No newline at end of file diff --git a/dist/verify.d.ts b/dist/verify.d.ts new file mode 100644 index 0000000..ed05cc3 --- /dev/null +++ b/dist/verify.d.ts @@ -0,0 +1,3 @@ +import type { Certificate, KeyDoc, VerifyResult } from "./types.js"; +export declare function verifyCertificate(cert: Certificate, trustedKeys: KeyDoc, datasetPath?: string): Promise; +//# sourceMappingURL=verify.d.ts.map \ No newline at end of file diff --git a/dist/verify.d.ts.map b/dist/verify.d.ts.map new file mode 100644 index 0000000..be32026 --- /dev/null +++ b/dist/verify.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEpE,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,WAAW,EACjB,WAAW,EAAE,MAAM,EACnB,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,YAAY,CAAC,CAkDvB"} \ No newline at end of file diff --git a/dist/verify.js b/dist/verify.js new file mode 100644 index 0000000..6cafb7e --- /dev/null +++ b/dist/verify.js @@ -0,0 +1,115 @@ +// Core verification path. Read top-to-bottom — there is no clever indirection. +// 1. Validate certificate shape. +// 2. Look up cert.key_id in the trusted keys document; reject if missing/revoked. +// 3. Build the canonical payload (cert minus signature) per RFC 8785 JCS. +// 4. crypto.verify('ed25519', canonicalBytes, publicKey, signatureBytes). +// 5. If a dataset path was supplied, recompute SHA-256 and compare to dataset_hash. +import { createPublicKey, verify as cryptoVerify } from "node:crypto"; +import { canonicalizeToBytes } from "./canonicalize.js"; +import { sha256File, formatDigest, parseDigest } from "./hash.js"; +import { findKey } from "./keys.js"; +import { REQUIRED_CERT_FIELDS } from "./types.js"; +export async function verifyCertificate(cert, trustedKeys, datasetPath) { + const result = blankResult(cert); + const shapeError = validateShape(cert); + if (shapeError) + return finish(result, "MALFORMED", shapeError); + result.certification_id = cert.certification_id; + result.key_id = cert.key_id; + result.issuer = cert.issuer; + result.algorithm = cert.algorithm; + result.signed_at = cert.timestamp; + result.dataset_hash_expected = cert.dataset_hash; + result.rows = cert.rows; + result.columns = cert.columns; + const key = findKey(trustedKeys, cert.key_id); + if (!key || key.revoked_at || key.algorithm !== "ed25519") { + result.checks.key_trust = "fail"; + const reason = !key + ? `key_id ${cert.key_id} not in trusted keys` + : key.revoked_at + ? `key_id ${cert.key_id} was revoked at ${key.revoked_at}` + : `key ${cert.key_id} is not ed25519`; + return finish(result, "UNKNOWN_KEY", reason); + } + result.checks.key_trust = "pass"; + result.key_label = key.label; + const { signature: _sig, ...withoutSig } = cert; + const canonicalBytes = canonicalizeToBytes(withoutSig); + const sigBytes = decodeSignature(cert.signature); + if (!sigBytes) + return finish(result, "MALFORMED", "signature is not valid base64"); + const publicKey = createPublicKey({ key: pemFromRawEd25519(key.public_key), format: "pem" }); + const sigOk = cryptoVerify(null, canonicalBytes, publicKey, sigBytes); + result.checks.signature = sigOk ? "pass" : "fail"; + if (!sigOk) + return finish(result, "INVALID", "ed25519 signature does not verify against canonicalized payload"); + if (datasetPath) { + const actualHex = await sha256File(datasetPath); + const actual = formatDigest(actualHex); + result.dataset_hash_actual = actual; + const expected = parseDigest(cert.dataset_hash); + if (expected.algo !== "sha256" || expected.hex !== actualHex) { + result.checks.dataset_match = "fail"; + return finish(result, "DATASET_MISMATCH", `dataset hash mismatch (expected ${cert.dataset_hash}, got ${actual})`); + } + result.checks.dataset_match = "pass"; + } + return finish(result, "VALID", "signature verified and key is trusted"); +} +function validateShape(c) { + if (!c || typeof c !== "object") + return "certificate is not an object"; + for (const f of REQUIRED_CERT_FIELDS) + if (c[f] === undefined || c[f] === null) + return `missing required field: ${f}`; + if (c.schema_version !== "cert.v1") + return `unsupported schema_version: ${c.schema_version}`; + if (!/^sha256:[0-9a-f]{64}$/i.test(c.dataset_hash)) + return "dataset_hash must be sha256:<64-hex>"; + if (typeof c.rows !== "number" || typeof c.columns !== "number") + return "rows and columns must be numbers"; + return null; +} +function decodeSignature(b64) { + try { + const buf = Buffer.from(b64, "base64"); + if (buf.length !== 64) + return null; + return buf; + } + catch { + return null; + } +} +function pemFromRawEd25519(material) { + if (material.includes("BEGIN PUBLIC KEY")) + return material; + // Wrap a base64 raw 32-byte Ed25519 public key in the standard SPKI prefix. + const raw = Buffer.from(material, "base64"); + if (raw.length !== 32) + throw new Error(`expected 32-byte ed25519 key, got ${raw.length}`); + const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex"); + const der = Buffer.concat([spkiPrefix, raw]).toString("base64"); + return `-----BEGIN PUBLIC KEY-----\n${der.match(/.{1,64}/g).join("\n")}\n-----END PUBLIC KEY-----\n`; +} +function blankResult(cert) { + return { + verdict: "MALFORMED", + certification_id: cert.certification_id ?? null, + key_id: cert.key_id ?? null, + issuer: cert.issuer ?? null, + algorithm: cert.algorithm ?? null, + signed_at: cert.timestamp ?? null, + dataset_hash_expected: cert.dataset_hash ?? null, + dataset_hash_actual: null, + checks: { signature: "skipped", key_trust: "skipped", dataset_match: "skipped" }, + reason: "", + }; +} +function finish(r, verdict, reason) { + r.verdict = verdict; + r.reason = reason; + return r; +} +//# sourceMappingURL=verify.js.map \ No newline at end of file diff --git a/dist/verify.js.map b/dist/verify.js.map new file mode 100644 index 0000000..7fcf006 --- /dev/null +++ b/dist/verify.js.map @@ -0,0 +1 @@ +{"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/verify.test.d.ts b/dist/verify.test.d.ts new file mode 100644 index 0000000..2ebcdd3 --- /dev/null +++ b/dist/verify.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=verify.test.d.ts.map \ No newline at end of file diff --git a/dist/verify.test.d.ts.map b/dist/verify.test.d.ts.map new file mode 100644 index 0000000..70a2f9d --- /dev/null +++ b/dist/verify.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"verify.test.d.ts","sourceRoot":"","sources":["../src/verify.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/verify.test.js b/dist/verify.test.js new file mode 100644 index 0000000..a1ed1e0 --- /dev/null +++ b/dist/verify.test.js @@ -0,0 +1,69 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { verifyCertificate } from "./verify.js"; +const here = dirname(fileURLToPath(import.meta.url)); +const fixturesDir = join(here, "..", "fixtures"); +async function loadJson(name) { + return JSON.parse(await readFile(join(fixturesDir, name), "utf8")); +} +test("verifies a clean cert against trusted keys", async () => { + const cert = await loadJson("valid-cert.json"); + const keys = await loadJson("keys.json"); + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "VALID", r.reason); + assert.equal(r.checks.signature, "pass"); + assert.equal(r.checks.key_trust, "pass"); + assert.equal(r.checks.dataset_match, "skipped"); +}); +test("rejects a cert whose payload was mutated after signing", async () => { + const cert = await loadJson("tampered-cert.json"); + const keys = await loadJson("keys.json"); + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "INVALID"); + assert.equal(r.checks.signature, "fail"); + assert.equal(r.checks.key_trust, "pass"); +}); +test("returns UNKNOWN_KEY when the cert references an unlisted key", async () => { + const cert = await loadJson("unknown-key-cert.json"); + const keys = await loadJson("keys.json"); + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "UNKNOWN_KEY"); + assert.equal(r.checks.key_trust, "fail"); + assert.equal(r.checks.signature, "skipped"); +}); +test("returns MALFORMED for missing required fields", async () => { + const cert = await loadJson("malformed-cert.json"); + const keys = await loadJson("keys.json"); + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "MALFORMED"); + assert.match(r.reason, /missing required field: signature/); +}); +test("returns DATASET_MISMATCH when the dataset hash does not match", async () => { + const cert = await loadJson("valid-cert.json"); + const keys = await loadJson("keys.json"); + const wrongDataset = join(fixturesDir, "keys.json"); // hash will differ + const r = await verifyCertificate(cert, keys, wrongDataset); + assert.equal(r.verdict, "DATASET_MISMATCH"); + assert.equal(r.checks.dataset_match, "fail"); + assert.notEqual(r.dataset_hash_actual, r.dataset_hash_expected); +}); +test("returns VALID when the supplied dataset matches", async () => { + const cert = await loadJson("valid-cert.json"); + const keys = await loadJson("keys.json"); + const dataset = join(fixturesDir, "valid-dataset.csv"); + const r = await verifyCertificate(cert, keys, dataset); + assert.equal(r.verdict, "VALID"); + assert.equal(r.checks.dataset_match, "pass"); +}); +test("rejects a revoked key", async () => { + const cert = await loadJson("valid-cert.json"); + const keys = await loadJson("keys.json"); + keys.keys[0].revoked_at = "2026-04-01T00:00:00Z"; + const r = await verifyCertificate(cert, keys); + assert.equal(r.verdict, "UNKNOWN_KEY"); + assert.match(r.reason, /revoked/); +}); +//# sourceMappingURL=verify.test.js.map \ No newline at end of file diff --git a/dist/verify.test.js.map b/dist/verify.test.js.map new file mode 100644 index 0000000..4a99518 --- /dev/null +++ b/dist/verify.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"verify.test.js","sourceRoot":"","sources":["../src/verify.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/fixtures/malformed-receipt.json b/fixtures/malformed-receipt.json new file mode 100644 index 0000000..001a54b --- /dev/null +++ b/fixtures/malformed-receipt.json @@ -0,0 +1,53 @@ +{ + "valid": true, + "hashValid": true, + "signatureValid": true, + "storedReceiptHash": "sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22", + "recomputedReceiptHash": "sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22", + "signingKeyId": "ed25519-prod-2025-02", + "signatureAlg": "Ed25519", + "verificationError": null, + "receipt": { + "rail": "stripe", + "amount": 99, + "status": "succeeded", + "purpose": "Dataset purchase: fraud-detection-verification-sample (certificate-linked, 500 rows x 7 columns)", + "user_id": "066347d3-3472-483e-8eff-e7942d736453", + "agent_id": "38ed5be8-fb59-438e-b99d-73549b242b09", + "currency": "usd", + "policy_id": "0acaeed1-7dbc-4c17-b405-c31517a0be78", + "timestamp": "2026-08-20T22:39:38.008Z", + "agent_name": "Demo Agent (seed)", + "receipt_id": "2492a060-8fbc-40ae-beab-7258aefb0608", + "settled_at": "2026-08-20T22:39:38.008Z", + "merchant_id": null, + "policy_hash": "sha256:f4e59785c1f3107b3f6afac362051fe42bc25d3605ee641e8936f0e8c3a1eb8d", + "purpose_tag": "dataset_purchase", + "artifact_hash": "sha256:bd48985485c9a3e19838e29795bb89ddedd7f7e5c706b57c190cc6c46119a660", + "merchant_name": null, + "certificate_id": "fb914a90-b1b3-4355-8147-cc0194160e23", + "policy_version": "1.0", + "schema_version": "payment_receipt.v1", + "transaction_id": "b00d1924-6187-49f3-aee7-3bbffff1c78c", + "issuer_identity": { + "handle": "@certifieddata", + "provider": "cloudflare_wallets", + "identity_uri": "https://certifieddata.cloudflare.pay", + "controller_uri": "https://certifieddata.io", + "payment_status": "not_yet_available", + "registration_status": "reserved" + }, + "authorization_id": "df033885-5b76-4137-8aa4-a8e8a98a95b2", + "settlement_state": "succeeded_live", + "decision_record_id": "709519fd-40bb-445e-a6c4-b9b2b1cba9fb", + "external_charge_id": "ch_3U6ecXDRYQxqHzlc2pdDptCX", + "external_reference": "pi_3U6ecXDRYQxqHzlc2IoF5mAB", + "external_reference_type": "stripe_payment_intent", + "external_payment_intent_id": "pi_3U6ecXDRYQxqHzlc2IoF5mAB", + "external_decision_record_id": null + }, + "signature": "not-a-signature", + "canonicalization": "RFC8785-JCS", + "public_key_url": "https://certifieddata.io/.well-known/certifieddata-public-key.pem", + "transactionStatus": "succeeded" +} diff --git a/fixtures/tampered-receipt.json b/fixtures/tampered-receipt.json new file mode 100644 index 0000000..5aa9053 --- /dev/null +++ b/fixtures/tampered-receipt.json @@ -0,0 +1,53 @@ +{ + "valid": true, + "hashValid": true, + "signatureValid": true, + "storedReceiptHash": "sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22", + "recomputedReceiptHash": "sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22", + "signingKeyId": "ed25519-prod-2025-02", + "signatureAlg": "Ed25519", + "verificationError": null, + "receipt": { + "rail": "stripe", + "amount": 100, + "status": "succeeded", + "purpose": "Dataset purchase: fraud-detection-verification-sample (certificate-linked, 500 rows x 7 columns)", + "user_id": "066347d3-3472-483e-8eff-e7942d736453", + "agent_id": "38ed5be8-fb59-438e-b99d-73549b242b09", + "currency": "usd", + "policy_id": "0acaeed1-7dbc-4c17-b405-c31517a0be78", + "timestamp": "2026-08-20T22:39:38.008Z", + "agent_name": "Demo Agent (seed)", + "receipt_id": "2492a060-8fbc-40ae-beab-7258aefb0608", + "settled_at": "2026-08-20T22:39:38.008Z", + "merchant_id": null, + "policy_hash": "sha256:f4e59785c1f3107b3f6afac362051fe42bc25d3605ee641e8936f0e8c3a1eb8d", + "purpose_tag": "dataset_purchase", + "artifact_hash": "sha256:bd48985485c9a3e19838e29795bb89ddedd7f7e5c706b57c190cc6c46119a660", + "merchant_name": null, + "certificate_id": "fb914a90-b1b3-4355-8147-cc0194160e23", + "policy_version": "1.0", + "schema_version": "payment_receipt.v1", + "transaction_id": "b00d1924-6187-49f3-aee7-3bbffff1c78c", + "issuer_identity": { + "handle": "@certifieddata", + "provider": "cloudflare_wallets", + "identity_uri": "https://certifieddata.cloudflare.pay", + "controller_uri": "https://certifieddata.io", + "payment_status": "not_yet_available", + "registration_status": "reserved" + }, + "authorization_id": "df033885-5b76-4137-8aa4-a8e8a98a95b2", + "settlement_state": "succeeded_live", + "decision_record_id": "709519fd-40bb-445e-a6c4-b9b2b1cba9fb", + "external_charge_id": "ch_3U6ecXDRYQxqHzlc2pdDptCX", + "external_reference": "pi_3U6ecXDRYQxqHzlc2IoF5mAB", + "external_reference_type": "stripe_payment_intent", + "external_payment_intent_id": "pi_3U6ecXDRYQxqHzlc2IoF5mAB", + "external_decision_record_id": null + }, + "signature": "UkDRQJG/7OuO+7ldLfBZha5VpRspHJN1g9KdfbyrXG+SLLwAk62JGclD9SopaOGo1vr1kVHZsOBx1k7ZRhy7CA==", + "canonicalization": "RFC8785-JCS", + "public_key_url": "https://certifieddata.io/.well-known/certifieddata-public-key.pem", + "transactionStatus": "succeeded" +} diff --git a/fixtures/valid-receipt.json b/fixtures/valid-receipt.json new file mode 100644 index 0000000..444523b --- /dev/null +++ b/fixtures/valid-receipt.json @@ -0,0 +1,53 @@ +{ + "valid": true, + "hashValid": true, + "signatureValid": true, + "storedReceiptHash": "sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22", + "recomputedReceiptHash": "sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22", + "signingKeyId": "ed25519-prod-2025-02", + "signatureAlg": "Ed25519", + "verificationError": null, + "receipt": { + "rail": "stripe", + "amount": 99, + "status": "succeeded", + "purpose": "Dataset purchase: fraud-detection-verification-sample (certificate-linked, 500 rows x 7 columns)", + "user_id": "066347d3-3472-483e-8eff-e7942d736453", + "agent_id": "38ed5be8-fb59-438e-b99d-73549b242b09", + "currency": "usd", + "policy_id": "0acaeed1-7dbc-4c17-b405-c31517a0be78", + "timestamp": "2026-08-20T22:39:38.008Z", + "agent_name": "Demo Agent (seed)", + "receipt_id": "2492a060-8fbc-40ae-beab-7258aefb0608", + "settled_at": "2026-08-20T22:39:38.008Z", + "merchant_id": null, + "policy_hash": "sha256:f4e59785c1f3107b3f6afac362051fe42bc25d3605ee641e8936f0e8c3a1eb8d", + "purpose_tag": "dataset_purchase", + "artifact_hash": "sha256:bd48985485c9a3e19838e29795bb89ddedd7f7e5c706b57c190cc6c46119a660", + "merchant_name": null, + "certificate_id": "fb914a90-b1b3-4355-8147-cc0194160e23", + "policy_version": "1.0", + "schema_version": "payment_receipt.v1", + "transaction_id": "b00d1924-6187-49f3-aee7-3bbffff1c78c", + "issuer_identity": { + "handle": "@certifieddata", + "provider": "cloudflare_wallets", + "identity_uri": "https://certifieddata.cloudflare.pay", + "controller_uri": "https://certifieddata.io", + "payment_status": "not_yet_available", + "registration_status": "reserved" + }, + "authorization_id": "df033885-5b76-4137-8aa4-a8e8a98a95b2", + "settlement_state": "succeeded_live", + "decision_record_id": "709519fd-40bb-445e-a6c4-b9b2b1cba9fb", + "external_charge_id": "ch_3U6ecXDRYQxqHzlc2pdDptCX", + "external_reference": "pi_3U6ecXDRYQxqHzlc2IoF5mAB", + "external_reference_type": "stripe_payment_intent", + "external_payment_intent_id": "pi_3U6ecXDRYQxqHzlc2IoF5mAB", + "external_decision_record_id": null + }, + "signature": "UkDRQJG/7OuO+7ldLfBZha5VpRspHJN1g9KdfbyrXG+SLLwAk62JGclD9SopaOGo1vr1kVHZsOBx1k7ZRhy7CA==", + "canonicalization": "RFC8785-JCS", + "public_key_url": "https://certifieddata.io/.well-known/certifieddata-public-key.pem", + "transactionStatus": "succeeded" +} diff --git a/package.json b/package.json index 596989d..842b01c 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Verify CertifiedData.io certificates from the command line. Audit-friendly, zero crypto dependencies.", "type": "module", "bin": { + "verify": "dist/cli.js", "certifieddata-verify": "dist/cli.js", "cd-verify": "dist/cli.js" }, @@ -47,9 +48,11 @@ ], "scripts": { "build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", + "verify:dist": "npm run build && git diff --exit-code -- dist/", + "prepare": "npm run build", "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 dist/receipt.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 dist/receipt-vectors.test.js", "fixtures": "node fixtures/generate.mjs", "prepublishOnly": "npm run build && npm test" }, @@ -59,5 +62,8 @@ "eslint": "^9.15.0", "typescript-eslint": "^8.15.0", "@eslint/js": "^9.15.0" + }, + "publishConfig": { + "access": "public" } } diff --git a/src/receipt-vectors.test.ts b/src/receipt-vectors.test.ts new file mode 100644 index 0000000..52c10ae --- /dev/null +++ b/src/receipt-vectors.test.ts @@ -0,0 +1,115 @@ +/** + * Receipt signature test vectors. + * + * These existed for webhook-signature, idempotency, provenance and events, but + * not for receipts — so an outside implementer had nothing to check their + * attempt against, and the canonicalization was never pinned to a concrete + * expected hash anywhere in the repo. + * + * The tampered vector is the one that matters. An implementation that reports + * VALID for it is not verifying anything: it has an Ed25519 signature that is + * genuine, over a payload that has been altered. + * + * Fixtures are captured from production receipt + * 2492a060-8fbc-40ae-beab-7258aefb0608 — a $0.99 certificate-linked dataset + * purchase on the live rail. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { canonicalizeToBytes } from "./canonicalize.js"; + +const FIXTURES = join(import.meta.dirname ?? __dirname, "..", "fixtures"); + +function envelope(name: string) { +return JSON.parse(readFileSync(join(FIXTURES, name), "utf8")); +} + +const EXPECTED_HASH = +"sha256:2e14cf92c38d5d0cf2b577c4736404fad1c1092c3c4ef87e3b4efeb3923dde22"; + +// ── canonicalization is pinned to a concrete expected hash ── +test("JCS(receipt) hashes to the published storedReceiptHash", () => { + const env = envelope("valid-receipt.json"); + const bytes = canonicalizeToBytes(env.receipt); + const hash = "sha256:" + createHash("sha256").update(bytes).digest("hex"); + + assert.equal(hash, EXPECTED_HASH); + assert.equal(hash, env.storedReceiptHash); +}); + +test("the canonical payload excludes signature and the appended crypto fields", () => { + // Documented in RECEIPT-VERIFICATION.md §2. If any of these ever appear + // inside `receipt`, the hash above stops reproducing and every external + // verifier breaks at once. + const env = envelope("valid-receipt.json"); + for (const k of ["signature", "sha256_hash", "ed25519_sig"]) { + assert.ok(!Object.keys(env.receipt).includes(k)); + } +}); + +test("key ordering is what makes it canonical, not insertion order", () => { + // Re-serialise with keys deliberately reversed; the digest must not move. + const env = envelope("valid-receipt.json"); + const reversed: Record = {}; + for (const k of Object.keys(env.receipt).reverse()) reversed[k] = env.receipt[k]; + + const a = createHash("sha256").update(canonicalizeToBytes(env.receipt)).digest("hex"); + const b = createHash("sha256").update(canonicalizeToBytes(reversed)).digest("hex"); + assert.equal(b, a); +}); + +// ── the tampered vector must not verify ── +test("altering amount changes the canonical hash", () => { + const good = envelope("valid-receipt.json"); + const bad = envelope("tampered-receipt.json"); + + // The signature is byte-identical — only the payload differs. + assert.equal(bad.signature, good.signature); + assert.notEqual(bad.receipt.amount, good.receipt.amount); + + const hash = "sha256:" + createHash("sha256") + .update(canonicalizeToBytes(bad.receipt)) + .digest("hex"); + + assert.notEqual(hash, EXPECTED_HASH); +}); + +// ── the live receipt carries the bindings the category depends on ── +const r = envelope("valid-receipt.json").receipt; + +test("binds the artifact — the half authorization-only proofs scope out", () => { + assert.match(r.artifact_hash, /^sha256:[0-9a-f]{64}$/); + assert.ok(r.certificate_id); +}); + +test("binds the governing policy", () => { + assert.match(r.policy_hash, /^sha256:[0-9a-f]{64}$/); + assert.ok(r.policy_version); +}); + +test("states settlement rather than implying it", () => { + assert.equal(r.settlement_state, "succeeded_live"); + assert.ok(r.settled_at); +}); + +test("keeps typed reference fields distinct and correctly prefixed", () => { + // Receipt c2e70d98 held one PaymentIntent id in four fields, one of them a + // charge field. Distinct, correctly-prefixed values are the fix. + assert.match(r.external_payment_intent_id, /^pi_/); + assert.match(r.external_charge_id, /^ch_/); + assert.notEqual(r.external_charge_id, r.external_payment_intent_id); + assert.equal(r.external_reference_type, "stripe_payment_intent"); +}); + +test("carries no U+FFFD anywhere", () => { + assert.ok(!JSON.stringify(r).includes("�")); +}); + +test("declares no integrity caveats", () => { + // Absence means the pre-signature gate found nothing to declare. + assert.equal(r.integrity_notes ?? null, null); +});