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>
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. |