package main import ( "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "testing" "time" ) // journalRows drains the whole journal (test helper). func journalRows(t *testing.T, db *DB) []JournalRow { t.Helper() rows, err := db.journalAfter(0, 100000) if err != nil { t.Fatalf("journalAfter: %v", err) } return rows } // Every message write path must leave exactly one explaining journal row, in // the same transaction — a message the journal can't account for reads as // tampering to a verifier. func TestJournalFollowsMessageWrites(t *testing.T) { db := newTestDB(t) m, err := db.insertMessage("t1", "blob-one", "author-a") if err != nil { t.Fatalf("insertMessage: %v", err) } if _, ok, err := db.editMessage(m.ID, "blob-two", "author-a", time.Hour); err != nil || !ok { t.Fatalf("editMessage: ok=%v err=%v", ok, err) } if ok, err := db.deleteMessage(m.ID, "author-a"); err != nil || !ok { t.Fatalf("deleteMessage: ok=%v err=%v", ok, err) } rows := journalRows(t, db) // insert, edit, delete — plus the delete's tombstone, itself an ordinary // message row (empty blob) that must be journaled like any other insert. if len(rows) != 4 { t.Fatalf("want 4 journal rows, got %d: %+v", len(rows), rows) } wantKinds := []string{"msg", "edit", "delete"} wantHashes := []string{ciphertextHash("blob-one"), ciphertextHash("blob-two"), ""} for i, r := range rows[:3] { if r.Kind != wantKinds[i] || r.MsgID != m.ID || r.ThreadID != "t1" || r.BlobHash != wantHashes[i] { t.Errorf("row %d: got %+v", i, r) } if r.Seq != int64(i+1) { t.Errorf("row %d: seq %d, want %d", i, r.Seq, i+1) } } tomb := rows[3] if tomb.Kind != "msg" || tomb.MsgID == m.ID || tomb.ThreadID != "t1" || tomb.BlobHash != ciphertextHash("") { t.Errorf("tombstone journal row: got %+v", tomb) } // The journal must never hold ciphertext — hashes and empties only. for _, r := range rows { if r.BlobHash != "" { if _, err := hex.DecodeString(r.BlobHash); err != nil || len(r.BlobHash) != 64 { t.Errorf("blob_hash %q is not a sha256 hex digest", r.BlobHash) } } } } // A refused write (wrong author, expired window) must journal nothing. func TestJournalSkipsRefusedWrites(t *testing.T) { db := newTestDB(t) m, err := db.insertMessage("t1", "blob", "author-a") if err != nil { t.Fatalf("insertMessage: %v", err) } if _, ok, _ := db.editMessage(m.ID, "evil", "author-b", time.Hour); ok { t.Fatal("edit by non-author succeeded") } if ok, _ := db.deleteMessage(m.ID, "author-b"); ok { t.Fatal("delete by non-author succeeded") } if rows := journalRows(t, db); len(rows) != 1 { t.Fatalf("want only the insert row, got %d: %+v", len(rows), rows) } } // Removing a member hard-deletes their messages; each one needs its own // 'delete' journal row so the vanished rows are accounted for. func TestJournalCoversRemoveUserPurge(t *testing.T) { _, srv := newTestServer(t) u, err := srv.db.createUser("Mallory") if err != nil { t.Fatalf("createUser: %v", err) } if _, err := srv.db.insertMessage("t1", "b1", u); err != nil { t.Fatalf("insertMessage: %v", err) } if _, err := srv.db.insertMessage("t2", "b2", u); err != nil { t.Fatalf("insertMessage: %v", err) } if err := srv.db.removeUser(u); err != nil { t.Fatalf("removeUser: %v", err) } var kinds []string for _, r := range journalRows(t, srv.db) { kinds = append(kinds, r.Kind) } // Two sends, their purge deletes, then the purge's tombstones (#765) — // real rows clients replay, so each carries its own 'msg' journal row // (an unexplained messages row would read as tampering to a verifier). // (the key-transparency 'remove' event falls between the deletes and the // purge's tombstones — see TestKeyTransparencyRosterEvents.) want := []string{"msg", "msg", "delete", "delete", "remove", "msg", "msg"} if len(kinds) != len(want) { t.Fatalf("journal kinds %v, want %v", kinds, want) } for i := range want { if kinds[i] != want[i] { t.Fatalf("journal kinds %v, want %v", kinds, want) } } } // Freeze the leaf/node/chain encodings: these are wire format — if this test // breaks, every deployed chain breaks with it. Fix the code, not the vector. func TestCheckpointHashVectors(t *testing.T) { leaf := journalLeafHash(JournalRow{Seq: 1, TS: 1700000000, Kind: "msg", MsgID: 42, ThreadID: "th", BlobHash: "ab"}) if got := hex.EncodeToString(leaf[:]); got != "e88f7b9ea2a1fdec4ce59c21467efcef2c1b7227b9618d5581c0923328143b15" { t.Errorf("leaf hash drifted: %s", got) } l2 := journalLeafHash(JournalRow{Seq: 2, TS: 1700000001, Kind: "edit", MsgID: 42, ThreadID: "th", BlobHash: "cd"}) root := merkleRoot([][32]byte{leaf, l2}) if got := hex.EncodeToString(root[:]); got != "7a94be3e3002c1afe379246c90db6fd04fdf438b0dc78da06ca6c3f130a5cec0" { t.Errorf("node hash drifted: %s", got) } if got := chainHash("", hex.EncodeToString(root[:]), 2); got != "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e" { t.Errorf("chain hash drifted: %s", got) } } // A single-leaf tree is the leaf; unbalanced trees split at the largest power // of two below n (RFC 6962), so proofs stay standard. func TestMerkleRootShape(t *testing.T) { mk := func(b byte) [32]byte { var h [32]byte; h[0] = b; return h } one := merkleRoot([][32]byte{mk(1)}) if one != mk(1) { t.Error("single leaf must be its own root") } // 3 leaves: root = node(node(l1,l2), l3) — recompute by hand via merkleRoot // pieces to pin the split. l1, l2, l3 := mk(1), mk(2), mk(3) left := merkleRoot([][32]byte{l1, l2}) want := merkleRoot([][32]byte{left, l3}) // node(left, l3) — 2-leaf call IS the pair hash if got := merkleRoot([][32]byte{l1, l2, l3}); got != want { t.Error("3-leaf tree must split 2|1") } } // The sweep folds journal rows into a signed, chained checkpoint that an // independent verifier can check end-to-end: signature, root recomputation // from the journal, and chain linkage. func TestSweepCheckpointVerifies(t *testing.T) { _, srv := newTestServer(t) if _, err := srv.db.insertMessage("t1", "hello", "a"); err != nil { t.Fatal(err) } if _, err := srv.db.insertMessage("t1", "world", "a"); err != nil { t.Fatal(err) } if n := srv.sweepCheckpoint(); n != 1 { t.Fatalf("sweep wrote %d checkpoints, want 1", n) } // Idle sweep: nothing new, no heartbeat checkpoints. if n := srv.sweepCheckpoint(); n != 0 { t.Fatalf("idle sweep wrote %d checkpoints, want 0", n) } cp, ok, err := srv.db.latestCheckpoint() if err != nil || !ok { t.Fatalf("latestCheckpoint: ok=%v err=%v", ok, err) } priv, err := srv.checkpointKey() if err != nil { t.Fatalf("checkpointKey: %v", err) } pub := priv.Public().(ed25519.PublicKey) p, err := verifyCheckpoint(pub, cp.Signed) if err != nil { t.Fatalf("verifyCheckpoint: %v", err) } if p.SeqFrom != 1 || p.SeqTo != 2 || p.PrevChain != "" { t.Fatalf("payload %+v", p) } // Recompute the root from the journal — the verifier's real check. rows := journalRows(t, srv.db) leaves := make([][32]byte, len(rows)) for i, r := range rows { leaves[i] = journalLeafHash(r) } root := merkleRoot(leaves) if hex.EncodeToString(root[:]) != p.Root { t.Error("recomputed root does not match signed root") } if chainHash(p.PrevChain, p.Root, p.SeqTo) != p.Chain { t.Error("chain hash does not verify") } // A tampered signature must not verify. var sc signedCheckpoint json.Unmarshal([]byte(cp.Signed), &sc) sc.Payload = base64.StdEncoding.EncodeToString([]byte(`{"seq_from":1,"seq_to":2,"root":"00","prev_chain":"","chain":"00","ts":1}`)) forged, _ := json.Marshal(sc) if _, err := verifyCheckpoint(pub, string(forged)); err == nil { t.Error("forged payload verified") } } // Later checkpoints chain to earlier ones, so serving an older head than one // already witnessed is provable rollback, not a client-side anomaly. func TestCheckpointsChain(t *testing.T) { _, srv := newTestServer(t) if _, err := srv.db.insertMessage("t1", "one", "a"); err != nil { t.Fatal(err) } srv.sweepCheckpoint() if _, err := srv.db.insertMessage("t1", "two", "a"); err != nil { t.Fatal(err) } srv.sweepCheckpoint() cps, err := srv.db.checkpointsAfter(0, 10) if err != nil || len(cps) != 2 { t.Fatalf("checkpointsAfter: n=%d err=%v", len(cps), err) } priv, _ := srv.checkpointKey() pub := priv.Public().(ed25519.PublicKey) p1, err := verifyCheckpoint(pub, cps[0].Signed) if err != nil { t.Fatal(err) } p2, err := verifyCheckpoint(pub, cps[1].Signed) if err != nil { t.Fatal(err) } if p2.PrevChain != p1.Chain { t.Error("second checkpoint does not chain to the first") } if p2.SeqFrom != p1.SeqTo+1 { t.Errorf("ranges must abut: %d..%d then %d..%d", p1.SeqFrom, p1.SeqTo, p2.SeqFrom, p2.SeqTo) } } // A backlog bigger than one batch folds into several abutting checkpoints in // one sweep. func TestSweepDrainsBacklogInBatches(t *testing.T) { _, srv := newTestServer(t) // 3 rows with a batch size of 10000 is one batch; simulate multi-batch by // sweeping between writes instead — cheap, and pins the abutting contract. for i := 0; i < 3; i++ { if _, err := srv.db.insertMessage("t", "m", "a"); err != nil { t.Fatal(err) } if n := srv.sweepCheckpoint(); n != 1 { t.Fatalf("sweep %d wrote %d", i, n) } } cps, _ := srv.db.checkpointsAfter(0, 10) if len(cps) != 3 { t.Fatalf("want 3 checkpoints, got %d", len(cps)) } for i := 1; i < len(cps); i++ { if cps[i].SeqFrom != cps[i-1].SeqTo+1 { t.Error("checkpoint ranges must abut") } } } // The endpoint is member-gated and returns the chain plus the instance's // checkpoint public key. func TestCheckpointsEndpoint(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) } if _, err := srv.db.insertMessage("t1", "hello", "a"); err != nil { t.Fatal(err) } srv.sweepCheckpoint() resp, err := http.Get(ts.URL + "/api/checkpoints?token=nope") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("ungated: %d", resp.StatusCode) } resp, err = http.Get(ts.URL + "/api/checkpoints?token=" + tok) if err != nil { t.Fatal(err) } defer resp.Body.Close() var body struct { Checkpoints []Checkpoint `json:"checkpoints"` PublicKey string `json:"public_key"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatal(err) } if len(body.Checkpoints) != 1 { t.Fatalf("want 1 checkpoint, got %d", len(body.Checkpoints)) } pk, err := base64.StdEncoding.DecodeString(body.PublicKey) if err != nil || len(pk) != ed25519.PublicKeySize { t.Fatalf("bad public key %q", body.PublicKey) } if _, err := verifyCheckpoint(ed25519.PublicKey(pk), body.Checkpoints[0].Signed); err != nil { t.Fatalf("served checkpoint does not verify with served key: %v", err) } } // The signing key persists across restarts (same file → same key), is created // 0600, and never lands inside the DB file the escrow replicates. func TestCheckpointKeyPersistence(t *testing.T) { path := t.TempDir() + "/k.ckpt-key" k1, err := loadOrCreateCheckpointKey(path) if err != nil { t.Fatal(err) } k2, err := loadOrCreateCheckpointKey(path) if err != nil { t.Fatal(err) } if !k1.Equal(k2) { t.Error("key did not persist across loads") } fi, err := os.Stat(path) if err != nil { t.Fatal(err) } if fi.Mode().Perm() != 0o600 { t.Errorf("key file mode %v, want 0600", fi.Mode().Perm()) } } // The DB-stored seed persists across "restarts" (same DB → same key) — it must // ride every path that moves instance state (hibernation, migration), which // carry only the DB. func TestCheckpointSeedInDB(t *testing.T) { db := newTestDB(t) k1, err := db.ensureCheckpointSeed() if err != nil { t.Fatal(err) } k2, err := db.ensureCheckpointSeed() if err != nil { t.Fatal(err) } if !k1.Equal(k2) { t.Error("seed did not persist in config") } } // Messages that predate the journal get baselined at migration, so editing or // unsending an old message never produces a journal row for a message the // journal can't account for. func TestBackfillEventJournal(t *testing.T) { dir := t.TempDir() db, err := openDB(dir + "/test.db") if err != nil { t.Fatal(err) } m, err := db.insertMessage("t1", "old-blob", "a") if err != nil { t.Fatal(err) } // Simulate a pre-journal database: erase the journal and the marker, as if // the message had been written by a build before this feature. if _, err := db.sql.Exec("DELETE FROM event_journal"); err != nil { t.Fatal(err) } if _, err := db.sql.Exec("DELETE FROM config WHERE key = 'journal_backfilled'"); err != nil { t.Fatal(err) } // Re-running migrate (what a deploy does) must backfill exactly once. if err := db.migrate(); err != nil { t.Fatal(err) } if err := db.migrate(); err != nil { t.Fatal(err) } rows := journalRows(t, db) if len(rows) != 1 { t.Fatalf("want 1 backfilled row, got %d: %+v", len(rows), rows) } r := rows[0] if r.Kind != "msg" || r.MsgID != m.ID || r.ThreadID != "t1" || r.BlobHash != ciphertextHash("old-blob") || r.TS != m.CreatedAt { t.Errorf("backfilled row %+v", r) } } // The checkpoints table's bare columns are denormalized copies of the signed // payload; nothing may let them drift. func TestCheckpointColumnsMatchPayload(t *testing.T) { _, srv := newTestServer(t) if _, err := srv.db.insertMessage("t1", "x", "a"); err != nil { t.Fatal(err) } srv.sweepCheckpoint() cp, ok, err := srv.db.latestCheckpoint() if err != nil || !ok { t.Fatalf("latestCheckpoint: ok=%v err=%v", ok, err) } priv, _ := srv.checkpointKey() p, err := verifyCheckpoint(priv.Public().(ed25519.PublicKey), cp.Signed) if err != nil { t.Fatal(err) } if cp.SeqFrom != p.SeqFrom || cp.SeqTo != p.SeqTo || cp.Root != p.Root || cp.Chain != p.Chain || cp.TS != p.TS { t.Errorf("columns %+v drifted from payload %+v", cp, p) } } // Structural guard: every write to the messages table must journal in the same // transaction (see event_journal in db.go). This test scans the source so a // future write path can't quietly bypass the journal — an unexplained message // row reads as tampering to a verifier. If you add a legitimate, journaled // write site, update the expected counts here. func TestMessageWritesAreJournaled(t *testing.T) { want := map[string]int{ // file → number of messages-table write statements "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) } re := regexp.MustCompile(`INSERT INTO messages|UPDATE messages|DELETE FROM messages`) files, err := filepath.Glob("*.go") if err != nil { t.Fatal(err) } got := map[string]int{} for _, f := range files { if strings.HasSuffix(f, "_test.go") { continue } b, err := os.ReadFile(f) if err != nil { t.Fatal(err) } if n := len(re.FindAll(b, -1)); n > 0 { got[f] = n } } for f, n := range got { if want[f] != n { 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]) } } for f, n := range want { if got[f] != n { t.Errorf("expected %d messages-table writes in %s, found %d — update this test if sites moved", n, f, got[f]) } } } // 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) } } // Key transparency (slice 4, part 1): membership changes are journaled and // checkpointed like messages, so a monitor catches a server rewriting the // roster. A join fires once (add-device re-registering the same key doesn't // re-journal); removal fires a 'remove'. func TestKeyTransparencyRosterEvents(t *testing.T) { _, srv := newTestServer(t) u, err := srv.db.createUser("Alice") if err != nil { t.Fatal(err) } if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE", ""); err != nil { t.Fatal(err) } // Add-device: re-registering the identical key is not a new join. if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE", ""); err != nil { t.Fatal(err) } joins := 0 for _, r := range journalRows(t, srv.db) { if r.Kind == "join" { joins++ if r.ThreadID != u || r.BlobHash != ktPubHash("PUBKEY-ALICE") || r.MsgID != 0 { t.Errorf("join event malformed: %+v", r) } } } if joins != 1 { t.Fatalf("want exactly one join event, got %d", joins) } if err := srv.db.removeUser(u); err != nil { t.Fatal(err) } removes := 0 for _, r := range journalRows(t, srv.db) { if r.Kind == "remove" && r.ThreadID == u { removes++ } } if removes != 1 { t.Fatalf("want one remove event, got %d", removes) } // The roster events are committed to the checkpoint chain like messages — // the sweep folds them in, and the head covers their seqs. (With #807's // inclusion proofs now on main, this could also build an audit path; the // range check keeps this test independent of that.) rows := journalRows(t, srv.db) maxSeq := int64(0) for _, r := range rows { if r.Seq > maxSeq { maxSeq = r.Seq } } srv.sweepCheckpoint() cp, ok, err := srv.db.latestCheckpoint() if err != nil || !ok { t.Fatalf("no checkpoint: ok=%v err=%v", ok, err) } if cp.SeqTo < maxSeq { t.Fatalf("checkpoint head %d does not cover the roster events (max seq %d)", cp.SeqTo, maxSeq) } } // Key transparency part 2: the joiner's bind-MAC is stored beside its pubkey // and served in the roster, so a T-holder can verify it. Only the first key // publish records it (add-device re-register keeps the original). func TestBindMacStoredAndServed(t *testing.T) { _, srv := newTestServer(t) u, err := srv.db.createUser("Alice") if err != nil { t.Fatal(err) } // A pending-DM style connection so Alice shows in a caller's roster: create // a second user sharing a thread with Alice. other, _ := srv.db.createUser("Bob") if err := srv.db.setUserPublicKey(other, "PUBKEY-BOB", "mac-bob"); err != nil { t.Fatal(err) } if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE", "MAC-ALICE"); err != nil { t.Fatal(err) } // The full roster carries the bind_mac beside the key. entries, err := srv.db.roster() if err != nil { t.Fatal(err) } var alice *RosterEntry for i := range entries { if entries[i].UserID == u { alice = &entries[i] } } if alice == nil { t.Fatal("Alice not in roster") } if alice.PublicKey != "PUBKEY-ALICE" || alice.BindMAC != "MAC-ALICE" { t.Fatalf("roster entry missing/wrong bind_mac: %+v", alice) } // Add-device: re-registering the same key must NOT overwrite the bind_mac. if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE", "different-mac"); err != nil { t.Fatal(err) } entries, _ = srv.db.roster() for i := range entries { if entries[i].UserID == u && entries[i].BindMAC != "MAC-ALICE" { t.Fatalf("add-device overwrote bind_mac: %q", entries[i].BindMAC) } } }