diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/auth-confirm.md | 548 |
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. |