web: verify the checkpoint chain + gossip heads in E2EE traffic (#573 slice 2) (#775)
* web: verify the checkpoint chain + gossip heads in E2EE traffic (#573 slice 2) integrity.js verifies the signed checkpoint chain client-side (Ed25519 where WebCrypto has it, chain hashes + linkage always; rollback = a served chain that lost our verified head). The verified head rides outgoing encrypted payloads as _ckpt — the server can't read it, so it can't selectively strip it — and incoming gossip is compared against our own view: same seq + different chain = fork; a peer's head the server won't show us within a grace period = withholding. Detection only: alerts surface via the /integrity command (first floor command), no banner UX yet. Web-only by design this slice — native tolerates the field (Codable drops unknown keys) and follows next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: review fixes — alarm-proof verification, boundary forks, alert durability Adversarial review pass (2 finders) caught and fixed: - malformed sig/base64 now alerts instead of throwing (a garbled row could silently freeze every member's monitor — alarm suppression) - the chain is self-describing: each payload verifies under its own pub; a mid-chain key change alerts but the walk continues (rotation no longer bricks fresh devices; served public_key '' is harmless) - boundary-batching evasion closed: checkpoint boundaries are global, so a peer boundary inside our verified window that we lack is a fork - gossip is compared on EVERY decrypt path (history, catch-up, seed, previews — the host chooses the path, so live-only checking was bypassable) and _ckpt is stripped before folds/snapshots/sandbox - alerts persist via fresh read-modify-write; poll's save merges so a concurrent tab/gossip can't clobber the record; withheld only fires when catch-up is complete (no false alarm for far-behind devices); own echoes compare but don't count as peers; /integrity reply no longer auto-collapses; lastPollAt always updates - _ckpt tolerance vector added to test/event-vectors/chat.json; changelog held until native support lands (the copy would have overpromised and /integrity would post as plaintext from native) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: failure banner — a tamper alert comes to you, not just /integrity Paul's call: /integrity is fine as a pulled self-check for 'all clear', but a FAILURE must surface unprompted. A full-width top band appears only on an unresolved integrity alert (fork/rollback/withheld/bad-sig), plain-language per kind, with 'What's this?' (runs /integrity in place) and 'Dismiss' (acknowledges — a later, different alert re-raises). Inline band (no floating overlay, per the toast rule), amber + ⚠️ (never colour- alone), light/dark, keyboard-focusable, role=alert. onAlert hook fires once per genuinely-new alert; boot re-shows a past session's unresolved alert. Shell layout gains a .tt-body row so the band sits above it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+898
−19
package.json+1 −1test/event-vectors/chat.json+105 −12test/integrity.test.mjs+244 −0web/pages/index.html+6 −0web/static/integrity.js+338 −0web/static/shell.js+155 −5web/static/style.css+49 −1
diff --git a/package.json b/package.json| index ca5f792..64b91eb 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs" |
| } |
| } |
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json| index 58aa825..c098221 100644 |
| --- a/test/event-vectors/chat.json |
| +++ b/test/event-vectors/chat.json |
| @@ -159,31 +159,90 @@ |
| "note": "Multi-message vector: fold the sequence, then derive the thread rollup (web viewItems replyRoot/replyCount in chat.js; native ChatFeedModel.threadRoots/refreshReplyCounts). Message 102 is a LEGACY chain — it quotes reply 101, not the root — and must still resolve to root 100.", |
| "message_sequence": [ |
| { |
| - "server_message": { "id": 100, "created_at": 1700000000 }, |
| - "decrypted_payload": { "name": "Alice", "text": "Hello everyone", "ts": 1700000000000, "uid": "user-alice-1" } |
| + "server_message": { |
| + "id": 100, |
| + "created_at": 1700000000 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Alice", |
| + "text": "Hello everyone", |
| + "ts": 1700000000000, |
| + "uid": "user-alice-1" |
| + } |
| }, |
| { |
| - "server_message": { "id": 101, "created_at": 1700000001 }, |
| - "decrypted_payload": { "name": "Bob", "text": "Hi Alice", "ts": 1700000001000, "uid": "user-bob-1", "replyTo": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } } |
| + "server_message": { |
| + "id": 101, |
| + "created_at": 1700000001 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Bob", |
| + "text": "Hi Alice", |
| + "ts": 1700000001000, |
| + "uid": "user-bob-1", |
| + "replyTo": { |
| + "id": 100, |
| + "name": "Alice", |
| + "snippet": "Hello everyone" |
| + } |
| + } |
| }, |
| { |
| - "server_message": { "id": 102, "created_at": 1700000002 }, |
| - "decrypted_payload": { "name": "Charlie", "text": "Welcome!", "ts": 1700000002000, "uid": "user-charlie-1", "replyTo": { "id": 101, "name": "Bob", "snippet": "Hi Alice" } } |
| + "server_message": { |
| + "id": 102, |
| + "created_at": 1700000002 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Charlie", |
| + "text": "Welcome!", |
| + "ts": 1700000002000, |
| + "uid": "user-charlie-1", |
| + "replyTo": { |
| + "id": 101, |
| + "name": "Bob", |
| + "snippet": "Hi Alice" |
| + } |
| + } |
| }, |
| { |
| - "server_message": { "id": 103, "created_at": 1700000003 }, |
| - "decrypted_payload": { "name": "Dana", "text": "Good to be here", "ts": 1700000003000, "uid": "user-dana-1", "replyTo": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } } |
| + "server_message": { |
| + "id": 103, |
| + "created_at": 1700000003 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Dana", |
| + "text": "Good to be here", |
| + "ts": 1700000003000, |
| + "uid": "user-dana-1", |
| + "replyTo": { |
| + "id": 100, |
| + "name": "Alice", |
| + "snippet": "Hello everyone" |
| + } |
| + } |
| } |
| ], |
| "expected_threads": { |
| - "roots": { "101": 100, "102": 100, "103": 100 }, |
| - "reply_counts": { "100": 3 }, |
| - "first_reply": { "100": 101 } |
| + "roots": { |
| + "101": 100, |
| + "102": 100, |
| + "103": 100 |
| + }, |
| + "reply_counts": { |
| + "100": 3 |
| + }, |
| + "first_reply": { |
| + "100": 101 |
| + } |
| }, |
| "expected_reply_target": { |
| "note": "Replying to ANY message in the thread (here the legacy chain reply 102) stages the root's id with a fresh snapshot of the root's name/text.", |
| "for_message": 102, |
| - "replyTo": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } |
| + "replyTo": { |
| + "id": 100, |
| + "name": "Alice", |
| + "snippet": "Hello everyone" |
| + } |
| } |
| }, |
| { |
| @@ -403,6 +462,40 @@ |
| "attachments": [] |
| }, |
| "note": "Both implementations should treat missing uid as valid and fall back to name-based matching for reply attribution." |
| + }, |
| + { |
| + "name": "message_with_transport_gossip", |
| + "description": "A message whose payload carries the host-level _ckpt tamper-evidence gossip field (docs/tamper-evidence.md). _ckpt is transport metadata injected/stripped by the web host: folds must drop it (never render or store it), and native decode must tolerate and ignore it — it is not app or chat content and must never be re-emitted.", |
| + "note": "Added with #573 slice 2. The web host strips _ckpt before folding; this vector pins that even an unstripped payload folds without it (the reduce allow-lists fields). Native: Codable ignores unknown keys — this vector documents that as contract, not accident.", |
| + "decrypted_payload": { |
| + "name": "Alice", |
| + "text": "Hello with gossip", |
| + "ts": 1700000000000, |
| + "uid": "user-alice-1", |
| + "_ckpt": [ |
| + 9, |
| + "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e" |
| + ] |
| + }, |
| + "server_message": { |
| + "id": 12399, |
| + "created_at": 1700000000, |
| + "blob": "encrypted(decrypted_payload)" |
| + }, |
| + "expected_fold_event": { |
| + "type": "chat.msg", |
| + "name": "Alice", |
| + "text": "Hello with gossip", |
| + "ts": 1700000000000, |
| + "uid": "user-alice-1", |
| + "attachments": [] |
| + }, |
| + "expected_native_fields": { |
| + "name": "Alice", |
| + "text": "Hello with gossip", |
| + "uid": "user-alice-1", |
| + "isRenderableChat": true |
| + } |
| } |
| ] |
| } |
diff --git a/test/integrity.test.mjs b/test/integrity.test.mjs| new file mode 100644 |
| index 0000000..e7afd52 |
| --- /dev/null |
| +++ b/test/integrity.test.mjs |
| @@ -0,0 +1,244 @@ |
| +// 🤖 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, |
| +} 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"); |
| +}); |
| + |
| +for (const r of results) console.log(r); |
| +if (process.exitCode) console.log("\nSOME TESTS FAILED"); |
| +else console.log("\nALL PASSED"); |
diff --git a/web/pages/index.html b/web/pages/index.html| index 80057c3..45b4bac 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -111,6 +111,11 @@ |
| <template id="app-tpl"> |
| <div class="tt" id="tt"> |
| <div class="conn-status hidden" id="conn-status" role="status" aria-live="polite">Reconnecting…</div> |
| + <!-- Tamper-evidence failure banner (docs/tamper-evidence.md): a full-width |
| + top band shown ONLY when this space fails an integrity check. Inline |
| + (occupies layout, never overlays); contents built in shell.js. --> |
| + <div class="integrity-banner hidden" id="integrity-banner" role="alert"></div> |
| + <div class="tt-body"> |
| <aside class="sidebar"> |
| <header class="side-head"> |
| <button class="avatar-btn" id="me-btn" aria-label="Edit profile" title="Edit profile"> |
| @@ -236,6 +241,7 @@ |
| </form> |
| </div> |
| </section> |
| + </div> |
| </div> |
| </template> |
diff --git a/web/static/integrity.js b/web/static/integrity.js| new file mode 100644 |
| index 0000000..418da62 |
| --- /dev/null |
| +++ b/web/static/integrity.js |
| @@ -0,0 +1,338 @@ |
| +// 🤖 integrity.js — client-side tamper-evidence: verify the instance's signed |
| +// checkpoint chain and gossip chain heads between members inside E2EE traffic |
| +// (docs/tamper-evidence.md, #573 slice 2). |
| +// |
| +// The shell polls GET /api/checkpoints, verifies each new checkpoint (Ed25519 |
| +// signature where WebCrypto supports it, chain-hash recomputation and linkage |
| +// always), and remembers the verified head. Outgoing messages carry that head |
| +// as a tiny `_ckpt` field inside the encrypted payload — the host can't read |
| +// it, so it can't selectively strip it — and incoming gossip (live AND from |
| +// history/catch-up, since the host chooses which path a message arrives by) |
| +// is compared against our own view. An honest server serves ONE checkpoints |
| +// table to every member, so checkpoint boundaries are global: a peer boundary |
| +// inside our verified window that we don't have is itself a fork, the same as |
| +// a mismatched chain at a shared boundary. A peer's head the server won't |
| +// show us within a grace period is withholding. Detection only in this slice — |
| +// alerts are recorded and surfaced via /integrity, never a scary banner (that |
| +// UX needs its own design pass). |
| +// |
| +// Every verification failure must END as a recorded alert, never a thrown |
| +// exception — a monitor that dies quietly on malformed input is an alarm- |
| +// suppression vector (serve one garbled row, freeze every member's monitor). |
| +// |
| +// Pure functions up top (unit-tested in Node, test/integrity.test.mjs); the |
| +// stateful monitor below takes storage/fetch as plain objects so tests can |
| +// inject fakes. Wire-format constants mirror checkpoint.go — the Go test |
| +// vectors pin them on both sides. |
| + |
| +const CHAIN_DOMAIN = "lchat-ckpt-chain\n"; |
| + |
| +// How long a gossiped head may stay ahead of everything the server shows us |
| +// before we call it withholding. Generous: the sweep runs every 5 minutes. |
| +export const AHEAD_GRACE_MS = 15 * 60 * 1000; |
| + |
| +const HIST_KEEP = 64; // (seqTo -> chain) boundary pairs kept for old gossip |
| +const ALERTS_KEEP = 20; |
| +const PAGE_SIZE = 100; // server page size (checkpointsAfter limit) |
| + |
| +async function sha256hex(str) { |
| + const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)); |
| + return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join(""); |
| +} |
| + |
| +// chainHashHex mirrors checkpoint.go's chainHash — a checkpoint's commitment |
| +// to its predecessor. prev is "" for the genesis checkpoint. |
| +export function chainHashHex(prev, root, seqTo) { |
| + return sha256hex(CHAIN_DOMAIN + prev + "\n" + root + "\n" + String(seqTo)); |
| +} |
| + |
| +function stdB64ToBytes(b64) { |
| + const bin = atob(b64); |
| + const out = new Uint8Array(bin.length); |
| + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| + return out; |
| +} |
| + |
| +// ed25519Verify checks one signature, returning false (never throwing) on any |
| +// malformed input. Returns null when this environment's WebCrypto lacks |
| +// Ed25519 — hash and linkage checks still run, signatures are recorded as |
| +// unverified (see /integrity's report). Keys are cached per pub so a page of |
| +// checkpoints doesn't re-import per row. |
| +const keyCache = new Map(); |
| +export async function ed25519Available() { |
| + try { |
| + await crypto.subtle.importKey("raw", new Uint8Array(32), { name: "Ed25519" }, false, ["verify"]); |
| + return true; |
| + } catch { |
| + return false; |
| + } |
| +} |
| +async function ed25519Verify(pubB64, payloadStr, sigB64) { |
| + try { |
| + let key = keyCache.get(pubB64); |
| + if (!key) { |
| + key = await crypto.subtle.importKey("raw", stdB64ToBytes(pubB64), { name: "Ed25519" }, false, ["verify"]); |
| + keyCache.set(pubB64, key); |
| + } |
| + return await crypto.subtle.verify("Ed25519", key, stdB64ToBytes(sigB64), new TextEncoder().encode(payloadStr)); |
| + } catch { |
| + return false; |
| + } |
| +} |
| + |
| +// verifyCheckpointPage walks one page of served checkpoints, extending prior |
| +// (the last verified head, or null on first sync). Each checkpoint's payload |
| +// names its own signing key: the chain of hashes is what holds history |
| +// together, the signature is attribution — so a mid-chain key change is |
| +// alerted (it's investigable: a restore, a rotation, or an attack) but the |
| +// walk CONTINUES; only broken hashes/links/signatures stop it. Returns the |
| +// new head, the verified (seqTo -> chain) boundary pairs, alerts, and whether |
| +// the walk completed the page. |
| +export async function verifyCheckpointPage({ checkpoints, prior, checkSigs }) { |
| + const alerts = []; |
| + const hist = []; |
| + let head = prior || null; |
| + let pub = prior ? prior.pub : null; |
| + for (const cp of checkpoints) { |
| + let payload; |
| + try { |
| + const wrapper = JSON.parse(cp.signed); |
| + payload = JSON.parse(new TextDecoder().decode(stdB64ToBytes(wrapper.payload))); |
| + if (typeof payload.pub !== "string" || !payload.pub) throw new Error("no pub"); |
| + if (checkSigs && !(await ed25519Verify(payload.pub, wrapper.payload, wrapper.sig))) { |
| + alerts.push({ kind: "bad-signature", id: cp.id }); |
| + break; |
| + } |
| + } catch { |
| + alerts.push({ kind: "malformed", id: cp.id }); |
| + break; |
| + } |
| + if ((await chainHashHex(payload.prev_chain, payload.root, payload.seq_to)) !== payload.chain) { |
| + alerts.push({ kind: "bad-chain-hash", id: cp.id }); |
| + break; |
| + } |
| + if (payload.prev_chain !== (head ? head.chain : "")) { |
| + alerts.push({ kind: "broken-link", id: cp.id, seqTo: payload.seq_to }); |
| + break; |
| + } |
| + if (head && payload.seq_from !== head.seqTo + 1) { |
| + alerts.push({ kind: "gap", id: cp.id, seqFrom: payload.seq_from, after: head.seqTo }); |
| + break; |
| + } |
| + if (cp.seq_to !== payload.seq_to || cp.chain !== payload.chain) { |
| + alerts.push({ kind: "row-drift", id: cp.id }); |
| + break; |
| + } |
| + if (pub && payload.pub !== pub) { |
| + // Legitimate-but-investigable (restore from a pre-key backup, deliberate |
| + // rotation) — alert once, keep verifying under the new key. |
| + alerts.push({ kind: "key-change", id: cp.id, seqTo: payload.seq_to, keep: true }); |
| + } |
| + pub = payload.pub; |
| + head = { id: cp.id, seqTo: payload.seq_to, chain: payload.chain, pub }; |
| + hist.push([payload.seq_to, payload.chain]); |
| + } |
| + const fatal = alerts.some((a) => !a.keep); |
| + return { head, hist, alerts, fatal }; |
| +} |
| + |
| +// gossipVerdict compares a peer's gossiped head [seqTo, chain] against our |
| +// verified head + bounded boundary history. Checkpoint boundaries are global |
| +// (one checkpoints table per instance), so within our kept window a peer |
| +// boundary we don't have at all is a fork, exactly like a chain mismatch at a |
| +// shared boundary. Below the window there's genuinely no verdict. |
| +export function gossipVerdict(head, hist, gossip) { |
| + if (!Array.isArray(gossip) || gossip.length !== 2) return "none"; |
| + const [s, c] = gossip; |
| + if (typeof s !== "number" || typeof c !== "string") return "none"; |
| + if (!head) return "no-head"; |
| + if (s > head.seqTo) return "ahead"; |
| + if (s === head.seqTo) return c === head.chain ? "match" : "fork"; |
| + const floor = hist.length ? hist[0][0] : head.seqTo; |
| + if (s < floor) return "unknown-old"; |
| + const ours = hist.find((h) => h[0] === s); |
| + if (!ours) return "fork"; // inside our window but not one of our boundaries |
| + return ours[1] === c ? "match" : "fork"; |
| +} |
| + |
| +// createIntegrity wires the pure core to storage + network. `store` is any |
| +// object with getItem/setItem (localStorage in the shell, a Map-backed fake |
| +// in tests); `fetchPage(afterID)` returns the parsed /api/checkpoints |
| +// response; `now()` defaults to Date.now. The shell calls poll() on connect + |
| +// a slow interval, gossipOut() when sending, gossipIn() when receiving |
| +// (live and history alike). |
| +// |
| +// Concurrency: several tabs/webviews share the storage key, and gossipIn can |
| +// interleave with a long-running poll in the same tab. Alerts — the part that |
| +// must never be lost — are appended with a fresh read-modify-write at record |
| +// time; poll's final save re-reads and merges so it can't clobber alerts or a |
| +// newer pending recorded mid-flight. head/hist are last-writer-wins on |
| +// purpose: every writer verified them against the same server, so they |
| +// converge. |
| +export function createIntegrity({ store, fetchPage, now = () => Date.now(), key = "lchat_ckpt", onAlert }) { |
| + const blank = () => ({ v: 1, head: null, hist: [], alerts: [], stats: {} }); |
| + const load = () => { |
| + try { |
| + return { ...blank(), ...(JSON.parse(store.getItem(key) || "null") || {}) }; |
| + } catch { |
| + return blank(); |
| + } |
| + }; |
| + const save = (st) => store.setItem(key, JSON.stringify(st)); |
| + const sameAlert = (a, b) => a.kind === b.kind && a.seqTo === b.seqTo && a.id === b.id; |
| + // addAlert persists immediately against fresh state (one alert per |
| + // kind+location — a persistent condition must not spam), then mirrors into |
| + // the caller's working copy. |
| + const addAlert = (st, a) => { |
| + const fresh = load(); |
| + let added = false; |
| + if (!fresh.alerts.some((x) => sameAlert(x, a))) { |
| + fresh.alerts = fresh.alerts.slice(-(ALERTS_KEEP - 1)).concat([{ ...a, at: now() }]); |
| + save(fresh); |
| + console.warn("integrity:", a.kind, a); |
| + added = true; |
| + } |
| + st.alerts = fresh.alerts; |
| + // Fire the host hook (renders the failure banner) only for a genuinely new |
| + // alert, so a persistent condition re-detected every poll doesn't re-nag. |
| + if (added && onAlert) { try { onAlert(a); } catch {} } |
| + }; |
| + |
| + return { |
| + // poll fetches everything past our head and verifies it. The first page |
| + // re-fetches the head row itself (after = head.id - 1) so a served chain |
| + // that no longer contains our verified head — a rollback — is caught, not |
| + // mistaken for silence. Network failures are silent (next poll retries); |
| + // verification failures alert and stop; malformed rows alert, never throw. |
| + async poll() { |
| + const st = load(); |
| + let pages = 0; |
| + let first = true; |
| + let caughtUp = false; |
| + try { |
| + if (st.sigOk === undefined) st.sigOk = await ed25519Available(); |
| + while (pages++ < 50) { |
| + let r; |
| + try { |
| + r = await fetchPage(st.head ? st.head.id - (first ? 1 : 0) : 0); |
| + } catch { |
| + return; // offline — nothing to conclude |
| + } |
| + if (!r || !Array.isArray(r.checkpoints)) return; |
| + const rawLen = r.checkpoints.length; |
| + let cps = r.checkpoints; |
| + if (st.head && first) { |
| + const mine = cps.find((c) => c.id === st.head.id); |
| + if (!mine || mine.seq_to !== st.head.seqTo || mine.chain !== st.head.chain) { |
| + addAlert(st, { kind: "rollback", id: st.head.id, seqTo: st.head.seqTo }); |
| + return; |
| + } |
| + cps = cps.filter((c) => c.id > st.head.id); |
| + } |
| + first = false; |
| + if (cps.length) { |
| + const out = await verifyCheckpointPage({ checkpoints: cps, prior: st.head, checkSigs: st.sigOk }); |
| + for (const a of out.alerts) addAlert(st, a); |
| + if (out.head) st.head = out.head; |
| + st.hist = st.hist.concat(out.hist).slice(-HIST_KEEP); |
| + if (out.fatal) return; |
| + } |
| + if (rawLen < PAGE_SIZE) { |
| + caughtUp = true; |
| + break; |
| + } |
| + } |
| + // A pending "ahead" gossip resolves once our verified head reaches its |
| + // seq: matching boundary → clean; mismatched or MISSING boundary → |
| + // fork (boundaries are global). Unresolved past the grace window with |
| + // catch-up complete → the server is withholding from us what a peer |
| + // already verified. Never judged mid-catch-up (the page cap) — a |
| + // device far behind isn't being withheld from, it's just behind. |
| + if (st.pending && st.head && st.head.seqTo >= st.pending.s) { |
| + if (gossipVerdict(st.head, st.hist, [st.pending.s, st.pending.c]) === "fork") { |
| + addAlert(st, { kind: "gossip-fork", seqTo: st.pending.s }); |
| + } |
| + st.pendingDone = st.pending; |
| + st.pending = null; |
| + } else if (st.pending && caughtUp && now() - st.pending.at > AHEAD_GRACE_MS) { |
| + addAlert(st, { kind: "withheld", seqTo: st.pending.s }); |
| + st.pendingDone = st.pending; |
| + st.pending = null; |
| + } |
| + st.stats.lastPollAt = now(); |
| + } finally { |
| + // Merge over fresh state: alerts and gossip stats recorded mid-flight |
| + // (this tab's gossipIn, another tab's poll) must survive this save. |
| + const fresh = load(); |
| + for (const a of fresh.alerts) if (!st.alerts.some((x) => sameAlert(x, a))) st.alerts.push(a); |
| + st.alerts = st.alerts.slice(-ALERTS_KEEP); |
| + if (!st.head || (fresh.head && fresh.head.seqTo > st.head.seqTo)) { |
| + st.head = fresh.head; |
| + st.hist = fresh.hist; |
| + } |
| + if (fresh.stats.gossipSeen > (st.stats.gossipSeen || 0)) st.stats.gossipSeen = fresh.stats.gossipSeen; |
| + if (fresh.stats.gossipMatch > (st.stats.gossipMatch || 0)) st.stats.gossipMatch = fresh.stats.gossipMatch; |
| + // Keep a pending another writer set mid-flight — unless this poll just |
| + // resolved that very one. |
| + if (!st.pending && fresh.pending && !(st.pendingDone && fresh.pending.at === st.pendingDone.at)) { |
| + st.pending = fresh.pending; |
| + } |
| + delete st.pendingDone; |
| + save(st); |
| + } |
| + }, |
| + |
| + // gossipOut returns the `_ckpt` value for an outgoing payload (or null |
| + // before the first verified head). |
| + gossipOut() { |
| + const st = load(); |
| + return st.head ? [st.head.seqTo, st.head.chain] : null; |
| + }, |
| + |
| + // gossipIn folds a peer's `_ckpt` from a decrypted incoming payload — |
| + // live or history; the host chooses which path a message arrives by, so |
| + // both must count as evidence. `countPeer` guards the stats (own echoes |
| + // and history refolds shouldn't inflate "peer heads compared"). |
| + gossipIn(gossip, { countPeer = true } = {}) { |
| + const st = load(); |
| + const v = gossipVerdict(st.head, st.hist, gossip); |
| + if (countPeer && (v === "match" || v === "fork" || v === "ahead")) { |
| + st.stats.gossipSeen = (st.stats.gossipSeen || 0) + 1; |
| + if (v === "match") st.stats.gossipMatch = (st.stats.gossipMatch || 0) + 1; |
| + } |
| + if (v === "fork") addAlert(st, { kind: "gossip-fork", seqTo: gossip[0] }); |
| + if (v === "ahead" && (!st.pending || gossip[0] > st.pending.s)) { |
| + st.pending = { s: gossip[0], c: gossip[1], at: st.pending ? st.pending.at : now() }; |
| + } |
| + save(st); |
| + return v; |
| + }, |
| + |
| + // state powers the /integrity command's private reply. |
| + state() { |
| + return load(); |
| + }, |
| + |
| + // unresolvedAlerts returns alerts the member hasn't dismissed yet — what |
| + // the failure banner shows. Acknowledged alerts stay in the record (for |
| + // /integrity and this device's evidence) but don't re-raise the banner. |
| + unresolvedAlerts() { |
| + const st = load(); |
| + const acked = st.acked || []; |
| + return st.alerts.filter((a) => !acked.some((k) => k === alertKey(a))); |
| + }, |
| + |
| + // acknowledge dismisses every current alert from the banner. A LATER, |
| + // different alert (new kind or new seq) is not covered, so it re-raises. |
| + acknowledge() { |
| + const st = load(); |
| + st.acked = [...new Set((st.acked || []).concat(st.alerts.map(alertKey)))].slice(-ALERTS_KEEP * 2); |
| + save(st); |
| + }, |
| + }; |
| +} |
| + |
| +// alertKey identifies an alert for de-dup + acknowledgement (kind + location). |
| +function alertKey(a) { |
| + return `${a.kind}:${a.seqTo ?? a.id ?? ""}`; |
| +} |
diff --git a/web/static/shell.js b/web/static/shell.js| index 5a4acb0..334125c 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -55,7 +55,9 @@ import { emojify, emojiMatches } from "/static/emoji.js"; |
| import { startBootScene, finishBootScene } from "/static/bootscene.js"; |
| import { encodeBlurhash } from "/static/blurhash.js"; |
| import { MESSAGE_TYPE_TYPING } from "/static/message.js"; |
| +import { createIntegrity } from "/static/integrity.js"; |
| import { |
| + registerFloorCommand, |
| setEmitTransport, |
| setScheduleTransport, |
| setEphemeralTransport, |
| @@ -2180,7 +2182,7 @@ async function applyThreads(r) { |
| readMarks[t.id] = Math.max(readMarks[t.id] || 0, t.last_read_at || 0); |
| if (t.last_blob && threadKeys[t.id]) { |
| try { |
| - const payload = await decrypt(threadKeys[t.id], t.last_blob); |
| + const payload = takeGossip(await decrypt(threadKeys[t.id], t.last_blob), false); |
| // A typed app event (e.g. vault.add) has no .text — previewText would |
| // read it as an empty chat message and render "Name: " with nothing |
| // after the colon. Route it through the OWNING APP's preview hook |
| @@ -3500,6 +3502,135 @@ function offerAppReload() { |
| b.append(msg, row); |
| } |
| +// ---- tamper evidence (docs/tamper-evidence.md, integrity.js) ------------- |
| +// The client verifies the instance's signed checkpoint chain and gossips its |
| +// verified head inside encrypted payloads — the server can't read the gossip, |
| +// so it can't selectively strip it, and two members shown different histories |
| +// betray the fork to each other. Detection only: alerts surface via the |
| +// /integrity command, never a banner (that UX needs its own pass). |
| +const integrity = createIntegrity({ |
| + store: localStorage, |
| + fetchPage: (after) => |
| + fetch(`/api/checkpoints?token=${encodeURIComponent(session.token)}&after=${after}`).then((r) => (r.ok ? r.json() : null)), |
| + // A new integrity FAILURE raises the banner (a pulled self-check via |
| + // /integrity is enough for "all clear"; a failure has to come find you). |
| + onAlert: () => renderIntegrityBanner(), |
| +}); |
| +let integrityTimer = null; // slow poll while the socket is open |
| + |
| +// Plain-language line per alert kind — members, not developers. Every kind |
| +// here is a server misbehaviour the E2E rule is meant to make impossible to |
| +// hide; the wording says what it means and what to do, without jargon. |
| +function integrityAlertMessage(kind) { |
| + switch (kind) { |
| + case "gossip-fork": |
| + return "Your record of this space's history disagrees with another member's. A trustworthy server shows everyone the same history — this one may not have."; |
| + case "rollback": |
| + return "This space's server served an older version of its history than your device had already verified."; |
| + case "withheld": |
| + return "Another member has verified activity in this space that this space's server won't show you."; |
| + case "bad-signature": |
| + case "malformed": |
| + return "This space's activity record failed its cryptographic check — it may have been altered."; |
| + case "broken-link": |
| + case "bad-chain-hash": |
| + case "gap": |
| + case "row-drift": |
| + return "This space's activity record doesn't hold together as it should — a sign it may have been altered."; |
| + default: |
| + return "This space failed an integrity check."; |
| + } |
| +} |
| + |
| +// renderIntegrityBanner shows/updates the top failure band from the monitor's |
| +// unresolved alerts. Called on every new alert and once on boot (a failure |
| +// recorded in a past session must greet you). Never shown for "all clear". |
| +function renderIntegrityBanner() { |
| + const el = document.getElementById("integrity-banner"); |
| + if (!el) return; |
| + const alerts = integrity.unresolvedAlerts(); |
| + if (!alerts.length) { |
| + el.classList.add("hidden"); |
| + el.replaceChildren(); |
| + return; |
| + } |
| + const latest = alerts[alerts.length - 1]; |
| + el.replaceChildren(); |
| + const icon = document.createElement("span"); |
| + icon.className = "ib-icon"; |
| + icon.textContent = "⚠️"; |
| + icon.setAttribute("aria-hidden", "true"); |
| + const text = document.createElement("div"); |
| + text.className = "ib-text"; |
| + const title = document.createElement("div"); |
| + title.className = "ib-title"; |
| + title.textContent = alerts.length > 1 ? `Integrity check failed (${alerts.length} alerts)` : "Integrity check failed"; |
| + const body = document.createElement("div"); |
| + body.textContent = `${integrityAlertMessage(latest.kind)} Keep this device — its records are the evidence — and contact your space's admin.`; |
| + text.append(title, body); |
| + const actions = document.createElement("div"); |
| + actions.className = "ib-actions"; |
| + const details = document.createElement("button"); |
| + details.type = "button"; |
| + details.className = "ib-primary"; |
| + details.textContent = "What's this?"; |
| + details.addEventListener("click", () => { |
| + // Answer in place: run /integrity so the full read-out lands in the open |
| + // feed as a private notice (same sink the composer uses for commands). |
| + if (activeId) runCommand("/integrity", activeId, (r) => appendNotice(activeId, r)); |
| + }); |
| + const dismiss = document.createElement("button"); |
| + dismiss.type = "button"; |
| + dismiss.textContent = "Dismiss"; |
| + dismiss.addEventListener("click", () => { |
| + integrity.acknowledge(); |
| + renderIntegrityBanner(); |
| + }); |
| + actions.append(details, dismiss); |
| + el.append(icon, text, actions); |
| + el.classList.remove("hidden"); |
| +} |
| + |
| +// takeGossip inspects and strips a decrypted payload's `_ckpt` before anything |
| +// downstream (folds, previews, app snapshots, the sandbox) sees it. Applied at |
| +// EVERY message-decrypt site, live and history alike — the host chooses which |
| +// path a message arrives by, so gossip that only counted when live would let |
| +// it route damning messages through catch-up. countPeer guards the stats: |
| +// own echoes and history refolds compare (evidence is evidence) but don't |
| +// inflate "peer heads compared". |
| +function takeGossip(payload, countPeer) { |
| + if (payload && payload._ckpt) { |
| + try { |
| + integrity.gossipIn(payload._ckpt, { countPeer: !!countPeer }); |
| + } catch {} |
| + delete payload._ckpt; |
| + } |
| + return payload; |
| +} |
| + |
| +registerFloorCommand("/integrity", { |
| + desc: "Check this space's tamper-evidence chain (only visible to you)", |
| + run: (_args, _threadId, reply) => { |
| + const st = integrity.state(); |
| + const lines = []; |
| + if (!st.head) { |
| + lines.push("No verified checkpoints yet — the chain starts with this space's first activity."); |
| + } else { |
| + lines.push(`Verified head: entry ${st.head.seqTo}, chain ${st.head.chain.slice(0, 12)}…`); |
| + if (st.stats.lastPollAt) lines.push(`Last checked: ${new Date(st.stats.lastPollAt).toLocaleString()}`); |
| + lines.push(st.sigOk ? "Signatures: verified (Ed25519)" : "Signatures: not checked (this browser lacks WebCrypto Ed25519); hash chain still verified"); |
| + const seen = st.stats.gossipSeen || 0; |
| + if (seen) lines.push(`Peer heads compared: ${seen} (${st.stats.gossipMatch || 0} matched)`); |
| + } |
| + if (st.alerts && st.alerts.length) { |
| + lines.push(`⚠️ ${st.alerts.length} integrity alert(s) — latest: ${st.alerts.at(-1).kind}. Keep this device's records and contact your admin.`); |
| + } else { |
| + lines.push("No integrity alerts."); |
| + } |
| + reply({ text: lines.join("\n") }); |
| + }, |
| +}); |
| + |
| // ---- mini-apps ---------------------------------------------------------- |
| // Mini-app events ride the exact same encrypted-message path as chat: the app |
| // only produces a typed payload, and the transport wraps it with the same |
| @@ -3509,6 +3640,10 @@ setEmitTransport((threadId, event) => { |
| if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(false); |
| const payload = { ...event, name: session.name, ts: Date.now() }; |
| if (myUserId) payload.uid = myUserId; |
| + // Tuck our verified chain head into the encrypted payload (tamper-evidence |
| + // gossip) — peers compare it against their own view; apps never see it. |
| + const head = integrity.gossipOut(); |
| + if (head) payload._ckpt = head; |
| return encrypt(key, payload).then((blob) => { |
| ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| return true; |
| @@ -4698,7 +4833,7 @@ async function seedAppState(threadId) { |
| for (const m of msgs) { |
| after = Math.max(after, m.id); |
| try { |
| - const data = await decrypt(threadKeys[threadId], m.blob); |
| + const data = takeGossip(await decrypt(threadKeys[threadId], m.blob), false); |
| if (isAppType(data.type)) foldEvent(threadId, data, { id: m.id, ts: data.ts }); |
| } catch {} |
| } |
| @@ -5954,7 +6089,7 @@ async function chatAppFoldMessage(threadId, m) { |
| return; |
| } |
| try { |
| - const data = await decrypt(threadKeys[threadId], m.blob); |
| + const data = takeGossip(await decrypt(threadKeys[threadId], m.blob), false); |
| if (data.type === "reaction") { |
| foldHostEvent(threadId, "chat", { type: "chat.reaction", targetSid: data.targetSid, emoji: data.emoji, uid: data.uid, on: data.on, ts: data.ts }); |
| return; |
| @@ -6083,7 +6218,7 @@ async function foldAppThreadMessage(threadId, ownerApp, m) { |
| return; |
| } |
| try { |
| - const data = await decrypt(threadKeys[threadId], m.blob); |
| + const data = takeGossip(await decrypt(threadKeys[threadId], m.blob), false); |
| if (isAppType(data.type)) { |
| foldEvent(threadId, data, { id: m.id, ts: data.ts }); |
| foldAppEventBubble(threadId, ownerApp, data, { id: m.id, ts: data.ts }); |
| @@ -6216,7 +6351,7 @@ async function openAppThreadView(id, gen, appName = threadApp[id], ownerApp = nu |
| if (appName === "chat") await chatAppFoldMessage(id, m); |
| else { |
| try { |
| - const data = await decrypt(threadKeys[id], m.blob); |
| + const data = takeGossip(await decrypt(threadKeys[id], m.blob), false); |
| if (isAppType(data.type)) foldEvent(id, data, { id: m.id, ts: data.ts }); |
| } catch {} |
| } |
| @@ -7099,6 +7234,13 @@ function connectWS() { |
| pingTimer = setInterval(() => { |
| if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); |
| }, 25000); |
| + // Tamper evidence: verify any new checkpoints now, then on a slow tick — |
| + // the sweep server-side runs every 5 minutes, so match its cadence. Show |
| + // the failure banner up front if a past session left an unresolved alert. |
| + if (integrityTimer) clearInterval(integrityTimer); |
| + renderIntegrityBanner(); |
| + integrity.poll(); |
| + integrityTimer = setInterval(() => integrity.poll(), 5 * 60 * 1000); |
| // A reconnect (not the first connect) means we were offline — pull anything |
| // the socket missed while it was down. The initial connect follows boot()'s |
| // fresh load, so there's nothing to catch up on then. Presence transitions |
| @@ -7395,6 +7537,10 @@ function connectWS() { |
| try { |
| payload = await decrypt(threadKeys[tid], msg.blob); |
| } catch {} |
| + // Tamper-evidence gossip: compare the sender's chain head against ours and |
| + // strip it before anything downstream (folds, previews) sees the payload. |
| + // Own echoes still compare (per-device split view) but don't count as peers. |
| + takeGossip(payload, payload && payload.uid && payload.uid !== myUserId); |
| // A reaction event: fold it so the target bubble's chips update live, then |
| // stop — reactions stay silent in the thread list (no preview, no unread |
| // dot, no native notify), like the plaintext reaction frames they replace. |
| @@ -7496,6 +7642,10 @@ function connectWS() { |
| clearInterval(pingTimer); |
| pingTimer = null; |
| } |
| + if (integrityTimer) { |
| + clearInterval(integrityTimer); |
| + integrityTimer = null; |
| + } |
| // Only flash "Reconnecting…" if the outage persists past a brief blip. |
| if (!reconnectStatusTimer) { |
| reconnectStatusTimer = setTimeout(() => showConnStatus("Reconnecting…"), 2500); |
diff --git a/web/static/style.css b/web/static/style.css| index bb861cd..6d11195 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1891,7 +1891,55 @@ input.switch:disabled { opacity: 0.5; cursor: default; } |
| .nick-area .row { display: flex; justify-content: center; gap: 10px; margin-top: 8px; } |
| /* ---- multi-thread layout (docs/threads-and-dms.md) ---- */ |
| -.tt { display: flex; height: 100%; width: 100%; background: var(--panel); } |
| +.tt { display: flex; flex-direction: column; height: 100%; width: 100%; background: var(--panel); } |
| +/* The sidebar + thread row lives below the (usually absent) integrity banner. */ |
| +.tt-body { display: flex; flex: 1; min-height: 0; width: 100%; } |
| + |
| +/* Tamper-evidence failure banner: a full-width band across the top of the whole |
| + shell, shown ONLY on an unresolved integrity alert (fork/rollback/withheld/ |
| + bad-signature). Amber, with a ⚠️ glyph so it's never colour-alone; occupies |
| + layout (pushes the shell down), never a floating overlay. */ |
| +.integrity-banner { |
| + flex: none; |
| + display: flex; |
| + align-items: flex-start; |
| + gap: 10px; |
| + padding: 12px 16px calc(12px + env(safe-area-inset-top, 0px)); |
| + background: color-mix(in srgb, #d97706 16%, var(--panel)); |
| + border-bottom: 1px solid color-mix(in srgb, #d97706 45%, transparent); |
| + color: var(--ink); |
| + font-size: 13.5px; |
| + line-height: 1.45; |
| +} |
| +.integrity-banner.hidden { display: none; } |
| +.integrity-banner .ib-icon { font-size: 18px; line-height: 1.2; flex: none; } |
| +.integrity-banner .ib-text { flex: 1; min-width: 0; } |
| +.integrity-banner .ib-title { font-weight: 700; } |
| +.integrity-banner .ib-actions { display: flex; gap: 8px; flex: none; } |
| +.integrity-banner button { |
| + font: inherit; |
| + font-weight: 600; |
| + border-radius: 8px; |
| + padding: 6px 12px; |
| + min-height: 32px; |
| + cursor: pointer; |
| + border: 1px solid color-mix(in srgb, #d97706 55%, transparent); |
| + background: transparent; |
| + color: var(--ink); |
| +} |
| +.integrity-banner button.ib-primary { |
| + background: color-mix(in srgb, #d97706 85%, black 5%); |
| + color: #fff; |
| + border-color: transparent; |
| +} |
| +.integrity-banner button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| +@media (prefers-color-scheme: dark) { |
| + .integrity-banner { background: color-mix(in srgb, #f59e0b 22%, var(--panel)); } |
| +} |
| +@media (max-width: 640px) { |
| + .integrity-banner { flex-wrap: wrap; } |
| + .integrity-banner .ib-actions { width: 100%; } |
| +} |
| /* floating connection-status pill (only shown during a sustained outage) */ |
| .conn-status { |
| position: fixed; |