summaryrefslogtreecommitdiffstats
path: root/docs/auth-confirm.md
blob: 96bf7d79927770e6478d6189b96a5ec268a3c532 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
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.