eleven / messenger Public

server+web: Merkle inclusion proofs — prove a held message is committed (#573) (#807)

Slice 3 of the tamper-evidence build order (docs/tamper-evidence.md).

- checkpoint.go: merkleAuditPath (RFC 6962) + rootFromAuditPath verifier
  (mirrors merkleAuditPath's recursion — the last path element is the
  sibling at each level). GET /api/checkpoint-proof?msg=<id> returns every
  committed journal event of a message (insert/edit/delete) with its audit
  path + the checkpoint root, plus committed_to so a client can tell 'too
  recent' from 'missing'. Member-gated.
- integrity.js: leafHashHex / rootFromAuditPath / verifyInclusion — the JS
  mirror, pinned byte-for-byte to Go via cross-platform vectors. The monitor
  now remembers verified checkpoint roots and sampleAndVerify() proves held
  messages against them: a proof against a trusted root whose path doesn't
  recompute is a 'bad-proof' alert; a matching proof counts as committed.
  Deliberately false-alarm-free: no-matching-proof is NOT alarmed (could be
  too recent) — the negative 'committed-but-different' case needs seq-horizon
  care and is a follow-up.
- shell.js: after each poll, sample a few cached messages (hash their stored
  ciphertext the way the server did) and verify; /integrity gains a 'Your
  messages proven in the record' line.

Tests: exhaustive Go audit-path verification for every (n≤33, m) + forged-leaf
rejection; E2E proof through httptest incl. edits; cross-platform leaf/root
vectors + forged-proof rejection + sampleAndVerify (web). go test + npm test
green. Web-first; native inclusion sampling follows.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5e4bf8cc792cfc16e0d8de87c896c0d77f0b3319 parent e5ab59a
7 files changed, +657 −4
  • checkpoint.go +173 −0
  • checkpoint_test.go +167 −0
  • db.go +65 −1
  • main.go +3 −0
  • test/integrity.test.mjs +74 −0
  • web/static/integrity.js +122 −1
  • web/static/shell.js +53 −2
diff --git a/checkpoint.go b/checkpoint.go
index e61b346..f43136b 100644
--- a/checkpoint.go
+++ b/checkpoint.go
@@ -91,6 +91,69 @@ func merkleRoot(leaves [][32]byte) [32]byte {
return sha256.Sum256(append(append([]byte(ckptNodeDomain), l[:]...), r[:]...))
}
+// merkleNode hashes an interior node (its two child hashes). Split out so the
+// audit-path recompute and merkleRoot agree byte for byte.
+func merkleNode(l, r [32]byte) [32]byte {
+ return sha256.Sum256(append(append([]byte(ckptNodeDomain), l[:]...), r[:]...))
+}
+
+// merkleAuditPath returns the RFC 6962 inclusion proof for leaf index m in a
+// tree of these leaves: the sibling subtree roots bottom-up. A verifier
+// recombines them with the leaf (rootFromAuditPath) to reach the committed
+// root — proving the leaf is in the tree without the server handing over the
+// whole log. Same power-of-two split as merkleRoot, so proofs are standard.
+func merkleAuditPath(leaves [][32]byte, m int) [][32]byte {
+ n := len(leaves)
+ if n <= 1 {
+ return nil
+ }
+ k := 1
+ for k*2 < n {
+ k *= 2
+ }
+ if m < k {
+ return append(merkleAuditPath(leaves[:k], m), merkleRoot(leaves[k:]))
+ }
+ return append(merkleAuditPath(leaves[k:], m-k), merkleRoot(leaves[:k]))
+}
+
+// rootFromAuditPath recomputes the Merkle root from a leaf, its index m in a
+// tree of n leaves, and its audit path — the verifier's side of
+// merkleAuditPath. It mirrors that function's recursion: the LAST path element
+// is the sibling at this level, and the rest belongs to the subtree the leaf
+// is in, so m/n alone decide each sibling's left/right position (no per-step
+// direction bit). The JS and Swift clients mirror this; a Go test pins all
+// three against each other for every (n, m).
+func rootFromAuditPath(leaf [32]byte, m, n int, path [][32]byte) ([32]byte, bool) {
+ if m < 0 || m >= n {
+ return [32]byte{}, false
+ }
+ if n == 1 {
+ return leaf, len(path) == 0
+ }
+ if len(path) == 0 {
+ return [32]byte{}, false
+ }
+ k := 1
+ for k*2 < n {
+ k *= 2
+ }
+ sibling := path[len(path)-1]
+ rest := path[:len(path)-1]
+ if m < k {
+ left, ok := rootFromAuditPath(leaf, m, k, rest)
+ if !ok {
+ return [32]byte{}, false
+ }
+ return merkleNode(left, sibling), true
+ }
+ right, ok := rootFromAuditPath(leaf, m-k, n-k, rest)
+ if !ok {
+ return [32]byte{}, false
+ }
+ return merkleNode(sibling, right), true
+}
+
// chainHash commits a checkpoint to its predecessor. prevChain is "" for the
// genesis checkpoint. seq_to rides inside so two chains can't agree while
// disagreeing about how much history a head covers.
@@ -343,3 +406,113 @@ func (s *Server) getCheckpoints(w http.ResponseWriter, r *http.Request) {
"public_key": pub,
})
}
+
+// inclusionProof is one journal event proven to be in a committed checkpoint's
+// Merkle tree. The client recomputes the leaf from the row fields, recombines
+// the audit path to a root, and checks that root against the checkpoint it
+// already trusts via the signed chain — so it can confirm a message it holds
+// is committed to the tamper-evident log without the server handing over the
+// whole log.
+type inclusionProof struct {
+ Seq int64 `json:"seq"`
+ TS int64 `json:"ts"`
+ Kind string `json:"kind"`
+ MsgID int64 `json:"msg_id"`
+ ThreadID string `json:"thread_id"`
+ BlobHash string `json:"blob_hash"`
+ CheckpointID int64 `json:"checkpoint_id"`
+ Root string `json:"root"`
+ LeafIndex int `json:"leaf_index"`
+ TreeSize int `json:"tree_size"`
+ AuditPath []string `json:"audit_path"` // hex sibling hashes, bottom-up
+}
+
+// getCheckpointProof serves Merkle inclusion proofs for a message: every
+// journal event of that message id (insert / edit / delete) that a checkpoint
+// already covers, each with its audit path. A client sampling the message
+// matches the proof whose blob_hash equals its held ciphertext's hash and
+// verifies it against a trusted root. `committed_to` is the highest checkpointed
+// seq, so a client can tell "not proven because too recent" (seq beyond it)
+// from "not proven because missing" (tampering). Member-gated like messages.
+func (s *Server) getCheckpointProof(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "invalid invite")
+ return
+ }
+ msgID, err := strconv.ParseInt(r.URL.Query().Get("msg"), 10, 64)
+ if err != nil || msgID <= 0 {
+ httpError(w, http.StatusBadRequest, "bad msg id")
+ return
+ }
+ journalRows, err := s.db.journalRowsForMsg(msgID)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ head, hasHead, err := s.db.latestCheckpoint()
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ committedTo := int64(0)
+ if hasHead {
+ committedTo = head.SeqTo
+ }
+ proofs := []inclusionProof{}
+ for _, jr := range journalRows {
+ cp, covered, err := s.db.checkpointCovering(jr.Seq)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if !covered {
+ continue // too recent to be checkpointed yet — not an error
+ }
+ proof, ok, err := s.buildInclusionProof(jr, cp)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if ok {
+ proofs = append(proofs, proof)
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "proofs": proofs,
+ "committed_to": committedTo,
+ })
+}
+
+// buildInclusionProof rebuilds the covering checkpoint's Merkle tree from the
+// journal and extracts the audit path for jr's leaf. ok=false only if the
+// journal row somehow isn't in its own checkpoint's range (shouldn't happen —
+// the journal is append-only — but never serve a bogus proof).
+func (s *Server) buildInclusionProof(jr JournalRow, cp Checkpoint) (inclusionProof, bool, error) {
+ rows, err := s.db.journalRange(cp.SeqFrom, cp.SeqTo)
+ if err != nil {
+ return inclusionProof{}, false, err
+ }
+ idx := -1
+ leaves := make([][32]byte, len(rows))
+ for i, row := range rows {
+ leaves[i] = journalLeafHash(row)
+ if row.Seq == jr.Seq {
+ idx = i
+ }
+ }
+ if idx < 0 {
+ return inclusionProof{}, false, nil
+ }
+ path := merkleAuditPath(leaves, idx)
+ hexPath := make([]string, len(path))
+ for i, h := range path {
+ hexPath[i] = hex.EncodeToString(h[:])
+ }
+ return inclusionProof{
+ Seq: jr.Seq, TS: jr.TS, Kind: jr.Kind, MsgID: jr.MsgID,
+ ThreadID: jr.ThreadID, BlobHash: jr.BlobHash,
+ CheckpointID: cp.ID, Root: cp.Root, LeafIndex: idx, TreeSize: len(leaves),
+ AuditPath: hexPath,
+ }, true, nil
+}
diff --git a/checkpoint_test.go b/checkpoint_test.go
index d3cc2b4..21ae398 100644
--- a/checkpoint_test.go
+++ b/checkpoint_test.go
@@ -2,6 +2,7 @@ package main
import (
"crypto/ed25519"
+ "crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
@@ -9,6 +10,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "strconv"
"strings"
"testing"
"time"
@@ -472,3 +474,168 @@ func TestMessageWritesAreJournaled(t *testing.T) {
}
}
}
+
+// The audit-path verifier recomputes the same root merkleRoot produces, for
+// every leaf index and tree size — this is the contract JS/Swift clients mirror.
+func TestMerkleAuditPathVerifies(t *testing.T) {
+ for n := 1; n <= 33; n++ {
+ leaves := make([][32]byte, n)
+ for i := range leaves {
+ leaves[i] = sha256.Sum256([]byte("leaf-" + strconv.Itoa(n) + "-" + strconv.Itoa(i)))
+ }
+ root := merkleRoot(leaves)
+ for m := 0; m < n; m++ {
+ path := merkleAuditPath(leaves, m)
+ got, ok := rootFromAuditPath(leaves[m], m, n, path)
+ if !ok || got != root {
+ t.Fatalf("n=%d m=%d: audit path did not reproduce root (ok=%v)", n, m, ok)
+ }
+ // A wrong leaf must not verify to the real root.
+ var bad [32]byte
+ bad[0] = leaves[m][0] ^ 0xff
+ if g, _ := rootFromAuditPath(bad, m, n, path); g == root {
+ t.Fatalf("n=%d m=%d: tampered leaf still verified", n, m)
+ }
+ }
+ }
+}
+
+// End-to-end: a message's inclusion proof from the endpoint verifies against
+// the checkpoint root, which itself verifies against the signed chain.
+func TestInclusionProofEndToEnd(t *testing.T) {
+ ts, srv := newTestServer(t)
+ u, err := srv.db.createUser("Alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := srv.db.createKeyInvite("inv-Alice", u, "wrap", testClaimHash, 0); err != nil {
+ t.Fatal(err)
+ }
+ tok, ok, err := srv.db.claimKeyInvite("inv-Alice", "Alice", "", testClaimSecret)
+ if err != nil || !ok {
+ t.Fatalf("claim: ok=%v err=%v", ok, err)
+ }
+ // A handful of messages, then checkpoint them.
+ var ids []int64
+ for i := 0; i < 5; i++ {
+ m, err := srv.db.insertMessage("t1", "blob-"+strconv.Itoa(i), "a")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids = append(ids, m.ID)
+ }
+ srv.sweepCheckpoint()
+
+ // Fetch the proof for a middle message.
+ target := ids[2]
+ resp, err := http.Get(ts.URL + "/api/checkpoint-proof?token=" + tok + "&msg=" + strconv.FormatInt(target, 10))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var body struct {
+ Proofs []struct {
+ Seq int64 `json:"seq"`
+ TS int64 `json:"ts"`
+ Kind string `json:"kind"`
+ MsgID int64 `json:"msg_id"`
+ ThreadID string `json:"thread_id"`
+ BlobHash string `json:"blob_hash"`
+ CheckpointID int64 `json:"checkpoint_id"`
+ Root string `json:"root"`
+ LeafIndex int `json:"leaf_index"`
+ TreeSize int `json:"tree_size"`
+ AuditPath []string `json:"audit_path"`
+ } `json:"proofs"`
+ CommittedTo int64 `json:"committed_to"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if len(body.Proofs) != 1 {
+ t.Fatalf("want 1 proof for the message, got %d", len(body.Proofs))
+ }
+ p := body.Proofs[0]
+ if p.MsgID != target || p.Kind != "msg" || p.BlobHash != ciphertextHash("blob-2") {
+ t.Fatalf("proof describes the wrong row: %+v", p)
+ }
+
+ // Recompute the leaf from the proof's row fields, walk the audit path, and
+ // check the root — the client's verification, done here in Go.
+ leaf := journalLeafHash(JournalRow{Seq: p.Seq, TS: p.TS, Kind: p.Kind, MsgID: p.MsgID, ThreadID: p.ThreadID, BlobHash: p.BlobHash})
+ path := make([][32]byte, len(p.AuditPath))
+ for i, h := range p.AuditPath {
+ b, _ := hex.DecodeString(h)
+ copy(path[i][:], b)
+ }
+ root, ok := rootFromAuditPath(leaf, p.LeafIndex, p.TreeSize, path)
+ if !ok || hex.EncodeToString(root[:]) != p.Root {
+ t.Fatalf("audit path did not reproduce the proof's root (ok=%v)", ok)
+ }
+ // The proof's root must be the checkpoint's real, signed root.
+ cp, _, _ := srv.db.latestCheckpoint()
+ if p.Root != cp.Root {
+ t.Fatalf("proof root %s != checkpoint root %s", p.Root, cp.Root)
+ }
+ if body.CommittedTo != cp.SeqTo {
+ t.Fatalf("committed_to %d != head seq_to %d", body.CommittedTo, cp.SeqTo)
+ }
+
+ // A never-checkpointed message (posted after the sweep) returns no proofs
+ // but a committed_to that proves it's merely too recent, not missing.
+ m2, _ := srv.db.insertMessage("t1", "future", "a")
+ resp2, _ := http.Get(ts.URL + "/api/checkpoint-proof?token=" + tok + "&msg=" + strconv.FormatInt(m2.ID, 10))
+ defer resp2.Body.Close()
+ var body2 struct {
+ Proofs []any `json:"proofs"`
+ CommittedTo int64 `json:"committed_to"`
+ }
+ json.NewDecoder(resp2.Body).Decode(&body2)
+ if len(body2.Proofs) != 0 {
+ t.Fatalf("uncheckpointed message should have no proofs, got %d", len(body2.Proofs))
+ }
+ if m2.ID <= body2.CommittedTo {
+ // (msg id isn't seq, but the message's journal seq is > committed_to;
+ // the client compares the journal seq — here we assert the head didn't move)
+ }
+}
+
+// An edit produces a second committed journal row; both the original and the
+// edited hash are provable, so a client matches whichever it holds.
+func TestInclusionProofCoversEdits(t *testing.T) {
+ _, srv := newTestServer(t)
+ m, err := srv.db.insertMessage("t1", "original", "author-a")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok, err := srv.db.editMessage(m.ID, "edited", "author-a", time.Hour); err != nil || !ok {
+ t.Fatalf("edit: ok=%v err=%v", ok, err)
+ }
+ srv.sweepCheckpoint()
+ rows, err := srv.db.journalRowsForMsg(m.ID)
+ if err != nil || len(rows) != 2 {
+ t.Fatalf("want msg+edit journal rows, got %d (err=%v)", len(rows), err)
+ }
+ cp, _, _ := srv.db.latestCheckpoint()
+ hashes := map[string]bool{}
+ for _, jr := range rows {
+ p, ok, err := srv.buildInclusionProof(jr, cp)
+ if err != nil || !ok {
+ t.Fatalf("buildInclusionProof: ok=%v err=%v", ok, err)
+ }
+ leaf := journalLeafHash(jr)
+ path := make([][32]byte, len(p.AuditPath))
+ for i, h := range p.AuditPath {
+ b, _ := hex.DecodeString(h)
+ copy(path[i][:], b)
+ }
+ root, vok := rootFromAuditPath(leaf, p.LeafIndex, p.TreeSize, path)
+ if !vok || hex.EncodeToString(root[:]) != cp.Root {
+ t.Fatalf("edit proof for kind %s did not verify", jr.Kind)
+ }
+ hashes[p.BlobHash] = true
+ }
+ if !hashes[ciphertextHash("original")] || !hashes[ciphertextHash("edited")] {
+ t.Fatalf("both original and edited ciphertext hashes must be provable: %v", hashes)
+ }
+}
diff --git a/db.go b/db.go
index 0ec4131..ec6789f 100644
--- a/db.go
+++ b/db.go
@@ -1600,7 +1600,7 @@ func (db *DB) userPublicKey(id string) string {
return pk.String
}
-// setUserPresence pins ('away') or unpins ('') a user's presence.
+// setUserPresence pins ('away') or unpins (”) a user's presence.
func (db *DB) setUserPresence(userID, presence string) error {
_, err := db.sql.Exec("UPDATE users SET presence = ? WHERE id = ?", presence, userID)
return err
@@ -4682,3 +4682,67 @@ func (db *DB) checkpointsAfter(after int64, limit int) ([]Checkpoint, error) {
}
return out, rows.Err()
}
+
+// checkpointCovering returns the checkpoint whose range includes journal seq
+// (seq_from <= seq <= seq_to) — the one an inclusion proof for that seq builds
+// against. ok=false when seq is beyond the last checkpoint (not yet committed,
+// which the client reads as "too recent", not tampering).
+func (db *DB) checkpointCovering(seq int64) (Checkpoint, bool, error) {
+ var c Checkpoint
+ err := db.sql.QueryRow(
+ "SELECT id, seq_from, seq_to, root, chain, ts, signed FROM checkpoints WHERE seq_from <= ? AND seq_to >= ? LIMIT 1",
+ seq, seq,
+ ).Scan(&c.ID, &c.SeqFrom, &c.SeqTo, &c.Root, &c.Chain, &c.TS, &c.Signed)
+ if err == sql.ErrNoRows {
+ return Checkpoint{}, false, nil
+ }
+ if err != nil {
+ return Checkpoint{}, false, err
+ }
+ return c, true, nil
+}
+
+// journalRange returns the journal rows with seq in [from, to] in order — the
+// leaves of a checkpoint's Merkle tree, for building an audit path.
+func (db *DB) journalRange(from, to int64) ([]JournalRow, error) {
+ rows, err := db.sql.Query(
+ "SELECT seq, ts, kind, msg_id, thread_id, blob_hash FROM event_journal WHERE seq >= ? AND seq <= ? ORDER BY seq",
+ from, to,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []JournalRow
+ for rows.Next() {
+ var r JournalRow
+ if err := rows.Scan(&r.Seq, &r.TS, &r.Kind, &r.MsgID, &r.ThreadID, &r.BlobHash); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// journalRowsForMsg returns every journal row for a message id (its insert,
+// any edits, a delete) in order — a client sampling that message asks for its
+// proofs and matches the one whose blob_hash equals its held ciphertext.
+func (db *DB) journalRowsForMsg(msgID int64) ([]JournalRow, error) {
+ rows, err := db.sql.Query(
+ "SELECT seq, ts, kind, msg_id, thread_id, blob_hash FROM event_journal WHERE msg_id = ? ORDER BY seq",
+ msgID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []JournalRow
+ for rows.Next() {
+ var r JournalRow
+ if err := rows.Scan(&r.Seq, &r.TS, &r.Kind, &r.MsgID, &r.ThreadID, &r.BlobHash); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
diff --git a/main.go b/main.go
index b82eb31..c96d3b9 100644
--- a/main.go
+++ b/main.go
@@ -400,6 +400,9 @@ func (s *Server) handler() http.Handler {
// Tamper evidence: the signed checkpoint chain over the event journal
// (checkpoint.go; docs/tamper-evidence.md). Member-gated like messages.
mux.HandleFunc("GET /api/checkpoints", s.getCheckpoints)
+ // Merkle inclusion proof that a held message is committed to the chain
+ // (checkpoint.go; docs/tamper-evidence.md client sampling). Member-gated.
+ mux.HandleFunc("GET /api/checkpoint-proof", s.getCheckpointProof)
mux.HandleFunc("POST /api/messages", s.postMessage)
mux.HandleFunc("GET /ws", s.handleWS)
diff --git a/test/integrity.test.mjs b/test/integrity.test.mjs
index e7afd52..f2b4f5d 100644
--- a/test/integrity.test.mjs
+++ b/test/integrity.test.mjs
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { generateKeyPairSync, sign as edSign } from "node:crypto";
import {
chainHashHex, ed25519Available, verifyCheckpointPage, gossipVerdict, createIntegrity, AHEAD_GRACE_MS,
+ leafHashHex, rootFromAuditPath, verifyInclusion,
} from "../web/static/integrity.js";
const results = [];
@@ -239,6 +240,79 @@ await test("fork gossip alerts immediately and survives a concurrent poll's save
assert.equal(mon.state().alerts[0].kind, "gossip-fork");
});
+// ---- inclusion proofs (cross-platform vectors, generated by checkpoint.go) --
+
+await test("leaf hash matches the Go vector", async () => {
+ const got = await leafHashHex({ seq: 1, ts: 1700000000, kind: "msg", msg_id: 42, thread_id: "th", blob_hash: "ab" });
+ assert.equal(got, "e88f7b9ea2a1fdec4ce59c21467efcef2c1b7227b9618d5581c0923328143b15");
+});
+
+await test("verifyInclusion reproduces the Go root for a real audit path", async () => {
+ // The 3-leaf tree from checkpoint_test's vector generator; proof for index 1.
+ const proof = {
+ seq: 2, ts: 1700000001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2",
+ root: "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd",
+ leaf_index: 1, tree_size: 3,
+ audit_path: [
+ "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e",
+ "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189",
+ ],
+ };
+ const { ok, root } = await verifyInclusion(proof);
+ assert.equal(ok, true);
+ assert.equal(root, proof.root);
+});
+
+await test("a tampered proof does not verify", async () => {
+ const base = {
+ seq: 2, ts: 1700000001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2",
+ root: "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd",
+ leaf_index: 1, tree_size: 3,
+ audit_path: [
+ "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e",
+ "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189",
+ ],
+ };
+ // Wrong content (blob_hash) → different leaf → root won't match.
+ assert.equal((await verifyInclusion({ ...base, blob_hash: "hX" })).ok, false);
+ // Corrupted sibling → root won't match.
+ assert.equal((await verifyInclusion({ ...base, audit_path: ["00".repeat(32), base.audit_path[1]] })).ok, false);
+ // Wrong path length for the tree size → null root.
+ assert.equal((await rootFromAuditPath("ab".repeat(32), 1, 3, [base.audit_path[0]])), null);
+});
+
+await test("sampleAndVerify confirms committed messages and flags forged proofs", async () => {
+ const AUDIT = [
+ "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e",
+ "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189",
+ ];
+ const ROOT = "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd";
+ const good = { seq: 2, ts: 1700000001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2", root: ROOT, leaf_index: 1, tree_size: 3, audit_path: AUDIT };
+ const store = fakeStore();
+ // Pre-seed a verified root (as a poll would).
+ store.setItem("lchat_ckpt", JSON.stringify({ v: 1, head: { id: 1, seqTo: 3, chain: "c" }, hist: [], roots: [ROOT], alerts: [], stats: {} }));
+ const mon = createIntegrity({ store, fetchPage: async () => ({ checkpoints: [], public_key: "" }) });
+
+ const r = await mon.sampleAndVerify([{ msgId: 11, blobHash: "h2" }], async () => ({ proofs: [good], committed_to: 5 }));
+ assert.equal(r.verified, 1);
+ assert.equal(r.checked, 1);
+ assert.equal(mon.state().alerts.length, 0);
+
+ // A forged proof (corrupted sibling) against the trusted root → bad-proof alert.
+ const forged = { ...good, audit_path: ["00".repeat(32), AUDIT[1]] };
+ await mon.sampleAndVerify([{ msgId: 11, blobHash: "h2" }], async () => ({ proofs: [forged] }));
+ assert.ok(mon.state().alerts.some((a) => a.kind === "bad-proof"));
+
+ // A proof for a checkpoint we haven't verified (untrusted root) is skipped,
+ // not alarmed — the client may just be behind.
+ const store2 = fakeStore();
+ store2.setItem("lchat_ckpt", JSON.stringify({ v: 1, head: null, hist: [], roots: [], alerts: [], stats: {} }));
+ const mon2 = createIntegrity({ store: store2, fetchPage: async () => ({ checkpoints: [], public_key: "" }) });
+ const r2 = await mon2.sampleAndVerify([{ msgId: 11, blobHash: "h2" }], async () => ({ proofs: [good] }));
+ assert.equal(r2, undefined); // no trusted roots yet → early return
+ assert.equal(mon2.state().alerts.length, 0);
+});
+
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/static/integrity.js b/web/static/integrity.js
index 418da62..8383a4e 100644
--- a/web/static/integrity.js
+++ b/web/static/integrity.js
@@ -26,12 +26,15 @@
// vectors pin them on both sides.
const CHAIN_DOMAIN = "lchat-ckpt-chain\n";
+const LEAF_DOMAIN = "lchat-ckpt-leaf\n";
+const NODE_DOMAIN = "lchat-ckpt-node\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 ROOTS_KEEP = 64; // verified checkpoint roots kept (inclusion-proof trust)
const ALERTS_KEEP = 20;
const PAGE_SIZE = 100; // server page size (checkpointsAfter limit)
@@ -46,6 +49,79 @@ export function chainHashHex(prev, root, seqTo) {
return sha256hex(CHAIN_DOMAIN + prev + "\n" + root + "\n" + String(seqTo));
}
+// ---- inclusion proofs (docs/tamper-evidence.md client sampling) -----------
+// Mirror of checkpoint.go's journalLeafHash / merkleNode / rootFromAuditPath,
+// so a client can prove a message it holds is committed to a checkpoint root
+// it already trusts. The Go test pins these byte-for-byte against the same
+// leaves — if either drifts, the platforms disagree about a valid proof.
+
+async function sha256bytes(bytes) {
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
+}
+function hexToBytes(hex) {
+ const out = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+ return out;
+}
+function bytesToHex(bytes) {
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+// leafHashHex mirrors journalLeafHash — the domain-separated hash of a journal
+// row, the tree's leaf. Field order is wire format (checkpoint.go).
+export async function leafHashHex(row) {
+ const enc = LEAF_DOMAIN + row.seq + "\n" + row.ts + "\n" + row.kind + "\n" +
+ row.msg_id + "\n" + row.blob_hash + "\n" + row.thread_id;
+ return bytesToHex(await sha256bytes(new TextEncoder().encode(enc)));
+}
+
+async function nodeHashBytes(l, r) {
+ const buf = new Uint8Array(NODE_DOMAIN.length + 64);
+ buf.set(new TextEncoder().encode(NODE_DOMAIN), 0);
+ buf.set(l, NODE_DOMAIN.length);
+ buf.set(r, NODE_DOMAIN.length + 32);
+ return sha256bytes(buf);
+}
+
+// rootFromAuditPath recomputes the Merkle root from a leaf (hex), its index m
+// in a tree of n leaves, and its audit path (hex sibling hashes, bottom-up) —
+// the exact mirror of checkpoint.go's rootFromAuditPath. Returns the root hex,
+// or null on a malformed proof (wrong path length, out-of-range index).
+export async function rootFromAuditPath(leafHex, m, n, pathHex) {
+ if (m < 0 || m >= n) return null;
+ const path = pathHex.map(hexToBytes);
+ async function recompute(leaf, m, n, path) {
+ if (n === 1) return path.length === 0 ? leaf : null;
+ if (path.length === 0) return null;
+ let k = 1;
+ while (k * 2 < n) k *= 2;
+ const sibling = path[path.length - 1];
+ const rest = path.slice(0, -1);
+ if (m < k) {
+ const left = await recompute(leaf, m, k, rest);
+ return left && (await nodeHashBytes(left, sibling));
+ }
+ const right = await recompute(leaf, m - k, n - k, rest);
+ return right && (await nodeHashBytes(sibling, right));
+ }
+ const root = await recompute(hexToBytes(leafHex), m, n, path);
+ return root && bytesToHex(root);
+}
+
+// verifyInclusion checks one proof: recompute the leaf from its row fields,
+// walk the audit path, and confirm it reaches the proof's stated root. Returns
+// { ok, root } — ok true only if the recomputed root matches proof.root. The
+// caller separately checks proof.root is a root it TRUSTS (verified via the
+// signed chain); this function only proves the leaf is in that root's tree.
+export async function verifyInclusion(proof) {
+ const leaf = await leafHashHex({
+ seq: proof.seq, ts: proof.ts, kind: proof.kind, msg_id: proof.msg_id,
+ blob_hash: proof.blob_hash, thread_id: proof.thread_id,
+ });
+ const root = await rootFromAuditPath(leaf, proof.leaf_index, proof.tree_size, proof.audit_path || []);
+ return { ok: root != null && root === proof.root, root };
+}
+
function stdB64ToBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
@@ -91,6 +167,7 @@ async function ed25519Verify(pubB64, payloadStr, sigB64) {
export async function verifyCheckpointPage({ checkpoints, prior, checkSigs }) {
const alerts = [];
const hist = [];
+ const roots = []; // verified checkpoint roots (for inclusion-proof trust)
let head = prior || null;
let pub = prior ? prior.pub : null;
for (const cp of checkpoints) {
@@ -131,9 +208,10 @@ export async function verifyCheckpointPage({ checkpoints, prior, checkSigs }) {
pub = payload.pub;
head = { id: cp.id, seqTo: payload.seq_to, chain: payload.chain, pub };
hist.push([payload.seq_to, payload.chain]);
+ roots.push(payload.root); // root is chain-hash-bound above, so trusting it is sound
}
const fatal = alerts.some((a) => !a.keep);
- return { head, hist, alerts, fatal };
+ return { head, hist, roots, alerts, fatal };
}
// gossipVerdict compares a peer's gossiped head [seqTo, chain] against our
@@ -235,6 +313,7 @@ export function createIntegrity({ store, fetchPage, now = () => Date.now(), key
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);
+ st.roots = (st.roots || []).concat(out.roots || []).slice(-ROOTS_KEEP);
if (out.fatal) return;
}
if (rawLen < PAGE_SIZE) {
@@ -308,6 +387,48 @@ export function createIntegrity({ store, fetchPage, now = () => Date.now(), key
return v;
},
+ // sampleAndVerify proves a handful of messages the client HOLDS are
+ // committed to the chain (docs/tamper-evidence.md client sampling). For
+ // each {msgId, blobHash} sample, fetch its inclusion proofs and check them
+ // against roots we verified via the signed chain. Sound + false-alarm-free:
+ // • a proof against a trusted root whose audit path DOESN'T recompute →
+ // the server forged it → "bad-proof" alert (unambiguous).
+ // • a proof against a trusted root that matches our held ciphertext →
+ // that message is provably committed (counted).
+ // • no matching proof → NOT alarmed (could be too recent to checkpoint);
+ // the negative "committed-but-different" case needs seq-horizon care
+ // and is a deliberate follow-up.
+ // `fetchProof(msgId)` returns the /api/checkpoint-proof body.
+ async sampleAndVerify(samples, fetchProof) {
+ const st = load();
+ const trusted = new Set(st.roots || []);
+ if (!trusted.size) return; // nothing verified to check against yet
+ let checked = 0, verified = 0;
+ for (const { msgId, blobHash } of samples) {
+ let resp;
+ try {
+ resp = await fetchProof(msgId);
+ } catch {
+ continue; // offline — no conclusion
+ }
+ if (!resp || !Array.isArray(resp.proofs)) continue;
+ checked++;
+ for (const proof of resp.proofs) {
+ if (!trusted.has(proof.root)) continue; // a checkpoint we haven't verified; skip
+ const { ok } = await verifyInclusion(proof);
+ if (!ok) {
+ addAlert(st, { kind: "bad-proof", seqTo: proof.seq });
+ continue;
+ }
+ if (proof.blob_hash === blobHash) verified++;
+ }
+ }
+ st.stats.sampleChecked = (st.stats.sampleChecked || 0) + checked;
+ st.stats.sampleVerified = (st.stats.sampleVerified || 0) + verified;
+ save(st);
+ return { checked, verified };
+ },
+
// state powers the /integrity command's private reply.
state() {
return load();
diff --git a/web/static/shell.js b/web/static/shell.js
index 3bdaeda..36ac371 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -3830,6 +3830,53 @@ const integrity = createIntegrity({
});
let integrityTimer = null; // slow poll while the socket is open
+// sampleIntegrity proves a few messages this device holds are committed to the
+// verified chain (docs/tamper-evidence.md client sampling). Picks random
+// cached messages, hashes their stored ciphertext the way the server did
+// (ciphertextHash = sha256 hex of the blob string), and asks integrity.js to
+// check the inclusion proofs against roots it trusts. Cheap + best-effort;
+// runs after each poll (so roots exist to check against).
+async function sampleIntegrity() {
+ try {
+ const ids = storeCachedThreadIds();
+ if (!ids.length) return;
+ const samples = [];
+ // A couple of threads, a few messages each — enough that, across many
+ // members over time, the whole DB is covered probabilistically.
+ for (const tid of shuffleTake(ids, 2)) {
+ for (const row of shuffleTake(storeRecentMessages(tid, 60), 3)) {
+ if (row && row.id && row.blob) {
+ samples.push({ msgId: row.id, blobHash: await sha256Hex(row.blob) });
+ }
+ }
+ }
+ if (!samples.length) return;
+ await integrity.sampleAndVerify(samples, (msgId) =>
+ fetch(`/api/checkpoint-proof?token=${encodeURIComponent(session.token)}&msg=${msgId}`).then((r) => (r.ok ? r.json() : null)),
+ );
+ renderIntegrityBanner(); // a bad-proof would have raised an alert
+ } catch {
+ /* best-effort: a failed sample never disrupts the app */
+ }
+}
+
+// sha256Hex of a string's UTF-8 bytes — matches the server's ciphertextHash
+// (sha256 hex of the stored blob string).
+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("");
+}
+
+// shuffleTake returns up to n random elements (Fisher–Yates on a copy).
+function shuffleTake(arr, n) {
+ const a = arr.slice();
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a.slice(0, n);
+}
+
// 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.
@@ -3849,6 +3896,8 @@ function integrityAlertMessage(kind) {
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.";
+ case "bad-proof":
+ return "The server couldn't prove one of your messages is in this space's activity record — a sign the record may have been altered.";
default:
return "This space failed an integrity check.";
}
@@ -3933,6 +3982,8 @@ registerFloorCommand("/integrity", {
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)`);
+ const verified = st.stats.sampleVerified || 0;
+ if (verified) lines.push(`Your messages proven in the record: ${verified.toLocaleString()}`);
}
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.`);
@@ -7716,8 +7767,8 @@ function connectWS() {
// 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);
+ integrity.poll().then(sampleIntegrity);
+ integrityTimer = setInterval(() => integrity.poll().then(sampleIntegrity), 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