| 1 | // π€ integrity.test.mjs β the client half of the tamper-evidence wire format. |
| 2 | // The chain-hash vector here is the SAME frozen value checkpoint_test.go pins |
| 3 | // (TestCheckpointHashVectors): if either side drifts, web and server disagree |
| 4 | // about what a valid chain looks like β fix the code, not the vector. |
| 5 | import assert from "node:assert/strict"; |
| 6 | import { generateKeyPairSync, sign as edSign } from "node:crypto"; |
| 7 | import { |
| 8 | chainHashHex, ed25519Available, verifyCheckpointPage, gossipVerdict, createIntegrity, AHEAD_GRACE_MS, |
| 9 | leafHashHex, rootFromAuditPath, verifyInclusion, |
| 10 | } from "../web/static/integrity.js"; |
| 11 | |
| 12 | const results = []; |
| 13 | async function test(name, fn) { |
| 14 | try { |
| 15 | await fn(); |
| 16 | results.push(`PASS ${name}`); |
| 17 | } catch (e) { |
| 18 | results.push(`FAIL ${name}: ${e.message}`); |
| 19 | process.exitCode = 1; |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | // ---- test keys + checkpoint builder --------------------------------------- |
| 24 | |
| 25 | const rawPub = (kp) => kp.publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64"); |
| 26 | const keyA = generateKeyPairSync("ed25519"); |
| 27 | const keyB = generateKeyPairSync("ed25519"); // the "rotated to" key |
| 28 | const pubA = rawPub(keyA); |
| 29 | const pubB = rawPub(keyB); |
| 30 | |
| 31 | async function mkCheckpoint({ id, seqFrom, seqTo, prevChain, key = keyA, pub = pubA, tamper = {} }) { |
| 32 | const root = String(seqTo).repeat(4).padEnd(64, "a"); // roots aren't checked client-side yet |
| 33 | const chain = await chainHashHex(prevChain, root, seqTo); |
| 34 | const payload = { seq_from: seqFrom, seq_to: seqTo, root, prev_chain: prevChain, chain, ts: 1700000000, pub, ...tamper.payload }; |
| 35 | const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64"); |
| 36 | const sig = tamper.sig ?? edSign(null, Buffer.from(payloadB64, "utf8"), key.privateKey).toString("base64"); |
| 37 | return { |
| 38 | id, seq_from: payload.seq_from, seq_to: tamper.rowSeqTo ?? payload.seq_to, |
| 39 | root, chain: tamper.rowChain ?? payload.chain, ts: payload.ts, |
| 40 | signed: JSON.stringify({ payload: payloadB64, sig }), |
| 41 | _chain: chain, |
| 42 | }; |
| 43 | } |
| 44 | |
| 45 | // ---- pure core ------------------------------------------------------------ |
| 46 | |
| 47 | await test("chain hash matches the Go frozen vector", async () => { |
| 48 | const got = await chainHashHex("", "7a94be3e3002c1afe379246c90db6fd04fdf438b0dc78da06ca6c3f130a5cec0", 2); |
| 49 | assert.equal(got, "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e"); |
| 50 | }); |
| 51 | |
| 52 | await test("verifies a well-formed two-link page", async () => { |
| 53 | assert.ok(await ed25519Available(), "node WebCrypto should support Ed25519"); |
| 54 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); |
| 55 | const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: c1._chain }); |
| 56 | const out = await verifyCheckpointPage({ checkpoints: [c1, c2], prior: null, checkSigs: true }); |
| 57 | assert.equal(out.alerts.length, 0); |
| 58 | assert.equal(out.fatal, false); |
| 59 | assert.deepEqual(out.head, { id: 2, seqTo: 9, chain: c2._chain, pub: pubA }); |
| 60 | assert.deepEqual(out.hist.map((h) => h[0]), [3, 9]); |
| 61 | }); |
| 62 | |
| 63 | await test("a tampered signature stops the walk", async () => { |
| 64 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "", tamper: { sig: Buffer.alloc(64).toString("base64") } }); |
| 65 | const out = await verifyCheckpointPage({ checkpoints: [c1], prior: null, checkSigs: true }); |
| 66 | assert.equal(out.alerts[0].kind, "bad-signature"); |
| 67 | assert.equal(out.fatal, true); |
| 68 | assert.equal(out.head, null); |
| 69 | }); |
| 70 | |
| 71 | await test("garbage in the sig field alerts, never throws (alarm suppression)", async () => { |
| 72 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "", tamper: { sig: "!!not-base64!!" } }); |
| 73 | const out = await verifyCheckpointPage({ checkpoints: [c1], prior: null, checkSigs: true }); |
| 74 | assert.equal(out.alerts[0].kind, "bad-signature"); |
| 75 | assert.equal(out.fatal, true); |
| 76 | }); |
| 77 | |
| 78 | await test("a key rotation alerts but the walk continues", async () => { |
| 79 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); |
| 80 | const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: c1._chain, key: keyB, pub: pubB }); |
| 81 | const out = await verifyCheckpointPage({ checkpoints: [c1, c2], prior: null, checkSigs: true }); |
| 82 | assert.equal(out.alerts.length, 1); |
| 83 | assert.equal(out.alerts[0].kind, "key-change"); |
| 84 | assert.equal(out.fatal, false); // investigable, not a dead chain |
| 85 | assert.equal(out.head.seqTo, 9); |
| 86 | assert.equal(out.head.pub, pubB); |
| 87 | }); |
| 88 | |
| 89 | await test("a broken link stops the walk", async () => { |
| 90 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); |
| 91 | const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: "f".repeat(64) }); |
| 92 | const out = await verifyCheckpointPage({ checkpoints: [c1, c2], prior: null, checkSigs: false }); |
| 93 | assert.equal(out.alerts[0].kind, "broken-link"); |
| 94 | assert.equal(out.head.seqTo, 3); // c1 stands, c2 rejected |
| 95 | }); |
| 96 | |
| 97 | await test("row columns drifting from the payload stop the walk", async () => { |
| 98 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "", tamper: { rowChain: "0".repeat(64) } }); |
| 99 | const out = await verifyCheckpointPage({ checkpoints: [c1], prior: null, checkSigs: false }); |
| 100 | assert.equal(out.alerts[0].kind, "row-drift"); |
| 101 | }); |
| 102 | |
| 103 | await test("gossip verdicts β boundaries are global, so a missing one is a fork", async () => { |
| 104 | const head = { id: 2, seqTo: 9, chain: "c9" }; |
| 105 | const hist = [[3, "c3"], [9, "c9"]]; |
| 106 | assert.equal(gossipVerdict(head, hist, [9, "c9"]), "match"); |
| 107 | assert.equal(gossipVerdict(head, hist, [9, "XX"]), "fork"); |
| 108 | assert.equal(gossipVerdict(head, hist, [12, "cX"]), "ahead"); |
| 109 | assert.equal(gossipVerdict(head, hist, [3, "c3"]), "match"); |
| 110 | assert.equal(gossipVerdict(head, hist, [3, "XX"]), "fork"); |
| 111 | // Inside our verified window but not one of our boundaries: an honest server |
| 112 | // serves everyone the same checkpoints table, so this is divergence. |
| 113 | assert.equal(gossipVerdict(head, hist, [5, "c5"]), "fork"); |
| 114 | // Below the kept window there is genuinely no verdict. |
| 115 | assert.equal(gossipVerdict(head, hist, [2, "c2"]), "unknown-old"); |
| 116 | assert.equal(gossipVerdict(null, [], [9, "c9"]), "no-head"); |
| 117 | assert.equal(gossipVerdict(head, hist, "junk"), "none"); |
| 118 | }); |
| 119 | |
| 120 | // ---- stateful monitor ------------------------------------------------------ |
| 121 | |
| 122 | function fakeStore() { |
| 123 | const m = new Map(); |
| 124 | return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, v) }; |
| 125 | } |
| 126 | |
| 127 | await test("poll advances the head and gossips it out", async () => { |
| 128 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); |
| 129 | const mon = createIntegrity({ |
| 130 | store: fakeStore(), |
| 131 | fetchPage: async () => ({ checkpoints: [c1], public_key: pubA }), |
| 132 | }); |
| 133 | await mon.poll(); |
| 134 | assert.deepEqual(mon.gossipOut(), [3, c1._chain]); |
| 135 | assert.equal(mon.state().alerts.length, 0); |
| 136 | assert.equal(mon.state().sigOk, true); |
| 137 | }); |
| 138 | |
| 139 | await test("served public_key '' is harmless β the chain is self-describing", async () => { |
| 140 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); |
| 141 | const mon = createIntegrity({ |
| 142 | store: fakeStore(), |
| 143 | fetchPage: async () => ({ checkpoints: [c1], public_key: "" }), |
| 144 | }); |
| 145 | await mon.poll(); |
| 146 | assert.equal(mon.state().alerts.length, 0); |
| 147 | assert.equal(mon.state().head.seqTo, 3); |
| 148 | }); |
| 149 | |
| 150 | await test("a vanished head is a rollback alert", async () => { |
| 151 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); |
| 152 | let serve = [c1]; |
| 153 | const mon = createIntegrity({ |
| 154 | store: fakeStore(), |
| 155 | fetchPage: async () => ({ checkpoints: serve, public_key: pubA }), |
| 156 | }); |
| 157 | await mon.poll(); |
| 158 | serve = []; // the server "forgot" the chain |
| 159 | await mon.poll(); |
| 160 | assert.equal(mon.state().alerts[0].kind, "rollback"); |
| 161 | }); |
| 162 | |
| 163 | await test("ahead gossip: resolves on catch-up, forks on a missing boundary, withholds on timeout", async () => { |
| 164 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); |
| 165 | const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: c1._chain }); |
| 166 | let t = 0; |
| 167 | let serve = [c1]; |
| 168 | const mon = createIntegrity({ |
| 169 | store: fakeStore(), |
| 170 | fetchPage: async (after) => ({ checkpoints: serve.filter((c) => c.id > after), public_key: pubA }), |
| 171 | now: () => t, |
| 172 | }); |
| 173 | await mon.poll(); |
| 174 | // Peer verified c2's head before we saw it β pending, not an alert. |
| 175 | assert.equal(mon.gossipIn([9, c2._chain]), "ahead"); |
| 176 | serve = [c1, c2]; |
| 177 | await mon.poll(); // catches up, pending resolves silently |
| 178 | assert.equal(mon.state().alerts.length, 0); |
| 179 | assert.ok(!mon.state().pending); |
| 180 | // A peer boundary inside our (now caught-up) range that we don't have β |
| 181 | // batching is global, so this is a fork, not a shrug. |
| 182 | assert.equal(mon.gossipIn([5, "peer-chain"]), "fork"); |
| 183 | assert.equal(mon.state().alerts.at(-1).kind, "gossip-fork"); |
| 184 | // A peer ahead with a head the server never shows us β withheld after grace. |
| 185 | assert.equal(mon.gossipIn([20, "x".repeat(64)]), "ahead"); |
| 186 | t += AHEAD_GRACE_MS + 1; |
| 187 | await mon.poll(); |
| 188 | assert.equal(mon.state().alerts.at(-1).kind, "withheld"); |
| 189 | }); |
| 190 | |
| 191 | await test("onAlert fires once per new alert; unresolved/acknowledge drive the banner", async () => { |
| 192 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); |
| 193 | const c2 = await mkCheckpoint({ id: 2, seqFrom: 4, seqTo: 9, prevChain: c1._chain }); |
| 194 | const fired = []; |
| 195 | let serve = [c1]; |
| 196 | const mon = createIntegrity({ |
| 197 | store: fakeStore(), |
| 198 | fetchPage: async (after) => ({ checkpoints: serve.filter((c) => c.id > after), public_key: pubA }), |
| 199 | onAlert: (a) => fired.push(a.kind), |
| 200 | }); |
| 201 | await mon.poll(); |
| 202 | // First forged gossip at our head β one alert, one onAlert, one unresolved. |
| 203 | mon.gossipIn([3, "wrong-a"]); |
| 204 | assert.deepEqual(fired, ["gossip-fork"]); |
| 205 | assert.equal(mon.unresolvedAlerts().length, 1); |
| 206 | // Re-seeing the SAME alert (same seq) must not re-fire or re-raise. |
| 207 | mon.gossipIn([3, "wrong-b"]); |
| 208 | assert.equal(fired.length, 1); |
| 209 | // Acknowledge clears the banner but keeps the record for /integrity. |
| 210 | mon.acknowledge(); |
| 211 | assert.equal(mon.unresolvedAlerts().length, 0); |
| 212 | assert.equal(mon.state().alerts.length, 1); |
| 213 | // A fork at a NEW head (grow the chain, then fork there) re-raises past the |
| 214 | // acknowledgement β a fresh event, not the dismissed one. |
| 215 | serve = [c1, c2]; |
| 216 | await mon.poll(); |
| 217 | mon.gossipIn([9, "wrong-c"]); |
| 218 | assert.equal(mon.unresolvedAlerts().length, 1); |
| 219 | assert.equal(fired.length, 2); |
| 220 | }); |
| 221 | |
| 222 | await test("fork gossip alerts immediately and survives a concurrent poll's save", async () => { |
| 223 | const c1 = await mkCheckpoint({ id: 1, seqFrom: 1, seqTo: 3, prevChain: "" }); |
| 224 | const store = fakeStore(); |
| 225 | let mid = null; // set during the second poll's fetch to interleave a gossip |
| 226 | const mon = createIntegrity({ |
| 227 | store, |
| 228 | fetchPage: async () => { |
| 229 | if (mid) mid(); |
| 230 | return { checkpoints: [c1], public_key: pubA }; |
| 231 | }, |
| 232 | }); |
| 233 | await mon.poll(); |
| 234 | // Second poll: while it's mid-flight (state loaded, fetch awaited), a WS |
| 235 | // message records a fork via gossipIn. The poll's final save must not |
| 236 | // clobber that alert. |
| 237 | mid = () => assert.equal(mon.gossipIn([3, "not-our-chain"]), "fork"); |
| 238 | await mon.poll(); |
| 239 | assert.equal(mon.state().alerts.length, 1); |
| 240 | assert.equal(mon.state().alerts[0].kind, "gossip-fork"); |
| 241 | }); |
| 242 | |
| 243 | // ---- inclusion proofs (cross-platform vectors, generated by checkpoint.go) -- |
| 244 | |
| 245 | await test("leaf hash matches the Go vector", async () => { |
| 246 | const got = await leafHashHex({ seq: 1, ts: 1700000000, kind: "msg", msg_id: 42, thread_id: "th", blob_hash: "ab" }); |
| 247 | assert.equal(got, "e88f7b9ea2a1fdec4ce59c21467efcef2c1b7227b9618d5581c0923328143b15"); |
| 248 | }); |
| 249 | |
| 250 | await test("verifyInclusion reproduces the Go root for a real audit path", async () => { |
| 251 | // The 3-leaf tree from checkpoint_test's vector generator; proof for index 1. |
| 252 | const proof = { |
| 253 | seq: 2, ts: 1700000001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2", |
| 254 | root: "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd", |
| 255 | leaf_index: 1, tree_size: 3, |
| 256 | audit_path: [ |
| 257 | "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e", |
| 258 | "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189", |
| 259 | ], |
| 260 | }; |
| 261 | const { ok, root } = await verifyInclusion(proof); |
| 262 | assert.equal(ok, true); |
| 263 | assert.equal(root, proof.root); |
| 264 | }); |
| 265 | |
| 266 | await test("a tampered proof does not verify", async () => { |
| 267 | const base = { |
| 268 | seq: 2, ts: 1700000001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2", |
| 269 | root: "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd", |
| 270 | leaf_index: 1, tree_size: 3, |
| 271 | audit_path: [ |
| 272 | "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e", |
| 273 | "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189", |
| 274 | ], |
| 275 | }; |
| 276 | // Wrong content (blob_hash) β different leaf β root won't match. |
| 277 | assert.equal((await verifyInclusion({ ...base, blob_hash: "hX" })).ok, false); |
| 278 | // Corrupted sibling β root won't match. |
| 279 | assert.equal((await verifyInclusion({ ...base, audit_path: ["00".repeat(32), base.audit_path[1]] })).ok, false); |
| 280 | // Wrong path length for the tree size β null root. |
| 281 | assert.equal((await rootFromAuditPath("ab".repeat(32), 1, 3, [base.audit_path[0]])), null); |
| 282 | }); |
| 283 | |
| 284 | await test("sampleAndVerify confirms committed messages and flags forged proofs", async () => { |
| 285 | const AUDIT = [ |
| 286 | "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e", |
| 287 | "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189", |
| 288 | ]; |
| 289 | const ROOT = "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd"; |
| 290 | 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 }; |
| 291 | const store = fakeStore(); |
| 292 | // Pre-seed a verified root (as a poll would). |
| 293 | store.setItem("lchat_ckpt", JSON.stringify({ v: 1, head: { id: 1, seqTo: 3, chain: "c" }, hist: [], roots: [ROOT], alerts: [], stats: {} })); |
| 294 | const mon = createIntegrity({ store, fetchPage: async () => ({ checkpoints: [], public_key: "" }) }); |
| 295 | |
| 296 | const r = await mon.sampleAndVerify([{ msgId: 11, blobHash: "h2" }], async () => ({ proofs: [good], committed_to: 5 })); |
| 297 | assert.equal(r.verified, 1); |
| 298 | assert.equal(r.checked, 1); |
| 299 | assert.equal(mon.state().alerts.length, 0); |
| 300 | |
| 301 | // A forged proof (corrupted sibling) against the trusted root β bad-proof alert. |
| 302 | const forged = { ...good, audit_path: ["00".repeat(32), AUDIT[1]] }; |
| 303 | await mon.sampleAndVerify([{ msgId: 11, blobHash: "h2" }], async () => ({ proofs: [forged] })); |
| 304 | assert.ok(mon.state().alerts.some((a) => a.kind === "bad-proof")); |
| 305 | |
| 306 | // A proof for a checkpoint we haven't verified (untrusted root) is skipped, |
| 307 | // not alarmed β the client may just be behind. |
| 308 | const store2 = fakeStore(); |
| 309 | store2.setItem("lchat_ckpt", JSON.stringify({ v: 1, head: null, hist: [], roots: [], alerts: [], stats: {} })); |
| 310 | const mon2 = createIntegrity({ store: store2, fetchPage: async () => ({ checkpoints: [], public_key: "" }) }); |
| 311 | const r2 = await mon2.sampleAndVerify([{ msgId: 11, blobHash: "h2" }], async () => ({ proofs: [good] })); |
| 312 | assert.equal(r2, undefined); // no trusted roots yet β early return |
| 313 | assert.equal(mon2.state().alerts.length, 0); |
| 314 | }); |
| 315 | |
| 316 | await test("recordAlert files a deduped, banner-raising alert (KT key-substitution)", async () => { |
| 317 | const store = fakeStore(); |
| 318 | const raised = []; |
| 319 | const mon = createIntegrity({ store, fetchPage: async () => ({ checkpoints: [], public_key: "" }), onAlert: (a) => raised.push(a.kind) }); |
| 320 | mon.recordAlert("key-substitution", "user-mallory"); |
| 321 | assert.equal(mon.unresolvedAlerts().length, 1); |
| 322 | assert.equal(mon.unresolvedAlerts()[0].kind, "key-substitution"); |
| 323 | assert.deepEqual(raised, ["key-substitution"]); |
| 324 | // Re-checking the SAME user doesn't re-file (keyed by id). |
| 325 | mon.recordAlert("key-substitution", "user-mallory"); |
| 326 | assert.equal(mon.unresolvedAlerts().length, 1); |
| 327 | assert.equal(raised.length, 1); |
| 328 | // A DIFFERENT user is a distinct alert. |
| 329 | mon.recordAlert("key-substitution", "user-eve"); |
| 330 | assert.equal(mon.unresolvedAlerts().length, 2); |
| 331 | }); |
| 332 | |
| 333 | for (const r of results) console.log(r); |
| 334 | if (process.exitCode) console.log("\nSOME TESTS FAILED"); |
| 335 | else console.log("\nALL PASSED"); |
| 336 | |