// 🤖 integrity.test.mjs — the client half of the tamper-evidence wire format. // The chain-hash vector here is the SAME frozen value checkpoint_test.go pins // (TestCheckpointHashVectors): if either side drifts, web and server disagree // about what a valid chain looks like — fix the code, not the vector. import assert from "node:assert/strict"; import { generateKeyPairSync, sign as edSign } from "node:crypto"; import { chainHashHex, ed25519Available, verifyCheckpointPage, gossipVerdict, createIntegrity, AHEAD_GRACE_MS, leafHashHex, rootFromAuditPath, verifyInclusion, } from "../web/static/integrity.js"; const results = []; async function test(name, fn) { try { await fn(); results.push(`PASS ${name}`); } catch (e) { results.push(`FAIL ${name}: ${e.message}`); process.exitCode = 1; } } // ---- test keys + checkpoint builder --------------------------------------- const rawPub = (kp) => kp.publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64"); const keyA = generateKeyPairSync("ed25519"); const keyB = generateKeyPairSync("ed25519"); // the "rotated to" key const pubA = rawPub(keyA); const pubB = rawPub(keyB); async function mkCheckpoint({ id, seqFrom, seqTo, prevChain, key = keyA, pub = pubA, tamper = {} }) { const root = String(seqTo).repeat(4).padEnd(64, "a"); // roots aren't checked client-side yet const chain = await chainHashHex(prevChain, root, seqTo); const payload = { seq_from: seqFrom, seq_to: seqTo, root, prev_chain: prevChain, chain, ts: 1700000000, pub, ...tamper.payload }; const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64"); const sig = tamper.sig ?? edSign(null, Buffer.from(payloadB64, "utf8"), key.privateKey).toString("base64"); return { id, seq_from: payload.seq_from, seq_to: tamper.rowSeqTo ?? payload.seq_to, root, chain: tamper.rowChain ?? payload.chain, ts: payload.ts, signed: JSON.stringify({ payload: payloadB64, sig }), _chain: chain, }; } // ---- pure core ------------------------------------------------------------ await test("chain hash matches the Go frozen vector", async () => { const got = await chainHashHex("", "7a94be3e3002c1afe379246c90db6fd04fdf438b0dc78da06ca6c3f130a5cec0", 2); assert.equal(got, "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e"); }); await test("verifies a well-formed two-link page", async () => { assert.ok(await ed25519Available(), "node WebCrypto should support Ed25519"); const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: c1._chain }); const out = await verifyCheckpointPage({ checkpoints: [c1, c2], prior: null, checkSigs: true }); assert.equal(out.alerts.length, 0); assert.equal(out.fatal, false); assert.deepEqual(out.head, { id: 2, seqTo: 9, chain: c2._chain, pub: pubA }); assert.deepEqual(out.hist.map((h) => h[0]), [3, 9]); }); await test("a tampered signature stops the walk", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "", tamper: { sig: Buffer.alloc(64).toString("base64") } }); const out = await verifyCheckpointPage({ checkpoints: [c1], prior: null, checkSigs: true }); assert.equal(out.alerts[0].kind, "bad-signature"); assert.equal(out.fatal, true); assert.equal(out.head, null); }); await test("garbage in the sig field alerts, never throws (alarm suppression)", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "", tamper: { sig: "!!not-base64!!" } }); const out = await verifyCheckpointPage({ checkpoints: [c1], prior: null, checkSigs: true }); assert.equal(out.alerts[0].kind, "bad-signature"); assert.equal(out.fatal, true); }); await test("a key rotation alerts but the walk continues", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: c1._chain, key: keyB, pub: pubB }); const out = await verifyCheckpointPage({ checkpoints: [c1, c2], prior: null, checkSigs: true }); assert.equal(out.alerts.length, 1); assert.equal(out.alerts[0].kind, "key-change"); assert.equal(out.fatal, false); // investigable, not a dead chain assert.equal(out.head.seqTo, 9); assert.equal(out.head.pub, pubB); }); await test("a broken link stops the walk", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: "f".repeat(64) }); const out = await verifyCheckpointPage({ checkpoints: [c1, c2], prior: null, checkSigs: false }); assert.equal(out.alerts[0].kind, "broken-link"); assert.equal(out.head.seqTo, 3); // c1 stands, c2 rejected }); await test("row columns drifting from the payload stop the walk", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "", tamper: { rowChain: "0".repeat(64) } }); const out = await verifyCheckpointPage({ checkpoints: [c1], prior: null, checkSigs: false }); assert.equal(out.alerts[0].kind, "row-drift"); }); await test("gossip verdicts — boundaries are global, so a missing one is a fork", async () => { const head = { id: 2, seqTo: 9, chain: "c9" }; const hist = [[3, "c3"], [9, "c9"]]; assert.equal(gossipVerdict(head, hist, [9, "c9"]), "match"); assert.equal(gossipVerdict(head, hist, [9, "XX"]), "fork"); assert.equal(gossipVerdict(head, hist, [12, "cX"]), "ahead"); assert.equal(gossipVerdict(head, hist, [3, "c3"]), "match"); assert.equal(gossipVerdict(head, hist, [3, "XX"]), "fork"); // Inside our verified window but not one of our boundaries: an honest server // serves everyone the same checkpoints table, so this is divergence. assert.equal(gossipVerdict(head, hist, [5, "c5"]), "fork"); // Below the kept window there is genuinely no verdict. assert.equal(gossipVerdict(head, hist, [2, "c2"]), "unknown-old"); assert.equal(gossipVerdict(null, [], [9, "c9"]), "no-head"); assert.equal(gossipVerdict(head, hist, "junk"), "none"); }); // ---- stateful monitor ------------------------------------------------------ function fakeStore() { const m = new Map(); return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, v) }; } await test("poll advances the head and gossips it out", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); const mon = createIntegrity({ store: fakeStore(), fetchPage: async () => ({ checkpoints: [c1], public_key: pubA }), }); await mon.poll(); assert.deepEqual(mon.gossipOut(), [3, c1._chain]); assert.equal(mon.state().alerts.length, 0); assert.equal(mon.state().sigOk, true); }); await test("served public_key '' is harmless — the chain is self-describing", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); const mon = createIntegrity({ store: fakeStore(), fetchPage: async () => ({ checkpoints: [c1], public_key: "" }), }); await mon.poll(); assert.equal(mon.state().alerts.length, 0); assert.equal(mon.state().head.seqTo, 3); }); await test("a vanished head is a rollback alert", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); let serve = [c1]; const mon = createIntegrity({ store: fakeStore(), fetchPage: async () => ({ checkpoints: serve, public_key: pubA }), }); await mon.poll(); serve = []; // the server "forgot" the chain await mon.poll(); assert.equal(mon.state().alerts[0].kind, "rollback"); }); await test("ahead gossip: resolves on catch-up, forks on a missing boundary, withholds on timeout", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: c1._chain }); let t = 0; let serve = [c1]; const mon = createIntegrity({ store: fakeStore(), fetchPage: async (after) => ({ checkpoints: serve.filter((c) => c.id > after), public_key: pubA }), now: () => t, }); await mon.poll(); // Peer verified c2's head before we saw it → pending, not an alert. assert.equal(mon.gossipIn([9, c2._chain]), "ahead"); serve = [c1, c2]; await mon.poll(); // catches up, pending resolves silently assert.equal(mon.state().alerts.length, 0); assert.ok(!mon.state().pending); // A peer boundary inside our (now caught-up) range that we don't have — // batching is global, so this is a fork, not a shrug. assert.equal(mon.gossipIn([5, "peer-chain"]), "fork"); assert.equal(mon.state().alerts.at(-1).kind, "gossip-fork"); // A peer ahead with a head the server never shows us → withheld after grace. assert.equal(mon.gossipIn([20, "x".repeat(64)]), "ahead"); t += AHEAD_GRACE_MS + 1; await mon.poll(); assert.equal(mon.state().alerts.at(-1).kind, "withheld"); }); await test("onAlert fires once per new alert; unresolved/acknowledge drive the banner", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: c1._chain }); const fired = []; let serve = [c1]; const mon = createIntegrity({ store: fakeStore(), fetchPage: async (after) => ({ checkpoints: serve.filter((c) => c.id > after), public_key: pubA }), onAlert: (a) => fired.push(a.kind), }); await mon.poll(); // First forged gossip at our head → one alert, one onAlert, one unresolved. mon.gossipIn([3, "wrong-a"]); assert.deepEqual(fired, ["gossip-fork"]); assert.equal(mon.unresolvedAlerts().length, 1); // Re-seeing the SAME alert (same seq) must not re-fire or re-raise. mon.gossipIn([3, "wrong-b"]); assert.equal(fired.length, 1); // Acknowledge clears the banner but keeps the record for /integrity. mon.acknowledge(); assert.equal(mon.unresolvedAlerts().length, 0); assert.equal(mon.state().alerts.length, 1); // A fork at a NEW head (grow the chain, then fork there) re-raises past the // acknowledgement — a fresh event, not the dismissed one. serve = [c1, c2]; await mon.poll(); mon.gossipIn([9, "wrong-c"]); assert.equal(mon.unresolvedAlerts().length, 1); assert.equal(fired.length, 2); }); await test("fork gossip alerts immediately and survives a concurrent poll's save", async () => { const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); const store = fakeStore(); let mid = null; // set during the second poll's fetch to interleave a gossip const mon = createIntegrity({ store, fetchPage: async () => { if (mid) mid(); return { checkpoints: [c1], public_key: pubA }; }, }); await mon.poll(); // Second poll: while it's mid-flight (state loaded, fetch awaited), a WS // message records a fork via gossipIn. The poll's final save must not // clobber that alert. mid = () => assert.equal(mon.gossipIn([3, "not-our-chain"]), "fork"); await mon.poll(); assert.equal(mon.state().alerts.length, 1); assert.equal(mon.state().alerts[0].kind, "gossip-fork"); }); // ---- inclusion proofs (cross-platform vectors, generated by checkpoint.go) -- await test("leaf hash matches the Go vector", async () => { const got = await leafHashHex({ seq: 1, ts: 1700000000, kind: "msg", msg_id: 42, thread_id: "th", blob_hash: "ab" }); assert.equal(got, "e88f7b9ea2a1fdec4ce59c21467efcef2c1b7227b9618d5581c0923328143b15"); }); await test("verifyInclusion reproduces the Go root for a real audit path", async () => { // The 3-leaf tree from checkpoint_test's vector generator; proof for index 1. const proof = { seq: 2, ts: 1700000001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2", root: "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd", leaf_index: 1, tree_size: 3, audit_path: [ "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e", "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189", ], }; const { ok, root } = await verifyInclusion(proof); assert.equal(ok, true); assert.equal(root, proof.root); }); await test("a tampered proof does not verify", async () => { const base = { seq: 2, ts: 1700000001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2", root: "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd", leaf_index: 1, tree_size: 3, audit_path: [ "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e", "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189", ], }; // Wrong content (blob_hash) → different leaf → root won't match. assert.equal((await verifyInclusion({ ...base, blob_hash: "hX" })).ok, false); // Corrupted sibling → root won't match. assert.equal((await verifyInclusion({ ...base, audit_path: ["00".repeat(32), base.audit_path[1]] })).ok, false); // Wrong path length for the tree size → null root. assert.equal((await rootFromAuditPath("ab".repeat(32), 1, 3, [base.audit_path[0]])), null); }); await test("sampleAndVerify confirms committed messages and flags forged proofs", async () => { const AUDIT = [ "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e", "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189", ]; const ROOT = "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd"; const good = { seq: 2, ts: 1700000001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2", root: ROOT, leaf_index: 1, tree_size: 3, audit_path: AUDIT }; const store = fakeStore(); // Pre-seed a verified root (as a poll would). store.setItem("lchat_ckpt", JSON.stringify({ v: 1, head: { id: 1, seqTo: 3, chain: "c" }, hist: [], roots: [ROOT], alerts: [], stats: {} })); const mon = createIntegrity({ store, fetchPage: async () => ({ checkpoints: [], public_key: "" }) }); const r = await mon.sampleAndVerify([{ msgId: 11, blobHash: "h2" }], async () => ({ proofs: [good], committed_to: 5 })); assert.equal(r.verified, 1); assert.equal(r.checked, 1); assert.equal(mon.state().alerts.length, 0); // A forged proof (corrupted sibling) against the trusted root → bad-proof alert. const forged = { ...good, audit_path: ["00".repeat(32), AUDIT[1]] }; await mon.sampleAndVerify([{ msgId: 11, blobHash: "h2" }], async () => ({ proofs: [forged] })); assert.ok(mon.state().alerts.some((a) => a.kind === "bad-proof")); // A proof for a checkpoint we haven't verified (untrusted root) is skipped, // not alarmed — the client may just be behind. const store2 = fakeStore(); store2.setItem("lchat_ckpt", JSON.stringify({ v: 1, head: null, hist: [], roots: [], alerts: [], stats: {} })); const mon2 = createIntegrity({ store: store2, fetchPage: async () => ({ checkpoints: [], public_key: "" }) }); const r2 = await mon2.sampleAndVerify([{ msgId: 11, blobHash: "h2" }], async () => ({ proofs: [good] })); assert.equal(r2, undefined); // no trusted roots yet → early return assert.equal(mon2.state().alerts.length, 0); }); await test("recordAlert files a deduped, banner-raising alert (KT key-substitution)", async () => { const store = fakeStore(); const raised = []; const mon = createIntegrity({ store, fetchPage: async () => ({ checkpoints: [], public_key: "" }), onAlert: (a) => raised.push(a.kind) }); mon.recordAlert("key-substitution", "user-mallory"); assert.equal(mon.unresolvedAlerts().length, 1); assert.equal(mon.unresolvedAlerts()[0].kind, "key-substitution"); assert.deepEqual(raised, ["key-substitution"]); // Re-checking the SAME user doesn't re-file (keyed by id). mon.recordAlert("key-substitution", "user-mallory"); assert.equal(mon.unresolvedAlerts().length, 1); assert.equal(raised.length, 1); // A DIFFERENT user is a distinct alert. mon.recordAlert("key-substitution", "user-eve"); assert.equal(mon.unresolvedAlerts().length, 2); }); for (const r of results) console.log(r); if (process.exitCode) console.log("\nSOME TESTS FAILED"); else console.log("\nALL PASSED");