summaryrefslogtreecommitdiffstats
path: root/docs/auth-confirm.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
commitf059cb118c556d1f0279350507f74b8a47d5a98a (patch)
tree9a97762a844038a06134b4b7dcead1758477dfc1 /docs/auth-confirm.md
parentb045ba0010d69360b6a0265eb7c73a07900fe328 (diff)
downloadmeshbay-f059cb118c556d1f0279350507f74b8a47d5a98a.tar.gz
docs: remove the documents MESHBAY_DESIGN.md replaces
Twenty-four files, about 17 000 lines: the two architecture drafts, the three security reviews, eleven design notes, the roadmap, the decisions file, the v1–v4 archive, the deprecated user guide and the stale quickstart. Their content is in MESHBAY_DESIGN.md, and git history holds the originals. The reason to delete rather than keep bannered: a document that is superseded but present still gets read, and a reader cannot always tell which of two accounts of one mechanism is the live one. That was the argument for retiring the user guide rather than repairing it, and it applies to the whole set. What made this safe is the concordance. Roughly 290 comments and docstrings cite these files by section — `musicbay.md §6`, `mediacenter.md §5.5`, `draft-v6 §2.11` — and section 16 maps every one onto its replacement, so not a single comment needs editing to stay followable. It now says plainly that the files are gone and where to recover them, and it gained rows for the three reviews (their findings are section 13), and for the two guides. Four kept documents pointed into the set and were repointed first: `playlists.md` (nine references — it is a live proposal and must not dangle), `WINDOWS-PORT.md`, and CLAUDE.md's example. No dangling reference remains outside section 16. Two files were dropped from the list after checking what they hold. `HTTPS.md` is an operational runbook — Caddy, certificate renewal, DNS, troubleshooting — and MESHBAY_DESIGN.md deliberately covers no operations, so nothing would replace it; the versioned Caddyfile is the config, not the procedure. `cast-smart-tv.md` is the plan for the unbuilt DLNA phase of a feature whose first two phases ship, and section 11.4 summarises it in four lines rather than carrying the SSDP/UPnP work. There is no user guide now, and section 0.1 says so rather than leaving a reader to discover it. Suites green: 2258 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
Diffstat (limited to 'docs/auth-confirm.md')
-rw-r--r--docs/auth-confirm.md560
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.