eleven / messenger Public

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>
Paul Campbell 9b6887ea1abf7aec01c3deb3f5ca9685cc6c91e8 parent b04215a
7 files changed, +898 −19
  • package.json +1 −1
  • test/event-vectors/chat.json +105 −12
  • test/integrity.test.mjs +244 −0
  • web/pages/index.html +6 −0
  • web/static/integrity.js +338 −0
  • web/static/shell.js +155 −5
  • web/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;