| 1 | # π€ No plaintext on the server |
| 2 | |
| 3 | The goal of this work is one true, auditable sentence: |
| 4 | |
| 5 | > **The server stores no human-readable content. Every name, photo, |
| 6 | > description, message, and file at rest is ciphertext.** |
| 7 | |
| 8 | The E2E rule (CLAUDE.md) already covers everything members *say and share* β |
| 9 | sealed under full-strength member keys the server never holds. What remains in |
| 10 | plaintext today is a short tail: two genuine leftovers, one secret whose |
| 11 | exposure hibernation just amplified, and the deliberately-plaintext branding |
| 12 | set. This doc inventories all of it and designs the piece that closes the gap: |
| 13 | a **link-carried branding key** that seals the "plaintext by design" items |
| 14 | without breaking the surfaces that made them plaintext in the first place. |
| 15 | |
| 16 | Related: the secrets inventory (`docs/secrets.md`), tamper-evident hosting |
| 17 | (#573), the provider seam (#684β#686), hibernation/S3 (`docs/planet-scale.md` |
| 18 | β which raises the stakes: whatever is plaintext in `lchat.db` now also lands |
| 19 | in a bucket). |
| 20 | |
| 21 | ## Inventory: what an instance stores in plaintext today |
| 22 | |
| 23 | Four buckets, audited against `db.go`'s schema + every `config` key (2026-07-11). |
| 24 | |
| 25 | ### 1. Genuine leftovers (fix outright) |
| 26 | |
| 27 | - **`link_previews`** β plaintext URL, OpenGraph title/description/image URL |
| 28 | per row, derived directly from message content, with **no eviction ever**. |
| 29 | The documented unfurl exception covers the *fetch*, not an unbounded |
| 30 | plaintext archive of every URL a space has discussed. |
| 31 | - **`reactions`** β vestigial. Reactions moved into encrypted message events |
| 32 | (the `chat.react` fold); nothing inserts or selects β only two cleanup |
| 33 | `DELETE`s remain. Rows from the pre-event era hold the reactor's **raw |
| 34 | session token** and plaintext emoji. Precedent for removal already exists in |
| 35 | the migration: `DROP TABLE IF EXISTS invites`. |
| 36 | - **`users.label` legacy rows** β setting a name writes `label_blob` and |
| 37 | clears the plaintext `label` (humans only; bots keep plaintext labels by |
| 38 | design), but members who haven't re-set their name since `label_blob` |
| 39 | landed still have plaintext screen names at rest. |
| 40 | |
| 41 | ### 2. A secret at rest that hibernation amplified |
| 42 | |
| 43 | - **`config.vapid_private`** β a real private key inside the instance DB, |
| 44 | now replicated to S3 for hibernating instances. Small blast radius (junk |
| 45 | web pushes), but a bucket leak shouldn't hand out private keys of any kind. |
| 46 | (`docs/secrets.md` covers rotation; it should also note the S3 copy.) |
| 47 | |
| 48 | Everything else credential-shaped is already hashed at rest: |
| 49 | `admin_token_hash`, `device_tokens.token_hash`, push/APNs `token_hash`, |
| 50 | `messages.author_hash`, `key_invites.claim_hash`. |
| 51 | |
| 52 | ### 3. Plaintext by design β sealed under the branding key (this doc) |
| 53 | |
| 54 | Branding and space policy the server serves to people who hold no member key |
| 55 | yet: `group_name`, `group_description`, `server_avatar_alt`, the |
| 56 | `instance_avatar` bytes, `admin_name`, `admin_email`; later access-level |
| 57 | (tier) names. Today these are plaintext because they must render on the |
| 58 | join/onboarding cards, the native add-chat screens, and the home Save-passkey |
| 59 | card. The observation that unlocks sealing them: **every one of those |
| 60 | surfaces is reached via a link**, and links already have a server-blind |
| 61 | channel β the `#fragment`. (Prior art in-tree: invite fragments already carry |
| 62 | the space name client-side as `#<T>~<name>`.) |
| 63 | |
| 64 | ### 4. Structural metadata (accepted, listed deliberately) |
| 65 | |
| 66 | Not encryptable without redesigning the product; enumerated here so the claim |
| 67 | above stays honest about its scope: |
| 68 | |
| 69 | - **Enforcement machinery** the server must read to act: policy flags |
| 70 | (`user_invites_enabled`, `ai_enabled`, `apps_enabled`, `member_apps_enabled`, |
| 71 | `bots_enabled`), |
| 72 | roles, `is_agent`, presence, update channel/pin, `setup_complete`, |
| 73 | access-level pricing (`price_cents`/`currency`/`interval`) and Stripe ids. |
| 74 | - **Shape**: the membership graph, thread kinds/visibility, timestamps, |
| 75 | message/blob sizes, scheduled-message delivery times, push endpoints, the |
| 76 | hostname itself. |
| 77 | - **Transient disclosures** (never at rest, documented like the unfurl/Giphy |
| 78 | exceptions): the unfurl fetch sees URLs; Stripe sees a sellable level's |
| 79 | name at price-creation time; bot sidecars (deliberate members) decrypt the |
| 80 | threads they're in. |
| 81 | |
| 82 | Bot operator config (`bot_configs.system_prompt`/`docs`/`welcome`, |
| 83 | `custom_apps.source`) is admin-authored policy/code, kept plaintext today |
| 84 | like branding; both are phase-2 candidates under the branding key (Β§ Later). |
| 85 | |
| 86 | ## The branding key (B) |
| 87 | |
| 88 | A per-instance symmetric key one trust rung below the instance key: |
| 89 | |
| 90 | > user private key β thread keys β instance key (members) β tier keys |
| 91 | > β **branding key (anyone with a share link)** β public (nothing readable) |
| 92 | |
| 93 | - **Minted client-side** by an admin the first time branding is sealed (and |
| 94 | by the setup flow for new instances). The server never sees B β only |
| 95 | ciphertext sealed under a key derived from it. |
| 96 | - **Format**: 10 characters of lowercase base32 (50 bits), e.g. `k3m7p2qx4f`. |
| 97 | Never typed by humans β links and client storage carry it β so the length |
| 98 | is a link-aesthetics choice, not a usability one. The dial: 8 chars resists |
| 99 | bulk scraping only; 10 (default) makes a targeted offline attack cost |
| 100 | thousands of GPU-years; 16 is a real secret. See Β§ Honest threat math. |
| 101 | - **KDF**: PBKDF2-SHA256, 600,000 iterations, per-instance random salt stored |
| 102 | alongside the ciphertext (the salt is not secret). PBKDF2 because it is the |
| 103 | one stretching KDF native to both WebCrypto and CommonCrypto β Argon2id |
| 104 | would be strictly better against GPUs but needs WASM in the browser, and |
| 105 | the frontend has a no-build-step rule. The derived key is AES-GCM-256; |
| 106 | clients cache **B** (localStorage / Keychain) and re-derive on boot β a |
| 107 | sub-second, once-per-load cost. A pinned test vector |
| 108 | (`test/crypto.test.mjs`) freezes the parameters: changing them would brick |
| 109 | every sealed blob already at rest. |
| 110 | |
| 111 | ### What gets sealed |
| 112 | |
| 113 | One JSON blob (name, description, admin name, admin email, avatar alt), |
| 114 | AES-GCM under the derived key, stored in `config` as `branding_blob` + |
| 115 | `branding_salt`; the `instance_avatar` bytes sealed the same way. Plaintext |
| 116 | columns are cleared in the same write. Sealing is **opt-in per instance** |
| 117 | (the admin flips it in the console; new spaces seal at setup once native |
| 118 | support has shipped) β so the fleet is safe at every intermediate point. |
| 119 | |
| 120 | ### How B travels (all channels already exist) |
| 121 | |
| 122 | - **Invite / setup / add-device links**: the fragment becomes |
| 123 | `#<T>~<name>~<B>` β T first, so *old* parsers (web and native) still |
| 124 | extract T and can claim; they merely can't decrypt branding. The `~<name>` |
| 125 | slot is the *invitee's* name (a prefill), not the space's, and fragments |
| 126 | never reach the server β so it stays populated on sealed instances; B is |
| 127 | simply appended as a third slot. |
| 128 | - **Members**: cached client-side on first use; additionally one copy of B is |
| 129 | sealed under the **instance key** (`config.branding_key_sealed`), so every |
| 130 | existing member's client can bootstrap B without ever re-visiting a link. |
| 131 | - **Home** (follow-up, not yet wired): the saved-space vault entry (already |
| 132 | client-encrypted) gains B so the Save-passkey card can render the photo |
| 133 | too. Until then the enrol handoff already carries the decrypted name in |
| 134 | its fragment, so home shows the right name either way. |
| 135 | - **The only orphan**: typing the bare domain with no link and no session β |
| 136 | renders a generic unbranded page. That is a privacy improvement, not a |
| 137 | regression: an unauthenticated visitor learns nothing, not even the name. |
| 138 | |
| 139 | ### Server changes (verified narrow) |
| 140 | |
| 141 | - `__GROUP_NAME__` page substitution (`main.go:609`) falls back to the |
| 142 | generic product name when sealed; the client fills after decrypting. |
| 143 | - The public meta endpoint and `GET /api/key-invite/{id}` serve |
| 144 | `branding_blob`/`branding_salt` instead of plaintext fields when sealed. |
| 145 | - `GET /api/server-avatar` serves the sealed bytes; clients decrypt. |
| 146 | - `groupNameSet` (a boolean) and all enforcement flags are untouched. |
| 147 | |
| 148 | ### Honest threat math |
| 149 | |
| 150 | The ciphertext lives on the very server we hide it from, so the operator can |
| 151 | brute-force offline; short keys are **obfuscation against bulk exposure, not |
| 152 | secrecy against a targeted adversary**, and every place we describe this must |
| 153 | say so. At 600k-iteration PBKDF2-SHA256 (~10β΄ guesses/sec on a good GPU): |
| 154 | 8 chars (40 bits) β 3 GPU-years, 10 chars (50 bits) β 3,000 GPU-years, |
| 155 | 16 chars (80 bits) β never. Messages, files, and profiles are unaffected |
| 156 | either way β they stay under full-strength member keys. The user-facing copy |
| 157 | stays in lock-and-key language: *"even the space's name and photo are in the |
| 158 | locked box; the key rides inside your invitation, and the people who run the |
| 159 | servers are never sent one."* |
| 160 | |
| 161 | ### Compatibility & rollout order |
| 162 | |
| 163 | 1. Native apps learn the three-slot fragment, and degrade *gracefully* on a |
| 164 | sealed instance (a `sealed` group read renders as unnamed + photoless β |
| 165 | never the compiled-in default or ciphertext bytes). Old links keep |
| 166 | working forever (T parse is position-stable). New links work on old |
| 167 | natives for *claiming*; only the pre-claim branding display degrades. |
| 168 | Full native sealed-branding **rendering** (CommonCrypto PBKDF2 against |
| 169 | the pinned vector, B in the Keychain) is the follow-up β until it lands, |
| 170 | sealing an instance costs native surfaces the name and photo, a stated |
| 171 | trade the opt-in admin makes. |
| 172 | 2. Server + web land behind the opt-in. Canary seals; fleet unaffected. |
| 173 | 3. New-space setup seals by default; existing admins get a console nudge. |
| 174 | 4. Renaming re-seals under the same B. B rotates only with a deliberate |
| 175 | admin action (which re-mints links); expected never, like the instance key. |
| 176 | |
| 177 | ## The plan (small PRs, in order) |
| 178 | |
| 179 | 1. **Drop `reactions`** + the two dead `DELETE`s + fix the stale hub |
| 180 | description in CLAUDE.md. Trivial, immediate. |
| 181 | 2. **Seal the link-preview cache**: key rows by `sha256(url)`, encrypt |
| 182 | title/description/image under HKDF(url) β the server derives the key |
| 183 | transiently at request time (it already sees the URL then; that is the |
| 184 | documented exception) and stores only ciphertext. Existing plaintext rows |
| 185 | are dropped (it's a cache). Add TTL eviction while in there. |
| 186 | 3. **Re-seal legacy `users.label` rows**: a fill-once path β any |
| 187 | instance-key-holding client may write `label_blob` for a roster entry |
| 188 | that has a plaintext `label` and no `label_blob` (non-agents only); the |
| 189 | server clears the plaintext in the same statement. The web client does |
| 190 | this opportunistically on roster load; one visit from any member scrubs |
| 191 | the space. |
| 192 | 4. **The branding key** (this doc's core), server + web + native fragment |
| 193 | tolerance; full native sealed-branding rendering may follow as its own |
| 194 | PR before any real space opts in. |
| 195 | 5. **`vapid_private`**: move to the instance env file (generated at |
| 196 | provision time) or seal under a box-local key; update `docs/secrets.md` |
| 197 | either way. |
| 198 | |
| 199 | ## Later (phase 2, same key, own decisions) |
| 200 | |
| 201 | - Access-level (tier) names β sealed under B; Stripe still sees a sellable |
| 202 | level's name at price-mint (transient, external, documented). |
| 203 | - `custom_apps.source` and bot personas β admin content; the sidecar can |
| 204 | hold B like any link-holder. Needs a loader shim for custom-app JS. |
| 205 | - Opaque `thread_state` slot ids (today the slot name leaks which app a |
| 206 | thread runs). |
| 207 | - A "what your provider can see" page generated from this inventory, as the |
| 208 | member-facing close of the story. |
| 209 | |