eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Accounts, sign-in & recovery β€” the anchor model
2
3Status: **plan of record β€” phase 1 largely shipped** (decided with Paul
42026-07-16; status refreshed 2026-07-22). Live today: the six sign-in doors on
5home (passkey, email code, Apple, Google, Microsoft, GitHub β€” the #1112 card),
6identity anchors + ceremony proofs (`stateless.go`), the Sign-in options
7management step, account combine/merge (#1098), sealed anchor sessions, and
8the native passkey/Apple/email doors. Merged but dark: the synced DEK
9(#1144, flag off pending the device matrix). Not built yet: the `/invite`
10link redesign (the phase-0 locked-card rungs shipped on the Mac 2026-07-22;
11native add-device mint/QR still open), owner anchoring at creation,
12approve-from-another-device, the recovery-passkey offer, and the native OIDC
13doors (server legs done, client work open) β€” see *Phasing* below, per-item.
14(The **paper recovery kit** that used to sit in this list was cut on
152026-07-25 β€” Paul: recovery is a passkey, never a passphrase or a printed
16code; the recovery-passkey section below replaces it.)
17This document supersedes the *Invariants* section of
18`onboarding-and-home-server.md` β€” the "no email, no social login, ever" line β€”
19after that invariant was field-falsified by real lockouts. Everything else in
20that doc (the welcome page, the home server machinery, the blob schema,
21enrolment, the fragility banner) remains accurate and in force; this doc is
22about **what identifies a person and what brings their keys back**.
23
24## Why we're revising a "permanent" decision
25
26Passkey-only sign-in was declared a Day-1 invariant. Within a week of the
27native apps leaning on it, two of our first users were locked out:
28
29- The macOS ceremony sheet only reaches iCloud Keychain (plus QR/security
30 keys). A passkey living in a Chrome profile or a third-party manager is
31 invisible to it β€” the user sees "a passkey window and no way to enter a
32 password."
33- 1Password's macOS provider returns a PRF that doesn't match its own browser
34 extension's for the same credential, so even a *present* passkey can fail to
35 decrypt (the browser-handoff workaround exists because of this).
36- The locked-space card offered exactly one rung ("Sign In with Eleven…") and
37 a destructive one ("Remove from This Mac"). One-rung ladders read as "the
38 app is broken", not "the design is working".
39
40The root cause is architectural, not cosmetic: the passkey was doing **two
41different jobs at once**, so any failure of the ceremony was total.
42
43## The two jobs
44
45Every mechanism in this design does one or both of:
46
471. **Identity** β€” proving you're the same person who enrolled. Lets the home
48 server return your record and gates destructive operations.
492. **Keys** β€” holding or deriving the client-side secret (the KEK) that
50 unwraps the vault **DEK**. The DEK decrypts the blob, which holds live
51 sessions + per-instance private keys. Nothing server-side can ever do this
52 job β€” that's the one rule.
53
54| Mechanism | Identity | Keys | Notes |
55|---|---|---|---|
56| Passkey + PRF | βœ“ | βœ“ | today's model; elegant, and why its failure is total |
57| Sign in with Apple | βœ“ | β€” | stable team-scoped `sub`; can request no email at all |
58| Email code | βœ“ | β€” | ecosystem-neutral; blind-indexed (below) |
59| iCloud-synced DEK item | β€” | βœ“ | invisible; Apple-ecosystem only |
60| Device key + approval | β€” | βœ“ | transfers keys to a new device, given identity |
61| Recovery passkey (manager / hardware key) | βœ“ | βœ“ | a second, independent credential; ecosystem-free |
62| Guardian wrap | β€” | βœ“ | DEK/credentials wrapped to another member's key |
63
64An **identity anchor alone can never decrypt anything.** That single sentence
65forbids the tempting shortcut (email-gated server escrow of the DEK) and
66shapes everything below.
67
68## The new invariants
69
70What "no email, no social login, ever" weakens to β€” each line deliberate:
71
721. **Membership stays anonymous.** Joining a space requires no identifier,
73 ever β€” invites remain the only membership door. No change from today.
742. **Ownership is anchored.** Every space's recovery chain must terminate at
75 a member whose recovery survives total device loss β€” normally the owner.
76 Practically: *creating* a space requires an anchor; *joining* one never
77 does. The owner's anchor is a **pair**: an identity anchor (email, Sign in
78 with Apple, or a successor β€” see the open slot) *plus* a key anchor (on
79 Apple platforms the synced DEK rides along invisibly; otherwise a
80 recovery passkey in a synced manager or on a hardware key).
813. **Providers attest; they are never the identity.** A third-party sign-in
82 is at most a verifier of an anchor. Our record is a blind index (a hash) β€”
83 there is no provider user-id column, no stored email address, and swapping
84 providers never orphans an account.
854. **The server never holds a readable key.** No escrow of the DEK under any
86 server-side or identity-gated key, ever β€” a compromised home server plus a
87 passed email gate must still yield nothing. Recovery that "just works with
88 an email" is only achievable by breaking the one rule; we don't.
895. **The home server stays strictly additive** (unchanged): no enrolment
90 record β†’ no requests to it, ever.
916. **Copy stays human** (unchanged rule): never "passkey", "PRF", "key",
92 "escrow", or "backup" in UI. People sign in, save their spaces, and add
93 ways back in.
94
95## Account classes
96
97| Class | Identity (job 1) | Keys (job 2) | Lost everything |
98|---|---|---|---|
99| **Owner** (anchored) | SiwA / email / successor | synced DEK, or recovery passkey | full restore via anchor pair |
100| **Member, anchored** (optional) | same menu | same menu | same ladder as owner |
101| **Member, identifier-less** | the key *is* the identity | passkey(s) or nothing | re-invite floor |
102| **Guardian-managed** | none β€” guardian vouches | wrapped to guardian's key | guardian re-links, instantly |
103
104- **Owner.** Whoever creates a space attaches the anchor pair at creation
105 (or is nudged until they do β€” open decision). This lands the identifier
106 exactly where accountability already lives: space creation is the abuse
107 surface (quotas, reaping, billing), membership never was (invite-gated).
108- **Identifier-less member.** Today's model, kept on purpose for
109 privacy-maximalists: bootstrap by invite, optionally strengthen with a
110 passkey (or several), accept the re-invite floor. This class is why
111 the product can still truthfully say "no signup required".
112- **Guardian-managed** β€” the family case. A child has no email and no
113 passkey. The guardian's signed-in app mints the child's credentials
114 (an add-device link / QR β€” machinery that exists today) and keeps a copy
115 as a **managed entry in the guardian's own vault**. The child has *no home
116 account at all* β€” no server record, no identifier, nothing collected
117 (the right COPPA/GDPR-K posture by construction). Restore = guardian opens
118 their app β†’ managed member β†’ "link this device" QR. The guardian's own
119 recovery ladder is the redundancy: recover the guardian account and the
120 managed entries come back with the vault. Disclosed honestly: a guardian holding a member's keys can read
121 and act as that member β€” that is what guardianship *is*, and the UI says
122 so at creation. (A later variant can give an older child their own home
123 account with the DEK additionally wrapped to the guardian β€” the graduation
124 path β€” but the vault-entry form is the v1.)
125
126## What recovery restores β€” the ladder
127
128Top rung first; each rung only matters when everything above it is gone:
129
1301. **Any surviving signed-in device** β€” everything, silently (the cached DEK;
131 today's silent sync).
1322. **A synced key anchor** β€” a passkey that synced, or the iCloud-synced DEK:
133 new device + one identity ceremony = full restore, zero surviving devices
134 needed.
1353. **Approve from another device** β€” a new device signs in with an identity
136 anchor; an enrolled device gets a push ("Approve this Mac?"); on approval
137 the DEK is re-wrapped to the new device's ephemeral key. This is the
138 add-device flow wearing mainstream clothes (WhatsApp/Signal/Apple trained
139 everyone on it) and is the workhorse for web and non-Apple platforms.
1404. **A recovery passkey outside the ecosystem** β€” a passkey in a
141 cross-platform manager or on a hardware security key, enrolled as one
142 more way in. This is what survives losing a whole vendor account: it is
143 deliberately not parked where rung 2 lives.
1445. **Control-only recovery + the re-invite floor** β€” identity anchor alone.
145 The owner recovers *control* of the space (see below) and can re-admit
146 anyone; a re-invited member gets the **same stable thread keys re-wrapped
147 to their new keypair by any live member's client, so full history
148 decrypts**. What this rung cannot do: if literally every member's key
149 material is gone (a solo space; a whole family's devices at once), the
150 history is cryptographically sealed. That is the design working β€” and the
151 reason invariant 2 requires the owner's anchor to be a *pair*, not an
152 email alone.
153
154## Sign in with Apple, precisely
155
156The seamless path for the current (Apple-heavy) user base:
157
158- **Request no email scope.** The stable team-scoped `sub` is the account
159 key, stored as `HMAC(pepper, sub)` β€” nothing mailable, nothing to leak.
160 Apple learns sign-in timing; we learn nothing new.
161- **Pair it with the synced DEK.** The native DEK cache is deliberately
162 device-local today (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,
163 `ios/LChatCore/.../Keychain.swift`). Storing the DEK *also* as a
164 synchronizable iCloud Keychain item makes the key travel invisibly β€” same
165 trust anchor as an iCloud passkey, zero WebAuthn ceremonies, and the
166 third-party-provider bug class (the 1Password PRF mismatch) disappears
167 because credential managers are never in the loop. *Built (issue #1089):
168 `ios/LChatCore/.../DEKSync.swift` β€” a second item beside the device-local
169 one (service `com.lchat.client.dek-synced`, added by addition, never
170 delete-then-add), write-gated on the account holding a SiwA anchor.
171 Build-flag gated, OFF by default until the device matrix passes.*
172- **Job 1 gates job 2.** A synced DEK alone would auto-unlock on any device
173 signed into the same Apple ID (family iPad on a parent's account). The
174 synced item is used only after a fresh SiwA assertion for the matching
175 hashed `sub`. Face ID on the SiwA sheet is the one visible moment. *In the
176 built slice `DEKSync.release` is the item's only reader, called solely
177 with the token a fresh SiwA verify just returned; release further requires
178 the resolved account's anchor list to contain the very anchor the item was
179 written under AND the DEK to actually open that account's blob.*
180- **The honest trade, written down:** the "zero surviving devices" recovery
181 trust moves *into* Apple's ecosystem (Apple ID + iCloud Keychain escrow)
182 rather than being eliminated. That is exactly what the seamless experience
183 buys, and the identifier-less class is the opt-out.
184- **Web and non-Apple:** SiwA's web flow exists but presumes an Apple ID.
185 The web keeps passkey-PRF as its local mechanism and gains rung 3
186 (approve-from-phone). The email/successor anchor is the ecosystem-neutral
187 spine when it lands.
188
189## Handoff: onboarding that travels
190
191The goal (Paul, 2026-07-16): with identity at the *front* of onboarding, a
192person can start joining on the web and finish in the app β€” or the reverse β€”
193**without losing any fidelity**. Identity-first is what makes this nearly
194free:
195
196- **Today's order strands progress.** Claim-first-save-later means the
197 claimed credentials, chosen name, and onboarding position live in one
198 browser until the person explicitly enrols β€” the "Open in Eleven" jump has
199 to smuggle state across (the fragment parked in `sessionStorage`, the
200 enrolment popup dance).
201- **Identity-first inverts it.** Sign in is the first step (one Face ID via
202 SiwA on Apple devices), so every artifact of onboarding lands in the vault
203 *as it is created*: the claim writes the credential entry in the same
204 breath (the old doc's "golden path", now the default because sign-in got
205 cheap). The vault is the handoff bus.
206- **Fidelity is then free, not built.** Once keys + session cross via the
207 vault, the rest follows automatically: the onboarding conversation is
208 messages in a thread and mini-app state snapshots, both of which sync to
209 any device holding the keys. Jumping devices resumes the scripted
210 onboarding at the exact message β€” no state machine to serialize, no
211 "continue where you left off" feature to build. The jump itself carries
212 nothing sensitive: "Open in Eleven" / universal link β†’ app signs in with
213 the same identity (same Apple ID: zero ceremony) β†’ vault pull β†’ the space
214 and the conversation are already there.
215- **Reverse direction** (native β†’ web) is rung 3 of the ladder: the web
216 signs in with the identity anchor and an enrolled device approves it β€” or
217 a QR from the app, exactly the add-device mechanics.
218- **Identifier-less joiners** keep today's mechanics (fragment rides the
219 universal link; post-join nudge) β€” handoff fidelity is one of the things
220 an anchor *buys*, stated as such in the nudge copy.
221
222## Invite links: pretty, and how short they can get
223
224Today's link is `…/join/join#e1.<T>~<encoded name>~<brandingKey>` β€” and each
225ugly part exists for a different reason, so they improve on different
226schedules:
227
228- **`/join/join` β€” pure accident.** The path label slot is cosmetic
229 (`invite-redesign.md`) and every minter hardcodes it to `"join"`. Free to
230 fix: a single `/invite` path, doubling as the format version (old
231 `/join/join#e1.` links parse forever).
232- **`~<name>` β€” in the fragment on purpose.** Member names are E2EE; a name
233 in the *path* would hand it to the server (access logs included). So
234 `…/invite/Paul/<secret>` as a default breaks the rule. The name can lead
235 the fragment instead β€” `…/invite#Paul/<secret>` β€” which reads like a path
236 (the `#` is the only tell) and stays server-blind. `/` is the right
237 separator, not `.`: it's a legal, unescaped fragment character that
238 browsers/linkifiers/QRs carry verbatim, and `encodeURIComponent` escapes
239 `/` inside names (but leaves `.` alone), so the slots are collision-free
240 by construction β€” a raw `/` can only be our delimiter. (Name-in-path could
241 be offered as an explicit per-link "include my name in the address"
242 opt-in β€” the space name of an unsealed instance is already public
243 branding, so *that* variant leaks nothing β€” but the default stays blind.)
244- **`~<brandingKey>` β€” the price of sealed instances.** On a sealed instance
245 nothing is public, so the link must carry the key that unlocks the welcome
246 card. Unsealed instances don't need it. Unavoidable where it applies.
247- **`<T>` (22 chars) β€” load-bearing, for now.** T is not a ticket; it
248 offline-wraps the group key (`wrapped = AES-GCM(groupKey, HKDF(T))` sits in
249 the server's DB). A shorter T is brute-forceable *offline* by anyone who
250 steals the DB β€” a 10-char secret falls to a GPU farm in days β€” which would
251 break "compromise the server, get nothing". 128 bits (22 chars, re-encoded
252 base58 so it at least *looks* less like line noise) is the floor **while
253 invites carry the key offline**.
254- **Redirects can't shorten the secret.** Browsers do carry a fragment
255 through a redirect (that's how a short-*domain* bouncer could work), but a
256 server can only put in a `Location` what it knows β€” and it must never know
257 T. The secret travels in the link the person shares, full stop.
258
259So there are two roads:
260
2611. **Cosmetic redesign (no crypto change):**
262 `https://xyz.11ch.at/invite#Paul/2vXk9QmR4pTnWb8zJcHe6f` β€” one path word,
263 name up front, base58 secret, branding key as a third slot only where
264 sealed (`#Paul/<T>/<brandingKey>`). Roughly half today's length, and
265 honest about which half remains. Grammar notes: the name slot is always
266 present (falling back to the space name) so a new link is never
267 confusable with a legacy raw-T fragment; version detection is
268 fragment-contains-`/` β†’ this format, `e1.` prefix β†’ current, else legacy.
2692. **Genuinely short links = the parked PAKE phase** (`invite-redesign.md`
270 Phase 2). A low-entropy secret is only safe in a *live* exchange
271 (Magic-Wormhole style): the server relays an exchange it can't read, the
272 short code authenticates it against MITM, and the key travels
273 device-to-device β€” no offline-crackable blob, nothing a compromised
274 server can claim for itself. The historical blocker was needing a member
275 online during the join; **the sidecar bot dissolves it** β€” it's a member,
276 it holds keys, it's always on, so bot-attended spaces get instant short
277 joins (and unattended ones degrade to "waiting for Paul to let you in",
278 which is respectable UX in its own right). This is also where invite
279 links become *words* β€” `…/invite#Paul/coral-piano-42` β€” which is the
280 least scary a secret can look.
281
282## Email (and the successor slot)
283
284Email is one implementation of the terminal identity anchor β€” **the slot is
285deliberately abstract** because a stronger alternative is being designed
286(Paul, 2026-07-16; unspecified here). Whatever fills the slot must provide:
287a user-memorable claim ("this account is mine"), a reachable out-of-band
288channel to complete a challenge, and a blind-indexable identifier.
289
290If plain email fills it:
291
292- **Blind index:** store `HMAC(pepper, normalize(email))` only. At recovery
293 the user types their address; we hash, match, and send a code to what they
294 typed. No plaintext address is ever stored. Normalization is lowercase
295 only β€” plus-addressing and provider dot-games are respected, not stripped.
296- **Honest limits, for the copy and the threat model:** email hashes are
297 dictionary-reversible for anyone holding the pepper (tiny input space);
298 the running server and the mail provider both see the plaintext address
299 transiently at send time. The claim softens from "we can't spam what we
300 don't have" to *"we only see your address the moment you use it, and never
301 keep it."* Defensible; say it exactly like that.
302- **Fully native-able:** a 6-digit code works in a plain text field β€” no
303 WebAuthn sheet, no browser bounce, no provider roulette. The exact
304 complaint that started this ("no way to enter a password") dissolves.
305- **Costs:** outbound mail infrastructure (SES/Postmark, SPF/DKIM, bounces);
306 the provider becomes a party who sees addresses and timing; per-address
307 rate limits and disposable-domain policy become the abuse levers.
308
309## The recovery passkey
310
311**Decision (Paul, 2026-07-25): recovery is a passkey, never a passphrase or
312a printed code.** The earlier paper-kit design (a client-generated PDF
313wrapping the DEK under a printed code, riding the vault's passphrase-wrap
314primitive #995–#997) is dead β€” that primitive was removed from the product
315the same day. One kind of key everywhere; nothing to type, transcribe, or
316find in a drawer.
317
318- **Mechanically it's just another enrolment.** Home already wraps the DEK
319 per enrolled credential, so "add a recovery passkey" is the existing
320 add-a-passkey ceremony pointed at a *different home* for the credential: a
321 cross-platform password manager (1Password, Bitwarden, Google Password
322 Manager) or a hardware security key. No new crypto, no new server surface.
323- **What paper used to buy β€” surviving a lost vendor account β€” a
324 hardware key or cross-platform manager buys instead.** The offer's copy
325 steers there: a second passkey parked in the *same* iCloud account as
326 rung 2 adds nearly nothing; one on a YubiKey in a drawer, or in a manager
327 that syncs everywhere, is the real ecosystem escape hatch.
328- **Losing it is survivable and revocable.** A found hardware key is inert
329 without its PIN, and the credential (plus its DEK wrap) can be removed
330 from the account like any other β€” unlike a printed code, which could
331 never be un-printed.
332- **Framing:** never a sign-up speed bump β€” offered from settings and at
333 space creation ("add another way back in"), in the same no-jargon voice as
334 the rest (invariant 6: the word "passkey" stays out of the UI).
335- **Managed members** need no kit of their own: their credentials live as
336 entries in the guardian's vault (above), and the guardian's ladder is the
337 redundancy.
338
339## Control-plane recovery (why the owner can always re-admit)
340
341The admin token is authorisation, not decryption β€” its hash lives in the
342instance DB, and the cloud provisioner already knows which home account
343created which space. So "recover control" is a service-layer capability
344re-issue gated on the owner's identity anchor: verify the anchor, re-issue
345the admin link, and the owner is back to roster management β€” able to
346re-invite themselves and everyone else (rung 5). Self-hosters have the same
347power through `/data` on their own box. No key material is involved at any
348step, which is what makes this rung safe to gate on identity alone.
349
350## App Store guideline 4.8
351
352- An **own-account system** (email code, passkey) does not trigger 4.8 at
353 all β€” same exemption we rely on today.
354- **Sign in with Apple, offered alone, satisfies 4.8 by construction** β€” it
355 *is* the privacy-respecting option the rule demands β€” and future-proofs
356 the day a second provider (Google-as-attester) is ever added, because the
357 compliant option is already present.
358- The order therefore stays: SiwA and/or email first; other providers only
359 ever as additional attesters, never the primary.
360
361## Who sees what β€” the deltas
362
363| Party | Sees (new, per class) |
364|---|---|
365| Apple | that a SiwA user signs in, and when |
366| Mail provider | addresses + timing, at send time only (email class) |
367| Home server | blind indexes; one extra wrapped copy of the DEK per anchor |
368| A guardian | everything their managed member can see (disclosed at creation) |
369| A found hardware key | nothing without its PIN β€” and the credential is revocable from the account |
370| A compromised home server | still nothing readable (invariant 4) |
371
372## Phasing
373
374Status per item as of 2026-07-22:
375
3760. **Sweep (no model change):** the locked-space card gains its
377 missing rungs β€” "Link from your iPhone" (add-device QR) and "Ask for a
378 new invite" β€” and failure copy that never dead-ends at a ceremony sheet.
379 The **cosmetic invite-link redesign** (`/invite#Name/<T>`) also rides
380 here: no crypto change, old links parse forever. β€” **partially built**
381 (2026-07-22): the Mac locked pane now carries both rungs (each opens the
382 Add-from-link sheet with contextual guidance β€” the consume side of
383 add-device links already worked) plus signed-in-but-still-locked
384 feedback. Still open: a native mint/QR for add-device links (minting is
385 web-only today), and no `/invite` route exists.
3861. **SiwA + synced DEK** on iOS/Mac; web gains approve-from-phone.
387 Identity-first onboarding lands with it β€” the vault becomes the handoff
388 bus and web↔native jumps stop losing fidelity. β€” **partially shipped**:
389 SiwA is live on web + both natives; the synced DEK is merged behind an
390 off flag (#1144, device matrix owed); approve-from-phone and the
391 identity-first ordering are not built. The web doors overshot the plan β€”
392 Google/Microsoft/GitHub ship too; natively their server legs
393 (`elevenmessenger://home-oidc`) are done and the client halves are open.
3942. **Guardian-managed members + the recovery-passkey offer** (the family
395 story; settings + creation). β€” **not built.**
3963. **Owner anchoring at creation** β€” email or the successor anchor; the
397 provisioner starts requiring/nudging it. β€” **not built** (creation from
398 home is effectively anchored via the home session; a bare setup link
399 still claims anonymously).
4004. **Providers-as-attesters**, if ever. **Short/word-code invites** (the
401 PAKE phase, bot-attended) slot in whenever they earn their build. β€”
402 **not built.**
403
404## Open decisions
405
406- **The successor identity anchor** (Paul's design) β€” this doc holds the
407 slot; email is the fallback filler.
408- Anchor **required** at space creation vs **nudged** until attached.
409- Synced DEK: default-on for SiwA accounts vs opt-in ("Keep me signed in on
410 my other devices").
411- What identity-only recovery may show before a key anchor confirms:
412 recommendation **nothing** β€” the space list is metadata worth the same
413 protection, so rungs 3–5 reveal it only after approval/passkey/re-invite.
414- Invite links: whether "include my name in the address" (name-in-path)
415 is offered as an explicit opt-in, or names stay fragment-only always.
416- When the PAKE short-invite phase earns its build β€” and whether the
417 sidecar bot is a *sufficient* counterparty for unattended spaces or the
418 flow always needs a human approval option too.
419