crypto+server+web: key transparency part 2 — bind-MAC (advisory) for #573 (#813)
* crypto+server+web: key transparency part 2 — bind-MAC (advisory) (#573) Closes the join-path key-substitution attack (advisory rollout per Paul): - crypto.js: bind_key = HKDF(T, "lchat-invite-bind") in deriveInvite; bindMac/verifyBindMac (HMAC-SHA256 over the base64url pubkey string). - Joiner publishes bind_mac with POST /api/pubkey (join.js + shell.js claimInvite; fresh-person only, add-device keeps its original binding). - Server: users.bind_mac column (additive), set atomically with the pubkey + KT 'join' journal on FIRST key only (never overwritten); served in the roster beside public_key. - Advisory verify-before-wrap: the inviter (the one T-holder) stashes each invitee's bind key, and on roster load confirms the roster key is the one they published. Mismatch → 'key-substitution' integrity alert (raises the banner), NEVER blocks a wrap; a missing MAC (pre-KT member) is unverified, not an alarm; a verified binding is dropped (the key can't be re-registered). Tests: crypto (bindKey determinism/domain-sep, MAC verify + substitution/ wrong-key/malformed rejection), Go (bind_mac stored + served, add-device doesn't overwrite), web (recordAlert dedup). go + npm green. Web-first; native follows. STAGED for adversarial review + canary before merge — it touches the crown-jewel join path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * kt: harden bind-MAC per adversarial review Two-reviewer pass (crypto soundness + join-path integration). Core primitive confirmed unforgeable by the server; fixes to the verification layer: - MAC over the DECODED key bytes, not the base64url string — a server can re-encode the roster string (padding, +/- alphabet) without changing the key, which would have broken an honest member's verify (a cry-wolf / false- alarm primitive). Now canonical. (crypto reviewer #4) - verifyInviteBindings re-verifies on EVERY roster load instead of once-and- discard, so a substitution at a LATER wrap (new group, tier re-key) is caught, not just the join window. (crypto reviewer #2) - A keyed invitee with no bind-MAC is surfaced in /integrity as 'couldn't be key-verified' instead of silently tolerated forever — the downgrade the compromised server could otherwise do quietly. (crypto #1) - bindMac computed OUTSIDE the load-bearing pubkey-publish try in both join.js and shell.js — a MAC hiccup can never skip key registration (claimed-but-keyless broken join). (integration reviewer #1/#2) - lchat_invite_binds bounded: prune members who left the roster + hard cap. (integration #3) - Comment guard: only 1:1 single-use invites stash a bind key; reusable/ cohort invites share T so their MAC proves nothing. (crypto #5) Integration reviewer found NO hard breakage (Scan counts match, all callers updated, native/bot tolerate the field). New tests: canonical-bytes property. go + npm green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
20 files changed,
+365
−73
ban_test.go+1 −2bot_appbuild_test.go+1 −1bot_serviceop_test.go+1 −1bots_test.go+5 −5checkpoint_test.go+50 −2db.go+26 −10db_test.go+3 −5handlers_test.go+11 −11invites_test.go+8 −8labels_test.go+2 −2main.go+9 −1push_ssrf_test.go+7 −7release_test.go+10 −6subscriptions_test.go+2 −2test/crypto.test.mjs+42 −0test/integrity.test.mjs+17 −0web/static/crypto.js+39 −5web/static/integrity.js+13 −0web/static/join.js+15 −1web/static/shell.js+103 −4
diff --git a/ban_test.go b/ban_test.go| index bcc7a25..ea55ca7 100644 |
| --- a/ban_test.go |
| +++ b/ban_test.go |
| @@ -17,7 +17,7 @@ func TestBan(t *testing.T) { |
| t.Fatal(err) |
| } |
| tokBad, uidBad, tidBad := joinedMember(t, srv, "Spammer") |
| - if err := srv.db.setUserPublicKey(uidBad, "PUBKEY-SPAMMER"); err != nil { |
| + if err := srv.db.setUserPublicKey(uidBad, "PUBKEY-SPAMMER", ""); err != nil { |
| t.Fatal(err) |
| } |
| // A queued future message from the spammer. |
| @@ -131,4 +131,3 @@ func TestModerationBrakes(t *testing.T) { |
| } |
| } |
| } |
| - |
diff --git a/bot_appbuild_test.go b/bot_appbuild_test.go| index eea8539..3a7901f 100644 |
| --- a/bot_appbuild_test.go |
| +++ b/bot_appbuild_test.go |
| @@ -349,7 +349,7 @@ func TestBotBuildsCompanionCommandApp(t *testing.T) { |
| {"type": "text", "text": "Building it."}, |
| {"type": "tool_use", "name": "create_app", "input": map[string]any{ |
| "name": "habit", "title": "Habits", "glyph": "🌱", |
| - "spec": "a habit tracker; /habit adds one; the thread reads as a conversation", |
| + "spec": "a habit tracker; /habit adds one; the thread reads as a conversation", |
| "commands": []any{map[string]any{"name": "/habit", "desc": "Track a habit", "args": "<name>"}}, |
| }}, |
| }, |
diff --git a/bot_serviceop_test.go b/bot_serviceop_test.go| index e272fb3..b5e459e 100644 |
| --- a/bot_serviceop_test.go |
| +++ b/bot_serviceop_test.go |
| @@ -105,7 +105,7 @@ func TestServiceOpEndToEnd(t *testing.T) { |
| ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ |
| "type": "connect.op", "service": "dnsimple", "op": "domain.register", |
| - "args": map[string]any{"account": "123", "domain": "blip.com", "registrant_id": "77"}, |
| + "args": map[string]any{"account": "123", "domain": "blip.com", "registrant_id": "77"}, |
| "grant": grant, "rid": rid, "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), |
| }) |
| } |
diff --git a/bots_test.go b/bots_test.go| index ea3ab46..42141aa 100644 |
| --- a/bots_test.go |
| +++ b/bots_test.go |
| @@ -17,7 +17,7 @@ func TestHelpersCatalogInConnections(t *testing.T) { |
| if err != nil { |
| t.Fatalf("createUser %s: %v", name, err) |
| } |
| - if err := db.setUserPublicKey(id, "pub-"+name); err != nil { |
| + if err := db.setUserPublicKey(id, "pub-"+name, ""); err != nil { |
| t.Fatalf("setUserPublicKey %s: %v", name, err) |
| } |
| return id |
| @@ -121,7 +121,7 @@ func TestHelpersCatalogHTTP(t *testing.T) { |
| paul, _ := srv.db.createUser("Paul") |
| srv.db.createKeyInvite("inv-paul", paul, "", testClaimHash, 0) |
| tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "", testClaimSecret) |
| - srv.db.setUserPublicKey(paul, "pub-paul") |
| + srv.db.setUserPublicKey(paul, "pub-paul", "") |
| // Admin mints an agent invite with a description, shown to members. |
| r := adminPost(t, ts.URL+"/api/admin/key-invites", |
| @@ -154,7 +154,7 @@ func TestHelpersCatalogHTTP(t *testing.T) { |
| t.Fatalf("bot claim: ok=%v err=%v", ok, err) |
| } |
| botID, _ := srv.db.deviceTokenUser(botTok) |
| - srv.db.setUserPublicKey(botID, "pub-sunny") |
| + srv.db.setUserPublicKey(botID, "pub-sunny", "") |
| var helper *RosterEntry |
| roster := fetchRoster() |
| @@ -180,7 +180,7 @@ func TestVettedAgentSeesFullRoster(t *testing.T) { |
| uid, _ := srv.db.createUser(label) |
| srv.db.createKeyInvite(inv, uid, "", testClaimHash, 0) |
| tok, _, _ := srv.db.claimKeyInvite(inv, label, "", testClaimSecret) |
| - srv.db.setUserPublicKey(uid, "pub-"+label) |
| + srv.db.setUserPublicKey(uid, "pub-"+label, "") |
| return uid, tok |
| } |
| _, paulTok := join("Paul", "inv-p") |
| @@ -311,7 +311,7 @@ func TestBotKindDeclareAndWelcome(t *testing.T) { |
| uid, _ := srv.db.createUser("Eleven") |
| srv.db.createKeyInvite("inv-k", uid, "", testClaimHash, 0) |
| tok, _, _ := srv.db.claimKeyInvite("inv-k", "Eleven", "", testClaimSecret) |
| - srv.db.setUserPublicKey(uid, "pub-eleven") |
| + srv.db.setUserPublicKey(uid, "pub-eleven", "") |
| srv.db.setUserAgent(uid) |
| declare := func(token, kind string) int { |
diff --git a/checkpoint_test.go b/checkpoint_test.go| index 4bb4819..78fe8ce 100644 |
| --- a/checkpoint_test.go |
| +++ b/checkpoint_test.go |
| @@ -652,11 +652,11 @@ func TestKeyTransparencyRosterEvents(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE"); err != nil { |
| + 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 { |
| + if err := srv.db.setUserPublicKey(u, "PUBKEY-ALICE", ""); err != nil { |
| t.Fatal(err) |
| } |
| joins := 0 |
| @@ -705,3 +705,51 @@ func TestKeyTransparencyRosterEvents(t *testing.T) { |
| 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) |
| + } |
| + } |
| +} |
diff --git a/db.go b/db.go| index 763f2f5..9b04a94 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -644,6 +644,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_checkpoints_seq_from ON checkpoints(seq_fr |
| // and each user's ECDH public key (the roster). Both additive over old DBs. |
| "ALTER TABLE messages ADD COLUMN thread_id TEXT", |
| "ALTER TABLE users ADD COLUMN public_key TEXT", |
| + // Key transparency (#573, docs/tamper-evidence.md): the joiner's HMAC of |
| + // its public key under bind_key = HKDF(invite secret T). A T-holder (the |
| + // inviter) verifies it before trusting/wrapping to the member, catching a |
| + // server that substituted its own key. Opaque to the server (no T). |
| + "ALTER TABLE users ADD COLUMN bind_mac TEXT", |
| // Profile photo: an encrypted blob id (encrypted with the instance key). |
| "ALTER TABLE users ADD COLUMN avatar_blob TEXT", |
| // A person's display name encrypted under the instance key (server-blind). |
| @@ -1607,7 +1612,11 @@ type RosterEntry struct { |
| Label string `json:"label"` |
| LabelBlob string `json:"label_blob,omitempty"` |
| PublicKey string `json:"public_key"` |
| - Avatar string `json:"avatar,omitempty"` |
| + // Key transparency (#573): the joiner's HMAC of PublicKey under HKDF(invite |
| + // secret T). A verifier holding T recomputes it (crypto.js verifyBindMac) to |
| + // confirm the server didn't substitute its own key. "" for pre-KT members. |
| + BindMAC string `json:"bind_mac,omitempty"` |
| + Avatar string `json:"avatar,omitempty"` |
| // The photo's alt text, encrypted under the instance key (client decrypts). |
| AvatarAltBlob string `json:"avatar_alt_blob,omitempty"` |
| // A person's free-text "about me", encrypted under the instance key (client |
| @@ -1630,7 +1639,7 @@ type RosterEntry struct { |
| Presence string `json:"presence,omitempty"` |
| } |
| -func (db *DB) setUserPublicKey(userID, pub string) error { |
| +func (db *DB) setUserPublicKey(userID, pub, bindMac string) error { |
| // 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. |
| @@ -1641,15 +1650,22 @@ func (db *DB) setUserPublicKey(userID, pub string) error { |
| 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. |
| + // First key for this member (empty → set): store the pubkey AND its |
| + // key-transparency bind-MAC, and journal the 'join'. The bind-MAC is written |
| + // ONLY here and never overwritten, so an add-device re-register of the same |
| + // key can't drop (or replace) the original binding (#573). |
| if cur == "" && pub != "" { |
| + if _, err := tx.Exec("UPDATE users SET public_key = ?, bind_mac = ? WHERE id = ?", pub, bindMac, userID); err != nil { |
| + return err |
| + } |
| if err := appendRosterEventTx(tx, time.Now().Unix(), "join", userID, ktPubHash(pub)); err != nil { |
| return err |
| } |
| + } else { |
| + // Re-registering the same key (add-device) — keep the existing bind_mac. |
| + if _, err := tx.Exec("UPDATE users SET public_key = ? WHERE id = ?", pub, userID); err != nil { |
| + return err |
| + } |
| } |
| return tx.Commit() |
| } |
| @@ -2005,7 +2021,7 @@ func (db *DB) setAvatar(userID, blobID, altBlob string) error { |
| // they share a thread with). Kept for admin/back-end use and tests. |
| func (db *DB) roster() ([]RosterEntry, error) { |
| rows, err := db.sql.Query( |
| - `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.description_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, '') |
| + `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.description_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, ''), COALESCE(u.bind_mac, '') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.public_key IS NOT NULL AND u.public_key <> '' ORDER BY u.label`, |
| ) |
| @@ -2028,7 +2044,7 @@ func (db *DB) roster() ([]RosterEntry, error) { |
| // connections-only; only vetted, clearly-badged helpers are instance-visible. |
| func (db *DB) connections(userID string) ([]RosterEntry, error) { |
| rows, err := db.sql.Query(` |
| - SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.description_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, '') |
| + SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.description_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, ''), COALESCE(u.bind_mac, '') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.id = ? |
| OR u.id IN ( |
| @@ -2052,7 +2068,7 @@ func scanRoster(rows *sql.Rows) ([]RosterEntry, error) { |
| var e RosterEntry |
| var isAgent int |
| var kind string |
| - if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &e.AvatarAltBlob, &e.DescriptionBlob, &e.Role, &isAgent, &e.Description, &kind, &e.Presence); err != nil { |
| + if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &e.AvatarAltBlob, &e.DescriptionBlob, &e.Role, &isAgent, &e.Description, &kind, &e.Presence, &e.BindMAC); err != nil { |
| return nil, err |
| } |
| e.IsAgent = isAgent != 0 |
diff --git a/db_test.go b/db_test.go| index 1ba5ed2..22cb733 100644 |
| --- a/db_test.go |
| +++ b/db_test.go |
| @@ -16,7 +16,6 @@ func newTestDB(t *testing.T) *DB { |
| return db |
| } |
| - |
| func TestAdminToken(t *testing.T) { |
| db := newTestDB(t) |
| if _, err := db.ensureAdminToken("s3cret"); err != nil { |
| @@ -98,8 +97,8 @@ func TestRemoveUserTombstonesMessages(t *testing.T) { |
| db := newTestDB(t) |
| uidA, _ := db.createUser("Alice") |
| uidB, _ := db.createUser("Bea") |
| - db.setUserPublicKey(uidA, "pub-a") |
| - db.setUserPublicKey(uidB, "pub-b") |
| + db.setUserPublicKey(uidA, "pub-a", "") |
| + db.setUserPublicKey(uidB, "pub-b", "") |
| shared, err := db.createThread("group", "n", []ThreadMemberKey{ |
| {UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}, |
| }) |
| @@ -320,7 +319,6 @@ func TestSelectRecipientsExcludesAllSenderDevices(t *testing.T) { |
| } |
| } |
| - |
| func TestGroupName(t *testing.T) { |
| db := newTestDB(t) |
| if got := db.getGroupName(); got != defaultGroupName { |
| @@ -486,7 +484,7 @@ func TestThreadsAndRoster(t *testing.T) { |
| if err != nil { |
| t.Fatalf("createUser %s: %v", name, err) |
| } |
| - if err := db.setUserPublicKey(id, "pub-"+name); err != nil { |
| + if err := db.setUserPublicKey(id, "pub-"+name, ""); err != nil { |
| t.Fatalf("setUserPublicKey: %v", err) |
| } |
| ids[name] = id |
diff --git a/handlers_test.go b/handlers_test.go| index 039bc7d..191ff8d 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -72,7 +72,7 @@ func TestAdminAuthGating(t *testing.T) { |
| func TestRenameUser(t *testing.T) { |
| ts, srv := newTestServer(t) |
| uid, _ := srv.db.createUser("Claude") |
| - srv.db.setUserPublicKey(uid, "pub-bot") |
| + srv.db.setUserPublicKey(uid, "pub-bot", "") |
| srv.db.setUserAgent(uid) // only bots have an admin-settable plaintext name now |
| if r, _ := http.Post(ts.URL+"/api/admin/users/"+uid+"/label", "application/json", strings.NewReader(`{"label":"Eleven"}`)); r.StatusCode != http.StatusUnauthorized { |
| @@ -784,7 +784,7 @@ func TestPostThreadDedupesDM(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey %s: %v", label, err) |
| } |
| return tk, u |
| @@ -845,7 +845,7 @@ func TestThreadHiddenAndRemove(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey %s: %v", label, err) |
| } |
| return tk, u |
| @@ -1165,7 +1165,7 @@ func joinedMember(t *testing.T, srv *Server, label string) (token, userID, threa |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(uid, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(uid, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey: %v", err) |
| } |
| tid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uid, Wrapped: "wrap"}}) |
| @@ -1868,7 +1868,7 @@ func TestOpenGroups(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey %s: %v", label, err) |
| } |
| return tk, u |
| @@ -2079,7 +2079,7 @@ func TestDefaultGroups(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey %s: %v", label, err) |
| } |
| return tk, u |
| @@ -2195,7 +2195,7 @@ func TestTiers(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey %s: %v", label, err) |
| } |
| return tk, u |
| @@ -2356,7 +2356,7 @@ func TestLevelGatedGroups(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey %s: %v", label, err) |
| } |
| return tk, u |
| @@ -2503,7 +2503,7 @@ func TestTierKeyLifecycle(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey %s: %v", label, err) |
| } |
| return tk, u |
| @@ -2709,7 +2709,7 @@ func TestCohortInviteFromChat(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claim: ok=%v err=%v", ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey: %v", err) |
| } |
| return tk, u |
| @@ -2786,7 +2786,7 @@ func TestTierVisibility(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claim: ok=%v err=%v", ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey: %v", err) |
| } |
| return tk, u |
diff --git a/invites_test.go b/invites_test.go| index 7399002..3ec9b90 100644 |
| --- a/invites_test.go |
| +++ b/invites_test.go |
| @@ -29,7 +29,7 @@ func TestConnectionsRoster(t *testing.T) { |
| if err != nil { |
| t.Fatalf("createUser %s: %v", name, err) |
| } |
| - if err := db.setUserPublicKey(id, "pub-"+name); err != nil { |
| + if err := db.setUserPublicKey(id, "pub-"+name, ""); err != nil { |
| t.Fatalf("setUserPublicKey %s: %v", name, err) |
| } |
| return id |
| @@ -70,7 +70,7 @@ func TestUserInviteFreshJoin(t *testing.T) { |
| if err != nil { |
| t.Fatalf("createUser: %v", err) |
| } |
| - if err := db.setUserPublicKey(inviter, "pub-paul"); err != nil { |
| + if err := db.setUserPublicKey(inviter, "pub-paul", ""); err != nil { |
| t.Fatalf("setUserPublicKey: %v", err) |
| } |
| @@ -129,7 +129,7 @@ func TestUserInviteAdoptExisting(t *testing.T) { |
| db := newTestDB(t) |
| mk := func(name string) string { |
| id, _ := db.createUser(name) |
| - db.setUserPublicKey(id, "pub-"+name) |
| + db.setUserPublicKey(id, "pub-"+name, "") |
| return id |
| } |
| inviter := mk("Paul") |
| @@ -174,7 +174,7 @@ func TestUserInviteAdoptDedupesExistingDM(t *testing.T) { |
| db := newTestDB(t) |
| mk := func(name string) string { |
| id, _ := db.createUser(name) |
| - db.setUserPublicKey(id, "pub-"+name) |
| + db.setUserPublicKey(id, "pub-"+name, "") |
| return id |
| } |
| inviter := mk("Paul") |
| @@ -212,7 +212,7 @@ func TestUserInviteAdoptDedupesExistingDM(t *testing.T) { |
| func TestCancelUserInvite(t *testing.T) { |
| db := newTestDB(t) |
| inviter, _ := db.createUser("Paul") |
| - db.setUserPublicKey(inviter, "pub-paul") |
| + db.setUserPublicKey(inviter, "pub-paul", "") |
| threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", testClaimHash, 0) |
| if err != nil { |
| t.Fatalf("createUserInvite: %v", err) |
| @@ -244,7 +244,7 @@ func TestCancelUserInvite(t *testing.T) { |
| func TestUserInviteClaimBlockedAfterJoin(t *testing.T) { |
| db := newTestDB(t) |
| inviter, _ := db.createUser("Paul") |
| - db.setUserPublicKey(inviter, "pub-paul") |
| + db.setUserPublicKey(inviter, "pub-paul", "") |
| threadID, invitee, err := db.createUserInvite(inviter, "invA", "Grandma", "boot", "self", testClaimHash, 0) |
| if err != nil { |
| t.Fatalf("createUserInvite: %v", err) |
| @@ -268,7 +268,7 @@ func TestUserInviteClaimBlockedAfterJoin(t *testing.T) { |
| func TestRemoveUserTearsDownPendingInvite(t *testing.T) { |
| db := newTestDB(t) |
| inviter, _ := db.createUser("Paul") |
| - db.setUserPublicKey(inviter, "pub-paul") |
| + db.setUserPublicKey(inviter, "pub-paul", "") |
| threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", testClaimHash, 0) |
| if err != nil { |
| t.Fatalf("createUserInvite: %v", err) |
| @@ -297,7 +297,7 @@ func TestUserInviteHTTPGateAndRoster(t *testing.T) { |
| uid, _ := srv.db.createUser("Paul") |
| srv.db.createKeyInvite("inv-paul", uid, "", testClaimHash, 0) |
| tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "", testClaimSecret) |
| - srv.db.setUserPublicKey(uid, "pub-paul") |
| + srv.db.setUserPublicKey(uid, "pub-paul", "") |
| body := func(id string) string { |
| return `{"token":"` + tok + `","label_blob":"Grandma","id":"` + id + |
diff --git a/labels_test.go b/labels_test.go| index 55fb136..4e81a4f 100644 |
| --- a/labels_test.go |
| +++ b/labels_test.go |
| @@ -15,7 +15,7 @@ func TestUserLabelsEncryptedAtRest(t *testing.T) { |
| // A person: nameless in plaintext, name lives in label_blob. |
| alice, _ := db.createUser("") |
| - db.setUserPublicKey(alice, "pub-alice") |
| + db.setUserPublicKey(alice, "pub-alice", "") |
| if err := db.setUserLabelBlob(alice, "ENC(alice)"); err != nil { |
| t.Fatalf("setUserLabelBlob: %v", err) |
| } |
| @@ -46,7 +46,7 @@ func TestUserLabelsEncryptedAtRest(t *testing.T) { |
| // (setUserLabelBlob only touches non-agents), so an accidental/hostile call |
| // can't blank a bot's name. |
| bot, _ := db.createUser("Sunny") |
| - db.setUserPublicKey(bot, "pub-bot") |
| + db.setUserPublicKey(bot, "pub-bot", "") |
| if err := db.setUserAgent(bot); err != nil { |
| t.Fatalf("setUserAgent: %v", err) |
| } |
diff --git a/main.go b/main.go| index 03f04fe..f05fa73 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -973,6 +973,10 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| Token string `json:"token"` |
| PublicKey string `json:"public_key"` |
| + // Key transparency (#573): HMAC of PublicKey under the invite secret T, |
| + // so a T-holder can verify the roster key. Opaque to us; stored beside |
| + // the key and served in the roster. Absent for pre-KT / add-device. |
| + BindMac string `json:"bind_mac"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -982,6 +986,10 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "not a member") |
| return |
| } |
| + if len(body.BindMac) > 200 { |
| + httpError(w, http.StatusBadRequest, "bad bind mac") |
| + return |
| + } |
| uid, ok := s.db.deviceTokenUser(body.Token) |
| if !ok { |
| httpError(w, http.StatusForbidden, "no user for token") |
| @@ -1006,7 +1014,7 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusConflict, "public key already registered") |
| return |
| } |
| - if err := s.db.setUserPublicKey(uid, body.PublicKey); err != nil { |
| + if err := s.db.setUserPublicKey(uid, body.PublicKey, body.BindMac); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
diff --git a/push_ssrf_test.go b/push_ssrf_test.go| index 5700805..2e84190 100644 |
| --- a/push_ssrf_test.go |
| +++ b/push_ssrf_test.go |
| @@ -10,13 +10,13 @@ import ( |
| // validatePushEndpoint is the ingest guard: only plain https URLs with a host. |
| func TestValidatePushEndpoint(t *testing.T) { |
| bad := []string{ |
| - "", // empty |
| - "http://fcm.googleapis.com/x", // not https |
| - "http://169.254.169.254/latest", // http + cloud metadata |
| - "https://", // no host |
| - "ftp://example.com/x", // wrong scheme |
| - "file:///etc/passwd", // wrong scheme |
| - "http://[::1]/x", // not https (loopback) |
| + "", // empty |
| + "http://fcm.googleapis.com/x", // not https |
| + "http://169.254.169.254/latest", // http + cloud metadata |
| + "https://", // no host |
| + "ftp://example.com/x", // wrong scheme |
| + "file:///etc/passwd", // wrong scheme |
| + "http://[::1]/x", // not https (loopback) |
| } |
| for _, e := range bad { |
| if err := validatePushEndpoint(e); err == nil { |
diff --git a/release_test.go b/release_test.go| index b2e249a..bed8e8c 100644 |
| --- a/release_test.go |
| +++ b/release_test.go |
| @@ -89,12 +89,16 @@ func TestReleaseManifestRejectsTamperAndWrongKey(t *testing.T) { |
| func TestValidateManifestRejectsBadEntries(t *testing.T) { |
| cases := map[string]func(*releaseManifest){ |
| - "unknown channel": func(m *releaseManifest) { m.Channels["nightly"] = m.Channels["edge"] }, |
| - "bad version": func(m *releaseManifest) { e := m.Channels["edge"]; e.Version = "../evil"; m.Channels["edge"] = e }, |
| - "dev version": func(m *releaseManifest) { e := m.Channels["edge"]; e.Version = "dev"; m.Channels["edge"] = e }, |
| - "http url": func(m *releaseManifest) { e := m.Channels["edge"]; e.URL = "http://x/lchat"; m.Channels["edge"] = e }, |
| - "short sha256": func(m *releaseManifest) { e := m.Channels["edge"]; e.SHA256 = "abcd"; m.Channels["edge"] = e }, |
| - "non-hex sha256": func(m *releaseManifest) { e := m.Channels["edge"]; e.SHA256 = strings.Repeat("zz", 32); m.Channels["edge"] = e }, |
| + "unknown channel": func(m *releaseManifest) { m.Channels["nightly"] = m.Channels["edge"] }, |
| + "bad version": func(m *releaseManifest) { e := m.Channels["edge"]; e.Version = "../evil"; m.Channels["edge"] = e }, |
| + "dev version": func(m *releaseManifest) { e := m.Channels["edge"]; e.Version = "dev"; m.Channels["edge"] = e }, |
| + "http url": func(m *releaseManifest) { e := m.Channels["edge"]; e.URL = "http://x/lchat"; m.Channels["edge"] = e }, |
| + "short sha256": func(m *releaseManifest) { e := m.Channels["edge"]; e.SHA256 = "abcd"; m.Channels["edge"] = e }, |
| + "non-hex sha256": func(m *releaseManifest) { |
| + e := m.Channels["edge"] |
| + e.SHA256 = strings.Repeat("zz", 32) |
| + m.Channels["edge"] = e |
| + }, |
| "unknown schema": func(m *releaseManifest) { m.Schema = 2 }, |
| "zero generatedAt": func(m *releaseManifest) { m.GeneratedAt = time.Time{} }, |
| } |
diff --git a/subscriptions_test.go b/subscriptions_test.go| index 8c54242..16620a4 100644 |
| --- a/subscriptions_test.go |
| +++ b/subscriptions_test.go |
| @@ -55,7 +55,7 @@ func seedTierAndMember(t *testing.T, srv *Server, level, label string) (string, |
| if err != nil { |
| t.Fatalf("createUser: %v", err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey: %v", err) |
| } |
| return tid, u |
| @@ -207,7 +207,7 @@ func TestSubscribeHandlers(t *testing.T) { |
| if err != nil || !ok { |
| t.Fatalf("claim: ok=%v err=%v", ok, err) |
| } |
| - if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil { |
| + if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| t.Fatalf("setUserPublicKey: %v", err) |
| } |
| return tk, u |
diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs| index 4f66b78..c65758d 100644 |
| --- a/test/crypto.test.mjs |
| +++ b/test/crypto.test.mjs |
| @@ -12,6 +12,8 @@ import { |
| bytesToB64url, |
| randomSecret, |
| deriveInvite, |
| + bindMac, |
| + verifyBindMac, |
| wrapGroupKey, |
| unwrapGroupKey, |
| generateUserKeypair, |
| @@ -184,5 +186,45 @@ await test("branding key: pinned KDF vector decrypts", async () => { |
| assert.deepEqual(opened, { name: "Vector Pin", emoji: "🔏" }); |
| }); |
| +await test("bindKey is deterministic from T and domain-separated", async () => { |
| + const T = randomSecret(); |
| + const a = await deriveInvite(T); |
| + const b = await deriveInvite(T); |
| + assert.equal(a.bindKey, b.bindKey, "same T → same bindKey"); |
| + const c = await deriveInvite(randomSecret()); |
| + assert.notEqual(a.bindKey, c.bindKey, "different T → different bindKey"); |
| + // bindKey must not equal the other derivations (domain separation). |
| + assert.notEqual(a.bindKey, a.claimSecret); |
| +}); |
| + |
| +await test("bindMac is over canonical key BYTES (re-encoding the string still verifies)", async () => { |
| + const { bindKey } = await deriveInvite(randomSecret()); |
| + const { publicKey: pub } = await generateUserKeypair(); |
| + const mac = await bindMac(bindKey, pub); |
| + // A server that re-encodes the SAME key bytes (add '=' padding, or the |
| + // standard +/ alphabet) changes the string but not the key — verification |
| + // must still hold, so it can't cry wolf on an honest member. (b64urlToBytes |
| + // tolerates both, and the MAC is over the decoded bytes.) |
| + const reencoded = pub + "="; // a tolerated padding variant of the same bytes |
| + assert.equal(await verifyBindMac(bindKey, reencoded, mac), true, "re-encoded key still verifies"); |
| +}); |
| + |
| +await test("bindMac verifies for the right key+pubkey and fails otherwise", async () => { |
| + const { bindKey } = await deriveInvite(randomSecret()); |
| + const { publicKey: pub } = await generateUserKeypair(); |
| + const mac = await bindMac(bindKey, pub); |
| + assert.equal(await verifyBindMac(bindKey, pub, mac), true, "correct MAC verifies"); |
| + // Wrong (substituted) public key must NOT verify — the whole point. |
| + const { publicKey: evilPub } = await generateUserKeypair(); |
| + assert.equal(await verifyBindMac(bindKey, evilPub, mac), false, "substituted key rejected"); |
| + // Wrong bind key (a different invite's T) must not verify. |
| + const { bindKey: otherKey } = await deriveInvite(randomSecret()); |
| + assert.equal(await verifyBindMac(otherKey, pub, mac), false, "wrong bindKey rejected"); |
| + // Malformed inputs return false, never throw. |
| + assert.equal(await verifyBindMac(bindKey, pub, "not-base64!!"), false); |
| + assert.equal(await verifyBindMac("", pub, mac), false); |
| + assert.equal(await verifyBindMac(bindKey, pub, ""), false); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/test/integrity.test.mjs b/test/integrity.test.mjs| index f2b4f5d..be02855 100644 |
| --- a/test/integrity.test.mjs |
| +++ b/test/integrity.test.mjs |
| @@ -313,6 +313,23 @@ await test("sampleAndVerify confirms committed messages and flags forged proofs" |
| assert.equal(mon2.state().alerts.length, 0); |
| }); |
| +await test("recordAlert files a deduped, banner-raising alert (KT key-substitution)", async () => { |
| + const store = fakeStore(); |
| + const raised = []; |
| + const mon = createIntegrity({ store, fetchPage: async () => ({ checkpoints: [], public_key: "" }), onAlert: (a) => raised.push(a.kind) }); |
| + mon.recordAlert("key-substitution", "user-mallory"); |
| + assert.equal(mon.unresolvedAlerts().length, 1); |
| + assert.equal(mon.unresolvedAlerts()[0].kind, "key-substitution"); |
| + assert.deepEqual(raised, ["key-substitution"]); |
| + // Re-checking the SAME user doesn't re-file (keyed by id). |
| + mon.recordAlert("key-substitution", "user-mallory"); |
| + assert.equal(mon.unresolvedAlerts().length, 1); |
| + assert.equal(raised.length, 1); |
| + // A DIFFERENT user is a distinct alert. |
| + mon.recordAlert("key-substitution", "user-eve"); |
| + assert.equal(mon.unresolvedAlerts().length, 2); |
| +}); |
| + |
| for (const r of results) console.log(r); |
| if (process.exitCode) console.log("\nSOME TESTS FAILED"); |
| else console.log("\nALL PASSED"); |
diff --git a/web/static/crypto.js b/web/static/crypto.js| index 9e4043c..c992eed 100644 |
| --- a/web/static/crypto.js |
| +++ b/web/static/crypto.js |
| @@ -103,10 +103,13 @@ async function sha256hex(str) { |
| return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); |
| } |
| -// deriveInvite returns { id, wrapKey, claimSecret, claimHash } from the secret T, |
| -// each domain-separated. claimSecret proves knowledge of T when claiming; claimHash |
| -// is what the inviter hands the server to store. The server keeps only id + claimHash, |
| -// so reading the DB isn't enough to claim — you still need T to derive claimSecret. |
| +// deriveInvite returns { id, wrapKey, claimSecret, claimHash, bindKey } from the |
| +// secret T, each domain-separated. claimSecret proves knowledge of T when |
| +// claiming; claimHash is what the inviter hands the server to store. bindKey is |
| +// the key-transparency binding key (#573): the joiner MACs its published public |
| +// key under it so a T-holder (the inviter) can verify the roster key wasn't |
| +// substituted (bindMac / verifyBindMac). The server keeps only id + claimHash, |
| +// so reading the DB isn't enough to claim OR to forge a bind-MAC — both need T. |
| export async function deriveInvite(tB64) { |
| const idd = await hkdf(tB64, "lchat-invite-id"); |
| const idBits = await crypto.subtle.deriveBits(idd.params, idd.ikm, 256); |
| @@ -118,7 +121,38 @@ export async function deriveInvite(tB64) { |
| const claimBits = await crypto.subtle.deriveBits(cld.params, cld.ikm, 256); |
| const claimSecret = bytesToB64url(new Uint8Array(claimBits)); |
| const claimHash = await sha256hex(claimSecret); |
| - return { id: bytesToB64url(new Uint8Array(idBits)), wrapKey, claimSecret, claimHash }; |
| + const bkd = await hkdf(tB64, "lchat-invite-bind"); |
| + const bindBits = await crypto.subtle.deriveBits(bkd.params, bkd.ikm, 256); |
| + const bindKey = bytesToB64url(new Uint8Array(bindBits)); |
| + return { id: bytesToB64url(new Uint8Array(idBits)), wrapKey, claimSecret, claimHash, bindKey }; |
| +} |
| + |
| +// bindMac / verifyBindMac — the key-transparency binding (#573, |
| +// docs/tamper-evidence.md). The joiner signs its published public key string |
| +// with HMAC-SHA256 under bindKey (derived from the invite secret T); the server |
| +// stores the MAC beside the key and serves it in the roster. A client that |
| +// holds T for that invite (the inviter) recomputes and verifies before it |
| +// trusts/wraps to the member — catching a server that substituted its own key. |
| +// MAC over the DECODED public-key bytes, not its base64url string: the wrap path |
| +// only ever uses the raw key bytes, so a server that re-encodes the roster string |
| +// (padding, +/- vs -/_ — all tolerated by b64urlToBytes) changes the string but |
| +// not the key. MAC'ing the string would let that re-encoding break an honest |
| +// member's verification (a false-alarm / cry-wolf primitive); MAC'ing the bytes |
| +// makes the binding canonical. |
| +export async function bindMac(bindKeyB64, pubB64) { |
| + const key = await crypto.subtle.importKey("raw", b64urlToBytes(bindKeyB64), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); |
| + const sig = await crypto.subtle.sign("HMAC", key, b64urlToBytes(pubB64)); |
| + return bytesToB64url(new Uint8Array(sig)); |
| +} |
| + |
| +export async function verifyBindMac(bindKeyB64, pubB64, macB64) { |
| + if (!bindKeyB64 || !pubB64 || !macB64) return false; |
| + try { |
| + const key = await crypto.subtle.importKey("raw", b64urlToBytes(bindKeyB64), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); |
| + return await crypto.subtle.verify("HMAC", key, b64urlToBytes(macB64), b64urlToBytes(pubB64)); |
| + } catch { |
| + return false; // malformed MAC/key never throws — a bad MAC is a "false", surfaced by the caller |
| + } |
| } |
| // wrap/unwrap the group key (base64url) under a derived wrapping CryptoKey. |
diff --git a/web/static/integrity.js b/web/static/integrity.js| index 8383a4e..0b89246 100644 |
| --- a/web/static/integrity.js |
| +++ b/web/static/integrity.js |
| @@ -429,6 +429,19 @@ export function createIntegrity({ store, fetchPage, now = () => Date.now(), key |
| return { checked, verified }; |
| }, |
| + // recordAlert lets other subsystems (e.g. key-transparency bind-MAC |
| + // verification in shell.js) file an integrity alert through the same |
| + // deduped, banner-raising path the chain checks use. `key` distinguishes |
| + // instances of the same kind (e.g. a user id) so re-checking the same |
| + // member doesn't spam. |
| + recordAlert(kind, key) { |
| + const st = load(); |
| + // `id` is the dedup discriminator (alertKey/sameAlert), so keying by e.g. |
| + // a user id means re-checking the same member doesn't re-file. |
| + addAlert(st, { kind, seqTo: undefined, id: key }); |
| + save(st); |
| + }, |
| + |
| // state powers the /integrity command's private reply. |
| state() { |
| return load(); |
diff --git a/web/static/join.js b/web/static/join.js| index dc27e47..92fa6ad 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -10,6 +10,7 @@ import { |
| b64urlToBytes, |
| generateUserKeypair, |
| deriveInvite, |
| + bindMac, |
| wrapThreadKey, |
| } from "/static/crypto.js"; |
| import { parseInviteFragment } from "/static/invite.js"; |
| @@ -66,6 +67,10 @@ function init() { |
| // The claim secret derived from T (proves knowledge of the invite secret when |
| // claiming/adopting). Set once in start(); read by finish()/adoptExisting(). |
| let inviteClaimSecret = ""; |
| +// The key-transparency binding key derived from T (#573): the joiner MACs its |
| +// published public key under it so the inviter (a T-holder) can verify the |
| +// roster key wasn't substituted. Set in start(), read by finish(). |
| +let inviteBindKey = ""; |
| // Whether this instance has a branding photo. Fetched in start() and shown on |
| // the onboarding cards in place of the generic emoji. On a sealed instance |
| @@ -84,6 +89,7 @@ async function start() { |
| id = derived.id; |
| wrapKey = derived.wrapKey; |
| inviteClaimSecret = derived.claimSecret; |
| + inviteBindKey = derived.bindKey; |
| info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json(); |
| } catch { |
| return showError("Couldn't reach the server. Try again."); |
| @@ -394,11 +400,19 @@ async function finish(id, name, pub, priv, wrappedSelf = "") { |
| localStorage.removeItem("lchat_welcome_pending"); |
| return showError("This invite was just used. Ask for a new one."); |
| } |
| + // Key transparency (#573): MAC our public key under the T-derived bind key so |
| + // the inviter can verify it's the key WE published. Computed OUTSIDE the |
| + // publish try — a bind-MAC hiccup must never skip the load-bearing pubkey |
| + // registration (that would leave a claimed-but-keyless broken join). |
| + let bindMacVal = ""; |
| + try { |
| + if (inviteBindKey) bindMacVal = await bindMac(inviteBindKey, pub); |
| + } catch {} |
| try { |
| await fetch("/api/pubkey", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: claim.token, public_key: pub }), |
| + body: JSON.stringify({ token: claim.token, public_key: pub, bind_mac: bindMacVal }), |
| }); |
| } catch {} |
| if (!window.__lchatEmbed) { |
diff --git a/web/static/shell.js b/web/static/shell.js| index af191a9..5cd6111 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -23,6 +23,8 @@ import { |
| b64urlToBytes, |
| randomSecret, |
| deriveInvite, |
| + bindMac, |
| + verifyBindMac, |
| mintBrandingKey, |
| deriveBrandingKey, |
| } from "/static/crypto.js"; |
| @@ -1558,6 +1560,77 @@ async function applyRoster(r) { |
| // they send anything before answering, recipients never see a blank sender. |
| setCurrentUser(myUserId, session.name || "New member"); // let apps do per-user logic (e.g. poll "your vote") |
| if (instanceKey) await decryptRosterLabels(); |
| + verifyInviteBindings(); // KT: check any invitees we hold bind keys for (#573) |
| +} |
| + |
| +// ---- key transparency: verify-before-wrap (advisory, #573) ---------------- |
| +// The inviter is the one member who holds T for a joiner, so only they can |
| +// check the bind-MAC. We stash each invitee's bind key at invite time, and |
| +// once they've joined + published a key, confirm the roster key is the one |
| +// THEY published — not a substitute the server slipped in. Advisory by design |
| +// (Paul's call): a mismatch records an integrity alert (raising the banner), |
| +// never blocks a wrap. A verified binding is dropped (the key can't change — |
| +// setUserPublicKey never overwrites it). Absence of a bind-MAC (a pre-KT |
| +// member) is "unverified", not an alarm. |
| +// Only 1:1 single-use invites (inviteSomeone) stash a bind key: their T is |
| +// known to exactly the inviter + joiner, so the MAC is unforgeable. Reusable |
| +// admin/cohort invites share T among every link holder, so their bind-MAC |
| +// proves nothing — NEVER call rememberInviteBindKey for those. |
| +const INVITE_BINDS_KEY = "lchat_invite_binds"; |
| +const INVITE_BINDS_MAX = 500; |
| +let bindUnverifiedCount = 0; // invited members keyed WITHOUT a MAC (rollout / downgrade); shown in /integrity |
| +function loadInviteBinds() { |
| + try { |
| + return JSON.parse(localStorage.getItem(INVITE_BINDS_KEY) || "{}") || {}; |
| + } catch { |
| + return {}; |
| + } |
| +} |
| +function rememberInviteBindKey(uid, bindKey) { |
| + if (!uid || !bindKey) return; |
| + const m = loadInviteBinds(); |
| + m[uid] = bindKey; |
| + localStorage.setItem(INVITE_BINDS_KEY, JSON.stringify(m)); |
| +} |
| +async function verifyInviteBindings() { |
| + const m = loadInviteBinds(); |
| + const uids = Object.keys(m); |
| + if (!uids.length) return; |
| + let unverified = 0; |
| + const next = {}; // rebuilt so members who've LEFT the roster are pruned (bounded growth) |
| + for (const uid of uids) { |
| + const e = roster.find((x) => x.user_id === uid); |
| + if (!e) continue; // member gone (removed) — drop the stale stash |
| + // Keep the bind key as long as the member is present and re-verify on EVERY |
| + // roster load, NOT once: a compromised server can substitute the key at any |
| + // later wrap (a new group, a tier re-key), so a one-shot check would miss it. |
| + next[uid] = m[uid]; |
| + if (!e.public_key) continue; // hasn't published a key yet — check next time |
| + if (!e.bind_mac) { |
| + // Keyed but no MAC. Benign during rollout (a pre-KT joiner), but for |
| + // someone WE invited post-rollout it's a possible downgrade — surface it |
| + // in /integrity (advisory), don't banner (rollout would be too noisy). |
| + unverified++; |
| + continue; |
| + } |
| + let ok = false; |
| + try { |
| + ok = await verifyBindMac(m[uid], e.public_key, e.bind_mac); |
| + } catch { |
| + continue; |
| + } |
| + if (!ok) { |
| + integrity.recordAlert("key-substitution", uid); |
| + renderIntegrityBanner(); |
| + } |
| + } |
| + // Hard backstop on growth (object keeps insertion order → drop the oldest). |
| + const keep = Object.keys(next); |
| + if (keep.length > INVITE_BINDS_MAX) { |
| + for (const k of keep.slice(0, keep.length - INVITE_BINDS_MAX)) delete next[k]; |
| + } |
| + bindUnverifiedCount = unverified; |
| + localStorage.setItem(INVITE_BINDS_KEY, JSON.stringify(next)); |
| } |
| // People's display names arrive encrypted under the instance key (label_blob). |
| @@ -4021,6 +4094,8 @@ function integrityAlertMessage(kind) { |
| return "This space's activity record doesn't hold together as it should — a sign it may have been altered."; |
| case "bad-proof": |
| return "The server couldn't prove one of your messages is in this space's activity record — a sign the record may have been altered."; |
| + case "key-substitution": |
| + return "The key shown for someone you invited isn't the one they set up with. The server may have swapped in its own key to read your messages — don't share anything sensitive, and check with them on another channel."; |
| default: |
| return "This space failed an integrity check."; |
| } |
| @@ -4107,6 +4182,16 @@ registerFloorCommand("/integrity", { |
| if (seen) lines.push(`Peer heads compared: ${seen} (${st.stats.gossipMatch || 0} matched)`); |
| const verified = st.stats.sampleVerified || 0; |
| if (verified) lines.push(`Your messages proven in the record: ${verified.toLocaleString()}`); |
| + // KT: members you invited who are keyed but published no bind-MAC — an |
| + // older client (normal during rollout) or, post-rollout, a possible |
| + // downgrade worth a second look. |
| + if (bindUnverifiedCount) { |
| + lines.push( |
| + bindUnverifiedCount === 1 |
| + ? "1 member you invited couldn't be key-verified (older client, or worth checking)." |
| + : `${bindUnverifiedCount.toLocaleString()} members you invited couldn't be key-verified (older clients, or worth checking).`, |
| + ); |
| + } |
| } |
| if (st.alerts && st.alerts.length) { |
| lines.push(`⚠️ ${st.alerts.length} integrity alert(s) — latest: ${st.alerts.at(-1).kind}. Keep this device's records and contact your admin.`); |
| @@ -10599,7 +10684,7 @@ function keylessInviteReason() { |
| async function inviteSomeone(name, tierID = "") { |
| const T = randomSecret(); |
| - const { id, wrapKey, claimHash } = await deriveInvite(T); |
| + const { id, wrapKey, claimHash, bindKey } = await deriveInvite(T); |
| const { key, b64 } = await generateKey(); |
| const me = roster.find((e) => e.user_id === myUserId); |
| if (!me || !me.public_key) throw friendlyError("Your account is still loading — give it a second and try again."); |
| @@ -10620,6 +10705,10 @@ async function inviteSomeone(name, tierID = "") { |
| threadKeys[r.thread_id] = key; |
| threadKeyB64[r.thread_id] = b64; |
| inviteLinks[r.thread_id] = inviteLink(name, T); |
| + // Key transparency (#573): stash the T-derived bind key against the invitee's |
| + // user id, so once they join and publish a key, verifyInviteBindings() can |
| + // confirm the roster key is the one THEY published (not a server substitute). |
| + if (r.user_id) rememberInviteBindKey(r.user_id, bindKey); |
| // Add the (keyless) invitee to our local roster now, so the pending DM titles by |
| // their name straight away — without waiting for the next connections reload. |
| if (r.user_id && !roster.some((e) => e.user_id === r.user_id)) { |
| @@ -11309,16 +11398,17 @@ function doSignout() { |
| // for a new person; unwrap the shipped one for an added device), register the |
| // public key, and store the session. |
| async function claimInvite(T, name) { |
| - const { id, wrapKey, claimSecret } = await deriveInvite(T); |
| + const { id, wrapKey, claimSecret, bindKey } = await deriveInvite(T); |
| const info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json(); |
| if (!info.ok) throw new Error("invite unavailable"); |
| - let pub, priv, wrappedSelf = ""; |
| + let pub, priv, wrappedSelf = "", addDevice = false; |
| if (info.wrapped) { |
| const payload = await decrypt(wrapKey, info.wrapped); |
| if (payload.priv) { |
| // Add-device link: it carries this user's own keypair. |
| pub = payload.pub; |
| priv = payload.priv; |
| + addDevice = true; |
| } else if (payload.threadKey) { |
| // User invite: a new person joining into a DM — fresh keypair, and re-wrap the |
| // DM key to it so we can read the chat straight away. (Paste-to-join only runs |
| @@ -11343,10 +11433,19 @@ async function claimInvite(T, name) { |
| }) |
| ).json(); |
| if (!claim.ok || !claim.token) throw new Error("claim failed"); |
| + // Key transparency (#573): a fresh joiner MACs its public key under the |
| + // T-derived bind key so the inviter can verify it. Add-device re-registers an |
| + // existing key (its original binding stands), so no MAC. Computed in its own |
| + // try — a bind-MAC hiccup must never break the load-bearing pubkey publish + |
| + // session save below (that would leave a claimed-but-keyless broken join). |
| + let mac = ""; |
| + try { |
| + if (!addDevice && bindKey) mac = await bindMac(bindKey, pub); |
| + } catch {} |
| await fetch("/api/pubkey", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: claim.token, public_key: pub }), |
| + body: JSON.stringify({ token: claim.token, public_key: pub, bind_mac: mac }), |
| }); |
| localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv })); |
| // Land straight in the DM this invite created (boot opens it). |