aboutsummaryrefslogtreecommitdiffstats
path: root/docs/auth-confirm.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/auth-confirm.md')
-rw-r--r--docs/auth-confirm.md560
1 files changed, 0 insertions, 560 deletions
diff --git a/docs/auth-confirm.md b/docs/auth-confirm.md
deleted file mode 100644
index 3e8b488..0000000
--- a/docs/auth-confirm.md
+++ /dev/null
@@ -1,560 +0,0 @@
-# MeshBay — Password change and recovery
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the passphrase change and recovery; its design
-> content now lives in §3.6.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **built** (the reset endpoints, the recovery key and the node fan-out
-> all shipped; this header said "not built" long after they did). The decision
-> record for two features that
-> look like one and are not: changing a passphrase you still know, and recovering
-> from one you have lost.
-> Follows the house convention: every claim names the adversary it holds against.
-> Builds on the email-verification work merged 2026-08-31 (`c6fd7ea`) — the
-> `EmailVerification` table, `mail.py`, `_generate_code`, the `limiter` — and reuses
-> all of it rather than adding a parallel mechanism.
-
----
-
-## 1. Why this is not one feature
-
-The passphrase never leaves the client. It derives **two independent values**, both
-salted by the username only (`keyderive.js`):
-
-| Value | Derivation | Consumer | Where it lives |
-|---|---|---|---|
-| `auth_key` | PBKDF2-SHA512 600k, domain `meshbay:auth:v1:<user>` | hub authentication | hub stores an Argon2id hash (`users.pw_hash` / `pw_salt` / `pw_version`) |
-| `bundle_key` | Argon2id 128 MB / t=3, domain `meshbay:bundle:v2:<user>` | AES-GCM key for the per-node identity bundle | on **each node**, in `keypair_bundles.bundle_enc` — never on the hub (C4, `per-node-identity-v1.md`) |
-
-Consequences that split the work in two:
-
-- **`auth_key` the hub can reset.** It holds a verifier for it and nothing else depends
- on that verifier. An email code is enough.
-- **`bundle_key` nobody can reset.** The hub has held no key material since H3 closed
- (2026-08-14); it cannot reach a `keypair_bundles` row (it is not a group member and
- bundles are served only over MNP to authenticated members). A lost passphrase means
- the per-node identities encrypted under it are gone unless a **second wrapping** was
- put in place beforehand.
-
-So:
-
-- **Flow A** (§3): the user knows the passphrase and wants a new one. Re-wrap every
- reachable node's bundle, then swap the hub verifier.
-- **Flow B** (§4): the user has lost the passphrase. An email code restores hub login;
- a **recovery key** set up at registration restores the per-node identities. Without
- that key, hub login is all that comes back.
-
----
-
-## 2. What already exists (reuse, do not duplicate)
-
-From `c6fd7ea`:
-
-- `EmailVerification(id, email_hash, email_encrypted, code, purpose, user_id, group_id,
- created_at, expires_at, verified_at, attempts)` — `purpose` is a free string today
- (`registration` | `email_change` | `invitation`).
-- `_generate_code()` → 6 digits; `VERIFICATION_TTL = 86400`; `VERIFICATION_MAX_ATTEMPTS = 10`.
-- `mail.py` — localhost Postfix, `_send`, `_mask_email`, one `send_*` per purpose.
-- `hash_email_blind(email)` — HMAC-SHA256 blind index, `users.email_hash` unique.
-- `limiter` on `/verify-email` etc. (`10/minute`).
-- Cleanup task prunes expired codes and stale pending accounts (`tasks/cleanup.py`).
-
-This design adds `purpose = "password_reset"`, one `mail.send_*`, three hub endpoints,
-one nullable column node-side, and an additive MNP field.
-
----
-
-## 3. Flow A — change a known passphrase
-
-Lives on the **Profile page**, next to the e-mail change and account deletion, behind a
-re-entry of the current passphrase (same bar as `DELETE /v1/users/me`: a live token is
-not enough for something with blast radius).
-
-### 3.1 Client sequence
-
-1. Prompt: current passphrase, new passphrase (enforce the existing floor — 12 chars,
- ~60 bits, client-side).
-2. Derive four values: `old_auth_key`, `new_auth_key`, `old_bundle_key`, `new_bundle_key`
- (`deriveAuthKey` / `deriveEncryptionKey`, once each).
-3. **Fan-out over nodes** (§3.2) — re-wrap every reachable identity bundle from
- `old_bundle_key` to `new_bundle_key`. Do this *before* touching the hub: if it fails
- the account is unchanged.
-4. `POST /v1/users/password` `{ old_auth_key, new_auth_key }` (§3.5).
-5. On success, keep the session (the caller proved the new passphrase); other sessions
- are dropped by the refresh-family revocation in §3.6.
-
-### 3.2 The fan-out over nodes
-
-`MeshBayTransport.rewrapAllNodes({ hubUrl, token, username, userId, oldPassphrase,
-newPassphrase, onProgress })` does the work. It derives the old key as a `{v2, v1}`
-pair (an old bundle may still be v1) and the new key as v2, then, for each group in
-`GET /v1/groups/mine`, visits every node in `GET /v1/groups/{id}/nodes`:
-
-- connect with the **old** key → the transport fetches and decrypts this node's
- identity bundle as part of its handshake, exposing the private keys on
- `transport.sessionKeys`;
-- `encryptBundleWithKey(skEd, skX, new_key_v2)` → `keypair_bundle_store`.
-
-A node the transport reports as a **first join** (`transport.newNodeBundle` set) had no
-bundle for this account — connect just minted one under the old key, which is *not*
-persisted: nothing is stranded there, and the normal group-open flow will create one
-under the current key later. Re-creating it here could also walk back a deliberate
-bundle withdrawal.
-
-It returns `{ updated, unreachable, failed, newBundleKey }`:
-
-- **`unreachable`** — the group has no online node right now;
-- **`failed`** — a node was online but the re-wrap errored (wrong current passphrase,
- a join that needs a code, a mid-flight drop);
-- a group **left** since an identity was created is simply absent from `/v1/groups/mine`
- and is never visited.
-
-Each bundle not re-wrapped stays encrypted under the old passphrase. At the next
-sign-in with the new passphrase, connecting to that node fails to recover the identity
-there and it looks broken for this account — hence the operator fallback in §3.4.
-(The `mb_nodepin_*` set in `localStorage` was considered as an extra source but dropped:
-a pinned node with no current membership offers no group to connect through, and such a
-node is exactly a group that must be rejoined anyway.)
-
-### 3.3 The confirmation dialog
-
-A modal, shown **always** — even when every group is reachable — because the operation
-is irreversible per node and partly outside the user's control:
-
-```
-Change passphrase
-
-These groups will be updated now (their node is online):
- • photos@ana • trip-2026@ana • books@sam
-
-These groups CANNOT be reached right now:
- • archive@sam — node offline
-
-For any group that cannot be reached, you will have to ask that group's
-operator to run `member unpin <you>` and send you a fresh invitation code,
-then rejoin. Your files and messages in that group are not lost; your
-ability to open it from a new sign-in is, until you rejoin.
-
-[ Cancel ] [ I understand — change it ]
-```
-
-The two lists are a pre-flight estimate from `node_online`. The **actual** per-node
-result is reported once the fan-out (§3.2) has run: any node that was expected online
-but failed mid fan-out is moved into the second list in the result screen, with the
-same guidance.
-
-### 3.4 Unreachable nodes — the fallback, spelled out
-
-This is the operator surface that already exists (`invite-pairing-v1.md`): the group
-operator runs `meshbay-node member unpin <user>`, then `member invite <user>` for a
-fresh single-use code. The user redeems it and the client generates a **new** per-node
-identity there, wrapped under the new passphrase. Nothing on the hub changes; the GEK is
-re-wrapped by the node on the next connection as usual.
-
-### 3.5 Hub endpoint
-
-```
-POST /v1/users/password (require_user_scope, limiter 5/minute)
- body: { old_auth_key, new_auth_key }
- - verify_password(old_auth_key, user.pw_hash, user.pw_salt, user.pw_version) or 403
- - new_auth_key == old_auth_key → 400
- - user.pw_hash, user.pw_salt = hash_password(new_auth_key)
- - user.pw_version = current_pw_version()
- - revoke all RefreshToken rows for user.id (see §3.6)
- - issue a fresh access token + a new refresh-token family for the caller
- - IPLog(event="password_change", user_id=...)
- - 200 { status: "changed", access_token, refresh_token, token_type, expires_in }
-```
-
-No email round-trip here: the current passphrase is the second factor, exactly as for
-account deletion. The fresh pair in the response is what keeps the tab that made the
-change signed in; the client swaps it in with `setAuth` and moves `session.bundleKey`
-(and its IndexedDB copy) forward to the new key.
-
-### 3.6 What is invalidated
-
-- **All refresh tokens** for the account are revoked (`UPDATE refresh_tokens SET
- revoked=1 WHERE user_id=?`), then the caller is handed a fresh pair in the response.
- Other browsers fail their next renewal and fall back to the sign-in form.
-- **Registered devices (`user_devices`) are kept.** The passphrase is still known and the
- device Ed25519 keys are independent of it; a device keeps working. (Contrast Flow B,
- §4.7.)
-
----
-
-## 4. Flow B — recover a lost passphrase
-
-### 4.1 What is and is not recoverable
-
-| | Recovered by |
-|---|---|
-| Hub login (`auth_key`) | email code alone |
-| Per-node identity keys → GEK unwrap, provable upload ownership, chat-sender identity, device countersigning | the **recovery key** (§4.3), per reachable node |
-| Chat history that needs forward-secret sender-key state (Phase 15) | not by this; sender redistribution on rejoin |
-| Identity on a node with no recovery-wrapped copy, or offline at recovery time | operator `member unpin` + fresh code (§3.4) |
-
-### 4.2 Email code — resetting hub login — built
-
-```
-POST /v1/users/password/reset-request (limiter 5/minute, per IP)
- body: { username, email } # both required
- - resolve the User by username
- - matched = user && user.status == "active"
- && user.email_hash == hash_email_blind(email)
- - if matched:
- code = _generate_code()
- EmailVerification(purpose="password_reset", user_id, email_hash, code=code,
- expires_at = now + 3600) # 1 h, shorter than sign-up
- mail.send_password_reset_code(decrypt_email(user.email), code)
- - always 200 { status: "sent_if_exists" }
-```
-
-The **username and the e-mail must be the pair on file**, checked against the blind
-`email_hash` (never decrypted). A mismatch — wrong e-mail, unknown username, non-active
-account — takes the identical no-op path: no `EmailVerification` row, no mail, same 200.
-So the endpoint reveals nothing, and it cannot be used to spray reset mail at an inbox
-from a username alone. A malformed e-mail is a 422 from the field validator.
-
-```
-POST /v1/users/password/reset (limiter 10/minute)
- body: { username, code, new_auth_key }
- - look up the newest unverified password_reset EmailVerification for that user
- - expiry / attempts / code checks exactly as verify_email
- - on match:
- verif.verified_at = now # code is single-use
- user.pw_hash, user.pw_salt = hash_password(new_auth_key)
- user.pw_version = current_pw_version()
- revoke all RefreshToken rows for the user
- delete all UserDevice rows for the user # §4.7
- IPLog(event="password_reset", user_id=...)
- - 200 { status: "reset" }
-```
-
-Both endpoints also write a `password_reset_request` / `password_reset` `IPLog` row.
-`reset-request` for an unknown or non-active account logs the attempt without a
-`user_id` and still answers `{status: "sent_if_exists"}`. Expired codes are pruned by
-the existing `tasks/cleanup.py` sweep (it deletes every expired `EmailVerification`,
-purpose-agnostic).
-
-The client derives `new_auth_key` from the new passphrase the user is choosing now, then
-signs in through the normal `login` path (which issues the tokens and the membership
-claim). **This step recovers nothing about group content** — see §4.6.
-
-### 4.3 The recovery key
-
-Set up once at registration, and re-loadable any time from Profile:
-
-- The **client** generates a full-entropy random secret `R` (32 bytes), rendered for the
- human as a mnemonic / grouped Base32 string. The hub never generates it.
-- `recovery_key = HKDF-SHA256(R, info = "meshbay:recovery:v1:" + username)`. HKDF, not
- Argon2: `R` has 256 bits, so there is nothing to brute-force and no reason to make the
- legitimate derivation slow. The username domain-separates it, as with `bundle_key` —
- which is why every client folds in the **trimmed** username (`RegisterPage` /
- `LoginPage` / `ResetPasswordPage` all `.trim()` before any derivation), matching the
- hub's stored form.
-- Every time an identity bundle is written to a node, a **second copy** is written next
- to it, wrapped under `recovery_key` instead of `bundle_key`, same AES-GCM bundle
- format. On the node: a new nullable column, opaque like the first.
-- The derived key lives in `session.recoveryKey` and is **persisted in IndexedDB**
- (slot `rk`, beside `bk`), so a group joined in a *later* session still leaves a
- recovery copy — not only groups joined in the unbroken session that generated `R`.
- Cleared with everything else on sign-out.
-- **Profile → Recovery key** re-loads `R` in a browser that never had it (or lost it)
- and runs `rewrapAllNodes` in `bundleKey` mode over every group: keep the live
- passphrase key, add the recovery-wrapped copy where it is missing. This is the answer
- to "I joined groups before entering `R`, or on another device."
-
-```
-keypair_bundles(
- user_id TEXT PRIMARY KEY,
- bundle_enc TEXT NOT NULL, -- wrapped under bundle_key (passphrase)
- bundle_enc_recovery TEXT, -- wrapped under recovery_key (R) [NEW]
- stored_at TEXT NOT NULL
-)
-```
-
-MNP: `keypair_bundle_store` gains an optional `bundle_enc_recovery` field and
-`keypair_bundle_resp` returns it when present. Additive — an older node ignores the
-field and simply holds no recovery copy; **MNP minor bump** (0.13 → 0.14).
-
-### 4.4 Delivering `R` — built
-
-Default: **folded into the registration verification e-mail**, the one that already
-carries the 6-digit code. The user's mailbox becomes the backup, which is the whole
-point of the convenience.
-
-How it is wired (step 3):
-
-- The client generates `R` **before** `registerUser`, so the mnemonic can travel in the
- register body: `keyderive.js registerUser(username, email, password, recoveryMnemonic?)`
- adds `recovery_key` to the `POST /v1/users/register` payload only when it is present.
-- `RegisterRequest.recovery_key` is an optional field. `register` passes it straight to
- `_create_and_send_verification(..., recovery_key)` → `mail.send_verification_code(email,
- code, recovery_key=...)`, which appends a fenced "Account recovery key" block to the
- body. Same path on the pending-account resend.
-- `R` is a **pass-through**. It is never written to the database — not to
- `EmailVerification.code`, not to a `User` column, nowhere. `mail.py` logs only
- `_mask_email` and `bool(recovery_key)`, never the value.
-- The registration form carries an **"Also email this recovery key to me"** checkbox,
- checked by default. Unchecking it omits `recovery_key` from the body; the `recovery`
- screen then says the key was *not* e-mailed and must be saved now. `session.recoveryKey`
- is set either way, so joins later in the session still leave a recovery copy.
-
-Optional hardening, documented but not mandated: split `R = R_screen ⊕ R_mail`, show
-one half, e-mail the other; recovery needs both. It defeats the "mailbox alone is my
-backup" convenience, so it is an opt-in, not the default.
-
-### 4.5 Recovery sequence — built
-
-`ResetPasswordPage` (route `#/reset`, linked from the sign-in form):
-
-1. **request phase** — username **and e-mail** → `POST /v1/users/password/reset-request`
- → moves on regardless of the answer.
-2. **form phase** — reset code, recovery key (a textarea, optional), new passphrase ×2.
- On submit: derive `new_auth_key`, `POST /v1/users/password/reset`, then `onLogin`
- (the normal sign-in, which sets `session.bundleKey`).
-3. With a recovery key: set `session.recoveryKey`, then
- `MeshBayTransport.rewrapAllNodes({ newPassphrase, recoveryKey, ... })`. `connect`
- tries the new passphrase key on `bundle_enc`, fails, and **falls back to
- `bundle_enc_recovery` + the recovery key**; the fan-out then re-wraps that identity
- under the new passphrase and writes a fresh recovery copy.
-4. **done phase** — reports which groups were restored and lists any that still need the
- operator fallback (offline / no recovery copy), same shape as §3.3.
-
-### 4.6 Without a recovery key — built
-
-If the recovery-key box is left blank, step 3 is skipped and the **norecovery phase**
-says it plainly: sign-in is restored, group identities are not; for each group ask the
-operator to `member unpin` you and send a fresh code, then rejoin. Files and messages
-are untouched; you rejoin with a new per-node identity.
-
-### 4.7 What Flow B invalidates — and what it does **not**
-
-Three things are called "device" around here; only one is touched.
-
-| | What it is | Flow B |
-|---|---|---|
-| `user_devices` (hub table) | an Ed25519 key that lets a client skip the passphrase prompt on launch (`POST /v1/users/auth`). A **hub-login convenience**, nothing else — no group key is wrapped for it, no node reads it | **deleted** |
-| per-node identity (`identities` on each node) | the Ed25519 + X25519 keys that unwrap the GEK, prove upload ownership and sign chat — **this is group access** | **recovered** from `bundle_enc_recovery` (§4.5), or via the operator fallback for the gaps |
-| roster pin `(user_id, pk_ed25519)` on a node | which per-node identities a node has admitted | untouched |
-
-So deleting `user_devices` does **not** cost group access. It costs one passphrase
-prompt per client on next launch: the client signs in with the new `auth_key`, gets a
-session, and re-registers itself (`POST /v1/users/devices` needs only a live session,
-which now means the new passphrase was just entered). That is the point — after a
-"control may be lost" event, a laptop still carrying a stored hub-auth key must stop
-signing in on its own until its owner proves the new passphrase on it.
-
-- All refresh-token families are revoked as well (as Flow A).
-
----
-
-## 5. Change list
-
-> **Step 1 (Flow A):** `POST /v1/users/password`, `MeshBayTransport.rewrapAllNodes`, the
-> Profile-page form + confirmation flow, `settings.passphrase*` locale keys — **built**
-> (`test_password_change.py`).
->
-> **Step 2 (recovery-key plumbing):** the node `bundle_enc_recovery` column + migration,
-> MNP 0.14, `keyderive.js` `generateRecoveryKey` / `deriveRecoveryKey`,
-> `storeKeypairBundle(bundleEnc, recoveryEnc?)`, the join path writing a recovery copy
-> when `session.recoveryKey` is set, and the registration-time `R` screen — **built**
-> (`test_bundle_store_recovery.py`, `test_recovery_key.py`).
->
-> **Step 3 (`R` by e-mail):** `registerUser` forwards an optional recovery mnemonic,
-> `RegisterRequest.recovery_key` → `mail.send_verification_code(..., recovery_key)`
-> appends it to the verification e-mail (never stored, never logged), and the
-> registration form has an "also e-mail it" opt-out — **built** (`test_recovery_email.py`).
->
-> **Step 4 (Flow B):** `POST /v1/users/password/reset-request` and `/reset`,
-> `mail.send_password_reset_code`, `rewrapAllNodes` recovery mode + `connect`'s
-> recovery-copy fallback, the `ResetPasswordPage` screen — **built**
-> (`test_password_reset.py`). Includes the step-5 items (device wipe, session
-> revocation, `password_reset*` IPLog events).
->
-> **Step 6 (coverage):** `test_rewrap_fanout.py` runs `rewrapAllNodes` under node
-> with the hub and per-node handshake stubbed and pins the bucketing and the
-> Flow A / B / C store calls; `test_recovery_key.py` gained the
-> wrong-key-rejected / right-key-opens check `connect`'s fallback rests on.
->
-> **Post-testing fixes:** `session.recoveryKey` is persisted in IndexedDB (slot
-> `rk`) and lazy-loaded on connect, so coverage is not limited to the unbroken
-> registration session; **Profile → Recovery key** re-loads `R` and backfills
-> every node via `rewrapAllNodes` in `bundleKey` mode (no passphrase);
-> `RegisterPage` / `LoginPage` `.trim()` the username so every key derivation
-> matches; a keyless browser gets a **passphrase prompt** on the group page
-> instead of a dead end.
->
-> **The stale-bundle trap (found in live logs).** `member unpin` deleted the
-> roster pin but left the `keypair_bundles` row. The next connection was handed
-> that stale bundle, could not open it (wrapped under the pre-reset passphrase,
-> no usable recovery copy), and `connect` **threw in the identity step before
-> ever reaching the join** the unpin was meant to enable — the client then
-> closed the channel, which read as "the node hung up". Two fixes: `ops.unpin_member`
-> now also `delete_keypair`s (the correct semantic — "start over" forgets the
-> bundle too), and `connect` treats an unopenable fetched bundle like `found:
-> false` — mint a fresh identity and let the join path take over — **except**
-> under `_rewrapOnly` (set by `rewrapAllNodes`), which must recover the exact
-> identity or report the node.
->
-> The one part with no automated coverage is the real WebRTC handshake and
-> `connect`'s identity/recovery branch in situ — integration territory, by hand.
-
-**Hub**
-
-- `"password_reset"` is a valid `EmailVerification.purpose` — the `purpose` column is a
- free string, and `tasks/cleanup.py` prunes expired rows purpose-agnostically, so no
- other change was needed there.
-- ✅ `POST /v1/users/password` (§3.5), `POST /v1/users/password/reset-request` and
- `POST /v1/users/password/reset` (§4.2) — in `api/users.py`, rate-limited via `limiter`.
-- ✅ `send_verification_code(to, code, recovery_key=None)` appends an `R` block (§4.4);
- `RegisterRequest.recovery_key` threads it through; `mail.send_password_reset_code(to,
- code)` for the reset e-mail.
-- ✅ `IPLog` events `password_change`, `password_reset`, `password_reset_request`.
-- No migration. (`email_verifications` is unchanged.)
-
-**Node**
-
-- ✅ `keypair_bundles.bundle_enc_recovery TEXT`. Node-only (`bundle_store.py`, plain
- aiosqlite, no Alembic): a `PRAGMA table_info` check plus `ALTER TABLE ADD COLUMN` in
- `BundleStore.open()` for existing DBs, and the column in `_SCHEMA_KEYPAIR` for fresh
- ones. The hub stores no keypair bundles and gains nothing here.
-- ✅ `store_keypair(user_id, bundle_enc, bundle_enc_recovery=None)` — an upsert that keeps
- an existing recovery copy when the new call omits one (a passphrase re-wrap does).
- `fetch_keypair` now returns `{bundle_enc, bundle_enc_recovery}`.
-- ✅ MNP handlers for `keypair_bundle_store` / `keypair_bundle_resp` pass the optional
- `bundle_enc_recovery` field through. **Version bump 0.13 → 0.14**, additive, N-2 intact.
-
-**Browser / client (`static/`)**
-
-- ✅ `keyderive.js`: `deriveEncryptionKey` / `deriveEncryptionKeyV1` (step 1);
- `generateRecoveryKey()` → `{ rawB64, mnemonic }` (32 bytes, grouped Base32);
- `deriveRecoveryKey(R, username)` → HKDF-SHA256, info `meshbay:recovery:v1:<username>`,
- accepts the mnemonic string or raw bytes; `generateNodeIdentity` takes an optional
- recovery key and returns `bundleEncRecovery`.
-- ✅ `transport.js`: `rewrapAllNodes(opts)` — passphrase-change mode
- (`{oldPassphrase, newPassphrase}`), Flow B (`{newPassphrase, recoveryKey}` — reads
- `bundle_enc_recovery` via `connect`'s fallback), and `bundleKey` mode
- (`{bundleKey, recoveryKey}` — keep the live key, just add the recovery copy: the
- Profile backfill, no passphrase). `storeKeypairBundle(bundleEnc, recoveryEnc?)`;
- `connect(...)` takes a 10th `recoveryKey` arg, exposes `newNodeBundleRecovery`, and
- its recovery fallback throws named errors, not an empty `OperationError`.
-- ✅ `hub-client.js`: `session.recoveryKey`, **persisted** in IndexedDB slot `rk`
- (`_storeRecoveryKey` / `_loadRecoveryKey`), cleared with `bk` on sign-out.
-- ✅ `group-page.js`: lazy-loads `session.recoveryKey` (like `bundleKey`) and passes it
- into `connect`; the recovery copy rides `storeKeypairBundle`. When `session.bundleKey`
- cannot be loaded (fresh browser, cleared storage, device-key sign-in), it shows a
- **passphrase prompt** (`needsPass`, `group.pass_*`) that derives and persists the
- bundle key and retries — instead of the old "go back to the browser you registered
- on" dead end. `connect`'s `no_keys` error is the backstop for the same case.
-- ✅ `profile-page.js`: the **Recovery key** section — paste `R`, `rewrapAllNodes` in
- `bundleKey` mode over every group. `settings.recovery*` keys in all ten catalogues.
-- ✅ `RegisterPage` / `LoginPage` / `onResend` `.trim()` the username before any
- derivation or request, matching the hub's stored form and `ResetPasswordPage`.
-- ✅ `profile-page.js`: passphrase-change form + §3.3 confirmation flow (step 1).
-- ✅ `auth-page.js`: the post-registration `recovery` phase (mnemonic shown once, "also
- e-mail it" checkbox, `registerUser` forwards it when checked), and `ResetPasswordPage`
- (route `#/reset`, linked from sign-in) — the Flow B screen of §4.5–§4.6.
-- ✅ `app.js`: `#/reset` route.
-- ✅ Locales: `settings.passphrase*` (step 1), `register.recovery_*` /
- `register.recovery_email*` (steps 2–3), `login.forgot` + `reset.*` (step 4) across all
- ten catalogues.
-
-**Tests**
-
-- ✅ `test_password_change.py` (step 1): new-passphrase sign-in, old refused, new must
- differ, unauthenticated rejected, other sessions die while the caller keeps a fresh
- pair, the change is logged.
-- ✅ `test_bundle_store_recovery.py` (step 2): recovery-column round-trip, a re-backup
- without a recovery copy keeps the existing one, the `ALTER TABLE` migration on a
- pre-0.14 database.
-- ✅ `test_recovery_key.py` (step 2): the mnemonic round-trips its exact bytes, the
- derived key is deterministic per account and domain-separated between accounts, a
- malformed key is rejected. Runs the real `keyderive.js` under node.
-- ✅ `test_recovery_email.py` (step 3): the register e-mail carries `R` when the body
- has it and only the code when it does not; `R` reaches no table; `mail.py` builds
- both body variants.
-- ✅ `test_password_reset.py` (step 4): reset lets the user sign in with the new
- passphrase and the old one stops working; `reset-request` needs the username **and
- e-mail** to match (a wrong e-mail is answered like an unknown account, no code
- created) and rejects a malformed e-mail with 422; never reveals whether an account
- exists; a wrong code is refused and counts toward the attempt cap; an expired code is
- refused; the code is single-use; the reset revokes sessions and wipes every device key
- (a stored one can no longer sign in); both events are logged.
-- ✅ `test_rewrap_fanout.py` (step 6): runs the real `rewrapAllNodes` under node with
- the hub HTTP calls and the per-node handshake stubbed — a reachable node with an
- identity is `updated` and gets one `keypair_bundle_store`; no online node ⇒
- `unreachable`; a `/nodes` error or a thrown handshake or a node returning no identity
- ⇒ `failed`; a freshly-minted identity ⇒ `updated` with no store; Flow A writes only
- the passphrase copy, Flow B writes both.
-- ✅ `test_recovery_key.py` also pins the crypto `connect`'s Flow B fallback rests on:
- a recovery-wrapped bundle opens under the matching key and not another.
-- **Not covered:** the real WebRTC handshake and `connect`'s recovery fallback in situ —
- no harness exists; verify by hand.
-
----
-
-## 6. Security — who this holds against
-
-| Capability | Passive hub | Active hub | Malicious node operator (a node you joined) | Mailbox compromise |
-|---|---|---|---|---|
-| Take over hub login | — | mint an OTP, get a session | — | read the OTP, get a session |
-| Read group content via that login | no | no — the session carries no key and no bundle | already can, on its own node | no |
-| Recover a per-node identity | needs `R` **and** a bundle handed over by a node as an authenticated member | sees `R` once at registration send-time; still not a group member, still cannot pull the bundle from any node | holds `bundle_enc` already (C4); `R` is a second target but full-entropy, so no easier | reads `R`; still cannot pull the bundle without being an authenticated member of that group |
-
-The load-bearing property: **the weak, emailed factors (OTP, and `R` in transit) cannot
-reach content on their own.** OTP grants a hub session, and a hub session opens nothing.
-`R` opens a bundle, but only a node hands out bundles, and only to a member over MNP.
-
-The honest cost of e-mailing `R`: a mailbox compromise becomes **equivalent to a
-passphrase compromise for identity recovery** — the passphrase's Argon2id wall no longer
-matters for an attacker who has `R`. It still requires reaching each node as an
-authenticated member, which a mailbox alone does not grant; combined with a stolen live
-session or hub↔node collusion it is game over for that node's identity. This is why `R`
-is shown on screen with an opt-out, and why the split-secret variant (§4.4) exists for
-users who want it.
-
-Not in scope, unchanged: a substituted hub (`GET /v1/hub/pubkey` is unpinned) can serve
-a malicious reset page to a browser — that is T3, and native clients load the page from
-the package.
-
----
-
-## 7. Still not solved
-
-- A node whose recovery copy was never written — joined before `bundle_enc_recovery`
- shipped, or before `R` was loaded in that browser. **Mitigated:** Profile → Recovery
- key backfills every reachable node (§4.3). What it cannot reach — a node offline at
- backfill time, or a group left since — still needs the operator fallback.
-- Nodes offline at recovery time — operator fallback.
-- Chat history needing Phase 15 forward-secret state — recovered identity can re-request
- sender-key distribution on rejoin, but past forward-secret segments stay unreadable by
- design.
-- The fallback itself: the operator runs `member unpin` and issues a fresh single-use
- code. `member unpin` now also drops the stored keypair bundle, and `connect` mints a
- fresh identity when handed a bundle it cannot open, so the rejoin actually completes
- (it used to throw before reaching the code prompt).
-
----
-
-## 8. Build order
-
-1. ✅ **Done.** Hub `POST /v1/users/password` + the §3.3 confirmation flow +
- `rewrapAllNodes` + locale keys — Flow A, no schema change, usable immediately.
-2. ✅ **Done.** `keypair_bundles.bundle_enc_recovery` + MNP 0.14 + `deriveRecoveryKey` +
- the registration-time `R` display (screen only, no e-mail yet).
-3. ✅ **Done.** `mail.py` `R` block + the register-body plumbing + the "also e-mail it"
- opt-out.
-4. ✅ **Done.** `password_reset` purpose + the two reset endpoints + the Flow B screen —
- and, folded in from step 5 because a reset is not safe without them, device wipe on
- reset, session revocation, and the `password_reset*` IP-log events.
-5. ✅ Folded into step 4.
-6. ✅ **Done.** Locale parity kept current throughout; `test_rewrap_fanout.py` covers the
- fan-out bucketing for both flows, and `test_recovery_key.py` covers the fallback
- crypto. The live WebRTC path stays a manual check.