# 🤖 Accounts, sign-in & recovery — the anchor model

Status: **plan of record — phase 1 largely shipped** (decided with Paul
2026-07-16; status refreshed 2026-07-22). Live today: the six sign-in doors on
home (passkey, email code, Apple, Google, Microsoft, GitHub — the #1112 card),
identity anchors + ceremony proofs (`stateless.go`), the Sign-in options
management step, account combine/merge (#1098), sealed anchor sessions, and
the native passkey/Apple/email doors. Merged but dark: the synced DEK
(#1144, flag off pending the device matrix). Not built yet: the `/invite`
link redesign (the phase-0 locked-card rungs shipped on the Mac 2026-07-22;
native add-device mint/QR still open), owner anchoring at creation,
approve-from-another-device, the recovery-passkey offer, and the native OIDC
doors (server legs done, client work open) — see *Phasing* below, per-item.
(The **paper recovery kit** that used to sit in this list was cut on
2026-07-25 — Paul: recovery is a passkey, never a passphrase or a printed
code; the recovery-passkey section below replaces it.)
This document supersedes the *Invariants* section of
`onboarding-and-home-server.md` — the "no email, no social login, ever" line —
after that invariant was field-falsified by real lockouts. Everything else in
that doc (the welcome page, the home server machinery, the blob schema,
enrolment, the fragility banner) remains accurate and in force; this doc is
about **what identifies a person and what brings their keys back**.

## Why we're revising a "permanent" decision

Passkey-only sign-in was declared a Day-1 invariant. Within a week of the
native apps leaning on it, two of our first users were locked out:

- The macOS ceremony sheet only reaches iCloud Keychain (plus QR/security
  keys). A passkey living in a Chrome profile or a third-party manager is
  invisible to it — the user sees "a passkey window and no way to enter a
  password."
- 1Password's macOS provider returns a PRF that doesn't match its own browser
  extension's for the same credential, so even a *present* passkey can fail to
  decrypt (the browser-handoff workaround exists because of this).
- The locked-space card offered exactly one rung ("Sign In with Eleven…") and
  a destructive one ("Remove from This Mac"). One-rung ladders read as "the
  app is broken", not "the design is working".

The root cause is architectural, not cosmetic: the passkey was doing **two
different jobs at once**, so any failure of the ceremony was total.

## The two jobs

Every mechanism in this design does one or both of:

1. **Identity** — proving you're the same person who enrolled. Lets the home
   server return your record and gates destructive operations.
2. **Keys** — holding or deriving the client-side secret (the KEK) that
   unwraps the vault **DEK**. The DEK decrypts the blob, which holds live
   sessions + per-instance private keys. Nothing server-side can ever do this
   job — that's the one rule.

| Mechanism | Identity | Keys | Notes |
|---|---|---|---|
| Passkey + PRF | ✓ | ✓ | today's model; elegant, and why its failure is total |
| Sign in with Apple | ✓ | — | stable team-scoped `sub`; can request no email at all |
| Email code | ✓ | — | ecosystem-neutral; blind-indexed (below) |
| iCloud-synced DEK item | — | ✓ | invisible; Apple-ecosystem only |
| Device key + approval | — | ✓ | transfers keys to a new device, given identity |
| Recovery passkey (manager / hardware key) | ✓ | ✓ | a second, independent credential; ecosystem-free |
| Guardian wrap | — | ✓ | DEK/credentials wrapped to another member's key |

An **identity anchor alone can never decrypt anything.** That single sentence
forbids the tempting shortcut (email-gated server escrow of the DEK) and
shapes everything below.

## The new invariants

What "no email, no social login, ever" weakens to — each line deliberate:

1. **Membership stays anonymous.** Joining a space requires no identifier,
   ever — invites remain the only membership door. No change from today.
2. **Ownership is anchored.** Every space's recovery chain must terminate at
   a member whose recovery survives total device loss — normally the owner.
   Practically: *creating* a space requires an anchor; *joining* one never
   does. The owner's anchor is a **pair**: an identity anchor (email, Sign in
   with Apple, or a successor — see the open slot) *plus* a key anchor (on
   Apple platforms the synced DEK rides along invisibly; otherwise a
   recovery passkey in a synced manager or on a hardware key).
3. **Providers attest; they are never the identity.** A third-party sign-in
   is at most a verifier of an anchor. Our record is a blind index (a hash) —
   there is no provider user-id column, no stored email address, and swapping
   providers never orphans an account.
4. **The server never holds a readable key.** No escrow of the DEK under any
   server-side or identity-gated key, ever — a compromised home server plus a
   passed email gate must still yield nothing. Recovery that "just works with
   an email" is only achievable by breaking the one rule; we don't.
5. **The home server stays strictly additive** (unchanged): no enrolment
   record → no requests to it, ever.
6. **Copy stays human** (unchanged rule): never "passkey", "PRF", "key",
   "escrow", or "backup" in UI. People sign in, save their spaces, and add
   ways back in.

## Account classes

| Class | Identity (job 1) | Keys (job 2) | Lost everything |
|---|---|---|---|
| **Owner** (anchored) | SiwA / email / successor | synced DEK, or recovery passkey | full restore via anchor pair |
| **Member, anchored** (optional) | same menu | same menu | same ladder as owner |
| **Member, identifier-less** | the key *is* the identity | passkey(s) or nothing | re-invite floor |
| **Guardian-managed** | none — guardian vouches | wrapped to guardian's key | guardian re-links, instantly |

- **Owner.** Whoever creates a space attaches the anchor pair at creation
  (or is nudged until they do — open decision). This lands the identifier
  exactly where accountability already lives: space creation is the abuse
  surface (quotas, reaping, billing), membership never was (invite-gated).
- **Identifier-less member.** Today's model, kept on purpose for
  privacy-maximalists: bootstrap by invite, optionally strengthen with a
  passkey (or several), accept the re-invite floor. This class is why
  the product can still truthfully say "no signup required".
- **Guardian-managed** — the family case. A child has no email and no
  passkey. The guardian's signed-in app mints the child's credentials
  (an add-device link / QR — machinery that exists today) and keeps a copy
  as a **managed entry in the guardian's own vault**. The child has *no home
  account at all* — no server record, no identifier, nothing collected
  (the right COPPA/GDPR-K posture by construction). Restore = guardian opens
  their app → managed member → "link this device" QR. The guardian's own
  recovery ladder is the redundancy: recover the guardian account and the
  managed entries come back with the vault. Disclosed honestly: a guardian holding a member's keys can read
  and act as that member — that is what guardianship *is*, and the UI says
  so at creation. (A later variant can give an older child their own home
  account with the DEK additionally wrapped to the guardian — the graduation
  path — but the vault-entry form is the v1.)

## What recovery restores — the ladder

Top rung first; each rung only matters when everything above it is gone:

1. **Any surviving signed-in device** — everything, silently (the cached DEK;
   today's silent sync).
2. **A synced key anchor** — a passkey that synced, or the iCloud-synced DEK:
   new device + one identity ceremony = full restore, zero surviving devices
   needed.
3. **Approve from another device** — a new device signs in with an identity
   anchor; an enrolled device gets a push ("Approve this Mac?"); on approval
   the DEK is re-wrapped to the new device's ephemeral key. This is the
   add-device flow wearing mainstream clothes (WhatsApp/Signal/Apple trained
   everyone on it) and is the workhorse for web and non-Apple platforms.
4. **A recovery passkey outside the ecosystem** — a passkey in a
   cross-platform manager or on a hardware security key, enrolled as one
   more way in. This is what survives losing a whole vendor account: it is
   deliberately not parked where rung 2 lives.
5. **Control-only recovery + the re-invite floor** — identity anchor alone.
   The owner recovers *control* of the space (see below) and can re-admit
   anyone; a re-invited member gets the **same stable thread keys re-wrapped
   to their new keypair by any live member's client, so full history
   decrypts**. What this rung cannot do: if literally every member's key
   material is gone (a solo space; a whole family's devices at once), the
   history is cryptographically sealed. That is the design working — and the
   reason invariant 2 requires the owner's anchor to be a *pair*, not an
   email alone.

## Sign in with Apple, precisely

The seamless path for the current (Apple-heavy) user base:

- **Request no email scope.** The stable team-scoped `sub` is the account
  key, stored as `HMAC(pepper, sub)` — nothing mailable, nothing to leak.
  Apple learns sign-in timing; we learn nothing new.
- **Pair it with the synced DEK.** The native DEK cache is deliberately
  device-local today (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,
  `ios/LChatCore/.../Keychain.swift`). Storing the DEK *also* as a
  synchronizable iCloud Keychain item makes the key travel invisibly — same
  trust anchor as an iCloud passkey, zero WebAuthn ceremonies, and the
  third-party-provider bug class (the 1Password PRF mismatch) disappears
  because credential managers are never in the loop. *Built (issue #1089):
  `ios/LChatCore/.../DEKSync.swift` — a second item beside the device-local
  one (service `com.lchat.client.dek-synced`, added by addition, never
  delete-then-add), write-gated on the account holding a SiwA anchor.
  Build-flag gated, OFF by default until the device matrix passes.*
- **Job 1 gates job 2.** A synced DEK alone would auto-unlock on any device
  signed into the same Apple ID (family iPad on a parent's account). The
  synced item is used only after a fresh SiwA assertion for the matching
  hashed `sub`. Face ID on the SiwA sheet is the one visible moment. *In the
  built slice `DEKSync.release` is the item's only reader, called solely
  with the token a fresh SiwA verify just returned; release further requires
  the resolved account's anchor list to contain the very anchor the item was
  written under AND the DEK to actually open that account's blob.*
- **The honest trade, written down:** the "zero surviving devices" recovery
  trust moves *into* Apple's ecosystem (Apple ID + iCloud Keychain escrow)
  rather than being eliminated. That is exactly what the seamless experience
  buys, and the identifier-less class is the opt-out.
- **Web and non-Apple:** SiwA's web flow exists but presumes an Apple ID.
  The web keeps passkey-PRF as its local mechanism and gains rung 3
  (approve-from-phone). The email/successor anchor is the ecosystem-neutral
  spine when it lands.

## Handoff: onboarding that travels

The goal (Paul, 2026-07-16): with identity at the *front* of onboarding, a
person can start joining on the web and finish in the app — or the reverse —
**without losing any fidelity**. Identity-first is what makes this nearly
free:

- **Today's order strands progress.** Claim-first-save-later means the
  claimed credentials, chosen name, and onboarding position live in one
  browser until the person explicitly enrols — the "Open in Eleven" jump has
  to smuggle state across (the fragment parked in `sessionStorage`, the
  enrolment popup dance).
- **Identity-first inverts it.** Sign in is the first step (one Face ID via
  SiwA on Apple devices), so every artifact of onboarding lands in the vault
  *as it is created*: the claim writes the credential entry in the same
  breath (the old doc's "golden path", now the default because sign-in got
  cheap). The vault is the handoff bus.
- **Fidelity is then free, not built.** Once keys + session cross via the
  vault, the rest follows automatically: the onboarding conversation is
  messages in a thread and mini-app state snapshots, both of which sync to
  any device holding the keys. Jumping devices resumes the scripted
  onboarding at the exact message — no state machine to serialize, no
  "continue where you left off" feature to build. The jump itself carries
  nothing sensitive: "Open in Eleven" / universal link → app signs in with
  the same identity (same Apple ID: zero ceremony) → vault pull → the space
  and the conversation are already there.
- **Reverse direction** (native → web) is rung 3 of the ladder: the web
  signs in with the identity anchor and an enrolled device approves it — or
  a QR from the app, exactly the add-device mechanics.
- **Identifier-less joiners** keep today's mechanics (fragment rides the
  universal link; post-join nudge) — handoff fidelity is one of the things
  an anchor *buys*, stated as such in the nudge copy.

## Invite links: pretty, and how short they can get

Today's link is `…/join/join#e1.<T>~<encoded name>~<brandingKey>` — and each
ugly part exists for a different reason, so they improve on different
schedules:

- **`/join/join` — pure accident.** The path label slot is cosmetic
  (`invite-redesign.md`) and every minter hardcodes it to `"join"`. Free to
  fix: a single `/invite` path, doubling as the format version (old
  `/join/join#e1.` links parse forever).
- **`~<name>` — in the fragment on purpose.** Member names are E2EE; a name
  in the *path* would hand it to the server (access logs included). So
  `…/invite/Paul/<secret>` as a default breaks the rule. The name can lead
  the fragment instead — `…/invite#Paul/<secret>` — which reads like a path
  (the `#` is the only tell) and stays server-blind. `/` is the right
  separator, not `.`: it's a legal, unescaped fragment character that
  browsers/linkifiers/QRs carry verbatim, and `encodeURIComponent` escapes
  `/` inside names (but leaves `.` alone), so the slots are collision-free
  by construction — a raw `/` can only be our delimiter. (Name-in-path could
  be offered as an explicit per-link "include my name in the address"
  opt-in — the space name of an unsealed instance is already public
  branding, so *that* variant leaks nothing — but the default stays blind.)
- **`~<brandingKey>` — the price of sealed instances.** On a sealed instance
  nothing is public, so the link must carry the key that unlocks the welcome
  card. Unsealed instances don't need it. Unavoidable where it applies.
- **`<T>` (22 chars) — load-bearing, for now.** T is not a ticket; it
  offline-wraps the group key (`wrapped = AES-GCM(groupKey, HKDF(T))` sits in
  the server's DB). A shorter T is brute-forceable *offline* by anyone who
  steals the DB — a 10-char secret falls to a GPU farm in days — which would
  break "compromise the server, get nothing". 128 bits (22 chars, re-encoded
  base58 so it at least *looks* less like line noise) is the floor **while
  invites carry the key offline**.
- **Redirects can't shorten the secret.** Browsers do carry a fragment
  through a redirect (that's how a short-*domain* bouncer could work), but a
  server can only put in a `Location` what it knows — and it must never know
  T. The secret travels in the link the person shares, full stop.

So there are two roads:

1. **Cosmetic redesign (no crypto change):**
   `https://xyz.11ch.at/invite#Paul/2vXk9QmR4pTnWb8zJcHe6f` — one path word,
   name up front, base58 secret, branding key as a third slot only where
   sealed (`#Paul/<T>/<brandingKey>`). Roughly half today's length, and
   honest about which half remains. Grammar notes: the name slot is always
   present (falling back to the space name) so a new link is never
   confusable with a legacy raw-T fragment; version detection is
   fragment-contains-`/` → this format, `e1.` prefix → current, else legacy.
2. **Genuinely short links = the parked PAKE phase** (`invite-redesign.md`
   Phase 2). A low-entropy secret is only safe in a *live* exchange
   (Magic-Wormhole style): the server relays an exchange it can't read, the
   short code authenticates it against MITM, and the key travels
   device-to-device — no offline-crackable blob, nothing a compromised
   server can claim for itself. The historical blocker was needing a member
   online during the join; **the sidecar bot dissolves it** — it's a member,
   it holds keys, it's always on, so bot-attended spaces get instant short
   joins (and unattended ones degrade to "waiting for Paul to let you in",
   which is respectable UX in its own right). This is also where invite
   links become *words* — `…/invite#Paul/coral-piano-42` — which is the
   least scary a secret can look.

## Email (and the successor slot)

Email is one implementation of the terminal identity anchor — **the slot is
deliberately abstract** because a stronger alternative is being designed
(Paul, 2026-07-16; unspecified here). Whatever fills the slot must provide:
a user-memorable claim ("this account is mine"), a reachable out-of-band
channel to complete a challenge, and a blind-indexable identifier.

If plain email fills it:

- **Blind index:** store `HMAC(pepper, normalize(email))` only. At recovery
  the user types their address; we hash, match, and send a code to what they
  typed. No plaintext address is ever stored. Normalization is lowercase
  only — plus-addressing and provider dot-games are respected, not stripped.
- **Honest limits, for the copy and the threat model:** email hashes are
  dictionary-reversible for anyone holding the pepper (tiny input space);
  the running server and the mail provider both see the plaintext address
  transiently at send time. The claim softens from "we can't spam what we
  don't have" to *"we only see your address the moment you use it, and never
  keep it."* Defensible; say it exactly like that.
- **Fully native-able:** a 6-digit code works in a plain text field — no
  WebAuthn sheet, no browser bounce, no provider roulette. The exact
  complaint that started this ("no way to enter a password") dissolves.
- **Costs:** outbound mail infrastructure (SES/Postmark, SPF/DKIM, bounces);
  the provider becomes a party who sees addresses and timing; per-address
  rate limits and disposable-domain policy become the abuse levers.

## The recovery passkey

**Decision (Paul, 2026-07-25): recovery is a passkey, never a passphrase or
a printed code.** The earlier paper-kit design (a client-generated PDF
wrapping the DEK under a printed code, riding the vault's passphrase-wrap
primitive #995–#997) is dead — that primitive was removed from the product
the same day. One kind of key everywhere; nothing to type, transcribe, or
find in a drawer.

- **Mechanically it's just another enrolment.** Home already wraps the DEK
  per enrolled credential, so "add a recovery passkey" is the existing
  add-a-passkey ceremony pointed at a *different home* for the credential: a
  cross-platform password manager (1Password, Bitwarden, Google Password
  Manager) or a hardware security key. No new crypto, no new server surface.
- **What paper used to buy — surviving a lost vendor account — a
  hardware key or cross-platform manager buys instead.** The offer's copy
  steers there: a second passkey parked in the *same* iCloud account as
  rung 2 adds nearly nothing; one on a YubiKey in a drawer, or in a manager
  that syncs everywhere, is the real ecosystem escape hatch.
- **Losing it is survivable and revocable.** A found hardware key is inert
  without its PIN, and the credential (plus its DEK wrap) can be removed
  from the account like any other — unlike a printed code, which could
  never be un-printed.
- **Framing:** never a sign-up speed bump — offered from settings and at
  space creation ("add another way back in"), in the same no-jargon voice as
  the rest (invariant 6: the word "passkey" stays out of the UI).
- **Managed members** need no kit of their own: their credentials live as
  entries in the guardian's vault (above), and the guardian's ladder is the
  redundancy.

## Control-plane recovery (why the owner can always re-admit)

The admin token is authorisation, not decryption — its hash lives in the
instance DB, and the cloud provisioner already knows which home account
created which space. So "recover control" is a service-layer capability
re-issue gated on the owner's identity anchor: verify the anchor, re-issue
the admin link, and the owner is back to roster management — able to
re-invite themselves and everyone else (rung 5). Self-hosters have the same
power through `/data` on their own box. No key material is involved at any
step, which is what makes this rung safe to gate on identity alone.

## App Store guideline 4.8

- An **own-account system** (email code, passkey) does not trigger 4.8 at
  all — same exemption we rely on today.
- **Sign in with Apple, offered alone, satisfies 4.8 by construction** — it
  *is* the privacy-respecting option the rule demands — and future-proofs
  the day a second provider (Google-as-attester) is ever added, because the
  compliant option is already present.
- The order therefore stays: SiwA and/or email first; other providers only
  ever as additional attesters, never the primary.

## Who sees what — the deltas

| Party | Sees (new, per class) |
|---|---|
| Apple | that a SiwA user signs in, and when |
| Mail provider | addresses + timing, at send time only (email class) |
| Home server | blind indexes; one extra wrapped copy of the DEK per anchor |
| A guardian | everything their managed member can see (disclosed at creation) |
| A found hardware key | nothing without its PIN — and the credential is revocable from the account |
| A compromised home server | still nothing readable (invariant 4) |

## Phasing

Status per item as of 2026-07-22:

0. **Sweep (no model change):** the locked-space card gains its
   missing rungs — "Link from your iPhone" (add-device QR) and "Ask for a
   new invite" — and failure copy that never dead-ends at a ceremony sheet.
   The **cosmetic invite-link redesign** (`/invite#Name/<T>`) also rides
   here: no crypto change, old links parse forever. — **partially built**
   (2026-07-22): the Mac locked pane now carries both rungs (each opens the
   Add-from-link sheet with contextual guidance — the consume side of
   add-device links already worked) plus signed-in-but-still-locked
   feedback. Still open: a native mint/QR for add-device links (minting is
   web-only today), and no `/invite` route exists.
1. **SiwA + synced DEK** on iOS/Mac; web gains approve-from-phone.
   Identity-first onboarding lands with it — the vault becomes the handoff
   bus and web↔native jumps stop losing fidelity. — **partially shipped**:
   SiwA is live on web + both natives; the synced DEK is merged behind an
   off flag (#1144, device matrix owed); approve-from-phone and the
   identity-first ordering are not built. The web doors overshot the plan —
   Google/Microsoft/GitHub ship too; natively their server legs
   (`elevenmessenger://home-oidc`) are done and the client halves are open.
2. **Guardian-managed members + the recovery-passkey offer** (the family
   story; settings + creation). — **not built.**
3. **Owner anchoring at creation** — email or the successor anchor; the
   provisioner starts requiring/nudging it. — **not built** (creation from
   home is effectively anchored via the home session; a bare setup link
   still claims anonymously).
4. **Providers-as-attesters**, if ever. **Short/word-code invites** (the
   PAKE phase, bot-attended) slot in whenever they earn their build. —
   **not built.**

## Open decisions

- **The successor identity anchor** (Paul's design) — this doc holds the
  slot; email is the fallback filler.
- Anchor **required** at space creation vs **nudged** until attached.
- Synced DEK: default-on for SiwA accounts vs opt-in ("Keep me signed in on
  my other devices").
- What identity-only recovery may show before a key anchor confirms:
  recommendation **nothing** — the space list is metadata worth the same
  protection, so rungs 3–5 reveal it only after approval/passkey/re-invite.
- Invite links: whether "include my name in the address" (name-in-path)
  is offered as an explicit opt-in, or names stay fragment-only always.
- When the PAKE short-invite phase earns its build — and whether the
  sidecar bot is a *sufficient* counterparty for unattended spaces or the
  flow always needs a human approval option too.
