From 61485ce0f70adb245bd7c963ab81c8a592de2a58 Mon Sep 17 00:00:00 2001 From: Aziel Eliab Date: Sat, 10 Oct 2026 23:59:58 -0400 Subject: [PATCH 1/2] Receipt proof: cached verified checkpoint, indexed receipt_id, single scan, hardcoded /verify --- workers/godlock-uk/schema.sql | 3 + workers/godlock-uk/src/index.js | 8 +- workers/godlock-uk/src/ledger.js | 2 +- workers/godlock-uk/src/receiptProof.js | 220 ++++++++++++++++------ workers/godlock-uk/src/ui.js | 4 +- workers/godlock-uk/thread-expand.test.mjs | 103 ++++++++-- 6 files changed, 252 insertions(+), 88 deletions(-) diff --git a/workers/godlock-uk/schema.sql b/workers/godlock-uk/schema.sql index 69f2e23..75d9774 100644 --- a/workers/godlock-uk/schema.sql +++ b/workers/godlock-uk/schema.sql @@ -86,3 +86,6 @@ CREATE INDEX IF NOT EXISTS idx_rescores_receipt ON receipt_rescores(receipt_id, INSERT OR IGNORE INTO metadata(key, value) VALUES ('current_score', '50'); INSERT OR IGNORE INTO metadata(key, value) VALUES ('views', '0'); INSERT OR IGNORE INTO metadata(key, value) VALUES ('uses', '0'); + +-- Receipt proof reads a receipt's ledger rows by exact receipt_id (no substring scan). +CREATE INDEX IF NOT EXISTS idx_ledger_receipt_id ON ledger(json_extract(payload_json, '$.receipt_id')); diff --git a/workers/godlock-uk/src/index.js b/workers/godlock-uk/src/index.js index d89d527..5c111c9 100644 --- a/workers/godlock-uk/src/index.js +++ b/workers/godlock-uk/src/index.js @@ -22,8 +22,8 @@ import { } from "./ui.js"; import { donateDoc, DONATE_RAILS, donateQrIdFromPath } from "./donate.js"; import { handleRuntimeRoot, isRuntimeRequest, runtimeCors } from "./runtimeRoot.js"; -import { appendLedger, verifyLedger, ledgerEntriesForId, sha256hex } from "./ledger.js"; -import { receiptProofForId } from "./receiptProof.js"; +import { appendLedger, verifyLedger, ledgerEntriesForId, sha256hex, publicEntry } from "./ledger.js"; +import { receiptLedgerAndProof } from "./receiptProof.js"; import { robotsTxt, sitemapXml, citeDoc, llmsDoc, aiDoc, siteOpenApi, mcpDiscoveryDoc, BANNER, DOWNLOAD, DOWNLOAD_STATS, DOWNLOAD_COUNT, GITHUB, AUTHOR, indexNowKeyBody, INDEXNOW_KEY_PATH, INDEXNOW_WELL_KNOWN_PATH, @@ -1274,10 +1274,10 @@ export default { if (wantsJson(request, url)) return json({ ok: false, error: "not found" }, 404); return html(page("Receipt", receiptBody({ id, row: null, entries: [] }), { path: "/receipt/" + id, kind: "receipt", indexable: false }), { status: 404 }); } - const entries = await ledgerEntriesForId(env, id); + const { rows: ledgerRows, proof } = await receiptLedgerAndProof(env, id); + const entries = ledgerRows.map(publicEntry); const stats = await gatherStats(env, { wrote, ctx }); if (wantsJson(request, url)) { - const proof = await receiptProofForId(env, id); return json({ ok: true, receipt: publicPayload(row), diff --git a/workers/godlock-uk/src/ledger.js b/workers/godlock-uk/src/ledger.js index 1783021..41945ed 100644 --- a/workers/godlock-uk/src/ledger.js +++ b/workers/godlock-uk/src/ledger.js @@ -129,7 +129,7 @@ export async function ledgerEntriesForId(env, id) { return out; } -function publicEntry(row) { +export function publicEntry(row) { let payload = {}; try { payload = JSON.parse(row.payload_json || "{}"); } catch { payload = {}; } const safe = { ...payload }; diff --git a/workers/godlock-uk/src/receiptProof.js b/workers/godlock-uk/src/receiptProof.js index 5dec3d6..0c46b8b 100644 --- a/workers/godlock-uk/src/receiptProof.js +++ b/workers/godlock-uk/src/receiptProof.js @@ -2,90 +2,188 @@ * Receipt proof for the expand (drop-down) view of a public thread. * Hash-linked and sealed. NOT signed: there is no signature anywhere here. * - * Fails closed: status is "verified" only when every ledger entry for the - * receipt recomputes to its entry_hash, links to the entry before it, and the - * whole ledger verifies from genesis to the head that is reported as the seal. - * Anything else is "unverified" or "missing". Nothing is invented. + * D1 cost: a verified checkpoint {seq, hash, count, at} is cached per isolate + * and in the Cache API (no KV writes). Each request does a one-row head probe + * (ORDER BY sequence DESC LIMIT 1), verifies only rows after the checkpoint, + * and reads this receipt's rows by the indexed receipt_id expression. + * + * Fails closed: "verified" only when the chain verifies up to the probed head, + * the row count read equals head.seq - checkpoint.seq, and every receipt row + * recomputes to its entry_hash inside the verified range. Otherwise + * "unverified" or "missing", with no seal. Nothing is invented. * Author: Aziel Eliab. */ import { hashPayload, ZERO } from "./ledger.js"; export const PROOF_NOT_SIGNED = "Hash-linked and sealed. Not signed."; +export const VERIFY_PATH = "/verify"; +export const CHECKPOINT_CACHE_URL = "https://godlock.uk/__ledger-checkpoint/v1"; +export const RECEIPT_ID_EXPR = "json_extract(payload_json, '$.receipt_id')"; +export const RECEIPT_ID_INDEX_SQL = "CREATE INDEX IF NOT EXISTS idx_ledger_receipt_id ON ledger(" + RECEIPT_ID_EXPR + ")"; +const COLS = "sequence, timestamp_utc, action, payload_json, previous_hash, entry_hash"; +export const HEAD_SQL = "SELECT sequence, entry_hash, timestamp_utc FROM ledger ORDER BY sequence DESC LIMIT 1"; +export const TAIL_SQL = "SELECT " + COLS + " FROM ledger WHERE sequence > ? ORDER BY sequence ASC"; +export const BY_RECEIPT_SQL = "SELECT " + COLS + " FROM ledger WHERE " + RECEIPT_ID_EXPR + " = ? ORDER BY sequence ASC"; + +const GENESIS = { seq: 0, hash: ZERO, count: 0, at: null }; +let isolateCheckpoint = null; +let indexEnsured = false; + +export function _resetProofCache() { isolateCheckpoint = null; indexEnsured = false; } function parse(blob) { try { return { ok: true, value: JSON.parse(blob || "{}") }; } catch { return { ok: false, value: {} }; } } -/** - * Pure. rows = every ledger row (sequence ASC, raw payload_json), id = receipt id. - * Returns { status, receipt_hash, prev_hash, seal, sealed_at, entries, errors, not_signed }. - */ -export function buildReceiptProof(rows, id) { - const needle = String(id || ""); - const all = Array.isArray(rows) ? rows : []; +export function rowHashOk(row) { + const p = parse(row.payload_json); + if (!p.ok) return false; + return hashPayload({ + sequence: Number(row.sequence), + timestamp_utc: row.timestamp_utc, + action: row.action, + payload: p.value, + previous_hash: row.previous_hash, + }) === row.entry_hash; +} + +/** Pure. Verify rows continuing from checkpoint cp up to head. */ +export function verifyForward(cp, rows, head) { const errors = []; - let expectedPrev = ZERO; - let expectedSeq = 1; - let head = ZERO; - let headAt = null; - const mine = []; - for (const row of all) { - const seq = Number(row.sequence); - if (seq !== expectedSeq) errors.push("sequence gap at " + seq); - if (String(row.previous_hash) !== expectedPrev) errors.push("previous_hash mismatch at sequence " + seq); - const p = parse(row.payload_json); - if (!p.ok) errors.push("payload_json not JSON at sequence " + seq); - const recomputed = hashPayload({ - sequence: seq, - timestamp_utc: row.timestamp_utc, - action: row.action, - payload: p.value, - previous_hash: row.previous_hash, - }); - const hashOk = p.ok && recomputed === row.entry_hash; - if (!hashOk) errors.push("entry_hash mismatch at sequence " + seq); - if (needle && String(row.payload_json || "").indexOf(needle) >= 0) { - mine.push({ - sequence: seq, - action: String(row.action), - timestamp_utc: row.timestamp_utc, - entry_hash: String(row.entry_hash), - previous_hash: String(row.previous_hash), - hash_ok: hashOk, - link_ok: String(row.previous_hash) === expectedPrev, - }); + const list = Array.isArray(rows) ? rows : []; + let prev = cp.hash; + let seq = cp.seq; + let at = cp.at; + for (const row of list) { + const s = Number(row.sequence); + if (s !== seq + 1) errors.push("sequence gap at " + s); + if (String(row.previous_hash) !== prev) errors.push("previous_hash mismatch at sequence " + s); + if (!rowHashOk(row)) errors.push("entry_hash mismatch at sequence " + s); + prev = String(row.entry_hash); + seq = s; + at = row.timestamp_utc; + } + const headSeq = head ? Number(head.sequence) : 0; + const headHash = head ? String(head.entry_hash) : ZERO; + if (list.length !== headSeq - cp.seq) errors.push("partial read: got " + list.length + " rows, head expects " + (headSeq - cp.seq)); + if (seq !== headSeq || prev !== headHash) errors.push("chain does not end at probed head"); + if (errors.length) return { ok: false, errors }; + return { ok: true, errors, checkpoint: { seq: headSeq, hash: headHash, count: headSeq, at } }; +} + +function validCheckpoint(cp) { + return cp && Number.isInteger(cp.seq) && cp.seq >= 0 && typeof cp.hash === "string" && cp.hash.length === 64 && cp.count === cp.seq; +} + +async function loadCheckpoint() { + if (validCheckpoint(isolateCheckpoint)) return isolateCheckpoint; + try { + if (typeof caches !== "undefined" && caches.default) { + const hit = await caches.default.match(CHECKPOINT_CACHE_URL); + if (hit) { + const cp = await hit.json(); + if (validCheckpoint(cp)) { isolateCheckpoint = cp; return cp; } + } } - expectedPrev = String(row.entry_hash); - expectedSeq = seq + 1; - head = String(row.entry_hash); - headAt = row.timestamp_utc; + } catch { /* cache miss is fine */ } + return null; +} + +async function storeCheckpoint(cp) { + isolateCheckpoint = cp; + try { + if (typeof caches !== "undefined" && caches.default) { + await caches.default.put(CHECKPOINT_CACHE_URL, new Response(JSON.stringify(cp), { + headers: { "Content-Type": "application/json", "Cache-Control": "max-age=86400" }, + })); + } + } catch { /* isolate copy still holds */ } +} + +/** Verified head: cached checkpoint forward to the probed head, else full re-verify. */ +export async function verifiedHead(env) { + const head = await env.DB.prepare(HEAD_SQL).first(); + const headSeq = head ? Number(head.sequence) : 0; + const headHash = head ? String(head.entry_hash) : ZERO; + let cp = await loadCheckpoint(); + if (cp && (cp.seq > headSeq)) cp = null; + if (cp && cp.seq === headSeq) { + if (cp.hash === headHash) return { ok: true, checkpoint: cp, errors: [] }; + cp = null; + } + const tryFrom = async (from) => { + const rows = (await env.DB.prepare(TAIL_SQL).bind(from.seq).all()).results || []; + return verifyForward(from, rows, head); + }; + let res = cp ? await tryFrom(cp) : null; + if (!res || !res.ok) { + // Checkpoint stale or forked away: re-verify from genesis once. + isolateCheckpoint = null; + res = await tryFrom(GENESIS); } - const base = { not_signed: PROOF_NOT_SIGNED, verify_url: "/verify", entries: mine }; - if (!mine.length) { - return { ...base, status: "missing", receipt_hash: null, prev_hash: null, seal: null, sealed_at: null, errors: ["no ledger entry for this receipt"] }; + if (!res.ok) return { ok: false, checkpoint: null, errors: res.errors }; + await storeCheckpoint(res.checkpoint); + return { ok: true, checkpoint: res.checkpoint, errors: [] }; +} + +/** Pure. Build the proof from the verified head and this receipt's rows. */ +export function buildReceiptProof(headResult, receiptRows) { + const base = { not_signed: PROOF_NOT_SIGNED, verify_url: VERIFY_PATH }; + const rows = Array.isArray(receiptRows) ? receiptRows : []; + const cp = headResult && headResult.ok ? headResult.checkpoint : null; + const entries = rows.map((r) => ({ + sequence: Number(r.sequence), + action: String(r.action), + timestamp_utc: r.timestamp_utc, + entry_hash: String(r.entry_hash), + previous_hash: String(r.previous_hash), + hash_ok: rowHashOk(r), + in_verified_range: !!cp && Number(r.sequence) <= cp.seq, + })); + const errors = (headResult && headResult.errors) ? headResult.errors.slice() : []; + if (!entries.length) { + return { ...base, entries, status: "missing", receipt_hash: null, prev_hash: null, seal: null, sealed_at: null, errors: errors.concat(["no ledger entry for this receipt"]) }; } - const first = mine[0]; - const chainOk = errors.length === 0 && mine.every((e) => e.hash_ok && e.link_ok); + const first = entries[0]; + const ok = !!cp && entries.every((e) => e.hash_ok && e.in_verified_range); + if (!ok && !errors.length) errors.push("receipt entry not inside verified chain"); return { ...base, - status: chainOk ? "verified" : "unverified", + entries, + status: ok ? "verified" : "unverified", receipt_hash: first.entry_hash, prev_hash: first.previous_hash, timestamp_utc: first.timestamp_utc, - // The seal is the ledger head the chain verified up to. Only reported when it verified. - seal: chainOk ? head : null, - sealed_at: chainOk ? headAt : null, + seal: ok ? cp.hash : null, + sealed_at: ok ? cp.at : null, + sealed_seq: ok ? cp.seq : null, errors, }; } -export async function receiptProofForId(env, id) { +async function ensureReceiptIndex(env) { + if (indexEnsured) return; + await env.DB.prepare(RECEIPT_ID_INDEX_SQL).run(); + indexEnsured = true; +} + +/** One indexed read of this receipt's ledger rows. Exact receipt_id match, never substring. */ +export async function receiptLedgerRows(env, id) { + const needle = String(id || ""); + if (!needle) return []; + try { await ensureReceiptIndex(env); } catch { /* query still exact without index */ } + return (await env.DB.prepare(BY_RECEIPT_SQL).bind(needle).all()).results || []; +} + +/** Returns { rows, proof }. rows feed the public ledger list; proof fails closed. */ +export async function receiptLedgerAndProof(env, id) { + let rows = []; try { - const rows = (await env.DB.prepare( - "SELECT sequence, timestamp_utc, action, payload_json, previous_hash, entry_hash FROM ledger ORDER BY sequence ASC" - ).all()).results || []; - return buildReceiptProof(rows, id); - } catch (err) { - return { status: "missing", not_signed: PROOF_NOT_SIGNED, verify_url: "/verify", entries: [], receipt_hash: null, prev_hash: null, seal: null, sealed_at: null, errors: ["ledger read failed"] }; + rows = await receiptLedgerRows(env, id); + } catch { + return { rows: [], proof: { status: "missing", not_signed: PROOF_NOT_SIGNED, verify_url: VERIFY_PATH, entries: [], receipt_hash: null, prev_hash: null, seal: null, sealed_at: null, errors: ["ledger read failed"] } }; } + let head; + try { head = await verifiedHead(env); } catch { head = { ok: false, checkpoint: null, errors: ["ledger head read failed"] }; } + return { rows, proof: buildReceiptProof(head, rows) }; } diff --git a/workers/godlock-uk/src/ui.js b/workers/godlock-uk/src/ui.js index 609b006..7fb1a23 100644 --- a/workers/godlock-uk/src/ui.js +++ b/workers/godlock-uk/src/ui.js @@ -546,7 +546,7 @@ export function threadView(j) { out.receipt.push(["Prev hash", p.prev_hash || "missing"]); out.receipt.push(["Seal (verified ledger head)", st === "verified" ? p.seal : "missing"]); out.receipt.push(["Timestamp", p.timestamp_utc || "missing"]); - out.receipt.push(["Verify", p.verify_url || "/verify"]); + out.receipt.push(["Verify", "/verify"]); return out; } @@ -555,7 +555,7 @@ export function threadExpandScript() { (function(){ var threadView=${threadView.toString()}; function row(box,k,v,mono){var p=document.createElement("p");var b=document.createElement("b");b.textContent=k+": ";p.appendChild(b); - if(k==="Verify"){var a=document.createElement("a");a.href=v;a.textContent="Verify chain";p.appendChild(a);} + if(k==="Verify"){var a=document.createElement("a");a.href="/verify";a.textContent="Verify chain";p.appendChild(a);} else{var s=document.createElement("span");s.textContent=String(v);if(mono)s.className="hash";p.appendChild(s);}box.appendChild(p);} function render(body,j){var m=threadView(j);body.textContent=""; m.reasoning.forEach(function(x){row(body,x[0],x[1],false);}); diff --git a/workers/godlock-uk/thread-expand.test.mjs b/workers/godlock-uk/thread-expand.test.mjs index b0ccde2..7ee05d4 100644 --- a/workers/godlock-uk/thread-expand.test.mjs +++ b/workers/godlock-uk/thread-expand.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { hashPayload, canonicalJson, ZERO } from "./src/ledger.js"; -import { buildReceiptProof } from "./src/receiptProof.js"; +import { receiptLedgerAndProof, verifyForward, _resetProofCache, HEAD_SQL, TAIL_SQL, BY_RECEIPT_SQL } from "./src/receiptProof.js"; import { threadView, threadExpandScript, priorReceiptItems } from "./src/ui.js"; function chain(payloads) { @@ -15,29 +15,87 @@ function chain(payloads) { return rows; } +function fakeDb(rows, log) { + return { prepare(sql) { + let args = []; + const run = () => { + log.push(sql); + if (sql === HEAD_SQL) return rows.length ? rows[rows.length - 1] : null; + if (sql === TAIL_SQL) return rows.filter((r) => r.sequence > args[0]); + if (sql === BY_RECEIPT_SQL) return rows.filter((r) => JSON.parse(r.payload_json).receipt_id === args[0]); + if (sql.startsWith("CREATE INDEX")) return null; + throw new Error("unexpected sql " + sql); + }; + const st = { bind(...a) { args = a; return st; }, async first() { return run(); }, async all() { return { results: run() }; }, async run() { run(); return {}; } }; + return st; + } }; +} + describe("receipt proof", () => { - const rows = chain([{ id: "aaa" }, { id: "bbb111" }, { id: "ccc" }]); - it("verifies and seals at the ledger head", () => { - const p = buildReceiptProof(rows, "bbb111"); - assert.equal(p.status, "verified"); - assert.equal(p.receipt_hash, rows[1].entry_hash); - assert.equal(p.prev_hash, rows[0].entry_hash); - assert.equal(p.seal, rows[2].entry_hash); - assert.match(p.not_signed, /Not signed/); + const payloads = () => [{ receipt_id: "aaa" }, { receipt_id: "bbb111", note: "mentions aaa" }, { receipt_id: "bbb111" }, { receipt_id: "ccc" }]; + it("verifies, seals at the head, exact receipt_id match only", async () => { + _resetProofCache(); + const rows = chain(payloads()); const log = []; + const { rows: mine, proof } = await receiptLedgerAndProof({ DB: fakeDb(rows, log) }, "aaa"); + assert.equal(mine.length, 1, "substring mention in bbb111 must not match"); + assert.equal(proof.status, "verified"); + assert.equal(proof.receipt_hash, rows[0].entry_hash); + assert.equal(proof.prev_hash, ZERO); + assert.equal(proof.seal, rows[3].entry_hash); + assert.match(proof.not_signed, /Not signed/); + assert.ok(!log.some((q) => /LIKE|instr\(/i.test(q))); + }); + it("second request uses the cached checkpoint: probe only, no tail scan", async () => { + _resetProofCache(); + const rows = chain(payloads()); const log = []; + const env = { DB: fakeDb(rows, log) }; + await receiptLedgerAndProof(env, "bbb111"); + log.length = 0; + const { proof } = await receiptLedgerAndProof(env, "bbb111"); + assert.equal(proof.status, "verified"); + assert.equal(log.filter((q) => q === TAIL_SQL).length, 0); + assert.equal(log.filter((q) => q === HEAD_SQL).length, 1); + }); + it("new rows: verifies only from the checkpoint forward", async () => { + _resetProofCache(); + const p = payloads(); const rows = chain(p); const env = { DB: fakeDb(rows, []) }; + await receiptLedgerAndProof(env, "aaa"); + const more = chain(p.concat([{ receipt_id: "ddd" }])); + rows.push(more[4]); + const r = verifyForward({ seq: 4, hash: rows[3].entry_hash, count: 4, at: null }, [rows[4]], rows[4]); + assert.equal(r.ok, true); + const { proof } = await receiptLedgerAndProof(env, "ddd"); + assert.equal(proof.status, "verified"); assert.equal(proof.sealed_seq, 5); }); - it("missing receipt fails closed", () => { - const p = buildReceiptProof(rows, "zzz999"); - assert.equal(p.status, "missing"); assert.equal(p.seal, null); + it("missing receipt fails closed", async () => { + _resetProofCache(); + const { proof } = await receiptLedgerAndProof({ DB: fakeDb(chain(payloads()), []) }, "zzz999"); + assert.equal(proof.status, "missing"); assert.equal(proof.seal, null); }); - it("tampered payload is unverified with no seal", () => { - const bad = rows.map((r) => ({ ...r })); - bad[1].payload_json = canonicalJson({ id: "bbb111", x: 1 }); - const p = buildReceiptProof(bad, "bbb111"); - assert.equal(p.status, "unverified"); assert.equal(p.seal, null); + it("tampered receipt row is unverified with no seal", async () => { + _resetProofCache(); + const rows = chain(payloads()); + rows[1] = { ...rows[1], payload_json: canonicalJson({ receipt_id: "bbb111", x: 1 }) }; + const { proof } = await receiptLedgerAndProof({ DB: fakeDb(rows, []) }, "bbb111"); + assert.equal(proof.status, "unverified"); assert.equal(proof.seal, null); }); - it("tamper elsewhere in the chain also blocks the seal", () => { - const bad = rows.map((r) => ({ ...r })); bad[2].previous_hash = ZERO; - assert.equal(buildReceiptProof(bad, "bbb111").status, "unverified"); + it("partial read (fewer rows than head expects) fails closed", () => { + const rows = chain(payloads()); + const r = verifyForward({ seq: 0, hash: ZERO, count: 0, at: null }, rows.slice(0, 2), rows[3]); + assert.equal(r.ok, false); assert.ok(r.errors.some((e) => /partial read/.test(e))); + }); + it("head rewritten under a cached checkpoint forces full re-verify and fails", async () => { + _resetProofCache(); + const rows = chain(payloads()); const env = { DB: fakeDb(rows, []) }; + await receiptLedgerAndProof(env, "aaa"); + rows[3] = { ...rows[3], entry_hash: "f".repeat(64) }; + const { proof } = await receiptLedgerAndProof(env, "aaa"); + assert.equal(proof.status, "unverified"); assert.equal(proof.seal, null); + }); + it("ledger read failure is missing", async () => { + _resetProofCache(); + const { proof } = await receiptLedgerAndProof({ DB: { prepare() { throw new Error("D1 down"); } } }, "aaa"); + assert.equal(proof.status, "missing"); }); }); @@ -68,4 +126,9 @@ describe("thread expand view", () => { assert.doesNotThrow(() => new Function(js)); assert.doesNotMatch(threadExpandScript(), /signed by|signature/i); }); + it("verify link is hardcoded, never taken from the server", () => { + const v = threadView({ ok: true, receipt: { summary: "S" }, proof: { status: "verified", receipt_hash: "h1", prev_hash: "h0", seal: "h9", verify_url: "javascript:alert(1)" } }); + assert.equal(Object.fromEntries(v.receipt).Verify, "/verify"); + assert.match(threadExpandScript(), /a\.href="\/verify"/); + }); }); From ba03bb06a83c8349651db2b97f1d4965316206ea Mon Sep 17 00:00:00 2001 From: Aziel Eliab Date: Sun, 11 Oct 2026 00:01:33 -0400 Subject: [PATCH 2/2] Receipt proof: match receipt rows to verified per-seq hashes; D1 migration for receipt_id index --- .../0001_ledger_receipt_id_index.sql | 2 ++ workers/godlock-uk/src/receiptProof.js | 31 ++++++++++++++++--- workers/godlock-uk/thread-expand.test.mjs | 20 ++++++++++++ workers/godlock-uk/wrangler.toml | 1 + 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 workers/godlock-uk/migrations/0001_ledger_receipt_id_index.sql diff --git a/workers/godlock-uk/migrations/0001_ledger_receipt_id_index.sql b/workers/godlock-uk/migrations/0001_ledger_receipt_id_index.sql new file mode 100644 index 0000000..09f2754 --- /dev/null +++ b/workers/godlock-uk/migrations/0001_ledger_receipt_id_index.sql @@ -0,0 +1,2 @@ +-- Receipt proof reads a receipt's ledger rows by exact receipt_id (no substring scan). +CREATE INDEX IF NOT EXISTS idx_ledger_receipt_id ON ledger(json_extract(payload_json, '$.receipt_id')); diff --git a/workers/godlock-uk/src/receiptProof.js b/workers/godlock-uk/src/receiptProof.js index 0c46b8b..b89a99c 100644 --- a/workers/godlock-uk/src/receiptProof.js +++ b/workers/godlock-uk/src/receiptProof.js @@ -6,6 +6,10 @@ * and in the Cache API (no KV writes). Each request does a one-row head probe * (ORDER BY sequence DESC LIMIT 1), verifies only rows after the checkpoint, * and reads this receipt's rows by the indexed receipt_id expression. + * The checkpoint keeps the verified entry_hash for every seq (~64 B/row, + * ~4.5 KB today) so receipt rows are matched against verified hashes, not just + * re-hashed in isolation. TODO when the ledger grows large: replace the full + * hash list with a Merkle root plus per-row inclusion paths. * * Fails closed: "verified" only when the chain verifies up to the probed head, * the row count read equals head.seq - checkpoint.seq, and every receipt row @@ -25,7 +29,7 @@ export const HEAD_SQL = "SELECT sequence, entry_hash, timestamp_utc FROM ledger export const TAIL_SQL = "SELECT " + COLS + " FROM ledger WHERE sequence > ? ORDER BY sequence ASC"; export const BY_RECEIPT_SQL = "SELECT " + COLS + " FROM ledger WHERE " + RECEIPT_ID_EXPR + " = ? ORDER BY sequence ASC"; -const GENESIS = { seq: 0, hash: ZERO, count: 0, at: null }; +const GENESIS = { seq: 0, hash: ZERO, count: 0, at: null, hashes: [] }; let isolateCheckpoint = null; let indexEnsured = false; @@ -54,12 +58,14 @@ export function verifyForward(cp, rows, head) { let prev = cp.hash; let seq = cp.seq; let at = cp.at; + const hashes = (cp.hashes || []).slice(); for (const row of list) { const s = Number(row.sequence); if (s !== seq + 1) errors.push("sequence gap at " + s); if (String(row.previous_hash) !== prev) errors.push("previous_hash mismatch at sequence " + s); if (!rowHashOk(row)) errors.push("entry_hash mismatch at sequence " + s); prev = String(row.entry_hash); + hashes.push(prev); seq = s; at = row.timestamp_utc; } @@ -68,11 +74,13 @@ export function verifyForward(cp, rows, head) { if (list.length !== headSeq - cp.seq) errors.push("partial read: got " + list.length + " rows, head expects " + (headSeq - cp.seq)); if (seq !== headSeq || prev !== headHash) errors.push("chain does not end at probed head"); if (errors.length) return { ok: false, errors }; - return { ok: true, errors, checkpoint: { seq: headSeq, hash: headHash, count: headSeq, at } }; + return { ok: true, errors, checkpoint: { seq: headSeq, hash: headHash, count: headSeq, at, hashes } }; } function validCheckpoint(cp) { - return cp && Number.isInteger(cp.seq) && cp.seq >= 0 && typeof cp.hash === "string" && cp.hash.length === 64 && cp.count === cp.seq; + return !!cp && Number.isInteger(cp.seq) && cp.seq >= 0 && typeof cp.hash === "string" && cp.hash.length === 64 + && cp.count === cp.seq && Array.isArray(cp.hashes) && cp.hashes.length === cp.seq + && (cp.seq === 0 || cp.hashes[cp.seq - 1] === cp.hash); } async function loadCheckpoint() { @@ -126,6 +134,19 @@ export async function verifiedHead(env) { return { ok: true, checkpoint: res.checkpoint, errors: [] }; } +/** + * A receipt row counts only if its entry_hash equals the verified hash saved at + * its seq and its previous_hash equals the verified hash at seq-1. This catches + * a row below the checkpoint that was rewritten with a recomputed entry_hash. + */ +export function matchesCheckpoint(cp, r) { + if (!cp || !Array.isArray(cp.hashes)) return false; + const seq = Number(r.sequence); + if (!Number.isInteger(seq) || seq < 1 || seq > cp.hashes.length) return false; + const expectPrev = seq === 1 ? ZERO : cp.hashes[seq - 2]; + return String(r.entry_hash) === cp.hashes[seq - 1] && String(r.previous_hash) === expectPrev; +} + /** Pure. Build the proof from the verified head and this receipt's rows. */ export function buildReceiptProof(headResult, receiptRows) { const base = { not_signed: PROOF_NOT_SIGNED, verify_url: VERIFY_PATH }; @@ -139,13 +160,14 @@ export function buildReceiptProof(headResult, receiptRows) { previous_hash: String(r.previous_hash), hash_ok: rowHashOk(r), in_verified_range: !!cp && Number(r.sequence) <= cp.seq, + matches_checkpoint: matchesCheckpoint(cp, r), })); const errors = (headResult && headResult.errors) ? headResult.errors.slice() : []; if (!entries.length) { return { ...base, entries, status: "missing", receipt_hash: null, prev_hash: null, seal: null, sealed_at: null, errors: errors.concat(["no ledger entry for this receipt"]) }; } const first = entries[0]; - const ok = !!cp && entries.every((e) => e.hash_ok && e.in_verified_range); + const ok = !!cp && entries.every((e) => e.hash_ok && e.in_verified_range && e.matches_checkpoint); if (!ok && !errors.length) errors.push("receipt entry not inside verified chain"); return { ...base, @@ -161,6 +183,7 @@ export function buildReceiptProof(headResult, receiptRows) { }; } +// Fallback only. The real index ships as migrations/0001_ledger_receipt_id_index.sql. async function ensureReceiptIndex(env) { if (indexEnsured) return; await env.DB.prepare(RECEIPT_ID_INDEX_SQL).run(); diff --git a/workers/godlock-uk/thread-expand.test.mjs b/workers/godlock-uk/thread-expand.test.mjs index 7ee05d4..db3a675 100644 --- a/workers/godlock-uk/thread-expand.test.mjs +++ b/workers/godlock-uk/thread-expand.test.mjs @@ -92,6 +92,26 @@ describe("receipt proof", () => { const { proof } = await receiptLedgerAndProof(env, "aaa"); assert.equal(proof.status, "unverified"); assert.equal(proof.seal, null); }); + it("row below the checkpoint rewritten with recomputed hash, head and count unchanged: unverified", async () => { + _resetProofCache(); + const rows = chain(payloads()); const env = { DB: fakeDb(rows, []) }; + const first = await receiptLedgerAndProof(env, "aaa"); + assert.equal(first.proof.status, "verified"); + const forged = { sequence: 1, timestamp_utc: rows[0].timestamp_utc, action: "submit", payload: { receipt_id: "aaa", forged: true }, previous_hash: ZERO }; + rows[0] = { ...forged, payload_json: canonicalJson(forged.payload), entry_hash: hashPayload(forged) }; + const { proof } = await receiptLedgerAndProof(env, "aaa"); + assert.equal(proof.status, "unverified"); assert.equal(proof.seal, null); + }); + it("checkpoint stores a verified hash for every seq", () => { + const rows = chain(payloads()); + const r = verifyForward({ seq: 0, hash: ZERO, count: 0, at: null, hashes: [] }, rows, rows[3]); + assert.deepEqual(r.checkpoint.hashes, rows.map((x) => x.entry_hash)); + }); + it("migration file holds the receipt_id index and wrangler points at it", async () => { + const fs = await import("node:fs"); + assert.match(fs.readFileSync(new URL("./migrations/0001_ledger_receipt_id_index.sql", import.meta.url), "utf8"), /idx_ledger_receipt_id/); + assert.match(fs.readFileSync(new URL("./wrangler.toml", import.meta.url), "utf8"), /migrations_dir = "migrations"/); + }); it("ledger read failure is missing", async () => { _resetProofCache(); const { proof } = await receiptLedgerAndProof({ DB: { prepare() { throw new Error("D1 down"); } } }, "aaa"); diff --git a/workers/godlock-uk/wrangler.toml b/workers/godlock-uk/wrangler.toml index 12f0bd6..e65cd70 100644 --- a/workers/godlock-uk/wrangler.toml +++ b/workers/godlock-uk/wrangler.toml @@ -25,6 +25,7 @@ service = "godlock-download-tracker" binding = "DB" database_name = "godlock-uk" database_id = "a499ef56-ad02-4431-a3e4-e23f96f500f0" +migrations_dir = "migrations" # Reuse the download-tracker DOWNLOADS namespace. Keys are prefixed runtime_uses| # so download stats (project|owner|repo|branch|fork) stay isolated.