server: key transparency part 1 — roster changes are tamper-evident (#573) (#808)
Slice 4a of the tamper-evidence build order (docs/tamper-evidence.md). Membership changes now append to the event_journal like message events, so they're committed to the signed checkpoint chain and a monitor catches a server rewriting the roster: - setUserPublicKey journals a 'join' KT event on a member's FIRST key publish (transactionally, so a keyed member can't lack the event); re-registering the same key (add-device) is not a new join. - removeUser journals a 'remove' event. - Roster events reuse the journal's generic columns as the kind's detail (thread_id = user id, blob_hash = sha256(pubkey) for join / '' for remove) — the frozen leaf encoding already covers these fields, no wire change. Safe, additive, no crypto: this is the substrate. The bind-MAC + verify-before-wrap crypto that closes the join-path key-substitution attack (part 3b in the doc) is the NEXT PR, gated on adversarial review + a canary because it touches the crown-jewel join path. go test ./... green (existing remove-purge journal test updated for the 'remove' event).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+131
−3
checkpoint_test.go+67 −1db.go+43 −2docs/tamper-evidence.md+21 −0
diff --git a/checkpoint_test.go b/checkpoint_test.go| index 21ae398..4bb4819 100644 |
| --- a/checkpoint_test.go |
| +++ b/checkpoint_test.go |
| @@ -115,7 +115,9 @@ func TestJournalCoversRemoveUserPurge(t *testing.T) { |
| // 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). |
| - want := []string{"msg", "msg", "delete", "delete", "msg", "msg"} |
| + // (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) |
| } |
| @@ -639,3 +641,67 @@ func TestInclusionProofCoversEdits(t *testing.T) { |
| 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) |
| + } |
| +} |
diff --git a/db.go b/db.go| index ec6789f..0ec71bd 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -1589,8 +1589,44 @@ type RosterEntry struct { |
| } |
| func (db *DB) setUserPublicKey(userID, pub string) error { |
| - _, err := db.sql.Exec("UPDATE users SET public_key = ? WHERE id = ?", pub, userID) |
| - return err |
| + // Transactional so the key transparency 'join' event can't be missing for a |
| + // member who's now keyed — a gap would read as roster tampering to a |
| + // verifier, exactly like a missing message journal row. |
| + tx, err := db.sql.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + var cur string |
| + tx.QueryRow("SELECT COALESCE(public_key, '') FROM users WHERE id = ?", userID).Scan(&cur) |
| + if _, err := tx.Exec("UPDATE users SET public_key = ? WHERE id = ?", pub, userID); err != nil { |
| + return err |
| + } |
| + // First key for this member (empty → set) is a 'join' KT event. Re-setting |
| + // the SAME key (add-device) is not a new join, so it doesn't re-journal. |
| + if cur == "" && pub != "" { |
| + if err := appendRosterEventTx(tx, time.Now().Unix(), "join", userID, ktPubHash(pub)); err != nil { |
| + return err |
| + } |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +// appendRosterEventTx journals a key-transparency roster event (docs/tamper- |
| +// evidence.md slice 4): membership changes are committed to the signed |
| +// checkpoint chain like message events, so a monitor catches a server that |
| +// rewrites the roster. The journal's generic columns carry the kind-scoped |
| +// detail: thread_id = the member's user id, blob_hash = sha256 of their public |
| +// key ('join') or "" ('remove'); msg_id is unused (0). |
| +func appendRosterEventTx(tx *sql.Tx, ts int64, kind, userID, pubHash string) error { |
| + return appendJournalTx(tx, ts, kind, 0, userID, pubHash) |
| +} |
| + |
| +// ktPubHash commits to a public key without storing it twice — sha256 hex of |
| +// the key string, the same shape ciphertextHash uses for message blobs. |
| +func ktPubHash(pub string) string { |
| + sum := sha256.Sum256([]byte(pub)) |
| + return hex.EncodeToString(sum[:]) |
| } |
| // userPublicKey returns a user's registered ECDH public key ("" if not set yet). |
| @@ -1702,6 +1738,11 @@ func (db *DB) removeUser(id string) error { |
| if err := journalMessageDeletesTx(tx, "author_hash = ?", id); err != nil { |
| return err |
| } |
| + // A 'remove' key-transparency event, so the roster change is committed to |
| + // the chain like the member's join was (docs/tamper-evidence.md slice 4). |
| + if err := appendRosterEventTx(tx, time.Now().Unix(), "remove", id, ""); err != nil { |
| + return err |
| + } |
| // Tombstone each doomed message (#765, same mechanism as deleteMessage's |
| // #581): members' devices that already hold these messages learn to drop |
| // them from ordinary forward catch-up — a purge is exactly when that |
diff --git a/docs/tamper-evidence.md b/docs/tamper-evidence.md| index 578f894..c0ed8bc 100644 |
| --- a/docs/tamper-evidence.md |
| +++ b/docs/tamper-evidence.md |
| @@ -271,6 +271,27 @@ substrate has soaked in production. It is deliberately not specified further |
| here: the failure mode of KT designs is specifying the whole cathedral before |
| the foundation has carried load. |
| +**Part 3a — roster/key journal events (built).** The first, safe increment: |
| +membership changes are journaled like message events, so they're committed to |
| +the signed checkpoint chain and a monitor catches a server rewriting the |
| +roster. A member's first key publish appends a `join` event; removal appends a |
| +`remove`. They reuse the journal's generic columns as the kind's detail record |
| +(`thread_id` = the member's user id, `blob_hash` = sha256 of the public key for |
| +`join` / empty for `remove`, `msg_id` unused) — the leaf encoding already |
| +covers these fields, so no wire-format change. Re-registering the same key |
| +(add-device) is not a new join. This touches no crypto and no join-path |
| +decision; it just makes the roster tamper-evident on the instance chain. |
| + |
| +**Part 3b — the bind MAC + verify-before-wrap (next, review-gated).** The |
| +security-critical increment that closes the key-substitution attack (Q2): |
| +`bind_key = HKDF(T, "lchat-invite-bind")`; the joiner publishes |
| +`bind_mac = HMAC(bind_key, joiner_pubkey)` at claim; the inviter's client |
| +verifies it against the published roster key before wrapping any thread key, |
| +and clients enforce verify-before-wrap generally. Because it changes the |
| +join/key-distribution path — the crown jewel of the one rule — it ships as its |
| +own PR with an adversarial review pass and a canary before it reaches any real |
| +space. |
| + |
| ## Part 4 — client sampling verification (design sketch) |
| Because clients hold plaintext truth, whole-DB verification needs no |