eleven / messenger Public

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>
Paul Campbell 7328e4970d52f22d5f324304bdeab6db147aea6d parent eed9117
4 files changed, +173 −1
  • db.go +19 −0
  • labels_test.go +84 −1
  • main.go +43 −0
  • web/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