Make member screen names private (server-blind) (#102)
* crypto: make member screen names private (server-blind) People's display names are now encrypted under the instance key and stored only as an opaque blob; the server never sees a readable name. Bots keep their admin-authored plaintext names (shown with a bot badge). - db: add users.label_blob; the roster serves the blob and the plaintext label is agent-only. setUserLabelBlob clears any plaintext label and refuses agents. - server: POST /api/label lets a member publish their own name (encrypted); renameUser is bots-only; key-invite/claim and device-invite no longer accept or store a plaintext name; getKeyInvite no longer returns a suggested name. - client: decrypt roster names under the instance key; self-publish own name on boot (migrating anyone still stored in plaintext); invite links carry the suggested name only in the #fragment (never sent to the server) behind a neutral /join/join slug. - admin: the console shows member/bot counts and pending invites, not a roster of names; admin-minted person invites send no server-side name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: show member names as soon as the instance key arrives A brand-new joiner gets the instance key asynchronously (nudge + poll), so at boot names can't be decrypted and their own name can't be published yet. Since threadName[] is cached at load and the late key-arrival path only refreshed avatars, a new member saw "Chat"/"?" for the very person they were talking to until a reload. - recompute cached thread titles (and re-sync the SW notification titles) once the instance key is ready: at boot after decrypting, and when it arrives late via pollForInstanceKey (which now also publishes the joiner's own name). - the live WS `label` event re-derives thread titles instead of re-rendering the stale cache. - rosterLabel falls back to "Someone" for a not-yet-decrypted person instead of the empty string that collapsed a DM title to "Chat". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: reliably propagate the instance key to new joiners (fix stuck "Someone") A new joiner nudges holders for the instance key on boot. But a holder handled the nudge against its *cached* roster — which doesn't include someone who joined after the holder's page loaded — so it skipped the very person asking. The joiner then never received the key, never published their name, and showed as "Someone" with no avatar to everyone (and couldn't see anyone) until some holder happened to reload. The live-grant handler on the joiner also only refreshed avatars, not names. - nudge handler (holders): reload the roster before granting, so brand-new joiners are included, then recompute our own titles from the fresh roster. - instance_key_granted handler (joiner): run the full key-ready recovery (decrypt names, publish our own, repaint) instead of only refreshing avatars. - factor that recovery into onInstanceKeyReady() and share it with the poll path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+281
−53
changelog/2026-07-02-private-names.md+1 −0db.go+22 −11handlers_test.go+1 −0invites_test.go+4 −4labels_test.go+54 −0main.go+57 −13web/static/admin.js+22 −7web/static/chat.js+115 −15web/static/join.js+5 −3
diff --git a/changelog/2026-07-02-private-names.md b/changelog/2026-07-02-private-names.md| new file mode 100644 |
| index 0000000..915118d |
| --- /dev/null |
| +++ b/changelog/2026-07-02-private-names.md |
| @@ -0,0 +1 @@ |
| +Added: your screen name is now private. It's encrypted on your device before it ever leaves, so the server only sees an anonymous ID — your name shows only to the people you're in chats with. (Helper bots keep their names, since those are set by the person who runs the chat.) |
diff --git a/db.go b/db.go| index 03a02ef..33f279b 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -210,6 +210,10 @@ CREATE TABLE IF NOT EXISTS bot_configs ( |
| "ALTER TABLE users ADD COLUMN public_key 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). |
| + // Bots keep their plaintext `label` (admin-authored); people move here so the |
| + // server never holds a readable name. Set by the member who holds the key. |
| + "ALTER TABLE users ADD COLUMN label_blob TEXT", |
| // Group photo: an encrypted blob id (encrypted with the thread key). |
| "ALTER TABLE threads ADD COLUMN avatar_blob TEXT", |
| // User invites: the pending DM thread an invite bootstraps the joiner into |
| @@ -485,11 +489,13 @@ func (db *DB) listUsers() ([]User, error) { |
| return out, rows.Err() |
| } |
| -// userLabel returns a user's display label (or "" if unknown). |
| -func (db *DB) userLabel(id string) string { |
| - var l string |
| - db.sql.QueryRow("SELECT label FROM users WHERE id = ?", id).Scan(&l) |
| - return l |
| +// setUserLabelBlob stores a person's display name encrypted under the instance |
| +// key and clears any plaintext label, so the server never holds a readable name |
| +// for them. Called by the member themselves (they hold the key); bots are never |
| +// routed here — they keep their admin-authored plaintext label. |
| +func (db *DB) setUserLabelBlob(userID, blob string) error { |
| + _, err := db.sql.Exec("UPDATE users SET label_blob = ?, label = '' WHERE id = ? AND COALESCE(is_agent, 0) = 0", blob, userID) |
| + return err |
| } |
| func (db *DB) userExists(id string) bool { |
| @@ -529,7 +535,7 @@ func (db *DB) createKeyInviteWithThread(id, userID, wrapped, threadID, claimHash |
| // inviter's own public key; bootstrapWrapped is it encrypted to the invite secret |
| // (so the joiner can recover it). The server sees only blobs it can't open. |
| // Returns the new thread id and the placeholder user id. |
| -func (db *DB) createUserInvite(inviterID, inviteID, label, bootstrapWrapped, inviterWrapped, claimHash string, expiresAt int64) (string, string, error) { |
| +func (db *DB) createUserInvite(inviterID, inviteID, labelBlob, bootstrapWrapped, inviterWrapped, claimHash string, expiresAt int64) (string, string, error) { |
| inviteeID, err := randToken() |
| if err != nil { |
| return "", "", err |
| @@ -556,7 +562,7 @@ func (db *DB) createUserInvite(inviterID, inviteID, label, bootstrapWrapped, inv |
| q string |
| args []any |
| }{ |
| - {"INSERT INTO users(id, label, created_at) VALUES(?, ?, ?)", []any{inviteeID, label, now}}, |
| + {"INSERT INTO users(id, label_blob, created_at) VALUES(?, ?, ?)", []any{inviteeID, labelBlob, now}}, |
| {"INSERT INTO threads(id, kind, name_blob, created_at) VALUES(?, 'dm', NULL, ?)", []any{threadID, now}}, |
| {"INSERT INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", []any{threadID, inviterID, now}}, |
| {"INSERT INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", []any{threadID, inviteeID, now}}, |
| @@ -696,8 +702,13 @@ func (db *DB) deviceTokenUserByHash(tokenHash string) (string, bool) { |
| // RosterEntry is one discoverable person: their label + ECDH public key (used to |
| // wrap thread/instance keys to them) + their profile avatar blob id (if set). |
| type RosterEntry struct { |
| - UserID string `json:"user_id"` |
| + UserID string `json:"user_id"` |
| + // Label is a plaintext name — only ever set for bots (admin-authored). For a |
| + // person it's empty; their name is in LabelBlob, encrypted under the instance |
| + // key, and the client decrypts it. (Un-migrated people may still have a |
| + // plaintext Label until their client republishes it encrypted — see setUserLabelBlob.) |
| Label string `json:"label"` |
| + LabelBlob string `json:"label_blob,omitempty"` |
| PublicKey string `json:"public_key"` |
| Avatar string `json:"avatar,omitempty"` |
| IsAgent bool `json:"is_agent,omitempty"` // an agent/bot, not a person |
| @@ -950,7 +961,7 @@ func (db *DB) setAvatar(userID, blobID 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, u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '') |
| + `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '') |
| 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`, |
| ) |
| @@ -973,7 +984,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.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '') |
| + SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '') |
| FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.id = ? |
| OR u.id IN ( |
| @@ -996,7 +1007,7 @@ func scanRoster(rows *sql.Rows) ([]RosterEntry, error) { |
| for rows.Next() { |
| var e RosterEntry |
| var isAgent int |
| - if err := rows.Scan(&e.UserID, &e.Label, &e.PublicKey, &e.Avatar, &isAgent, &e.Description); err != nil { |
| + if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &isAgent, &e.Description); err != nil { |
| return nil, err |
| } |
| e.IsAgent = isAgent != 0 |
diff --git a/handlers_test.go b/handlers_test.go| index 44de3f0..f167e40 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -66,6 +66,7 @@ func TestRenameUser(t *testing.T) { |
| ts, srv := newTestServer(t) |
| uid, _ := srv.db.createUser("Claude") |
| 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 { |
| t.Fatalf("no auth: want 401, got %d", r.StatusCode) |
diff --git a/invites_test.go b/invites_test.go| index 97a6d03..7399002 100644 |
| --- a/invites_test.go |
| +++ b/invites_test.go |
| @@ -102,8 +102,8 @@ func TestUserInviteFreshJoin(t *testing.T) { |
| if pending == nil { |
| t.Fatal("pending invitee should appear as a connection") |
| } |
| - if pending.PublicKey != "" || pending.Label != "Grandma" { |
| - t.Fatalf("pending invitee should be keyless with the given label: %+v", pending) |
| + if pending.PublicKey != "" || pending.LabelBlob != "Grandma" { |
| + t.Fatalf("pending invitee should be keyless with the given (encrypted) label: %+v", pending) |
| } |
| // Fresh join: the placeholder becomes them — token minted, DM key stored. |
| @@ -300,7 +300,7 @@ func TestUserInviteHTTPGateAndRoster(t *testing.T) { |
| srv.db.setUserPublicKey(uid, "pub-paul") |
| body := func(id string) string { |
| - return `{"token":"` + tok + `","label":"Grandma","id":"` + id + |
| + return `{"token":"` + tok + `","label_blob":"Grandma","id":"` + id + |
| `","wrapped":"boot","self_wrapped":"self","claim_hash":"` + testClaimHash + `"}` |
| } |
| @@ -337,7 +337,7 @@ func TestUserInviteHTTPGateAndRoster(t *testing.T) { |
| if e.UserID == uid { |
| self = true |
| } |
| - if e.Label == "Grandma" && e.PublicKey == "" { |
| + if e.LabelBlob == "Grandma" && e.PublicKey == "" { |
| pending = true |
| } |
| } |
diff --git a/labels_test.go b/labels_test.go| new file mode 100644 |
| index 0000000..1110b50 |
| --- /dev/null |
| +++ b/labels_test.go |
| @@ -0,0 +1,54 @@ |
| +package main |
| + |
| +import "testing" |
| + |
| +// A person's display name is stored only as an opaque blob (encrypted client-side |
| +// under the instance key); the plaintext label column is empty. Bots keep their |
| +// admin-authored plaintext name and are never overwritten by the blob path. |
| +func TestUserLabelsEncryptedAtRest(t *testing.T) { |
| + db := newTestDB(t) |
| + |
| + // A person: nameless in plaintext, name lives in label_blob. |
| + alice, _ := db.createUser("") |
| + db.setUserPublicKey(alice, "pub-alice") |
| + if err := db.setUserLabelBlob(alice, "ENC(alice)"); err != nil { |
| + t.Fatalf("setUserLabelBlob: %v", err) |
| + } |
| + var label, blob string |
| + if err := db.sql.QueryRow("SELECT label, COALESCE(label_blob, '') FROM users WHERE id = ?", alice). |
| + Scan(&label, &blob); err != nil { |
| + t.Fatalf("read user: %v", err) |
| + } |
| + if label != "" || blob != "ENC(alice)" { |
| + t.Fatalf("person row: label=%q blob=%q; want empty label + the blob", label, blob) |
| + } |
| + |
| + // The roster serves the blob, not a readable name. |
| + find := func(uid string) *RosterEntry { |
| + r, _ := db.roster() |
| + for i := range r { |
| + if r[i].UserID == uid { |
| + return &r[i] |
| + } |
| + } |
| + return nil |
| + } |
| + if e := find(alice); e == nil || e.Label != "" || e.LabelBlob != "ENC(alice)" { |
| + t.Fatalf("roster entry for person: %+v", e) |
| + } |
| + |
| + // A bot keeps its plaintext name and is NOT overwritten by the label-blob path |
| + // (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") |
| + if err := db.setUserAgent(bot); err != nil { |
| + t.Fatalf("setUserAgent: %v", err) |
| + } |
| + if err := db.setUserLabelBlob(bot, "ENC(hijack)"); err != nil { |
| + t.Fatalf("setUserLabelBlob(bot): %v", err) |
| + } |
| + if e := find(bot); e == nil || e.Label != "Sunny" || e.LabelBlob != "" { |
| + t.Fatalf("bot label should stay plaintext + untouched: %+v", e) |
| + } |
| +} |
diff --git a/main.go b/main.go| index 886002f..a205396 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -141,6 +141,7 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("GET /api/instance-key", s.getInstanceKey) |
| mux.HandleFunc("POST /api/instance-key", s.postInstanceKey) |
| mux.HandleFunc("POST /api/instance-key/nudge", s.nudgeInstanceKey) |
| + mux.HandleFunc("POST /api/label", s.postLabel) |
| mux.HandleFunc("POST /api/avatar", s.postAvatar) |
| mux.HandleFunc("GET /api/threads", s.getThreads) |
| mux.HandleFunc("POST /api/threads", s.postThread) |
| @@ -832,6 +833,36 @@ func (s *Server) nudgeInstanceKey(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| +// postLabel sets the caller's own display name, encrypted under the instance key |
| +// (a server-blind blob). This is how a person publishes their name — the server |
| +// never sees the plaintext. Broadcasts so every client refreshes the roster name. |
| +func (s *Server) postLabel(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + LabelBlob string `json:"label_blob"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if !s.tokenOK(body.Token) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| + uid, ok := s.db.deviceTokenUser(body.Token) |
| + if !ok || body.LabelBlob == "" || len(body.LabelBlob) > 4096 { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + if err := s.db.setUserLabelBlob(uid, body.LabelBlob); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + out, _ := json.Marshal(map[string]any{"type": "label", "user_id": uid, "label_blob": body.LabelBlob}) |
| + s.hub.broadcast <- outbound{data: out} |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| // postAvatar sets the caller's profile photo (an encrypted blob id, or "" to clear). |
| func (s *Server) postAvatar(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| @@ -1195,7 +1226,14 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| } |
| userID := body.UserID |
| if userID == "" { |
| - id, err := s.db.createUser(strings.TrimSpace(body.Label)) |
| + // A person gets no plaintext name — they publish theirs encrypted (under the |
| + // instance key) after joining, and the pre-fill name rides in the invite |
| + // link's #fragment. Only bots keep an admin-authored plaintext label. |
| + newLabel := "" |
| + if body.Agent { |
| + newLabel = strings.TrimSpace(body.Label) |
| + } |
| + id, err := s.db.createUser(newLabel) |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -1241,9 +1279,8 @@ func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // A user invite drops the joiner into a pending DM. The client uses this to |
| // (a) re-wrap the DM key to itself and (b) know an existing member can adopt. |
| resp["dm"] = ki.ThreadID != "" |
| - // The name the inviter suggested, to pre-fill the joiner's screen name (they |
| - // can still change it). Not secret — the id is only computable from the secret. |
| - resp["label"] = s.db.userLabel(ki.UserID) |
| + // The suggested pre-fill name is NOT returned here — it rides in the invite |
| + // link's #fragment (never sent to the server), so the server never sees it. |
| } |
| writeJSON(w, http.StatusOK, resp) |
| } |
| @@ -1255,7 +1292,6 @@ func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // thread id so the client opens it first. |
| func (s *Server) claimKeyInvite(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| - Label string `json:"label"` |
| Wrapped string `json:"wrapped"` |
| ClaimSecret string `json:"claim_secret"` |
| } |
| @@ -1267,7 +1303,9 @@ func (s *Server) claimKeyInvite(w http.ResponseWriter, r *http.Request) { |
| id := r.PathValue("id") |
| // Grab the pending DM (if any) before the claim consumes the invite. |
| ki, _, _ := s.db.getKeyInvite(id) |
| - tok, ok, err := s.db.claimKeyInvite(id, strings.TrimSpace(body.Label), body.Wrapped, body.ClaimSecret) |
| + // No plaintext device label: the joiner's screen name is never sent to the |
| + // server — they publish it encrypted (POST /api/label) after joining. |
| + tok, ok, err := s.db.claimKeyInvite(id, "", body.Wrapped, body.ClaimSecret) |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -1368,6 +1406,13 @@ func (s *Server) renameUser(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "bad label") |
| return |
| } |
| + // Only bots have an admin-settable plaintext name. A person's name is encrypted |
| + // under the instance key and set by the member themselves (POST /api/label), so |
| + // the admin can't (and shouldn't) rename people. |
| + if !s.db.isAgent(r.PathValue("id")) { |
| + httpError(w, http.StatusBadRequest, "people set their own name") |
| + return |
| + } |
| if err := s.db.setUserLabel(r.PathValue("id"), label); err == sql.ErrNoRows { |
| httpError(w, http.StatusBadRequest, "unknown user") |
| return |
| @@ -1397,7 +1442,6 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) { |
| func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| Token string `json:"token"` |
| - Label string `json:"label"` |
| ID string `json:"id"` |
| Wrapped string `json:"wrapped"` |
| ClaimHash string `json:"claim_hash"` |
| @@ -1417,8 +1461,9 @@ func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) { |
| } |
| userID, ok := s.db.deviceTokenUser(body.Token) |
| if !ok { |
| - // Legacy member (joined via an old #k= link) — give them a user now. |
| - id, err := s.db.createUser(strings.TrimSpace(body.Label)) |
| + // Legacy member (joined via an old #k= link) — give them a user now. No |
| + // plaintext name: they republish it encrypted (POST /api/label) on next load. |
| + id, err := s.db.createUser("") |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -1442,7 +1487,7 @@ func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) { |
| func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| Token string `json:"token"` |
| - Label string `json:"label"` |
| + LabelBlob string `json:"label_blob"` // invitee's name, encrypted to the instance key by the inviter |
| ID string `json:"id"` |
| Wrapped string `json:"wrapped"` |
| SelfWrapped string `json:"self_wrapped"` |
| @@ -1499,12 +1544,11 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - label := strings.TrimSpace(body.Label) |
| - if label == "" || len(label) > 60 || body.SelfWrapped == "" || len(body.SelfWrapped) > 4096 { |
| + if body.LabelBlob == "" || len(body.LabelBlob) > 4096 || body.SelfWrapped == "" || len(body.SelfWrapped) > 4096 { |
| httpError(w, http.StatusBadRequest, "bad request") |
| return |
| } |
| - threadID, inviteeID, err := s.db.createUserInvite(uid, body.ID, label, body.Wrapped, body.SelfWrapped, body.ClaimHash, 0) |
| + threadID, inviteeID, err := s.db.createUserInvite(uid, body.ID, body.LabelBlob, body.Wrapped, body.SelfWrapped, body.ClaimHash, 0) |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
diff --git a/web/static/admin.js b/web/static/admin.js| index 2c1e5f0..1c2c9ae 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -140,7 +140,10 @@ async function mintInvite(label, userId, agent, config) { |
| const T = randomSecret(); |
| const { id, claimHash } = await deriveInvite(T); |
| const res = await api("POST", "/api/admin/key-invites", { |
| - label, |
| + // Only agents carry a server-side plaintext name (admin-authored, and shown |
| + // as a bot badge). A person's suggested name is NEVER sent to the server — |
| + // it rides in the link #fragment below and the joiner publishes it encrypted. |
| + label: agent ? label : "", |
| user_id: userId || "", |
| id, |
| wrapped: "", |
| @@ -152,8 +155,10 @@ async function mintInvite(label, userId, agent, config) { |
| toast("Couldn't create invite"); |
| return null; |
| } |
| - const slug = (label || "join").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "join"; |
| - return { link: `${location.origin}/join/${encodeURIComponent(slug)}#${T}` }; |
| + // Neutral slug (the path reaches the server); a person's suggested name rides |
| + // in the #fragment, which browsers never send to the server. |
| + const frag = !agent && label ? `${T}~${encodeURIComponent(label)}` : T; |
| + return { link: `${location.origin}/join/join#${frag}` }; |
| } |
| async function loadUsers() { |
| @@ -174,7 +179,17 @@ function renderUsers(users) { |
| box.innerHTML = `<p class="sub">No one yet. Invite the first person above.</p>`; |
| return; |
| } |
| + // People are shown only as a count — their names are end-to-end encrypted, so |
| + // neither the server nor this console ever sees them. To act on a specific |
| + // person (e.g. remove them), do it from the app, where names are visible. Bots |
| + // are admin-authored, so they stay named and editable below. |
| + const joinedCount = users.filter((u) => !u.is_agent && u.devices > 0).length; |
| + const summary = document.createElement("p"); |
| + summary.className = "sub"; |
| + summary.textContent = `${joinedCount} ${joinedCount === 1 ? "member" : "members"}`; |
| + box.appendChild(summary); |
| for (const u of users) { |
| + if (!u.is_agent && u.devices > 0) continue; // joined people are counted, not listed |
| const row = document.createElement("div"); |
| row.className = "invite"; |
| @@ -182,7 +197,7 @@ function renderUsers(users) { |
| meta.className = "meta"; |
| const label = document.createElement("div"); |
| label.className = "label"; |
| - label.textContent = u.label || "(no name)"; |
| + label.textContent = u.is_agent ? u.label || "(bot)" : "Pending invite"; |
| if (u.is_agent) { |
| const tag = document.createElement("span"); |
| tag.textContent = "BOT"; |
| @@ -223,8 +238,8 @@ function renderUsers(users) { |
| reissue.className = "secondary"; |
| reissue.textContent = "Resend link"; |
| reissue.onclick = async () => { |
| - const minted = await mintInvite(u.label, u.id); |
| - if (minted) showInviteModal(minted.link, u.label || "Invite"); |
| + const minted = await mintInvite("", u.id); |
| + if (minted) showInviteModal(minted.link, "Invite"); |
| }; |
| row.appendChild(reissue); |
| } |
| @@ -243,7 +258,7 @@ function renderUsers(users) { |
| remove.className = "linkish danger"; |
| remove.textContent = "Remove"; |
| remove.onclick = async () => { |
| - if (!confirm(`Remove ${u.label || "this person"} from the chat? This can't be undone.`)) return; |
| + if (!confirm(u.is_agent ? `Remove ${u.label || "this bot"}? This can't be undone.` : "Cancel this pending invite? This can't be undone.")) return; |
| const res = await api("POST", `/api/admin/users/${encodeURIComponent(u.id)}/remove`); |
| toast(res.ok ? "Removed" : "Couldn't remove"); |
| if (res.ok) loadUsers(); |
diff --git a/web/static/chat.js b/web/static/chat.js| index 3c43f5f..0b56f82 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -380,6 +380,12 @@ async function boot() { |
| await ensurePubkey(); |
| await loadRoster(); |
| await Promise.all([setupInstanceKey(), loadThreads()]); |
| + // Names are encrypted under the instance key, which is only ready now — decrypt |
| + // the roster and publish our own (new joiner, or migrating an old plaintext name). |
| + await decryptRosterLabels(); |
| + await recomputeThreadNames(); // titles were cached (by loadThreads) before the key was ready |
| + syncThreadKeysToSW(); // and so were the SW's notification titles |
| + await publishMyLabel(); |
| const opened = renderApp(); // paints the shell; opens the initial thread (async) |
| connectWS(); |
| registerSW(session); // best-effort; subscribes if notifications already on |
| @@ -545,11 +551,50 @@ async function loadRoster() { |
| const me = roster.find((e) => e.public_key === session.userPub); |
| myUserId = me ? me.user_id : ""; |
| } |
| + if (instanceKey) await decryptRosterLabels(); |
| +} |
| + |
| +// People's display names arrive encrypted under the instance key (label_blob). |
| +// Decrypt them into .label so the synchronous rosterLabel() can read them. Bots |
| +// carry a plaintext .label and no blob; a not-yet-migrated person keeps whatever |
| +// plaintext .label they still have as a fallback. |
| +async function decryptRosterLabels() { |
| + if (!instanceKey) return; |
| + await Promise.all( |
| + roster.map(async (e) => { |
| + if (e.label_blob) { |
| + try { |
| + e.label = await decrypt(instanceKey, e.label_blob); |
| + } catch {} |
| + } |
| + }), |
| + ); |
| +} |
| + |
| +// Publish our own display name, encrypted under the instance key — for a fresh |
| +// joiner, and to migrate anyone whose name is still stored in plaintext. The |
| +// server only ever stores the blob. |
| +async function publishMyLabel() { |
| + if (!instanceKey || !myUserId || !session.name) return; |
| + const me = roster.find((e) => e.user_id === myUserId); |
| + if (me && me.label_blob && me.label === session.name) return; // already correct |
| + try { |
| + const blob = await encrypt(instanceKey, session.name); |
| + await fetch("/api/label", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, label_blob: blob }), |
| + }); |
| + if (me) { |
| + me.label = session.name; |
| + me.label_blob = blob; |
| + } |
| + } catch {} |
| } |
| function rosterLabel(userId) { |
| const e = roster.find((x) => x.user_id === userId); |
| - return e ? e.label : "Someone"; |
| + return e && e.label ? e.label : "Someone"; |
| } |
| // A small "BOT" tag to mark an agent in the roster picker, so it's clear before |
| @@ -641,8 +686,7 @@ async function pollForInstanceKey(tries = 6) { |
| try { |
| instanceKeyB64 = await unwrapThreadKey(r.wrapped, session.userPriv); |
| instanceKey = await importKey(instanceKeyB64); |
| - refreshAvatars(); |
| - refreshProfilePhotoBtn(); |
| + await onInstanceKeyReady(); |
| } catch {} |
| return; |
| } |
| @@ -851,6 +895,40 @@ async function displayName(t) { |
| return fallback; |
| } |
| +// threadName[] is cached from the roster labels available at load time. Those |
| +// names are encrypted under the instance key, so a new joiner who receives the |
| +// key late (or anyone getting a live label update) has stale titles. Re-derive |
| +// them from the current roster labels. |
| +async function recomputeThreadNames() { |
| + await Promise.all(threads.map(async (t) => (threadName[t.id] = await displayName(t)))); |
| +} |
| + |
| +// Recompute titles and repaint everywhere a name shows — the chat list, the open |
| +// thread header, and the service worker's notification titles. For use after the |
| +// app shell exists (post-boot: the key arriving, or a live label event). |
| +async function refreshNamesUI() { |
| + await recomputeThreadNames(); |
| + renderThreadList(); |
| + if (activeId) { |
| + const el = document.getElementById("thread-title"); |
| + if (el) el.textContent = threadName[activeId] || "Chat"; |
| + } |
| + syncThreadKeysToSW(); |
| +} |
| + |
| +// The instance key just became available AFTER the app shell was up — a new |
| +// joiner received it late (via poll, or a holder granting it over the socket). |
| +// Everything gated on the key can now run: decrypt others' names, publish our |
| +// own (so the people we're talking to stop seeing "Someone"), and repaint. |
| +async function onInstanceKeyReady() { |
| + if (!instanceKey) return; |
| + await decryptRosterLabels(); |
| + await publishMyLabel(); |
| + await refreshNamesUI(); |
| + refreshAvatars(); |
| + refreshProfilePhotoBtn(); |
| +} |
| + |
| // ---- shell -------------------------------------------------------------- |
| function renderApp() { |
| @@ -3026,24 +3104,39 @@ function connectWS() { |
| } |
| if (msg.type === "instance_key_nudge") { |
| // A member lacks the instance key. If we hold it, (re-)wrap it to whoever's |
| - // missing it so they can decrypt profile photos (and set their own). |
| + // missing it so they can decrypt names + photos (and publish their own). |
| + // Reload the roster first: the nudger is often a brand-new joiner who isn't |
| + // in our cached roster yet, so without this we'd skip the very person asking. |
| if (instanceKeyB64) { |
| try { |
| + await loadRoster(); |
| const ik = await (await fetch(`/api/instance-key?token=${encodeURIComponent(session.token)}`)).json(); |
| await grantInstanceKeyTo(ik.have || []); |
| + await refreshNamesUI(); // the reload may have added the new joiner to our roster |
| } catch {} |
| } |
| return; |
| } |
| if (msg.type === "instance_key_granted") { |
| // A holder just wrapped the instance key to us. Pick it up live (fetch + |
| - // unwrap) so an open tab stops showing "Photo syncing…" without a reload. |
| + // unwrap) so an open tab recovers names + photos — and publishes our own |
| + // name — without waiting for a reload. |
| if (!instanceKey) { |
| await setupInstanceKey(); |
| + await onInstanceKeyReady(); |
| + } |
| + return; |
| + } |
| + if (msg.type === "label") { |
| + const e = roster.find((x) => x.user_id === msg.user_id); |
| + if (e) { |
| + e.label_blob = msg.label_blob; |
| if (instanceKey) { |
| - refreshAvatars(); |
| - refreshProfilePhotoBtn(); |
| + try { |
| + e.label = await decrypt(instanceKey, msg.label_blob); |
| + } catch {} |
| } |
| + await refreshNamesUI(); // a name changed — re-derive any thread titled by it |
| } |
| return; |
| } |
| @@ -3960,13 +4053,17 @@ async function inviteSomeone(name) { |
| const { key, b64 } = await generateKey(); |
| const me = roster.find((e) => e.user_id === myUserId); |
| if (!me || !me.public_key) throw new Error("your account isn't loaded yet"); |
| + if (!instanceKey) throw new Error("still setting up — try again in a moment"); |
| const selfWrapped = await wrapThreadKey(b64, me.public_key); |
| const wrapped = await encrypt(wrapKey, { threadKey: b64 }); |
| + // The invitee's name is stored encrypted under the instance key (the server never |
| + // sees it); the plaintext name rides in the invite link's #fragment for pre-fill. |
| + const labelBlob = await encrypt(instanceKey, name); |
| const r = await ( |
| await fetch("/api/invites", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, label: name, id, wrapped, self_wrapped: selfWrapped, claim_hash: claimHash }), |
| + body: JSON.stringify({ token: session.token, label_blob: labelBlob, id, wrapped, self_wrapped: selfWrapped, claim_hash: claimHash }), |
| }) |
| ).json(); |
| if (!r.thread_id) throw new Error("invite failed"); |
| @@ -3976,7 +4073,7 @@ async function inviteSomeone(name) { |
| // 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)) { |
| - roster.push({ user_id: r.user_id, label: name, public_key: "", avatar: "" }); |
| + roster.push({ user_id: r.user_id, label: name, label_blob: labelBlob, public_key: "", avatar: "" }); |
| } |
| return r.thread_id; |
| } |
| @@ -4109,8 +4206,10 @@ function updatePendingBanner(t) { |
| // /join/<slug>#<secret> — the slug is cosmetic; the secret is the whole key. |
| function inviteLink(name, T) { |
| - const slug = (name || "join").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "join"; |
| - return `${location.origin}/join/${encodeURIComponent(slug)}#${T}`; |
| + // Neutral slug: the invitee's name rides in the #fragment (never sent to the |
| + // server), not the path — so opening a join link reveals no name to the server. |
| + const frag = name ? `${T}~${encodeURIComponent(name)}` : T; |
| + return `${location.origin}/join/join#${frag}`; |
| } |
| function selectAllText(el) { |
| @@ -4144,11 +4243,11 @@ async function addDevice() { |
| const res = await fetch("/api/device-invites", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, label: session.name, id, wrapped, claim_hash: claimHash }), |
| + body: JSON.stringify({ token: session.token, id, wrapped, claim_hash: claimHash }), |
| }); |
| if (!res.ok) throw new Error("server"); |
| - const slug = (session.name || "device").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "device"; |
| - link = `${location.origin}/join/${encodeURIComponent(slug)}#${T}~${encodeURIComponent(session.name)}`; |
| + // Neutral slug; the name rides in the #fragment (never sent to the server). |
| + link = `${location.origin}/join/join#${T}~${encodeURIComponent(session.name)}`; |
| } catch { |
| return alert("Couldn't create a device link. Try again."); |
| } |
| @@ -4454,7 +4553,8 @@ async function claimInvite(T, name) { |
| await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ label: name, wrapped: wrappedSelf, claim_secret: claimSecret }), |
| + // Name is never sent to the server — published encrypted after boot. |
| + body: JSON.stringify({ wrapped: wrappedSelf, claim_secret: claimSecret }), |
| }) |
| ).json(); |
| if (!claim.ok || !claim.token) throw new Error("claim failed"); |
diff --git a/web/static/join.js b/web/static/join.js| index 3a5a33c..b3c756a 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -68,7 +68,7 @@ async function start() { |
| } |
| // New person: pick a screen name (pre-filled with the inviter's suggestion), |
| // generate a keypair, join into the DM. |
| - return showForm(fragName || info.label, async (name) => { |
| + return showForm(fragName, async (name) => { |
| showStatus("Setting up…"); |
| const kp = await generateUserKeypair(); |
| const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| @@ -80,7 +80,7 @@ async function start() { |
| // New person: pick a screen name (pre-filled if the inviter suggested one), then |
| // generate a fresh keypair. |
| - showForm(fragName || info.label, async (name) => { |
| + showForm(fragName, async (name) => { |
| showStatus("Setting up…"); |
| const kp = await generateUserKeypair(); |
| finish(id, name, kp.publicKey, kp.privateKey); |
| @@ -94,7 +94,9 @@ async function finish(id, name, pub, priv, wrappedSelf = "") { |
| await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ label: name, wrapped: wrappedSelf, claim_secret: inviteClaimSecret }), |
| + // The screen name is never sent to the server — it's published encrypted |
| + // (POST /api/label) once the app loads and has the instance key. |
| + body: JSON.stringify({ wrapped: wrappedSelf, claim_secret: inviteClaimSecret }), |
| }) |
| ).json(); |
| } catch { |