eleven / messenger Public

docs: no plaintext on the server โ€” inventory + the branding-key design (#723)

* docs: no plaintext on the server โ€” inventory + branding-key design

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: fix the example branding key (9 is not in the base32 alphabet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e51f771df1fe64b889fdee4419635747f3bcaa11 parent 01652fa
1 file changed, +198 −0
  • docs/no-plaintext.md +198 −0
diff --git a/docs/no-plaintext.md b/docs/no-plaintext.md
new file mode 100644
index 0000000..b76a88f
--- /dev/null
+++ b/docs/no-plaintext.md
@@ -0,0 +1,198 @@
+# ๐Ÿค– No plaintext on the server
+
+The goal of this work is one true, auditable sentence:
+
+> **The server stores no human-readable content. Every name, photo,
+> description, message, and file at rest is ciphertext.**
+
+The E2E rule (CLAUDE.md) already covers everything members *say and share* โ€”
+sealed under full-strength member keys the server never holds. What remains in
+plaintext today is a short tail: two genuine leftovers, one secret whose
+exposure hibernation just amplified, and the deliberately-plaintext branding
+set. This doc inventories all of it and designs the piece that closes the gap:
+a **link-carried branding key** that seals the "plaintext by design" items
+without breaking the surfaces that made them plaintext in the first place.
+
+Related: the secrets inventory (`docs/secrets.md`), tamper-evident hosting
+(#573), the provider seam (#684โ€“#686), hibernation/S3 (`docs/planet-scale.md`
+โ€” which raises the stakes: whatever is plaintext in `lchat.db` now also lands
+in a bucket).
+
+## Inventory: what an instance stores in plaintext today
+
+Four buckets, audited against `db.go`'s schema + every `config` key (2026-07-11).
+
+### 1. Genuine leftovers (fix outright)
+
+- **`link_previews`** โ€” plaintext URL, OpenGraph title/description/image URL
+ per row, derived directly from message content, with **no eviction ever**.
+ The documented unfurl exception covers the *fetch*, not an unbounded
+ plaintext archive of every URL a space has discussed.
+- **`reactions`** โ€” vestigial. Reactions moved into encrypted message events
+ (the `chat.react` fold); nothing inserts or selects โ€” only two cleanup
+ `DELETE`s remain. Rows from the pre-event era hold the reactor's **raw
+ session token** and plaintext emoji. Precedent for removal already exists in
+ the migration: `DROP TABLE IF EXISTS invites`.
+- **`users.label` legacy rows** โ€” setting a name writes `label_blob` and
+ clears the plaintext `label` (humans only; bots keep plaintext labels by
+ design), but members who haven't re-set their name since `label_blob`
+ landed still have plaintext screen names at rest.
+
+### 2. A secret at rest that hibernation amplified
+
+- **`config.vapid_private`** โ€” a real private key inside the instance DB,
+ now replicated to S3 for hibernating instances. Small blast radius (junk
+ web pushes), but a bucket leak shouldn't hand out private keys of any kind.
+ (`docs/secrets.md` covers rotation; it should also note the S3 copy.)
+
+Everything else credential-shaped is already hashed at rest:
+`admin_token_hash`, `device_tokens.token_hash`, push/APNs `token_hash`,
+`messages.author_hash`, `key_invites.claim_hash`.
+
+### 3. Plaintext by design โ†’ sealed under the branding key (this doc)
+
+Branding and space policy the server serves to people who hold no member key
+yet: `group_name`, `group_description`, `server_avatar_alt`, the
+`instance_avatar` bytes, `admin_name`, `admin_email`; later access-level
+(tier) names. Today these are plaintext because they must render on the
+join/onboarding cards, the native add-chat screens, and the home Save-passkey
+card. The observation that unlocks sealing them: **every one of those
+surfaces is reached via a link**, and links already have a server-blind
+channel โ€” the `#fragment`. (Prior art in-tree: invite fragments already carry
+the space name client-side as `#<T>~<name>`.)
+
+### 4. Structural metadata (accepted, listed deliberately)
+
+Not encryptable without redesigning the product; enumerated here so the claim
+above stays honest about its scope:
+
+- **Enforcement machinery** the server must read to act: policy flags
+ (`user_invites_enabled`, `ai_enabled`, `apps_enabled`, `bots_enabled`),
+ roles, `is_agent`, presence, update channel/pin, `setup_complete`,
+ access-level pricing (`price_cents`/`currency`/`interval`) and Stripe ids.
+- **Shape**: the membership graph, thread kinds/visibility, timestamps,
+ message/blob sizes, scheduled-message delivery times, push endpoints, the
+ hostname itself.
+- **Transient disclosures** (never at rest, documented like the unfurl/Giphy
+ exceptions): the unfurl fetch sees URLs; Stripe sees a sellable level's
+ name at price-creation time; bot sidecars (deliberate members) decrypt the
+ threads they're in.
+
+Bot operator config (`bot_configs.system_prompt`/`docs`/`welcome`,
+`custom_apps.source`) is admin-authored policy/code, kept plaintext today
+like branding; both are phase-2 candidates under the branding key (ยง Later).
+
+## The branding key (B)
+
+A per-instance symmetric key one trust rung below the instance key:
+
+> user private key โ†’ thread keys โ†’ instance key (members) โ†’ tier keys
+> โ†’ **branding key (anyone with a share link)** โ†’ public (nothing readable)
+
+- **Minted client-side** by an admin the first time branding is sealed (and
+ by the setup flow for new instances). The server never sees B โ€” only
+ ciphertext sealed under a key derived from it.
+- **Format**: 10 characters of lowercase base32 (50 bits), e.g. `k3m7p2qx4f`.
+ Never typed by humans โ€” links and client storage carry it โ€” so the length
+ is a link-aesthetics choice, not a usability one. The dial: 8 chars resists
+ bulk scraping only; 10 (default) makes a targeted offline attack cost
+ thousands of GPU-years; 16 is a real secret. See ยง Honest threat math.
+- **KDF**: PBKDF2-SHA256, 600,000 iterations, per-instance random salt stored
+ alongside the ciphertext (the salt is not secret). PBKDF2 because it is the
+ one stretching KDF native to both WebCrypto and CommonCrypto โ€” Argon2id
+ would be strictly better against GPUs but needs WASM in the browser, and
+ the frontend has a no-build-step rule. The derived key is AES-GCM-256;
+ clients cache it (localStorage / Keychain) so the ~0.5 s derivation is paid
+ once per device, not per load.
+
+### What gets sealed
+
+One JSON blob (name, description, admin name, admin email, avatar alt),
+AES-GCM under the derived key, stored in `config` as `branding_blob` +
+`branding_salt`; the `instance_avatar` bytes sealed the same way. Plaintext
+columns are cleared in the same write. Sealing is **opt-in per instance**
+(the admin flips it in the console; new spaces seal at setup once native
+support has shipped) โ€” so the fleet is safe at every intermediate point.
+
+### How B travels (all channels already exist)
+
+- **Invite / setup / add-device links**: the fragment becomes
+ `#<T>~<name>~<B>` โ€” T first, so *old* parsers (web and native) still
+ extract T and can claim; they merely can't decrypt branding. Once an
+ instance seals, the plaintext `~<name>` slot is left empty (`#<T>~~<B>`) โ€”
+ the whole point is that links stop carrying readable names.
+- **Members**: cached client-side on first use; additionally one copy of B is
+ sealed under the **instance key** (`config.branding_key_sealed`), so every
+ existing member's client can bootstrap B without ever re-visiting a link.
+- **Home**: the saved-space vault entry (already client-encrypted) gains B,
+ so the Save-passkey card renders sealed branding.
+- **The only orphan**: typing the bare domain with no link and no session โ€”
+ renders a generic unbranded page. That is a privacy improvement, not a
+ regression: an unauthenticated visitor learns nothing, not even the name.
+
+### Server changes (verified narrow)
+
+- `__GROUP_NAME__` page substitution (`main.go:609`) falls back to the
+ generic product name when sealed; the client fills after decrypting.
+- The public meta endpoint and `GET /api/key-invite/{id}` serve
+ `branding_blob`/`branding_salt` instead of plaintext fields when sealed.
+- `GET /api/server-avatar` serves the sealed bytes; clients decrypt.
+- `groupNameSet` (a boolean) and all enforcement flags are untouched.
+
+### Honest threat math
+
+The ciphertext lives on the very server we hide it from, so the operator can
+brute-force offline; short keys are **obfuscation against bulk exposure, not
+secrecy against a targeted adversary**, and every place we describe this must
+say so. At 600k-iteration PBKDF2-SHA256 (~10โด guesses/sec on a good GPU):
+8 chars (40 bits) โ‰ˆ 3 GPU-years, 10 chars (50 bits) โ‰ˆ 3,000 GPU-years,
+16 chars (80 bits) โ‰ˆ never. Messages, files, and profiles are unaffected
+either way โ€” they stay under full-strength member keys. The user-facing copy
+stays in lock-and-key language: *"even the space's name and photo are in the
+locked box; the key rides inside your invitation, and the people who run the
+servers are never sent one."*
+
+### Compatibility & rollout order
+
+1. Native apps learn the three-slot fragment and the sealed-branding
+ rendering path (iOS first). Old links keep working forever (T parse is
+ position-stable). New links work on old natives for *claiming*; only the
+ pre-claim branding display degrades โ€” acceptable because sealing is
+ opt-in and ships after native support.
+2. Server + web land behind the opt-in. Canary seals; fleet unaffected.
+3. New-space setup seals by default; existing admins get a console nudge.
+4. Renaming re-seals under the same B. B rotates only with a deliberate
+ admin action (which re-mints links); expected never, like the instance key.
+
+## The plan (small PRs, in order)
+
+1. **Drop `reactions`** + the two dead `DELETE`s + fix the stale hub
+ description in CLAUDE.md. Trivial, immediate.
+2. **Seal the link-preview cache**: key rows by `sha256(url)`, encrypt
+ title/description/image under HKDF(url) โ€” the server derives the key
+ transiently at request time (it already sees the URL then; that is the
+ documented exception) and stores only ciphertext. Existing plaintext rows
+ are dropped (it's a cache). Add TTL eviction while in there.
+3. **Re-seal legacy `users.label` rows**: a fill-once path โ€” any
+ instance-key-holding client may write `label_blob` for a roster entry
+ that has a plaintext `label` and no `label_blob` (non-agents only); the
+ server clears the plaintext in the same statement. The web client does
+ this opportunistically on roster load; one visit from any member scrubs
+ the space.
+4. **The branding key** (this doc's core), server + web + native fragment
+ tolerance; full native sealed-branding rendering may follow as its own
+ PR before any real space opts in.
+5. **`vapid_private`**: move to the instance env file (generated at
+ provision time) or seal under a box-local key; update `docs/secrets.md`
+ either way.
+
+## Later (phase 2, same key, own decisions)
+
+- Access-level (tier) names โ€” sealed under B; Stripe still sees a sellable
+ level's name at price-mint (transient, external, documented).
+- `custom_apps.source` and bot personas โ€” admin content; the sidecar can
+ hold B like any link-holder. Needs a loader shim for custom-app JS.
+- Opaque `thread_state` slot ids (today the slot name leaks which app a
+ thread runs).
+- A "what your provider can see" page generated from this inventory, as the
+ member-facing close of the story.