eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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 DELETEs 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 DELETEs + 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.