eleven / ocho Public

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>
Paul Campbell fc2e4a63b2d23df2929376321bad62e55dbd5b7d parent 5e4bf8c
3 files changed, +131 −3
  • checkpoint_test.go +67 −1
  • db.go +43 −2
  • docs/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