| 1 | // 🤖 verify-checkpoints — E2E verifier for the tamper-evidence checkpoint |
| 2 | // chain (docs/tamper-evidence.md) against a live instance. Acts as an |
| 3 | // independent client: mints and claims a member, posts three messages, waits |
| 4 | // for the sweep, then verifies the served chain — Ed25519 signatures, chain |
| 5 | // hashes, linkage from genesis, column/payload consistency, and (when the new |
| 6 | // batch lands in its own checkpoint) a full Merkle-root recomputation from |
| 7 | // the expected journal rows. Needs the instance admin token; posts throwaway |
| 8 | // content, so point it at a canary, never a real member space. |
| 9 | // |
| 10 | // node tools/verify-checkpoints.mjs https://test-<slug>.11ch.at <admin-token> |
| 11 | import { createHash, createPublicKey, verify as edVerify } from "node:crypto"; |
| 12 | const { randomSecret, deriveInvite } = await import(new URL("../web/static/crypto.js", import.meta.url).href); |
| 13 | |
| 14 | const [base, admin] = process.argv.slice(2); |
| 15 | if (!base || !admin) { console.error("usage: node tools/verify-checkpoints.mjs <base-url> <admin-token>"); process.exit(2); } |
| 16 | |
| 17 | const sha256hex = (s) => createHash("sha256").update(s, "utf8").digest("hex"); |
| 18 | const sha256buf = (b) => createHash("sha256").update(b).digest(); |
| 19 | |
| 20 | async function api(path, opts = {}) { |
| 21 | const r = await fetch(base + path, opts); |
| 22 | const body = await r.json().catch(() => ({})); |
| 23 | if (!r.ok) throw new Error(`${path}: HTTP ${r.status} ${JSON.stringify(body)}`); |
| 24 | return body; |
| 25 | } |
| 26 | |
| 27 | // 1. Mint + claim a member. |
| 28 | const T = randomSecret(); |
| 29 | const { id, claimSecret, claimHash } = await deriveInvite(T); |
| 30 | await api("/api/admin/key-invites", { |
| 31 | method: "POST", headers: { Authorization: `Bearer ${admin}` }, |
| 32 | body: JSON.stringify({ label: "CkptTester", id, wrapped: "", claim_hash: claimHash }), |
| 33 | }); |
| 34 | const { token } = await api(`/api/key-invite/${encodeURIComponent(id)}/claim`, { |
| 35 | method: "POST", body: JSON.stringify({ claim_secret: claimSecret }), |
| 36 | }); |
| 37 | const me = await api(`/api/me?token=${encodeURIComponent(token)}`); |
| 38 | const uid = me.user_id || me.id || me.uid; |
| 39 | if (!uid) throw new Error("no uid in /api/me: " + JSON.stringify(me)); |
| 40 | console.log("joined as", uid); |
| 41 | |
| 42 | // Baseline: the journal head already covered by checkpoints (0 on a fresh |
| 43 | // instance), so re-runs against a lived-in canary expect the right seqs. |
| 44 | const before = await api(`/api/checkpoints?token=${encodeURIComponent(token)}`); |
| 45 | const baseSeq = before.checkpoints.length ? before.checkpoints[before.checkpoints.length - 1].seq_to : 0; |
| 46 | console.log("journal head before posting:", baseSeq); |
| 47 | |
| 48 | // 2. A self thread + three messages (blobs are opaque to the server; any |
| 49 | // string stands in for ciphertext). |
| 50 | const th = await api("/api/threads", { |
| 51 | method: "POST", |
| 52 | body: JSON.stringify({ token, kind: "self", name_blob: "", members: [{ user_id: uid, wrapped: "x" }] }), |
| 53 | }); |
| 54 | const threadID = th.id || th.thread_id || (th.thread && th.thread.id); |
| 55 | if (!threadID) throw new Error("no thread id: " + JSON.stringify(th)); |
| 56 | const posted = []; |
| 57 | for (const blob of ["ckpt-blob-1", "ckpt-blob-2", "ckpt-blob-3"]) { |
| 58 | const m = await api(`/api/messages?token=${encodeURIComponent(token)}`, { method: "POST", body: JSON.stringify({ thread_id: threadID, blob }) }); |
| 59 | posted.push({ msgID: m.id, ts: m.created_at, blob }); |
| 60 | } |
| 61 | console.log("posted", posted.map((p) => p.msgID).join(","), "in thread", threadID); |
| 62 | |
| 63 | // 3. Wait for the sweep (5-minute ticker) to fold them into a checkpoint. |
| 64 | let cps = [], pub = ""; |
| 65 | for (let i = 0; i < 24; i++) { |
| 66 | const r = await api(`/api/checkpoints?token=${encodeURIComponent(token)}`); |
| 67 | cps = r.checkpoints; pub = r.public_key; |
| 68 | const covered = cps.length && cps[cps.length - 1].seq_to >= baseSeq + posted.length; |
| 69 | if (covered) break; |
| 70 | process.stdout.write(`waiting for sweep (${i * 20}s, ${cps.length} checkpoints)\r`); |
| 71 | await new Promise((res) => setTimeout(res, 20000)); |
| 72 | } |
| 73 | console.log(`\ngot ${cps.length} checkpoint(s); served pub=${pub.slice(0, 12)}…`); |
| 74 | if (!cps.length) { console.error("FAIL: no checkpoints after ~8 min"); process.exit(1); } |
| 75 | |
| 76 | // 4. Verify: signature, payload self-consistency, chain linkage. |
| 77 | const spki = (raw) => createPublicKey({ |
| 78 | key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]), format: "der", type: "spki", |
| 79 | }); |
| 80 | const pubKey = spki(Buffer.from(pub, "base64")); |
| 81 | const chainHash = (prev, root, seqTo) => |
| 82 | sha256hex(`lchat-ckpt-chain\n${prev}\n${root}\n${seqTo}`); |
| 83 | let prevChain = "", fail = 0; |
| 84 | const payloads = []; |
| 85 | for (const cp of cps) { |
| 86 | const w = JSON.parse(cp.signed); |
| 87 | const ok = edVerify(null, Buffer.from(w.payload, "utf8"), pubKey, Buffer.from(w.sig, "base64")); |
| 88 | const p = JSON.parse(Buffer.from(w.payload, "base64").toString("utf8")); |
| 89 | payloads.push(p); |
| 90 | const checks = { |
| 91 | signature: ok, |
| 92 | "payload names served key": p.pub === pub, |
| 93 | "chain recomputes": chainHash(p.prev_chain, p.root, p.seq_to) === p.chain, |
| 94 | "links to predecessor": p.prev_chain === prevChain, |
| 95 | "columns match payload": cp.seq_from === p.seq_from && cp.seq_to === p.seq_to && cp.root === p.root && cp.chain === p.chain, |
| 96 | }; |
| 97 | for (const [name, v] of Object.entries(checks)) if (!v) { console.error(`FAIL cp#${cp.id}: ${name}`); fail++; } |
| 98 | prevChain = p.chain; |
| 99 | } |
| 100 | |
| 101 | // 5. Recompute the Merkle root from the EXPECTED journal (fresh instance: our |
| 102 | // three messages are journal seqs 1..3, kind 'msg') — the verifier's real check. |
| 103 | const leafHash = (r) => sha256buf(Buffer.from( |
| 104 | `lchat-ckpt-leaf\n${r.seq}\n${r.ts}\nmsg\n${r.msgID}\n${sha256hex(r.blob)}\n${threadID}`, "utf8")); |
| 105 | const node = (l, r) => sha256buf(Buffer.concat([Buffer.from("lchat-ckpt-node\n", "utf8"), l, r])); |
| 106 | const merkle = (ls) => { |
| 107 | if (ls.length === 1) return ls[0]; |
| 108 | let k = 1; while (k * 2 < ls.length) k *= 2; |
| 109 | return node(merkle(ls.slice(0, k)), merkle(ls.slice(k))); |
| 110 | }; |
| 111 | const expectRows = posted.map((p, i) => ({ seq: baseSeq + i + 1, ...p })); |
| 112 | const covering = payloads.find((p) => p.seq_from === baseSeq + 1 && p.seq_to === baseSeq + expectRows.length); |
| 113 | if (!covering) { |
| 114 | console.error(`NOTE: no single checkpoint covers seqs ${baseSeq + 1}..${baseSeq + expectRows.length} (ranges: ${payloads.map((p) => `${p.seq_from}-${p.seq_to}`).join(", ")}) — root recomputation skipped`); |
| 115 | } else if (Buffer.from(covering.root, "hex").equals(merkle(expectRows.map(leafHash)))) { |
| 116 | console.log("root recomputation from expected journal: MATCH"); |
| 117 | } else { console.error("FAIL: recomputed Merkle root does not match signed root"); fail++; } |
| 118 | |
| 119 | console.log(fail ? `E2E FAILED (${fail} checks)` : "E2E PASS: signature, chain, linkage, columns, and Merkle root all verify"); |
| 120 | process.exit(fail ? 1 : 0); |
| 121 | |