eleven / ocho Public
π€ 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 (thechat.reactfold); nothing inserts or selects β only two cleanupDELETEs 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.labellegacy rows β setting a name writeslabel_bloband clears the plaintextlabel(humans only; bots keep plaintext labels by design), but members who haven't re-set their name sincelabel_bloblanded 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.mdcovers 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}servebranding_blob/branding_saltinstead of plaintext fields when sealed. GET /api/server-avatarserves 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
- Native apps learn the three-slot fragment, and degrade gracefully on a sealed instance (a
sealedgroup 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. - Server + web land behind the opt-in. Canary seals; fleet unaffected.
- New-space setup seals by default; existing admins get a console nudge.
- 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)
- Drop
reactions+ the two deadDELETEs + fix the stale hub description in CLAUDE.md. Trivial, immediate. - 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. - Re-seal legacy
users.labelrows: a fill-once path β any instance-key-holding client may writelabel_blobfor a roster entry that has a plaintextlabeland nolabel_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. - 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.
vapid_private: move to the instance env file (generated at provision time) or seal under a box-local key; updatedocs/secrets.mdeither 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.sourceand bot personas β admin content; the sidecar can hold B like any link-holder. Needs a loader shim for custom-app JS.- Opaque
thread_stateslot 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.