// 🤖 verify-checkpoints — E2E verifier for the tamper-evidence checkpoint // chain (docs/tamper-evidence.md) against a live instance. Acts as an // independent client: mints and claims a member, posts three messages, waits // for the sweep, then verifies the served chain — Ed25519 signatures, chain // hashes, linkage from genesis, column/payload consistency, and (when the new // batch lands in its own checkpoint) a full Merkle-root recomputation from // the expected journal rows. Needs the instance admin token; posts throwaway // content, so point it at a canary, never a real member space. // // node tools/verify-checkpoints.mjs https://test-.11ch.at import { createHash, createPublicKey, verify as edVerify } from "node:crypto"; const { randomSecret, deriveInvite } = await import(new URL("../web/static/crypto.js", import.meta.url).href); const [base, admin] = process.argv.slice(2); if (!base || !admin) { console.error("usage: node tools/verify-checkpoints.mjs "); process.exit(2); } const sha256hex = (s) => createHash("sha256").update(s, "utf8").digest("hex"); const sha256buf = (b) => createHash("sha256").update(b).digest(); async function api(path, opts = {}) { const r = await fetch(base + path, opts); const body = await r.json().catch(() => ({})); if (!r.ok) throw new Error(`${path}: HTTP ${r.status} ${JSON.stringify(body)}`); return body; } // 1. Mint + claim a member. const T = randomSecret(); const { id, claimSecret, claimHash } = await deriveInvite(T); await api("/api/admin/key-invites", { method: "POST", headers: { Authorization: `Bearer ${admin}` }, body: JSON.stringify({ label: "CkptTester", id, wrapped: "", claim_hash: claimHash }), }); const { token } = await api(`/api/key-invite/${encodeURIComponent(id)}/claim`, { method: "POST", body: JSON.stringify({ claim_secret: claimSecret }), }); const me = await api(`/api/me?token=${encodeURIComponent(token)}`); const uid = me.user_id || me.id || me.uid; if (!uid) throw new Error("no uid in /api/me: " + JSON.stringify(me)); console.log("joined as", uid); // Baseline: the journal head already covered by checkpoints (0 on a fresh // instance), so re-runs against a lived-in canary expect the right seqs. const before = await api(`/api/checkpoints?token=${encodeURIComponent(token)}`); const baseSeq = before.checkpoints.length ? before.checkpoints[before.checkpoints.length - 1].seq_to : 0; console.log("journal head before posting:", baseSeq); // 2. A self thread + three messages (blobs are opaque to the server; any // string stands in for ciphertext). const th = await api("/api/threads", { method: "POST", body: JSON.stringify({ token, kind: "self", name_blob: "", members: [{ user_id: uid, wrapped: "x" }] }), }); const threadID = th.id || th.thread_id || (th.thread && th.thread.id); if (!threadID) throw new Error("no thread id: " + JSON.stringify(th)); const posted = []; for (const blob of ["ckpt-blob-1", "ckpt-blob-2", "ckpt-blob-3"]) { const m = await api(`/api/messages?token=${encodeURIComponent(token)}`, { method: "POST", body: JSON.stringify({ thread_id: threadID, blob }) }); posted.push({ msgID: m.id, ts: m.created_at, blob }); } console.log("posted", posted.map((p) => p.msgID).join(","), "in thread", threadID); // 3. Wait for the sweep (5-minute ticker) to fold them into a checkpoint. let cps = [], pub = ""; for (let i = 0; i < 24; i++) { const r = await api(`/api/checkpoints?token=${encodeURIComponent(token)}`); cps = r.checkpoints; pub = r.public_key; const covered = cps.length && cps[cps.length - 1].seq_to >= baseSeq + posted.length; if (covered) break; process.stdout.write(`waiting for sweep (${i * 20}s, ${cps.length} checkpoints)\r`); await new Promise((res) => setTimeout(res, 20000)); } console.log(`\ngot ${cps.length} checkpoint(s); served pub=${pub.slice(0, 12)}…`); if (!cps.length) { console.error("FAIL: no checkpoints after ~8 min"); process.exit(1); } // 4. Verify: signature, payload self-consistency, chain linkage. const spki = (raw) => createPublicKey({ key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]), format: "der", type: "spki", }); const pubKey = spki(Buffer.from(pub, "base64")); const chainHash = (prev, root, seqTo) => sha256hex(`lchat-ckpt-chain\n${prev}\n${root}\n${seqTo}`); let prevChain = "", fail = 0; const payloads = []; for (const cp of cps) { const w = JSON.parse(cp.signed); const ok = edVerify(null, Buffer.from(w.payload, "utf8"), pubKey, Buffer.from(w.sig, "base64")); const p = JSON.parse(Buffer.from(w.payload, "base64").toString("utf8")); payloads.push(p); const checks = { signature: ok, "payload names served key": p.pub === pub, "chain recomputes": chainHash(p.prev_chain, p.root, p.seq_to) === p.chain, "links to predecessor": p.prev_chain === prevChain, "columns match payload": cp.seq_from === p.seq_from && cp.seq_to === p.seq_to && cp.root === p.root && cp.chain === p.chain, }; for (const [name, v] of Object.entries(checks)) if (!v) { console.error(`FAIL cp#${cp.id}: ${name}`); fail++; } prevChain = p.chain; } // 5. Recompute the Merkle root from the EXPECTED journal (fresh instance: our // three messages are journal seqs 1..3, kind 'msg') — the verifier's real check. const leafHash = (r) => sha256buf(Buffer.from( `lchat-ckpt-leaf\n${r.seq}\n${r.ts}\nmsg\n${r.msgID}\n${sha256hex(r.blob)}\n${threadID}`, "utf8")); const node = (l, r) => sha256buf(Buffer.concat([Buffer.from("lchat-ckpt-node\n", "utf8"), l, r])); const merkle = (ls) => { if (ls.length === 1) return ls[0]; let k = 1; while (k * 2 < ls.length) k *= 2; return node(merkle(ls.slice(0, k)), merkle(ls.slice(k))); }; const expectRows = posted.map((p, i) => ({ seq: baseSeq + i + 1, ...p })); const covering = payloads.find((p) => p.seq_from === baseSeq + 1 && p.seq_to === baseSeq + expectRows.length); if (!covering) { 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`); } else if (Buffer.from(covering.root, "hex").equals(merkle(expectRows.map(leafHash)))) { console.log("root recomputation from expected journal: MATCH"); } else { console.error("FAIL: recomputed Merkle root does not match signed root"); fail++; } console.log(fail ? `E2E FAILED (${fail} checks)` : "E2E PASS: signature, chain, linkage, columns, and Merkle root all verify"); process.exit(fail ? 1 : 0);