server+web: seal legacy plaintext screen names via fill-once backfill — #724 step 3 (#728)
* server+web: seal legacy plaintext screen names (fill-once backfill) Members whose name predates label_blob keep a readable screen name at rest until THEY next load the app — which an inactive member never does. Any member's client that holds the instance key now seals those names on roster load via POST /api/label/backfill. The DB gate is strictly fill-once (people only, plaintext still present, no existing blob), so racing or hostile calls can never rename anyone — it only re-encodes the name the server already stored, and clears the plaintext in the same statement. Part of #724, step 3 of docs/no-plaintext.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server+web: state the honest trust tradeoff at postLabelBackfill The comments claimed the backfill 'can never rename anyone' — true only for honest clients: the server can't verify blob content, so a malicious member holding the instance key could seal a different name into a legacy row (once, display-name only, overridden the moment the victim re-sets their name). That's a deliberate trade against leaving inactive members' real names in plaintext at rest forever; say so where the endpoint lives instead of overclaiming. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+173
−1
db.go+19 −0labels_test.go+84 −1main.go+43 −0web/static/shell.js+27 −0
diff --git a/db.go b/db.go| index c561870..265cb50 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -1014,6 +1014,25 @@ func (db *DB) setUserLabelBlob(userID, blob string) error { |
| return err |
| } |
| +// backfillUserLabelBlob seals a LEGACY plaintext label (docs/no-plaintext.md): |
| +// members whose name predates label_blob keep a readable label at rest until |
| +// they next load the app — which an inactive member never does. Any |
| +// instance-key holder may seal it for them, but strictly fill-once: people |
| +// only, only while the plaintext is still there, and only if no blob exists — |
| +// so it can never overwrite a name someone set themselves, and racing or |
| +// repeated calls are harmless no-ops. Returns whether this call did the fill. |
| +func (db *DB) backfillUserLabelBlob(userID, blob string) (bool, error) { |
| + res, err := db.sql.Exec( |
| + `UPDATE users SET label_blob = ?, label = '' |
| + WHERE id = ? AND COALESCE(is_agent, 0) = 0 |
| + AND COALESCE(label_blob, '') = '' AND label != ''`, blob, userID) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, err := res.RowsAffected() |
| + return n > 0, err |
| +} |
| + |
| // setUserDescriptionBlob stores a person's "about me" text encrypted under the |
| // instance key ("" clears it). Like the label, it's set by the member themselves; |
| // an agent's blurb is the plaintext bot_configs.description instead. |
diff --git a/labels_test.go b/labels_test.go| index 1110b50..55fb136 100644 |
| --- a/labels_test.go |
| +++ b/labels_test.go |
| @@ -1,6 +1,11 @@ |
| package main |
| -import "testing" |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http" |
| + "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 |
| @@ -52,3 +57,81 @@ func TestUserLabelsEncryptedAtRest(t *testing.T) { |
| t.Fatalf("bot label should stay plaintext + untouched: %+v", e) |
| } |
| } |
| + |
| +// Legacy plaintext labels (names stored before label_blob existed) may be |
| +// sealed by any instance-key holder — but strictly fill-once: people only, |
| +// only while the plaintext is still there, never over an existing blob. |
| +func TestBackfillLegacyLabel(t *testing.T) { |
| + db := newTestDB(t) |
| + |
| + // A legacy person: plaintext label at rest, no blob. |
| + legacy, _ := db.createUser("Legacy Name") |
| + if ok, err := db.backfillUserLabelBlob(legacy, "ENC(legacy)"); err != nil || !ok { |
| + t.Fatalf("backfill legacy: ok=%v err=%v", ok, err) |
| + } |
| + var label, blob string |
| + db.sql.QueryRow("SELECT label, COALESCE(label_blob,'') FROM users WHERE id = ?", legacy).Scan(&label, &blob) |
| + if label != "" || blob != "ENC(legacy)" { |
| + t.Fatalf("after backfill: label=%q blob=%q", label, blob) |
| + } |
| + // Fill-once: a second call is a no-op — it cannot rename. |
| + if ok, _ := db.backfillUserLabelBlob(legacy, "ENC(attacker)"); ok { |
| + t.Fatal("backfill overwrote an existing blob") |
| + } |
| + |
| + // A person who already sealed their own name is untouchable. |
| + self, _ := db.createUser("") |
| + db.setUserLabelBlob(self, "ENC(self)") |
| + if ok, _ := db.backfillUserLabelBlob(self, "ENC(other)"); ok { |
| + t.Fatal("backfill touched a self-sealed name") |
| + } |
| + |
| + // A nameless person (nothing to seal) is a no-op. |
| + fresh, _ := db.createUser("") |
| + if ok, _ := db.backfillUserLabelBlob(fresh, "ENC(x)"); ok { |
| + t.Fatal("backfill filled a nameless row") |
| + } |
| + |
| + // A bot keeps its admin-authored plaintext label. |
| + bot, _ := db.createUser("Sunny") |
| + db.setUserAgent(bot) |
| + if ok, _ := db.backfillUserLabelBlob(bot, "ENC(bot)"); ok { |
| + t.Fatal("backfill touched a bot") |
| + } |
| +} |
| + |
| +// The endpoint: member-token gated, and the response says whether it filled. |
| +func TestLabelBackfillEndpoint(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + tok := mintDeviceToken(t, srv.db, "caller") |
| + legacy, _ := srv.db.createUser("Legacy Name") |
| + |
| + post := func(token, uid, blob string) (int, bool) { |
| + body, _ := json.Marshal(map[string]string{"token": token, "user_id": uid, "label_blob": blob}) |
| + r, err := http.Post(ts.URL+"/api/label/backfill", "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + defer r.Body.Close() |
| + var out struct { |
| + Filled bool `json:"filled"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&out) |
| + return r.StatusCode, out.Filled |
| + } |
| + |
| + if code, _ := post("not-a-token", legacy, "ENC(x)"); code != http.StatusForbidden { |
| + t.Fatalf("bad token: want 403, got %d", code) |
| + } |
| + if code, filled := post(tok, legacy, "ENC(legacy)"); code != http.StatusOK || !filled { |
| + t.Fatalf("fill: code=%d filled=%v", code, filled) |
| + } |
| + if code, filled := post(tok, legacy, "ENC(again)"); code != http.StatusOK || filled { |
| + t.Fatalf("refill must no-op: code=%d filled=%v", code, filled) |
| + } |
| + var label, blob string |
| + srv.db.sql.QueryRow("SELECT label, COALESCE(label_blob,'') FROM users WHERE id = ?", legacy).Scan(&label, &blob) |
| + if label != "" || blob != "ENC(legacy)" { |
| + t.Fatalf("at rest after endpoint fill: label=%q blob=%q", label, blob) |
| + } |
| +} |
diff --git a/main.go b/main.go| index a205611..7219c5b 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -411,6 +411,7 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("GET /api/admin/billing", s.getBillingStatus) |
| mux.HandleFunc("POST /api/billing/webhook", s.stripeConnectWebhook) |
| mux.HandleFunc("POST /api/label", s.postLabel) |
| + mux.HandleFunc("POST /api/label/backfill", s.postLabelBackfill) |
| mux.HandleFunc("POST /api/presence", s.postPresence) |
| mux.HandleFunc("POST /api/avatar", s.postAvatar) |
| mux.HandleFunc("POST /api/description", s.postDescription) |
| @@ -1905,6 +1906,48 @@ func (s *Server) postLabel(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| +// postLabelBackfill seals another member's LEGACY plaintext label under the |
| +// instance key (docs/no-plaintext.md): any member's client that holds the key |
| +// may do this on roster load, so one visit from anyone scrubs names members |
| +// left at rest before label_blob existed. The DB gate is strictly fill-once |
| +// (people only, plaintext still present, no existing blob), so an HONEST |
| +// client only re-encodes the name the server already stored. The server |
| +// cannot verify the blob's content (it has no key), so a malicious MEMBER |
| +// could seal a different name here — a deliberate, narrow trade: it needs a |
| +// roster member holding the instance key, it works once per legacy row only, |
| +// it touches nothing but a display name, and the victim re-setting their name |
| +// overrides it. The alternative (leaving inactive members' real names in |
| +// plaintext at rest, forever) loses to that. |
| +func (s *Server) postLabelBackfill(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + UserID string `json:"user_id"` |
| + 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 |
| + } |
| + if body.UserID == "" || body.LabelBlob == "" || len(body.LabelBlob) > 4096 { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + filled, err := s.db.backfillUserLabelBlob(body.UserID, body.LabelBlob) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + if filled { |
| + out, _ := json.Marshal(map[string]any{"type": "label", "user_id": body.UserID, "label_blob": body.LabelBlob}) |
| + s.hub.broadcast <- outbound{data: out} |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "filled": filled}) |
| +} |
| + |
| // 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 { |
diff --git a/web/static/shell.js b/web/static/shell.js| index e2e313e..145c0fc 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1130,6 +1130,33 @@ async function decryptRosterLabels() { |
| } |
| }), |
| ); |
| + backfillLegacyLabels(); |
| +} |
| + |
| +// Seal legacy plaintext names (docs/no-plaintext.md): a person whose name |
| +// predates label_blob still has it readable at rest until THEY next load the |
| +// app — which an inactive member never does. We hold the instance key, so seal |
| +// it for them; the server's gate is strictly fill-once (people only, plaintext |
| +// still present, no existing blob), so this client only re-encodes the name |
| +// the server already stored. (The server can't verify blob content — the |
| +// honest-member assumption here is stated at postLabelBackfill in main.go.) |
| +// Fire-and-forget. |
| +const labelBackfillSent = new Set(); |
| +function backfillLegacyLabels() { |
| + if (!instanceKey) return; |
| + for (const e of roster) { |
| + if (e.is_agent || e.label_blob || !e.label || labelBackfillSent.has(e.user_id)) continue; |
| + labelBackfillSent.add(e.user_id); |
| + encrypt(instanceKey, e.label) |
| + .then((blob) => |
| + fetch("/api/label/backfill", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, user_id: e.user_id, label_blob: blob }), |
| + }), |
| + ) |
| + .catch(() => {}); |
| + } |
| } |
| // Publish our own display name, encrypted under the instance key — for a fresh |