# 🤖 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`, `member_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 **B** (localStorage / Keychain) and re-derive on boot — a
  sub-second, once-per-load cost. A pinned test vector
  (`test/crypto.test.mjs`) freezes the parameters: changing them would brick
  every sealed blob already at rest.

### 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. The `~<name>`
  slot is the *invitee's* name (a prefill), not the space's, and fragments
  never reach the server — so it stays populated on sealed instances; B is
  simply appended as a third slot.
- **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** (follow-up, not yet wired): the saved-space vault entry (already
  client-encrypted) gains B so the Save-passkey card can render the photo
  too. Until then the enrol handoff already carries the decrypted name in
  its fragment, so home shows the right name either way.
- **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 degrade *gracefully* on a
   sealed instance (a `sealed` group read renders as unnamed + photoless —
   never the compiled-in default or ciphertext bytes). 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.
   Full native sealed-branding **rendering** (CommonCrypto PBKDF2 against
   the pinned vector, B in the Keychain) is the follow-up — until it lands,
   sealing an instance costs native surfaces the name and photo, a stated
   trade the opt-in admin makes.
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.
