# 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:` | 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:` | 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 ` 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 `, then `member invite ` 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:`, 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.