|
| 1 | +#!/usr/bin/env node |
| 2 | +// verify-receipt.mjs — offline verifier for GBLIN AI Action Receipts. |
| 3 | +// Zero dependencies (Node 18+). You do NOT need to trust gblin.digital: |
| 4 | +// this script re-derives everything from the receipt JSON itself. |
| 5 | +// |
| 6 | +// node verify-receipt.mjs receipt.json |
| 7 | +// curl -s https://gblin-mcp.gblin-mcp-worker.workers.dev/v1/receipt/0 | node verify-receipt.mjs /dev/stdin |
| 8 | +// |
| 9 | +// Checks: (1) canonical payload → leaf hash (RFC 6962, 0x00 prefix); |
| 10 | +// (2) Ed25519 receipt signature over "gblin-receipt/v1\n"+canonical; |
| 11 | +// (3) inclusion proof leaf→root (0x01 prefix); |
| 12 | +// (4) checkpoint note signature (C2SP) over origin/size/root by the SAME key; |
| 13 | +// (5) key hash inside the verifier_key matches name+alg+pubkey. |
| 14 | +// The daily EAS anchor on Base can be checked independently on base.easscan.org |
| 15 | +// (schema 0x9f433a96..., promiseId keccak256("gblin-receipts-log")). |
| 16 | + |
| 17 | +import { readFileSync } from "fs"; |
| 18 | +import { webcrypto as crypto } from "crypto"; |
| 19 | + |
| 20 | +const die = (m) => { console.error("FAIL:", m); process.exit(1); }; |
| 21 | +const ok = (m) => console.log(" ✓", m); |
| 22 | +const b64 = (u8) => Buffer.from(u8).toString("base64"); |
| 23 | +const unb64 = (s) => new Uint8Array(Buffer.from(s, "base64")); |
| 24 | +const te = new TextEncoder(); |
| 25 | +const cat = (...p) => { const n=p.reduce((a,x)=>a+x.length,0); const o=new Uint8Array(n); let i=0; for(const x of p){o.set(x,i);i+=x.length;} return o; }; |
| 26 | +const sha256 = async (u8) => new Uint8Array(await crypto.subtle.digest("SHA-256", u8)); |
| 27 | + |
| 28 | +function canonicalize(v) { |
| 29 | + if (v === null || typeof v !== "object") return JSON.stringify(v); |
| 30 | + if (Array.isArray(v)) return "[" + v.map(canonicalize).join(",") + "]"; |
| 31 | + return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canonicalize(v[k])).join(",") + "}"; |
| 32 | +} |
| 33 | + |
| 34 | +const file = process.argv[2] || die("usage: node verify-receipt.mjs receipt.json"); |
| 35 | +const r = JSON.parse(readFileSync(file, "utf8")); |
| 36 | +const receipt = r.receipt || r; // accept either the bare receipt or an API wrapper |
| 37 | +if (receipt.format !== "gblin-receipt/v1") die("unknown format: " + receipt.format); |
| 38 | + |
| 39 | +// (5) verifier key structure |
| 40 | +const m = /^([^+]+)\+([0-9a-f]{8})\+([A-Za-z0-9+/=]+)$/.exec(receipt.verifier_key) || die("bad verifier_key"); |
| 41 | +const keyRaw = unb64(m[3]); |
| 42 | +if (keyRaw[0] !== 0x01) die("verifier_key alg is not Ed25519 note key (0x01)"); |
| 43 | +const pub = keyRaw.slice(1); |
| 44 | +const kh = (await sha256(cat(te.encode(m[1] + "\n"), keyRaw))).slice(0, 4); |
| 45 | +if (Buffer.from(kh).toString("hex") !== m[2]) die("verifier_key hash mismatch"); |
| 46 | +ok(`verifier key: ${m[1]} (${m[2]})`); |
| 47 | + |
| 48 | +// (1) canonical → leaf |
| 49 | +const canonical = canonicalize(receipt.payload); |
| 50 | +const leaf = await sha256(cat(Uint8Array.of(0x00), te.encode(canonical))); |
| 51 | +if (b64(leaf) !== receipt.leaf) die("leaf hash does not match canonical payload"); |
| 52 | +ok("leaf = SHA256(0x00 || canonical(payload))"); |
| 53 | + |
| 54 | +// (2) receipt signature (present on freshly-sealed receipts; /v1/receipt omits it) |
| 55 | +const key = await crypto.subtle.importKey("raw", pub, { name: "Ed25519" }, false, ["verify"]); |
| 56 | +if (receipt.signature) { |
| 57 | + const good = await crypto.subtle.verify({ name: "Ed25519" }, key, unb64(receipt.signature), te.encode("gblin-receipt/v1\n" + canonical)); |
| 58 | + if (!good) die("receipt signature invalid"); |
| 59 | + ok("receipt Ed25519 signature valid"); |
| 60 | +} |
| 61 | + |
| 62 | +// (3) inclusion proof |
| 63 | +let h = leaf, idx = receipt.index, size = receipt.tree_size; |
| 64 | +let lo = 0, hi = size; |
| 65 | +for (const pB64 of receipt.inclusion_proof) { |
| 66 | + const sib = unb64(pB64); |
| 67 | + let k = 1; while (k * 2 < hi - lo) k *= 2; |
| 68 | + if (idx < lo + k) { h = await sha256(cat(Uint8Array.of(0x01), h, sib)); hi = lo + k; } |
| 69 | + else { h = await sha256(cat(Uint8Array.of(0x01), sib, h)); lo = lo + k; } |
| 70 | +} |
| 71 | +// NB: path is leaf→root; recompute bounds properly (redo forward): |
| 72 | +{ |
| 73 | + const proof = receipt.inclusion_proof.map(unb64); |
| 74 | + const rec = async (i, a, b, d) => { |
| 75 | + if (b - a === 1) return leaf; |
| 76 | + let k = 1; while (k * 2 < b - a) k *= 2; |
| 77 | + if (i < a + k) { const L = await rec(i, a, a + k, d); return sha256(cat(Uint8Array.of(0x01), L, proof[d.n++] ?? die("proof too short"))); } |
| 78 | + const R = await rec(i, a + k, b, d); |
| 79 | + return sha256(cat(Uint8Array.of(0x01), proof[d.n++] ?? die("proof too short"), R)); |
| 80 | + }; |
| 81 | + // proof array is ordered leaf-upward; rebuild with an index counter walking the same recursion order |
| 82 | + const d = { n: 0 }; |
| 83 | + const order = []; |
| 84 | + const collect = (i, a, b) => { if (b - a === 1) return; let k=1; while (k*2 < b-a) k*=2; if (i < a+k) { collect(i,a,a+k); order.push(["R",a+k,b]); } else { collect(i,a+k,b); order.push(["L",a,a+k]); } }; |
| 85 | + collect(receipt.index, 0, size); |
| 86 | + if (order.length !== proof.length) die(`proof length ${proof.length} != expected ${order.length}`); |
| 87 | + let cur = leaf; |
| 88 | + for (let j = 0; j < order.length; j++) { |
| 89 | + cur = order[j][0] === "R" ? await sha256(cat(Uint8Array.of(0x01), cur, proof[j])) : await sha256(cat(Uint8Array.of(0x01), proof[j], cur)); |
| 90 | + } |
| 91 | + if (b64(cur) !== receipt.root) die("inclusion proof does not reach the stated root"); |
| 92 | +} |
| 93 | +ok(`inclusion proof: leaf #${receipt.index} → root of tree size ${size}`); |
| 94 | + |
| 95 | +// (4) checkpoint note |
| 96 | +const note = receipt.checkpoint || die("no checkpoint in receipt"); |
| 97 | +const sep = note.indexOf("\n\n"); |
| 98 | +const body = note.slice(0, sep + 1); |
| 99 | +const lines = body.split("\n"); |
| 100 | +if (lines[0] !== m[1]) die("checkpoint origin mismatch"); |
| 101 | +if (Number(lines[1]) !== size) die("checkpoint size != receipt tree_size"); |
| 102 | +if (lines[2] !== receipt.root) die("checkpoint root != receipt root"); |
| 103 | +const sigLine = note.slice(sep + 2).split("\n").find((l) => l.startsWith("— " + m[1] + " ")) || die("no signature line for origin"); |
| 104 | +const payloadSig = unb64(sigLine.split(" ")[2]); |
| 105 | +if (payloadSig.length !== 68) die("bad checkpoint sig payload"); |
| 106 | +if (Buffer.from(payloadSig.slice(0, 4)).toString("hex") !== m[2]) die("checkpoint keyhash mismatch"); |
| 107 | +const goodCp = await crypto.subtle.verify({ name: "Ed25519" }, key, payloadSig.slice(4), te.encode(body)); |
| 108 | +if (!goodCp) die("checkpoint signature invalid"); |
| 109 | +ok("checkpoint (C2SP signed note) valid and consistent with receipt"); |
| 110 | + |
| 111 | +console.log(`\nPASS — receipt #${receipt.index} verified offline.`); |
| 112 | +console.log(`Action: ${receipt.payload.action}${receipt.payload.demo ? " (DEMO)" : ""} · ${receipt.payload.ts}`); |
| 113 | +console.log("Reminder: a seal proves existence and time. It does not certify the content."); |
0 commit comments