aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/auth-confirm.md548
1 files changed, 548 insertions, 0 deletions
diff --git a/docs/auth-confirm.md b/docs/auth-confirm.md
new file mode 100644
index 0000000..96bf7d7
--- /dev/null
+++ b/docs/auth-confirm.md
@@ -0,0 +1,548 @@
+# MeshBay — Password change and recovery
+
+> Status: **design, not built.** This is 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.