eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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.
5import assert from "node:assert/strict";
6import { generateKeyPairSync, sign as edSign } from "node:crypto";
7import {
8 chainHashHex, ed25519Available, verifyCheckpointPage, gossipVerdict, createIntegrity, AHEAD_GRACE_MS,
9 leafHashHex, rootFromAuditPath, verifyInclusion,
10} from "../web/static/integrity.js";
11
12const results = [];
13async 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
25const rawPub = (kp) => kp.publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
26const keyA = generateKeyPairSync("ed25519");
27const keyB = generateKeyPairSync("ed25519"); // the "rotated to" key
28const pubA = rawPub(keyA);
29const pubB = rawPub(keyB);
30
31async 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
47await test("chain hash matches the Go frozen vector", async () => {
48 const got = await chainHashHex("", "7a94be3e3002c1afe379246c90db6fd04fdf438b0dc78da06ca6c3f130a5cec0", 2);
49 assert.equal(got, "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e");
50});
51
52await 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
63await 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
71await 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
78await 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
89await 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
97await 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
103await 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
122function fakeStore() {
123 const m = new Map();
124 return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, v) };
125}
126
127await 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
139await 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
150await 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
163await 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
191await 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
222await 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
245await 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
250await 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
266await 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
284await 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
316await 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
333for (const r of results) console.log(r);
334if (process.exitCode) console.log("\nSOME TESTS FAILED");
335else console.log("\nALL PASSED");
336