| 1 | package main |
| 2 | |
| 3 | import ( |
| 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). |
| 20 | func 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. |
| 32 | func 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. |
| 77 | func 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. |
| 96 | func 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. |
| 133 | func 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. |
| 150 | func 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. |
| 169 | func 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. |
| 228 | func 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. |
| 263 | func 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. |
| 288 | func 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. |
| 341 | func 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. |
| 366 | func 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. |
| 384 | func 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. |
| 421 | func 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. |
| 446 | func 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. |
| 482 | func 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. |
| 507 | func 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. |
| 607 | func 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'. |
| 649 | func 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). |
| 712 | func 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 | |