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