eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– No plaintext on the server
2
3The 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
8The E2E rule (CLAUDE.md) already covers everything members *say and share* β€”
9sealed under full-strength member keys the server never holds. What remains in
10plaintext today is a short tail: two genuine leftovers, one secret whose
11exposure hibernation just amplified, and the deliberately-plaintext branding
12set. This doc inventories all of it and designs the piece that closes the gap:
13a **link-carried branding key** that seals the "plaintext by design" items
14without breaking the surfaces that made them plaintext in the first place.
15
16Related: 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
19in a bucket).
20
21## Inventory: what an instance stores in plaintext today
22
23Four 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
48Everything 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
54Branding and space policy the server serves to people who hold no member key
55yet: `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
58join/onboarding cards, the native add-chat screens, and the home Save-passkey
59card. The observation that unlocks sealing them: **every one of those
60surfaces is reached via a link**, and links already have a server-blind
61channel β€” the `#fragment`. (Prior art in-tree: invite fragments already carry
62the space name client-side as `#<T>~<name>`.)
63
64### 4. Structural metadata (accepted, listed deliberately)
65
66Not encryptable without redesigning the product; enumerated here so the claim
67above 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
82Bot operator config (`bot_configs.system_prompt`/`docs`/`welcome`,
83`custom_apps.source`) is admin-authored policy/code, kept plaintext today
84like branding; both are phase-2 candidates under the branding key (Β§ Later).
85
86## The branding key (B)
87
88A 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
113One JSON blob (name, description, admin name, admin email, avatar alt),
114AES-GCM under the derived key, stored in `config` as `branding_blob` +
115`branding_salt`; the `instance_avatar` bytes sealed the same way. Plaintext
116columns 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
118support 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
150The ciphertext lives on the very server we hide it from, so the operator can
151brute-force offline; short keys are **obfuscation against bulk exposure, not
152secrecy against a targeted adversary**, and every place we describe this must
153say so. At 600k-iteration PBKDF2-SHA256 (~10⁴ guesses/sec on a good GPU):
1548 chars (40 bits) β‰ˆ 3 GPU-years, 10 chars (50 bits) β‰ˆ 3,000 GPU-years,
15516 chars (80 bits) β‰ˆ never. Messages, files, and profiles are unaffected
156either way β€” they stay under full-strength member keys. The user-facing copy
157stays in lock-and-key language: *"even the space's name and photo are in the
158locked box; the key rides inside your invitation, and the people who run the
159servers are never sent one."*
160
161### Compatibility & rollout order
162
1631. 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.
1722. Server + web land behind the opt-in. Canary seals; fleet unaffected.
1733. New-space setup seals by default; existing admins get a console nudge.
1744. 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
1791. **Drop `reactions`** + the two dead `DELETE`s + fix the stale hub
180 description in CLAUDE.md. Trivial, immediate.
1812. **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.
1863. **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.
1924. **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.
1955. **`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