eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "crypto/ed25519"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/hex"
8 "encoding/json"
9 "net/http"
10 "os"
11 "path/filepath"
12 "regexp"
13 "strconv"
14 "strings"
15 "testing"
16 "time"
17)
18
19// journalRows drains the whole journal (test helper).
20func journalRows(t *testing.T, db *DB) []JournalRow {
21 t.Helper()
22 rows, err := db.journalAfter(0, 100000)
23 if err != nil {
24 t.Fatalf("journalAfter: %v", err)
25 }
26 return rows
27}
28
29// Every message write path must leave exactly one explaining journal row, in
30// the same transaction — a message the journal can't account for reads as
31// tampering to a verifier.
32func TestJournalFollowsMessageWrites(t *testing.T) {
33 db := newTestDB(t)
34
35 m, err := db.insertMessage("t1", "blob-one", "author-a")
36 if err != nil {
37 t.Fatalf("insertMessage: %v", err)
38 }
39 if _, ok, err := db.editMessage(m.ID, "blob-two", "author-a", time.Hour); err != nil || !ok {
40 t.Fatalf("editMessage: ok=%v err=%v", ok, err)
41 }
42 if ok, err := db.deleteMessage(m.ID, "author-a"); err != nil || !ok {
43 t.Fatalf("deleteMessage: ok=%v err=%v", ok, err)
44 }
45
46 rows := journalRows(t, db)
47 // insert, edit, delete — plus the delete's tombstone, itself an ordinary
48 // message row (empty blob) that must be journaled like any other insert.
49 if len(rows) != 4 {
50 t.Fatalf("want 4 journal rows, got %d: %+v", len(rows), rows)
51 }
52 wantKinds := []string{"msg", "edit", "delete"}
53 wantHashes := []string{ciphertextHash("blob-one"), ciphertextHash("blob-two"), ""}
54 for i, r := range rows[:3] {
55 if r.Kind != wantKinds[i] || r.MsgID != m.ID || r.ThreadID != "t1" || r.BlobHash != wantHashes[i] {
56 t.Errorf("row %d: got %+v", i, r)
57 }
58 if r.Seq != int64(i+1) {
59 t.Errorf("row %d: seq %d, want %d", i, r.Seq, i+1)
60 }
61 }
62 tomb := rows[3]
63 if tomb.Kind != "msg" || tomb.MsgID == m.ID || tomb.ThreadID != "t1" || tomb.BlobHash != ciphertextHash("") {
64 t.Errorf("tombstone journal row: got %+v", tomb)
65 }
66 // The journal must never hold ciphertext — hashes and empties only.
67 for _, r := range rows {
68 if r.BlobHash != "" {
69 if _, err := hex.DecodeString(r.BlobHash); err != nil || len(r.BlobHash) != 64 {
70 t.Errorf("blob_hash %q is not a sha256 hex digest", r.BlobHash)
71 }
72 }
73 }
74}
75
76// A refused write (wrong author, expired window) must journal nothing.
77func TestJournalSkipsRefusedWrites(t *testing.T) {
78 db := newTestDB(t)
79 m, err := db.insertMessage("t1", "blob", "author-a")
80 if err != nil {
81 t.Fatalf("insertMessage: %v", err)
82 }
83 if _, ok, _ := db.editMessage(m.ID, "evil", "author-b", time.Hour); ok {
84 t.Fatal("edit by non-author succeeded")
85 }
86 if ok, _ := db.deleteMessage(m.ID, "author-b"); ok {
87 t.Fatal("delete by non-author succeeded")
88 }
89 if rows := journalRows(t, db); len(rows) != 1 {
90 t.Fatalf("want only the insert row, got %d: %+v", len(rows), rows)
91 }
92}
93
94// Removing a member hard-deletes their messages; each one needs its own
95// 'delete' journal row so the vanished rows are accounted for.
96func TestJournalCoversRemoveUserPurge(t *testing.T) {
97 _, srv := newTestServer(t)
98 u, err := srv.db.createUser("Mallory")
99 if err != nil {
100 t.Fatalf("createUser: %v", err)
101 }
102 if _, err := srv.db.insertMessage("t1", "b1", u); err != nil {
103 t.Fatalf("insertMessage: %v", err)
104 }
105 if _, err := srv.db.insertMessage("t2", "b2", u); err != nil {
106 t.Fatalf("insertMessage: %v", err)
107 }
108 if err := srv.db.removeUser(u); err != nil {
109 t.Fatalf("removeUser: %v", err)
110 }
111 var kinds []string
112 for _, r := range journalRows(t, srv.db) {
113 kinds = append(kinds, r.Kind)
114 }
115 // Two sends, their purge deletes, then the purge's tombstones (#765) —
116 // real rows clients replay, so each carries its own 'msg' journal row
117 // (an unexplained messages row would read as tampering to a verifier).
118 // (the key-transparency 'remove' event falls between the deletes and the
119 // purge's tombstones — see TestKeyTransparencyRosterEvents.)
120 want := []string{"msg", "msg", "delete", "delete", "remove", "msg", "msg"}
121 if len(kinds) != len(want) {
122 t.Fatalf("journal kinds %v, want %v", kinds, want)
123 }
124 for i := range want {
125 if kinds[i] != want[i] {
126 t.Fatalf("journal kinds %v, want %v", kinds, want)
127 }
128 }
129}
130
131// Freeze the leaf/node/chain encodings: these are wire format — if this test
132// breaks, every deployed chain breaks with it. Fix the code, not the vector.
133func TestCheckpointHashVectors(t *testing.T) {
134 leaf := journalLeafHash(JournalRow{Seq: 1, TS: 1700000000, Kind: "msg", MsgID: 42, ThreadID: "th", BlobHash: "ab"})
135 if got := hex.EncodeToString(leaf[:]); got != "e88f7b9ea2a1fdec4ce59c21467efcef2c1b7227b9618d5581c0923328143b15" {
136 t.Errorf("leaf hash drifted: %s", got)
137 }
138 l2 := journalLeafHash(JournalRow{Seq: 2, TS: 1700000001, Kind: "edit", MsgID: 42, ThreadID: "th", BlobHash: "cd"})
139 root := merkleRoot([][32]byte{leaf, l2})
140 if got := hex.EncodeToString(root[:]); got != "7a94be3e3002c1afe379246c90db6fd04fdf438b0dc78da06ca6c3f130a5cec0" {
141 t.Errorf("node hash drifted: %s", got)
142 }
143 if got := chainHash("", hex.EncodeToString(root[:]), 2); got != "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e" {
144 t.Errorf("chain hash drifted: %s", got)
145 }
146}
147
148// A single-leaf tree is the leaf; unbalanced trees split at the largest power
149// of two below n (RFC 6962), so proofs stay standard.
150func TestMerkleRootShape(t *testing.T) {
151 mk := func(b byte) [32]byte { var h [32]byte; h[0] = b; return h }
152 one := merkleRoot([][32]byte{mk(1)})
153 if one != mk(1) {
154 t.Error("single leaf must be its own root")
155 }
156 // 3 leaves: root = node(node(l1,l2), l3) — recompute by hand via merkleRoot
157 // pieces to pin the split.
158 l1, l2, l3 := mk(1), mk(2), mk(3)
159 left := merkleRoot([][32]byte{l1, l2})
160 want := merkleRoot([][32]byte{left, l3}) // node(left, l3) — 2-leaf call IS the pair hash
161 if got := merkleRoot([][32]byte{l1, l2, l3}); got != want {
162 t.Error("3-leaf tree must split 2|1")
163 }
164}
165
166// The sweep folds journal rows into a signed, chained checkpoint that an
167// independent verifier can check end-to-end: signature, root recomputation
168// from the journal, and chain linkage.
169func TestSweepCheckpointVerifies(t *testing.T) {
170 _, srv := newTestServer(t)
171 if _, err := srv.db.insertMessage("t1", "hello", "a"); err != nil {
172 t.Fatal(err)
173 }
174 if _, err := srv.db.insertMessage("t1", "world", "a"); err != nil {
175 t.Fatal(err)
176 }
177 if n := srv.sweepCheckpoint(); n != 1 {
178 t.Fatalf("sweep wrote %d checkpoints, want 1", n)
179 }
180 // Idle sweep: nothing new, no heartbeat checkpoints.
181 if n := srv.sweepCheckpoint(); n != 0 {
182 t.Fatalf("idle sweep wrote %d checkpoints, want 0", n)
183 }
184
185 cp, ok, err := srv.db.latestCheckpoint()
186 if err != nil || !ok {
187 t.Fatalf("latestCheckpoint: ok=%v err=%v", ok, err)
188 }
189 priv, err := srv.checkpointKey()
190 if err != nil {
191 t.Fatalf("checkpointKey: %v", err)
192 }
193 pub := priv.Public().(ed25519.PublicKey)
194 p, err := verifyCheckpoint(pub, cp.Signed)
195 if err != nil {
196 t.Fatalf("verifyCheckpoint: %v", err)
197 }
198 if p.SeqFrom != 1 || p.SeqTo != 2 || p.PrevChain != "" {
199 t.Fatalf("payload %+v", p)
200 }
201
202 // Recompute the root from the journal — the verifier's real check.
203 rows := journalRows(t, srv.db)
204 leaves := make([][32]byte, len(rows))
205 for i, r := range rows {
206 leaves[i] = journalLeafHash(r)
207 }
208 root := merkleRoot(leaves)
209 if hex.EncodeToString(root[:]) != p.Root {
210 t.Error("recomputed root does not match signed root")
211 }
212 if chainHash(p.PrevChain, p.Root, p.SeqTo) != p.Chain {
213 t.Error("chain hash does not verify")
214 }
215
216 // A tampered signature must not verify.
217 var sc signedCheckpoint
218 json.Unmarshal([]byte(cp.Signed), &sc)
219 sc.Payload = base64.StdEncoding.EncodeToString([]byte(`{"seq_from":1,"seq_to":2,"root":"00","prev_chain":"","chain":"00","ts":1}`))
220 forged, _ := json.Marshal(sc)
221 if _, err := verifyCheckpoint(pub, string(forged)); err == nil {
222 t.Error("forged payload verified")
223 }
224}
225
226// Later checkpoints chain to earlier ones, so serving an older head than one
227// already witnessed is provable rollback, not a client-side anomaly.
228func TestCheckpointsChain(t *testing.T) {
229 _, srv := newTestServer(t)
230 if _, err := srv.db.insertMessage("t1", "one", "a"); err != nil {
231 t.Fatal(err)
232 }
233 srv.sweepCheckpoint()
234 if _, err := srv.db.insertMessage("t1", "two", "a"); err != nil {
235 t.Fatal(err)
236 }
237 srv.sweepCheckpoint()
238
239 cps, err := srv.db.checkpointsAfter(0, 10)
240 if err != nil || len(cps) != 2 {
241 t.Fatalf("checkpointsAfter: n=%d err=%v", len(cps), err)
242 }
243 priv, _ := srv.checkpointKey()
244 pub := priv.Public().(ed25519.PublicKey)
245 p1, err := verifyCheckpoint(pub, cps[0].Signed)
246 if err != nil {
247 t.Fatal(err)
248 }
249 p2, err := verifyCheckpoint(pub, cps[1].Signed)
250 if err != nil {
251 t.Fatal(err)
252 }
253 if p2.PrevChain != p1.Chain {
254 t.Error("second checkpoint does not chain to the first")
255 }
256 if p2.SeqFrom != p1.SeqTo+1 {
257 t.Errorf("ranges must abut: %d..%d then %d..%d", p1.SeqFrom, p1.SeqTo, p2.SeqFrom, p2.SeqTo)
258 }
259}
260
261// A backlog bigger than one batch folds into several abutting checkpoints in
262// one sweep.
263func TestSweepDrainsBacklogInBatches(t *testing.T) {
264 _, srv := newTestServer(t)
265 // 3 rows with a batch size of 10000 is one batch; simulate multi-batch by
266 // sweeping between writes instead — cheap, and pins the abutting contract.
267 for i := 0; i < 3; i++ {
268 if _, err := srv.db.insertMessage("t", "m", "a"); err != nil {
269 t.Fatal(err)
270 }
271 if n := srv.sweepCheckpoint(); n != 1 {
272 t.Fatalf("sweep %d wrote %d", i, n)
273 }
274 }
275 cps, _ := srv.db.checkpointsAfter(0, 10)
276 if len(cps) != 3 {
277 t.Fatalf("want 3 checkpoints, got %d", len(cps))
278 }
279 for i := 1; i < len(cps); i++ {
280 if cps[i].SeqFrom != cps[i-1].SeqTo+1 {
281 t.Error("checkpoint ranges must abut")
282 }
283 }
284}
285
286// The endpoint is member-gated and returns the chain plus the instance's
287// checkpoint public key.
288func TestCheckpointsEndpoint(t *testing.T) {
289 ts, srv := newTestServer(t)
290 u, err := srv.db.createUser("Alice")
291 if err != nil {
292 t.Fatal(err)
293 }
294 if err := srv.db.createKeyInvite("inv-Alice", u, "wrap", testClaimHash, 0); err != nil {
295 t.Fatal(err)
296 }
297 tok, ok, err := srv.db.claimKeyInvite("inv-Alice", "Alice", "", testClaimSecret)
298 if err != nil || !ok {
299 t.Fatalf("claim: ok=%v err=%v", ok, err)
300 }
301 if _, err := srv.db.insertMessage("t1", "hello", "a"); err != nil {
302 t.Fatal(err)
303 }
304 srv.sweepCheckpoint()
305
306 resp, err := http.Get(ts.URL + "/api/checkpoints?token=nope")
307 if err != nil {
308 t.Fatal(err)
309 }
310 resp.Body.Close()
311 if resp.StatusCode != http.StatusForbidden {
312 t.Fatalf("ungated: %d", resp.StatusCode)
313 }
314
315 resp, err = http.Get(ts.URL + "/api/checkpoints?token=" + tok)
316 if err != nil {
317 t.Fatal(err)
318 }
319 defer resp.Body.Close()
320 var body struct {
321 Checkpoints []Checkpoint `json:"checkpoints"`
322 PublicKey string `json:"public_key"`
323 }
324 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
325 t.Fatal(err)
326 }
327 if len(body.Checkpoints) != 1 {
328 t.Fatalf("want 1 checkpoint, got %d", len(body.Checkpoints))
329 }
330 pk, err := base64.StdEncoding.DecodeString(body.PublicKey)
331 if err != nil || len(pk) != ed25519.PublicKeySize {
332 t.Fatalf("bad public key %q", body.PublicKey)
333 }
334 if _, err := verifyCheckpoint(ed25519.PublicKey(pk), body.Checkpoints[0].Signed); err != nil {
335 t.Fatalf("served checkpoint does not verify with served key: %v", err)
336 }
337}
338
339// The signing key persists across restarts (same file → same key), is created
340// 0600, and never lands inside the DB file the escrow replicates.
341func TestCheckpointKeyPersistence(t *testing.T) {
342 path := t.TempDir() + "/k.ckpt-key"
343 k1, err := loadOrCreateCheckpointKey(path)
344 if err != nil {
345 t.Fatal(err)
346 }
347 k2, err := loadOrCreateCheckpointKey(path)
348 if err != nil {
349 t.Fatal(err)
350 }
351 if !k1.Equal(k2) {
352 t.Error("key did not persist across loads")
353 }
354 fi, err := os.Stat(path)
355 if err != nil {
356 t.Fatal(err)
357 }
358 if fi.Mode().Perm() != 0o600 {
359 t.Errorf("key file mode %v, want 0600", fi.Mode().Perm())
360 }
361}
362
363// The DB-stored seed persists across "restarts" (same DB → same key) — it must
364// ride every path that moves instance state (hibernation, migration), which
365// carry only the DB.
366func TestCheckpointSeedInDB(t *testing.T) {
367 db := newTestDB(t)
368 k1, err := db.ensureCheckpointSeed()
369 if err != nil {
370 t.Fatal(err)
371 }
372 k2, err := db.ensureCheckpointSeed()
373 if err != nil {
374 t.Fatal(err)
375 }
376 if !k1.Equal(k2) {
377 t.Error("seed did not persist in config")
378 }
379}
380
381// Messages that predate the journal get baselined at migration, so editing or
382// unsending an old message never produces a journal row for a message the
383// journal can't account for.
384func TestBackfillEventJournal(t *testing.T) {
385 dir := t.TempDir()
386 db, err := openDB(dir + "/test.db")
387 if err != nil {
388 t.Fatal(err)
389 }
390 m, err := db.insertMessage("t1", "old-blob", "a")
391 if err != nil {
392 t.Fatal(err)
393 }
394 // Simulate a pre-journal database: erase the journal and the marker, as if
395 // the message had been written by a build before this feature.
396 if _, err := db.sql.Exec("DELETE FROM event_journal"); err != nil {
397 t.Fatal(err)
398 }
399 if _, err := db.sql.Exec("DELETE FROM config WHERE key = 'journal_backfilled'"); err != nil {
400 t.Fatal(err)
401 }
402 // Re-running migrate (what a deploy does) must backfill exactly once.
403 if err := db.migrate(); err != nil {
404 t.Fatal(err)
405 }
406 if err := db.migrate(); err != nil {
407 t.Fatal(err)
408 }
409 rows := journalRows(t, db)
410 if len(rows) != 1 {
411 t.Fatalf("want 1 backfilled row, got %d: %+v", len(rows), rows)
412 }
413 r := rows[0]
414 if r.Kind != "msg" || r.MsgID != m.ID || r.ThreadID != "t1" || r.BlobHash != ciphertextHash("old-blob") || r.TS != m.CreatedAt {
415 t.Errorf("backfilled row %+v", r)
416 }
417}
418
419// The checkpoints table's bare columns are denormalized copies of the signed
420// payload; nothing may let them drift.
421func TestCheckpointColumnsMatchPayload(t *testing.T) {
422 _, srv := newTestServer(t)
423 if _, err := srv.db.insertMessage("t1", "x", "a"); err != nil {
424 t.Fatal(err)
425 }
426 srv.sweepCheckpoint()
427 cp, ok, err := srv.db.latestCheckpoint()
428 if err != nil || !ok {
429 t.Fatalf("latestCheckpoint: ok=%v err=%v", ok, err)
430 }
431 priv, _ := srv.checkpointKey()
432 p, err := verifyCheckpoint(priv.Public().(ed25519.PublicKey), cp.Signed)
433 if err != nil {
434 t.Fatal(err)
435 }
436 if cp.SeqFrom != p.SeqFrom || cp.SeqTo != p.SeqTo || cp.Root != p.Root || cp.Chain != p.Chain || cp.TS != p.TS {
437 t.Errorf("columns %+v drifted from payload %+v", cp, p)
438 }
439}
440
441// Structural guard: every write to the messages table must journal in the same
442// transaction (see event_journal in db.go). This test scans the source so a
443// future write path can't quietly bypass the journal — an unexplained message
444// row reads as tampering to a verifier. If you add a legitimate, journaled
445// write site, update the expected counts here.
446func TestMessageWritesAreJournaled(t *testing.T) {
447 want := map[string]int{ // file → number of messages-table write statements
448 "db.go": 9, // insertMessage, editMessage, deleteMessage (delete + tombstone insert), removeUser ×3 (purge tombstone insert #765 + delete ×2), deliverScheduledMessage, resetInstanceKey (space-thread purge, journaled via journalMessageDeletesTx)
449 }
450 re := regexp.MustCompile(`INSERT INTO messages|UPDATE messages|DELETE FROM messages`)
451 files, err := filepath.Glob("*.go")
452 if err != nil {
453 t.Fatal(err)
454 }
455 got := map[string]int{}
456 for _, f := range files {
457 if strings.HasSuffix(f, "_test.go") {
458 continue
459 }
460 b, err := os.ReadFile(f)
461 if err != nil {
462 t.Fatal(err)
463 }
464 if n := len(re.FindAll(b, -1)); n > 0 {
465 got[f] = n
466 }
467 }
468 for f, n := range got {
469 if want[f] != n {
470 t.Errorf("%s has %d messages-table writes (expected %d): a new write site MUST append to event_journal in the same tx (docs/tamper-evidence.md), then update this test", f, n, want[f])
471 }
472 }
473 for f, n := range want {
474 if got[f] != n {
475 t.Errorf("expected %d messages-table writes in %s, found %d — update this test if sites moved", n, f, got[f])
476 }
477 }
478}
479
480// The audit-path verifier recomputes the same root merkleRoot produces, for
481// every leaf index and tree size — this is the contract JS/Swift clients mirror.
482func TestMerkleAuditPathVerifies(t *testing.T) {
483 for n := 1; n <= 33; n++ {
484 leaves := make([][32]byte, n)
485 for i := range leaves {
486 leaves[i] = sha256.Sum256([]byte("leaf-" + strconv.Itoa(n) + "-" + strconv.Itoa(i)))
487 }
488 root := merkleRoot(leaves)
489 for m := 0; m < n; m++ {
490 path := merkleAuditPath(leaves, m)
491 got, ok := rootFromAuditPath(leaves[m], m, n, path)
492 if !ok || got != root {
493 t.Fatalf("n=%d m=%d: audit path did not reproduce root (ok=%v)", n, m, ok)
494 }
495 // A wrong leaf must not verify to the real root.
496 var bad [32]byte
497 bad[0] = leaves[m][0] ^ 0xff
498 if g, _ := rootFromAuditPath(bad, m, n, path); g == root {
499 t.Fatalf("n=%d m=%d: tampered leaf still verified", n, m)
500 }
501 }
502 }
503}
504
505// End-to-end: a message's inclusion proof from the endpoint verifies against
506// the checkpoint root, which itself verifies against the signed chain.
507func TestInclusionProofEndToEnd(t *testing.T) {
508 ts, srv := newTestServer(t)
509 u, err := srv.db.createUser("Alice")
510 if err != nil {
511 t.Fatal(err)
512 }
513 if err := srv.db.createKeyInvite("inv-Alice", u, "wrap", testClaimHash, 0); err != nil {
514 t.Fatal(err)
515 }
516 tok, ok, err := srv.db.claimKeyInvite("inv-Alice", "Alice", "", testClaimSecret)
517 if err != nil || !ok {
518 t.Fatalf("claim: ok=%v err=%v", ok, err)
519 }
520 // A handful of messages, then checkpoint them.
521 var ids []int64
522 for i := 0; i < 5; i++ {
523 m, err := srv.db.insertMessage("t1", "blob-"+strconv.Itoa(i), "a")
524 if err != nil {
525 t.Fatal(err)
526 }
527 ids = append(ids, m.ID)
528 }
529 srv.sweepCheckpoint()
530
531 // Fetch the proof for a middle message.
532 target := ids[2]
533 resp, err := http.Get(ts.URL + "/api/checkpoint-proof?token=" + tok + "&msg=" + strconv.FormatInt(target, 10))
534 if err != nil {
535 t.Fatal(err)
536 }
537 defer resp.Body.Close()
538 var body struct {
539 Proofs []struct {
540 Seq int64 `json:"seq"`
541 TS int64 `json:"ts"`
542 Kind string `json:"kind"`
543 MsgID int64 `json:"msg_id"`
544 ThreadID string `json:"thread_id"`
545 BlobHash string `json:"blob_hash"`
546 CheckpointID int64 `json:"checkpoint_id"`
547 Root string `json:"root"`
548 LeafIndex int `json:"leaf_index"`
549 TreeSize int `json:"tree_size"`
550 AuditPath []string `json:"audit_path"`
551 } `json:"proofs"`
552 CommittedTo int64 `json:"committed_to"`
553 }
554 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
555 t.Fatal(err)
556 }
557 if len(body.Proofs) != 1 {
558 t.Fatalf("want 1 proof for the message, got %d", len(body.Proofs))
559 }
560 p := body.Proofs[0]
561 if p.MsgID != target || p.Kind != "msg" || p.BlobHash != ciphertextHash("blob-2") {
562 t.Fatalf("proof describes the wrong row: %+v", p)
563 }
564
565 // Recompute the leaf from the proof's row fields, walk the audit path, and
566 // check the root — the client's verification, done here in Go.
567 leaf := journalLeafHash(JournalRow{Seq: p.Seq, TS: p.TS, Kind: p.Kind, MsgID: p.MsgID, ThreadID: p.ThreadID, BlobHash: p.BlobHash})
568 path := make([][32]byte, len(p.AuditPath))
569 for i, h := range p.AuditPath {
570 b, _ := hex.DecodeString(h)
571 copy(path[i][:], b)
572 }
573 root, ok := rootFromAuditPath(leaf, p.LeafIndex, p.TreeSize, path)
574 if !ok || hex.EncodeToString(root[:]) != p.Root {
575 t.Fatalf("audit path did not reproduce the proof's root (ok=%v)", ok)
576 }
577 // The proof's root must be the checkpoint's real, signed root.
578 cp, _, _ := srv.db.latestCheckpoint()
579 if p.Root != cp.Root {
580 t.Fatalf("proof root %s != checkpoint root %s", p.Root, cp.Root)
581 }
582 if body.CommittedTo != cp.SeqTo {
583 t.Fatalf("committed_to %d != head seq_to %d", body.CommittedTo, cp.SeqTo)
584 }
585
586 // A never-checkpointed message (posted after the sweep) returns no proofs
587 // but a committed_to that proves it's merely too recent, not missing.
588 m2, _ := srv.db.insertMessage("t1", "future", "a")
589 resp2, _ := http.Get(ts.URL + "/api/checkpoint-proof?token=" + tok + "&msg=" + strconv.FormatInt(m2.ID, 10))
590 defer resp2.Body.Close()
591 var body2 struct {
592 Proofs []any `json:"proofs"`
593 CommittedTo int64 `json:"committed_to"`
594 }
595 json.NewDecoder(resp2.Body).Decode(&body2)
596 if len(body2.Proofs) != 0 {
597 t.Fatalf("uncheckpointed message should have no proofs, got %d", len(body2.Proofs))
598 }
599 if m2.ID <= body2.CommittedTo {
600 // (msg id isn't seq, but the message's journal seq is > committed_to;
601 // the client compares the journal seq — here we assert the head didn't move)
602 }
603}
604
605// An edit produces a second committed journal row; both the original and the
606// edited hash are provable, so a client matches whichever it holds.
607func TestInclusionProofCoversEdits(t *testing.T) {
608 _, srv := newTestServer(t)
609 m, err := srv.db.insertMessage("t1", "original", "author-a")
610 if err != nil {
611 t.Fatal(err)
612 }
613 if _, ok, err := srv.db.editMessage(m.ID, "edited", "author-a", time.Hour); err != nil || !ok {
614 t.Fatalf("edit: ok=%v err=%v", ok, err)
615 }
616 srv.sweepCheckpoint()
617 rows, err := srv.db.journalRowsForMsg(m.ID)
618 if err != nil || len(rows) != 2 {
619 t.Fatalf("want msg+edit journal rows, got %d (err=%v)", len(rows), err)
620 }
621 cp, _, _ := srv.db.latestCheckpoint()
622 hashes := map[string]bool{}
623 for _, jr := range rows {
624 p, ok, err := srv.buildInclusionProof(jr, cp)
625 if err != nil || !ok {
626 t.Fatalf("buildInclusionProof: ok=%v err=%v", ok, err)
627 }
628 leaf := journalLeafHash(jr)
629 path := make([][32]byte, len(p.AuditPath))
630 for i, h := range p.AuditPath {
631 b, _ := hex.DecodeString(h)
632 copy(path[i][:], b)
633 }
634 root, vok := rootFromAuditPath(leaf, p.LeafIndex, p.TreeSize, path)
635 if !vok || hex.EncodeToString(root[:]) != cp.Root {
636 t.Fatalf("edit proof for kind %s did not verify", jr.Kind)
637 }
638 hashes[p.BlobHash] = true
639 }
640 if !hashes[ciphertextHash("original")] || !hashes[ciphertextHash("edited")] {
641 t.Fatalf("both original and edited ciphertext hashes must be provable: %v", hashes)
642 }
643}
644
645// Key transparency (slice 4, part 1): membership changes are journaled and
646// checkpointed like messages, so a monitor catches a server rewriting the
647// roster. A join fires once (add-device re-registering the same key doesn't
648// re-journal); removal fires a 'remove'.
649func TestKeyTransparencyRosterEvents(t *testing.T) {
650 _, srv := newTestServer(t)
651 u, err := srv.db.createUser("Alice")
652 if err != nil {
653 t.Fatal(err)
654 }
655 if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE", ""); err != nil {
656 t.Fatal(err)
657 }
658 // Add-device: re-registering the identical key is not a new join.
659 if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE", ""); err != nil {
660 t.Fatal(err)
661 }
662 joins := 0
663 for _, r := range journalRows(t, srv.db) {
664 if r.Kind == "join" {
665 joins++
666 if r.ThreadID != u || r.BlobHash != ktPubHash("PUBKEY-ALICE") || r.MsgID != 0 {
667 t.Errorf("join event malformed: %+v", r)
668 }
669 }
670 }
671 if joins != 1 {
672 t.Fatalf("want exactly one join event, got %d", joins)
673 }
674
675 if err := srv.db.removeUser(u); err != nil {
676 t.Fatal(err)
677 }
678 removes := 0
679 for _, r := range journalRows(t, srv.db) {
680 if r.Kind == "remove" && r.ThreadID == u {
681 removes++
682 }
683 }
684 if removes != 1 {
685 t.Fatalf("want one remove event, got %d", removes)
686 }
687
688 // The roster events are committed to the checkpoint chain like messages —
689 // the sweep folds them in, and the head covers their seqs. (With #807's
690 // inclusion proofs now on main, this could also build an audit path; the
691 // range check keeps this test independent of that.)
692 rows := journalRows(t, srv.db)
693 maxSeq := int64(0)
694 for _, r := range rows {
695 if r.Seq > maxSeq {
696 maxSeq = r.Seq
697 }
698 }
699 srv.sweepCheckpoint()
700 cp, ok, err := srv.db.latestCheckpoint()
701 if err != nil || !ok {
702 t.Fatalf("no checkpoint: ok=%v err=%v", ok, err)
703 }
704 if cp.SeqTo < maxSeq {
705 t.Fatalf("checkpoint head %d does not cover the roster events (max seq %d)", cp.SeqTo, maxSeq)
706 }
707}
708
709// Key transparency part 2: the joiner's bind-MAC is stored beside its pubkey
710// and served in the roster, so a T-holder can verify it. Only the first key
711// publish records it (add-device re-register keeps the original).
712func TestBindMacStoredAndServed(t *testing.T) {
713 _, srv := newTestServer(t)
714 u, err := srv.db.createUser("Alice")
715 if err != nil {
716 t.Fatal(err)
717 }
718 // A pending-DM style connection so Alice shows in a caller's roster: create
719 // a second user sharing a thread with Alice.
720 other, _ := srv.db.createUser("Bob")
721 if err := srv.db.setUserPublicKey(other, "PUBKEY-BOB", "mac-bob"); err != nil {
722 t.Fatal(err)
723 }
724 if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE", "MAC-ALICE"); err != nil {
725 t.Fatal(err)
726 }
727 // The full roster carries the bind_mac beside the key.
728 entries, err := srv.db.roster()
729 if err != nil {
730 t.Fatal(err)
731 }
732 var alice *RosterEntry
733 for i := range entries {
734 if entries[i].UserID == u {
735 alice = &entries[i]
736 }
737 }
738 if alice == nil {
739 t.Fatal("Alice not in roster")
740 }
741 if alice.PublicKey != "PUBKEY-ALICE" || alice.BindMAC != "MAC-ALICE" {
742 t.Fatalf("roster entry missing/wrong bind_mac: %+v", alice)
743 }
744
745 // Add-device: re-registering the same key must NOT overwrite the bind_mac.
746 if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE", "different-mac"); err != nil {
747 t.Fatal(err)
748 }
749 entries, _ = srv.db.roster()
750 for i := range entries {
751 if entries[i].UserID == u && entries[i].BindMAC != "MAC-ALICE" {
752 t.Fatalf("add-device overwrote bind_mac: %q", entries[i].BindMAC)
753 }
754 }
755}
756