| new file mode 100644 |
| index 0000000..92cb59d |
| --- /dev/null |
| +++ b/docs/accounts-and-recovery.md |
| @@ -0,0 +1,367 @@ |
| +# π€ Accounts, sign-in & recovery β the anchor model |
| + |
| +Status: **design, not built** (decided with Paul 2026-07-16; nothing here has |
| +shipped). 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 | |
| +| Paper recovery kit | β | β | printed wrap of the DEK; survives everything | |
| +| 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 the paper |
| + kit). |
| +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 paper kit | 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, paper kit, 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 a paper kit, 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. A printed **paper kit |
| + per child** is optional redundancy for the guardian losing their own |
| + account. 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. **The paper kit** β scan/upload, keys return. Survives ecosystems. |
| +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. |
| +- **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. |
| +- **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 paper recovery kit |
| + |
| +A one-page PDF, **generated client-side only** (browser or app β the server |
| +never sees the plaintext payload), in the tradition of 1Password's Emergency |
| +Kit and Signal's backup key: |
| + |
| +- **Owner kit:** the home credential id + the DEK wrapped under a printed |
| + code (the same passphrase-wrap primitive the operator vault's recovery |
| + escrow ships β `vault: recovery escrow`, #995β#997). Tiny; QR-encodable. |
| +- **Child kit:** the managed member's credential bundle (instance URL + |
| + keypair) as a QR. |
| +- **Restore:** scan/upload β keys return. For credential-bundle kits this |
| + needs one small server feature β proving possession of the instance |
| + keypair re-issues a session β specified when the kit is built, not here. |
| +- **Framing:** the paper is a house key and the UI says so ("Keep this |
| + somewhere safe at home"). We never generate it server-side, never store a |
| + copy, and never make it a sign-up speed bump β it's offered from settings |
| + and at space creation, not demanded. |
| + |
| +## 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) | |
| +| Paper kit finder | whatever the kit unlocks β physical custody is the control | |
| +| A compromised home server | still nothing readable (invariant 4) | |
| + |
| +## Phasing |
| + |
| +0. **Sweep (no model change, this week):** 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. |
| +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. |
| +2. **Guardian-managed members + paper kits** (the family story). |
| +3. **Owner anchoring at creation** β email or the successor anchor; the |
| + provisioner starts requiring/nudging it. |
| +4. **Providers-as-attesters**, if ever. **Short/word-code invites** (the |
| + PAKE phase, bot-attended) slot in whenever they earn their build. |
| + |
| +## 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/kit/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. |