eleven / ocho Public

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>
Paul Campbell 3dcb5124d79e5bff53cbd91ffffc4da3d7e98d88 parent 1b4bd80
20 files changed, +365 −73
  • ban_test.go +1 −2
  • bot_appbuild_test.go +1 −1
  • bot_serviceop_test.go +1 −1
  • bots_test.go +5 −5
  • checkpoint_test.go +50 −2
  • db.go +26 −10
  • db_test.go +3 −5
  • handlers_test.go +11 −11
  • invites_test.go +8 −8
  • labels_test.go +2 −2
  • main.go +9 −1
  • push_ssrf_test.go +7 −7
  • release_test.go +10 −6
  • subscriptions_test.go +2 −2
  • test/crypto.test.mjs +42 −0
  • test/integrity.test.mjs +17 −0
  • web/static/crypto.js +39 −5
  • web/static/integrity.js +13 −0
  • web/static/join.js +15 −1
  • web/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).