aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 01:03:43 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 01:03:43 +0200
commitfe30860c58e0f1b1efd457ff5eb5146d1e592da0 (patch)
tree99a3e96994738c4e96f969a365679475dc4cf5cd
parent51d2d734c228f1e46670962480258abfe586d6c4 (diff)
downloadmeshbay-fe30860c58e0f1b1efd457ff5eb5146d1e592da0.tar.gz
feat: passphrase change and account recovery (auth-confirm)
The passphrase derives two independent client-side values: auth_key (the hub verifier) and bundle_key (AES-GCM key for the per-node identity bundles, which live on nodes and never on the hub). Changing or recovering a passphrase is therefore two operations — swap the hub verifier, and re-wrap every reachable node's identity bundle. Flow A — change a known passphrase (Profile page) - POST /v1/users/password re-proves the current passphrase, swaps pw_hash/salt/version, revokes every refresh token and returns a fresh pair so the tab that made the change stays signed in. - MeshBayTransport.rewrapAllNodes: for every group's online node, connect with the old key, read the identity off the handshake, store it back under the new key. Returns updated / unreachable / failed so the UI can point at the operator-unpin fallback for the gaps. Always-shown confirmation dialog listing reachable and unreachable groups. Recovery key - keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>). - Every per-node identity gets a second copy wrapped under the recovery key: keypair_bundles.bundle_enc_recovery (node-only column, added in _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs), carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive. - session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded on connect, so a group joined in any later session still leaves a recovery copy. - Shown once at registration; optionally folded into the verification e-mail as a pass-through the hub never stores or logs, with an opt-out. - Profile -> Recovery key re-loads R and backfills every reachable node via rewrapAllNodes in bundleKey mode (no passphrase re-entry). Flow B — recover a lost passphrase (#/reset, linked from sign-in) - POST /v1/users/password/reset-request {username, email}: both 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 code, no mail, same 200), so it reveals nothing and cannot be used to spray reset mail from a username alone. 5/min, 1-hour single-use code. - POST /v1/users/password/reset {username, code, new_auth_key}: same expiry / attempts / single-use checks as e-mail verification; revokes every session and deletes every registered device key so a stored one cannot sign back in past the reset. - ResetPasswordPage: request code -> code + optional recovery key + new passphrase -> reset + sign-in -> fan-out. connect() falls back to the recovery-wrapped copy when the passphrase key cannot open bundle_enc. Without a recovery key: sign-in is restored and each group needs the operator-unpin fallback. Supporting fixes (found in live testing) - member unpin now also deletes the keypair bundle; connect() mints a fresh identity when handed a bundle it cannot open (unless _rewrapOnly, set by rewrapAllNodes), so a rejoin completes instead of dead-ending before the invite-code prompt. - A browser with no bundle key gets a passphrase prompt on the group page instead of a "go back to the browser you registered on" message. - RegisterPage / LoginPage / ResetPasswordPage trim the username so every key derivation matches the hub's stored form. Docs: docs/auth-confirm.md. Locale keys across all ten catalogues. Tests: test_password_change, test_password_reset, test_recovery_email, test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492 passed; node suite 741 passed (the lone test_packaging_units failure is a pre-existing RPM-spec flake, reproducible on main). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
-rw-r--r--docs/auth-confirm.md548
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py7
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py226
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py63
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js268
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js72
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js26
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js96
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js242
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js229
-rw-r--r--packages/meshbay-hub/tests/conftest.py2
-rw-r--r--packages/meshbay-hub/tests/test_password_change.py140
-rw-r--r--packages/meshbay-hub/tests/test_password_reset.py204
-rw-r--r--packages/meshbay-hub/tests/test_recovery_email.py99
-rw-r--r--packages/meshbay-hub/tests/test_recovery_key.py136
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py210
-rw-r--r--packages/meshbay-node/src/meshbay_node/bundle_store.py63
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py10
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py26
-rw-r--r--packages/meshbay-node/tests/test_admin_ops_mnp.py8
-rw-r--r--packages/meshbay-node/tests/test_bundle_store_recovery.py76
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py3
34 files changed, 3307 insertions, 78 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.
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index d1e7f6c..1265586 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -56,5 +56,10 @@ __version__ = "0.9.0"
# file's cached TMDB match so it re-resolves with the current matcher
# (§10.1/V13). Additive: an older node logs "unknown type", the client's
# button just does nothing.
-MNP_VERSION = "0.13"
+# 0.14: added an optional `bundle_enc_recovery` field on `keypair_bundle_store`
+# and `keypair_bundle_resp` — a second copy of the identity bundle wrapped
+# under the account's recovery key, so a forgotten passphrase does not strand
+# the identity (docs/auth-confirm.md §4.3). Additive: an older node ignores the
+# field on store and never returns one; an older client never sends it.
+MNP_VERSION = "0.14"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index ff951e7..c789287 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -79,8 +79,11 @@ class MNP:
GEK_BUNDLE_FETCH = "gek_bundle_fetch" # client → node: request own wrapped GEK
GEK_BUNDLE_RESP = "gek_bundle_resp" # node → client: wrapped GEK bundle
KEYPAIR_BUNDLE_STORE = "keypair_bundle_store" # client → node: store encrypted keypair bundle
+ # optional `bundle_enc_recovery` (MNP 0.14): a second copy wrapped under the
+ # account's recovery key (docs/auth-confirm.md §4.3)
KEYPAIR_BUNDLE_FETCH = "keypair_bundle_fetch" # client → node: request own keypair bundle
KEYPAIR_BUNDLE_RESP = "keypair_bundle_resp" # node → client: encrypted keypair bundle
+ # carries `bundle_enc_recovery` too when the node has one stored
KEYPAIR_BUNDLE_DELETE = "keypair_bundle_delete" # client → node: withdraw own backup
JOIN_REQUEST = "join_request" # client → node: pair/recognise this identity
JOIN_RESULT = "join_result" # node → client: outcome + wrapped GEK
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 953ad1b..fa74368 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -67,6 +67,10 @@ class RegisterRequest(BaseModel):
email: str
password: str | None = None # deprecated — legacy native clients
auth_key: str | None = None # PBKDF2-derived, new clients
+ # Client-generated account recovery key (docs/auth-confirm.md §4.4). Pure
+ # pass-through: appended to the verification e-mail so the user's mailbox
+ # backs it up, then dropped. Never written to any table, never logged.
+ recovery_key: str | None = None
@field_validator("username")
@classmethod
@@ -125,7 +129,8 @@ async def register(
if found:
if found.status == "pending" and found.email_hash == eh:
# Same person retrying before validation — resend a code
- await _create_and_send_verification(db, found, body.email, eh)
+ await _create_and_send_verification(
+ db, found, body.email, eh, body.recovery_key)
await db.commit()
return {"user_id": found.id, "email_verification_required": True}
raise HTTPException(status_code=409, detail="Username already taken")
@@ -161,7 +166,7 @@ async def register(
detail=body.username,
))
- await _create_and_send_verification(db, user, body.email, eh)
+ await _create_and_send_verification(db, user, body.email, eh, body.recovery_key)
await db.commit()
return {"user_id": user.id, "email_verification_required": True}
@@ -169,6 +174,7 @@ async def register(
async def _create_and_send_verification(
db: AsyncSession, user: User, email: str, eh: str,
+ recovery_key: str | None = None,
) -> None:
user.status = "pending"
# Invalidate any previous pending verification for this user
@@ -190,7 +196,7 @@ async def _create_and_send_verification(
expires_at=datetime.now(timezone.utc) + timedelta(seconds=VERIFICATION_TTL),
))
await db.flush()
- mail.send_verification_code(email, code)
+ mail.send_verification_code(email, code, recovery_key=recovery_key)
class VerifyEmailRequest(BaseModel):
@@ -716,6 +722,220 @@ async def verify_email_change(
return {"status": "verified", "email": email}
+# ── Passphrase change (Flow A) ──────────────────────────────────────────────
+#
+# docs/auth-confirm.md §3. The passphrase derives two independent values on the
+# client: auth_key (verified here) and bundle_key (AES-GCM key for the per-node
+# identity bundles, which live on nodes and never on the hub). The client
+# re-wraps those bundles from the old bundle_key to the new one on every
+# reachable node *before* calling this; the hub only swaps its auth_key
+# verifier. There is no email round-trip — the current passphrase is the second
+# factor, exactly as for account deletion.
+
+
+class ChangePasswordRequest(BaseModel):
+ old_auth_key: str
+ new_auth_key: str
+
+
+@router.post("/password")
+@limiter.limit("5/minute")
+async def change_password(
+ body: ChangePasswordRequest,
+ request: Request,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ if not verify_password(body.old_auth_key, current_user.pw_hash,
+ current_user.pw_salt, current_user.pw_version):
+ raise HTTPException(status_code=403,
+ detail="Current passphrase does not match")
+ if body.new_auth_key == body.old_auth_key:
+ raise HTTPException(status_code=400,
+ detail="New passphrase must differ from the current one")
+
+ new_hash, new_salt = hash_password(body.new_auth_key)
+ current_user.pw_hash = new_hash
+ current_user.pw_salt = new_salt
+ current_user.pw_version = current_pw_version()
+
+ # Every existing session goes stale. Revoke all refresh tokens, then hand
+ # this caller a fresh pair so the tab that made the change stays signed in;
+ # other browsers fail their next renewal and drop to the sign-in form.
+ await db.execute(
+ update(RefreshToken)
+ .where(RefreshToken.user_id == current_user.id)
+ .values(revoked=True))
+
+ memberships = await db.execute(
+ select(GroupMember.group_id).where(GroupMember.user_id == current_user.id))
+ group_ids = [gid for (gid,) in memberships.all()]
+ access_token = issue_access_token(current_user.id, ttl=_ttl(), groups=group_ids)
+ raw_rt, rt_hash = generate_refresh_token()
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ db.add(RefreshToken(
+ user_id=current_user.id, token_hash=rt_hash,
+ family_id=str(uuid.uuid4()), expires_at=expires_at,
+ ))
+ db.add(IPLog(user_id=current_user.id, event="password_change",
+ ip_address=client_ip(request)))
+ await db.commit()
+
+ return {
+ "status": "changed",
+ "access_token": access_token,
+ "refresh_token": raw_rt,
+ "token_type": "bearer",
+ "expires_in": _ttl(),
+ }
+
+
+# ── Passphrase reset (Flow B) ──────────────────────────────────────────────
+#
+# docs/auth-confirm.md §4.2. An e-mail code re-opens hub login for someone who
+# has lost their passphrase. It recovers no group content — that needs the
+# recovery key, which the client applies on its own after the reset.
+# reset-request never reveals whether an account exists.
+
+PASSWORD_RESET_TTL = 3600 # 1 hour — shorter than sign-up verification
+
+
+class ResetRequestRequest(BaseModel):
+ username: str
+ email: str # must match the address on file for `username`
+
+ @field_validator("email")
+ @classmethod
+ def email_shape(cls, v: str) -> str:
+ v = v.strip()
+ local, sep, domain = v.partition("@")
+ if (not sep or not local or not domain
+ or "." not in domain
+ or len(v) > 254
+ or any(c.isspace() or ord(c) < 32 for c in v)):
+ raise ValueError("invalid email address")
+ return v
+
+
+class ResetPasswordRequest(BaseModel):
+ username: str
+ code: str
+ new_auth_key: str
+
+
+@router.post("/password/reset-request")
+@limiter.limit("5/minute")
+async def password_reset_request(
+ body: ResetRequestRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(User).where(User.username == body.username))
+ user = result.scalar_one_or_none()
+
+ # The username and the e-mail must be the pair on file. A mismatch is
+ # answered exactly like an unknown account — no reset code is created, no
+ # mail is sent — so this reveals nothing and cannot be used to spray reset
+ # mail at someone by knowing only their username.
+ matched = (
+ user is not None
+ and user.status == "active"
+ and user.email_hash == hash_email_blind(body.email)
+ )
+
+ if matched:
+ prev = await db.execute(
+ select(EmailVerification).where(
+ EmailVerification.user_id == user.id,
+ EmailVerification.purpose == "password_reset",
+ EmailVerification.verified_at.is_(None),
+ ))
+ for old in prev.scalars().all():
+ await db.delete(old)
+
+ code = _generate_code()
+ db.add(EmailVerification(
+ email_hash=user.email_hash or "",
+ code=code,
+ purpose="password_reset",
+ user_id=user.id,
+ expires_at=datetime.now(timezone.utc)
+ + timedelta(seconds=PASSWORD_RESET_TTL),
+ ))
+ db.add(IPLog(user_id=user.id, event="password_reset_request",
+ ip_address=client_ip(request)))
+ await db.flush()
+ try:
+ mail.send_password_reset_code(decrypt_email(user.email), code)
+ except Exception:
+ log.exception("Failed to send passphrase reset code")
+ await db.commit()
+ else:
+ db.add(IPLog(
+ user_id=user.id if user else None,
+ event="password_reset_request",
+ ip_address=client_ip(request), detail=body.username))
+ await db.commit()
+
+ # Same answer whether or not the username/e-mail pair matched an account.
+ return {"status": "sent_if_exists"}
+
+
+@router.post("/password/reset")
+@limiter.limit("10/minute")
+async def password_reset(
+ body: ResetPasswordRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ now = datetime.now(timezone.utc)
+ result = await db.execute(select(User).where(User.username == body.username))
+ user = result.scalar_one_or_none()
+ if not user:
+ raise HTTPException(status_code=400, detail="Invalid code")
+
+ vres = await db.execute(
+ select(EmailVerification).where(
+ EmailVerification.user_id == user.id,
+ EmailVerification.purpose == "password_reset",
+ EmailVerification.verified_at.is_(None),
+ ).order_by(EmailVerification.created_at.desc()))
+ verif = vres.scalar_one_or_none()
+ if not verif:
+ raise HTTPException(status_code=404,
+ detail="No pending reset for this account")
+ if verif.expires_at.replace(tzinfo=timezone.utc) < now:
+ raise HTTPException(status_code=410, detail="Reset code expired")
+ if verif.attempts >= VERIFICATION_MAX_ATTEMPTS:
+ raise HTTPException(status_code=429, detail="Too many attempts")
+
+ verif.attempts += 1
+ if verif.code != body.code.strip():
+ await db.commit()
+ raise HTTPException(status_code=400, detail="Invalid code")
+
+ verif.verified_at = now
+ new_hash, new_salt = hash_password(body.new_auth_key)
+ user.pw_hash = new_hash
+ user.pw_salt = new_salt
+ user.pw_version = current_pw_version()
+
+ # A lost passphrase is a "control may be lost" event: every session dies and
+ # every registered device key is dropped, so a stored one cannot sign back
+ # in past the reset. Each device re-enrols with the new passphrase.
+ await db.execute(
+ update(RefreshToken).where(RefreshToken.user_id == user.id)
+ .values(revoked=True))
+ await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
+ db.add(IPLog(user_id=user.id, event="password_reset",
+ ip_address=client_ip(request)))
+ await db.commit()
+
+ # The client already holds new_auth_key; it signs in through the normal
+ # path next, which issues tokens and the membership claim.
+ return {"status": "reset"}
+
+
# ── User preferences ────────────────────────────────────────────────────────
ALLOWED_PREF_KEYS = frozenset([
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py
index ce6e264..4f776bf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/mail.py
+++ b/packages/meshbay-hub/src/meshbay_hub/mail.py
@@ -31,23 +31,46 @@ def _send(msg: EmailMessage) -> bool:
return False
-def send_verification_code(to: str, code: str) -> None:
- msg = EmailMessage()
- msg["From"] = f"noreply@{_hub_domain}"
- msg["To"] = to
- msg["Subject"] = f"MeshBay — Your verification code: {code}"
- msg.set_content(
+def send_verification_code(to: str, code: str, recovery_key: str | None = None) -> None:
+ """
+ Registration verification e-mail. When `recovery_key` is given it is
+ appended to the body so the recipient's mailbox becomes the backup for it
+ (docs/auth-confirm.md §4.4).
+
+ `recovery_key` is a **pass-through**: it is generated on the client, never
+ stored anywhere on the hub, and never logged — only whether one was present.
+ """
+ body = (
f"Your verification code is: {code}\n"
"\n"
"Enter this code to verify your email address.\n"
"This code expires in 24 hours.\n"
+ )
+ if recovery_key:
+ body += (
+ "\n"
+ "---- Account recovery key ----\n"
+ "\n"
+ "Keep this message. If you ever forget your passphrase, this key is\n"
+ "what restores your access to your groups. It is not stored on the\n"
+ f"server and nobody at {_hub_domain} can recover it for you.\n"
+ "\n"
+ f" {recovery_key}\n"
+ )
+ body += (
"\n"
"If you did not create a MeshBay account, ignore this email.\n"
"\n"
f"{_hub_url}\n"
)
+ msg = EmailMessage()
+ msg["From"] = f"noreply@{_hub_domain}"
+ msg["To"] = to
+ msg["Subject"] = f"MeshBay — Your verification code: {code}"
+ msg.set_content(body)
_send(msg)
- log.info("Verification code sent to %s", _mask_email(to))
+ log.info("Verification code sent to %s (recovery_key=%s)",
+ _mask_email(to), bool(recovery_key))
def send_email_change_code(to: str, code: str) -> None:
@@ -69,6 +92,32 @@ def send_email_change_code(to: str, code: str) -> None:
log.info("Email change code sent to %s", _mask_email(to))
+def send_password_reset_code(to: str, code: str) -> None:
+ """
+ Passphrase-reset code (docs/auth-confirm.md §4.2). This only re-opens hub
+ login; it recovers no group content — that needs the recovery key.
+ """
+ msg = EmailMessage()
+ msg["From"] = f"noreply@{_hub_domain}"
+ msg["To"] = to
+ msg["Subject"] = f"MeshBay — Passphrase reset code: {code}"
+ msg.set_content(
+ f"Your passphrase reset code is: {code}\n"
+ "\n"
+ "Enter it to set a new passphrase. This code expires in 1 hour.\n"
+ "\n"
+ "This restores your sign-in only. If you also have your recovery key,\n"
+ "you can restore access to your groups in the same step.\n"
+ "\n"
+ "If you did not request this, ignore this email — your account is\n"
+ "unchanged.\n"
+ "\n"
+ f"{_hub_url}\n"
+ )
+ _send(msg)
+ log.info("Passphrase reset code sent to %s", _mask_email(to))
+
+
def send_invite_notification(
to: str, code: str, inviter: str, group_name: str,
) -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index acd4c1d..994cb73 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -20,7 +20,7 @@ import { MusicPlayerBar } from './music-player.js';
import { SettingsPage } from './settings-page.js';
import { ProfilePage } from './profile-page.js';
import { ExplorePage } from './explore-page.js';
-import { FirstRunPage, LoginPage, RegisterPage } from './auth-page.js';
+import { FirstRunPage, LoginPage, RegisterPage, ResetPasswordPage } from './auth-page.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -804,10 +804,12 @@ function App() {
// Signing in with this device's key. Showing a form here would be showing
// one the user is about to be taken past.
page = html`<p class="page-message">${t('status.connecting')}</p>`;
- } else if (route === '/login' || route === '/register') {
+ } else if (route === '/login' || route === '/register' || route === '/reset') {
page = route === '/register'
? html`<${RegisterPage} />`
- : html`<${LoginPage} onLogin=${authCtx.login} />`;
+ : route === '/reset'
+ ? html`<${ResetPasswordPage} onLogin=${authCtx.login} />`
+ : html`<${LoginPage} onLogin=${authCtx.login} />`;
} else if (!user) {
page = html`<${LoginPage} onLogin=${authCtx.login} />`;
} else if (route === '/search') {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 860f890..7c92d52 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -2,7 +2,9 @@ import {
html, useState,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
-import { hubFetch, navigate } from './hub-client.js';
+import {
+ hubFetch, navigate, session, HUB, loadAuth, _storeRecoveryKey,
+} from './hub-client.js';
import * as platform from './platform.js';
const PASSWORD_MIN_BITS = 60;
@@ -72,12 +74,15 @@ export function LoginPage({ onLogin }) {
const onSubmit = async (e) => {
e.preventDefault();
- if (!username || !password) return;
+ const name = username.trim();
+ if (!name || !password) return;
setError('');
setPendingVerif(false);
setLoading(true);
try {
- await onLogin(username, password);
+ // Trimmed to match the hub's stored username and every client-side key
+ // derivation (auth_key, bundle_key, recovery_key all fold the username in).
+ await onLogin(name, password);
navigate('/');
} catch (err) {
if (err.message === 'email_verification_required') {
@@ -114,6 +119,9 @@ export function LoginPage({ onLogin }) {
<div class="login-footer">
${t('login.no_account')} <a href="#/register">${t('login.register_link')}</a>
</div>
+ <div class="login-footer">
+ <a href="#/reset">${t('login.forgot')}</a>
+ </div>
</div>
</div>
`;
@@ -125,14 +133,19 @@ export function RegisterPage() {
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
- const [phase, setPhase] = useState('form'); // form | verify | done
+ const [phase, setPhase] = useState('form'); // form | recovery | verify | done
const [loading, setLoading] = useState(false);
const [code, setCode] = useState('');
const [verifying, setVerifying] = useState(false);
const [resent, setResent] = useState(false);
+ const [recoveryMnemonic, setRecoveryMnemonic] = useState('');
+ const [recoverySaved, setRecoverySaved] = useState(false);
+ const [recoveryCopied, setRecoveryCopied] = useState(false);
+ const [emailRecovery, setEmailRecovery] = useState(true);
const onSubmit = async (e) => {
e.preventDefault();
+ const name = username.trim();
if (password !== confirm) { setError(t('register.err_mismatch')); return; }
if (password.length < PASSWORD_MIN_LEN) {
setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return;
@@ -144,14 +157,30 @@ export function RegisterPage() {
setLoading(true);
try {
if (window.MeshBayKeys) {
- await window.MeshBayKeys.registerUser(username, email, password);
+ // The account recovery key (docs/auth-confirm.md §4.3/§4.4): generated
+ // here, shown once on the next screen. When the user leaves "e-mail it"
+ // checked, the mnemonic goes in the register body so the hub appends it
+ // to the verification e-mail (and stores it nowhere); otherwise it is
+ // screen-only. The derived key is kept in `session.recoveryKey` and
+ // persisted, so groups joined later — this session or a future one —
+ // still leave a recovery-wrapped copy on their node.
+ const rk = window.MeshBayKeys.generateRecoveryKey();
+ // `name` (trimmed), not the raw field: the hub stores the trimmed
+ // username and every key derivation must fold in the same string.
+ await window.MeshBayKeys.registerUser(
+ name, email, password, emailRecovery ? rk.mnemonic : null);
+ setRecoveryMnemonic(rk.mnemonic);
+ session.recoveryKey =
+ await window.MeshBayKeys.deriveRecoveryKey(rk.mnemonic, name);
+ await _storeRecoveryKey(session.recoveryKey);
+ setPhase('recovery');
} else {
await hubFetch('/v1/users/register', {
method: 'POST',
- body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
+ body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
});
+ setPhase('verify');
}
- setPhase('verify');
} catch (err) {
setError(err.message);
} finally {
@@ -183,7 +212,10 @@ export function RegisterPage() {
try {
await hubFetch('/v1/users/register', {
method: 'POST',
- body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
+ body: {
+ username: username.trim(), email, password,
+ pk_user_ed25519: '', pk_user_x25519: '',
+ },
});
setResent(true);
} catch (err) {
@@ -205,6 +237,48 @@ export function RegisterPage() {
`;
}
+ if (phase === 'recovery') {
+ const copyRecovery = async () => {
+ try {
+ await navigator.clipboard.writeText(recoveryMnemonic);
+ setRecoveryCopied(true);
+ setTimeout(() => setRecoveryCopied(false), 2000);
+ } catch { /* clipboard blocked — the text is on screen to copy by hand */ }
+ };
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('register.recovery_title')}</h2>
+ <p style="margin-bottom:12px; color:var(--text-secondary)">
+ ${t('register.recovery_intro')}
+ </p>
+ <code style="display:block; padding:12px; border:1px solid var(--border);
+ border-radius:6px; font-size:1.05em; letter-spacing:0.12em;
+ line-height:1.9; word-spacing:0.3em; text-align:center;
+ user-select:all; background:var(--bg-secondary, transparent)">
+ ${recoveryMnemonic}
+ </code>
+ <button class="btn-secondary" style="margin-top:8px" onClick=${copyRecovery}>
+ ${recoveryCopied ? t('register.recovery_copied') : t('register.recovery_copy')}
+ </button>
+ <p style="margin-top:12px; color:var(--text-secondary)">
+ ${emailRecovery ? t('register.recovery_emailed') : t('register.recovery_not_emailed')}
+ </p>
+ <p class="error-msg" style="margin-top:8px">${t('register.recovery_warning')}</p>
+ <label style="display:flex; gap:8px; align-items:flex-start; margin-top:12px">
+ <input type="checkbox" checked=${recoverySaved}
+ onChange=${e => setRecoverySaved(e.target.checked)} />
+ <span>${t('register.recovery_saved')}</span>
+ </label>
+ <button style="margin-top:12px" disabled=${!recoverySaved}
+ onClick=${() => setPhase('verify')}>
+ ${t('register.recovery_continue')}
+ </button>
+ </div>
+ </div>
+ `;
+ }
+
if (phase === 'verify') {
return html`
<div class="page-center">
@@ -263,6 +337,12 @@ export function RegisterPage() {
<input type="password" placeholder="${t('register.confirm')}" value=${confirm}
onInput=${e => setConfirm(e.target.value)}
autocomplete="new-password" required />
+ <label style="display:flex; gap:8px; align-items:flex-start; margin:4px 0 2px;
+ font-size:0.88em; color:var(--text-secondary)">
+ <input type="checkbox" checked=${emailRecovery}
+ onChange=${e => setEmailRecovery(e.target.checked)} />
+ <span>${t('register.recovery_email_opt')}</span>
+ </label>
${error && html`<div class="error-msg">${error}</div>`}
<button type="submit" disabled=${loading}>
${loading ? t('register.loading') : t('register.submit')}
@@ -275,3 +355,175 @@ export function RegisterPage() {
</div>
`;
}
+
+
+// ── Passphrase reset — Flow B (docs/auth-confirm.md §4) ─────────────────────
+//
+// An e-mail code restores hub login. A recovery key, if the user still has one,
+// restores the per-node identities in the same step: the fan-out reads each
+// node's recovery-wrapped bundle and re-wraps it under the new passphrase.
+// Without a recovery key, sign-in comes back and the groups do not.
+export function ResetPasswordPage({ onLogin }) {
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [phase, setPhase] = useState('request'); // request | form | working | done | norecovery
+ const [code, setCode] = useState('');
+ const [recovery, setRecovery] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [progress, setProgress] = useState(null);
+ const [result, setResult] = useState(null);
+
+ const requestCode = async (e) => {
+ e.preventDefault();
+ if (!username.trim() || !email.trim()) return;
+ setError('');
+ setBusy(true);
+ try {
+ await hubFetch('/v1/users/password/reset-request', {
+ method: 'POST',
+ body: { username: username.trim(), email: email.trim() },
+ });
+ setPhase('form');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const doReset = async (e) => {
+ e.preventDefault();
+ if (!window.MeshBayKeys) { setError(t('reset.err_unsupported')); return; }
+ if (password.length < PASSWORD_MIN_LEN) {
+ setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return;
+ }
+ if (passwordBits(password) < PASSWORD_MIN_BITS) {
+ setError(t('register.err_too_weak')); return;
+ }
+ if (password !== confirm) { setError(t('register.err_mismatch')); return; }
+ setError('');
+ setBusy(true);
+ let signedIn = false;
+ try {
+ const name = username.trim();
+ const newAuthKey = await window.MeshBayKeys.deriveAuthKey(password, name);
+ // Before this point a failure means the code or passphrase is wrong and
+ // the account is untouched — go back to the form.
+ await hubFetch('/v1/users/password/reset', {
+ method: 'POST',
+ body: { username: name, code: code.trim(), new_auth_key: newAuthKey },
+ });
+ await onLogin(name, password); // sets session.bundleKey
+ signedIn = true;
+
+ const mnemonic = recovery.trim();
+ if (!mnemonic) { setPhase('norecovery'); return; }
+
+ session.recoveryKey =
+ await window.MeshBayKeys.deriveRecoveryKey(mnemonic, name);
+ await _storeRecoveryKey(session.recoveryKey);
+ setPhase('working');
+ const auth = loadAuth() || {};
+ const r = await window.MeshBayTransport.rewrapAllNodes({
+ hubUrl: HUB, token: auth.token, username: name, userId: auth.userId,
+ newPassphrase: password, recoveryKey: mnemonic,
+ onProgress: setProgress,
+ });
+ setResult(r);
+ setPhase('done');
+ } catch (err) {
+ setError(err.message);
+ // After sign-in the reset already happened and the code is spent — do not
+ // send the user back to re-enter it. Land on the done screen with the
+ // error shown; their groups may need the operator fallback.
+ setPhase(signedIn ? 'done' : 'form');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (phase === 'working') {
+ return html`
+ <div class="page-center"><div class="card login-card">
+ <h2>${t('reset.title')}</h2>
+ <p class="settings-hint">
+ ${t('reset.working')}
+ ${progress && progress.total ? ` (${progress.done}/${progress.total})` : ''}
+ </p>
+ </div></div>
+ `;
+ }
+
+ if (phase === 'done' || phase === 'norecovery') {
+ const stragglers = phase === 'done' && result
+ ? result.unreachable.concat(result.failed) : [];
+ return html`
+ <div class="page-center"><div class="card login-card">
+ <h2>${t('reset.title')}</h2>
+ <p style="color:var(--success)">${t('reset.signin_restored')}</p>
+ ${error && html`<div class="error-msg" style="margin-top:8px">${error}</div>`}
+ ${phase === 'norecovery' && html`
+ <p class="settings-hint" style="margin-top:8px">${t('reset.no_recovery')}</p>`}
+ ${phase === 'done' && !error && stragglers.length === 0 && html`
+ <p class="settings-hint" style="margin-top:8px">${t('reset.groups_restored')}</p>`}
+ ${stragglers.length > 0 && html`
+ <p class="settings-hint" style="margin-top:8px">${t('reset.needs_operator')}</p>
+ <ul style="margin:0 0 8px 18px">
+ ${stragglers.map(g => html`<li>${g.name}${g.reason ? ` — ${g.reason}` : ''}</li>`)}
+ </ul>`}
+ <button style="margin-top:12px" onClick=${() => navigate('/')}>
+ ${t('reset.go_app')}
+ </button>
+ </div></div>
+ `;
+ }
+
+ return html`
+ <div class="page-center"><div class="card login-card">
+ <h2>${t('reset.title')}</h2>
+ ${phase === 'request' && html`
+ <p style="margin-bottom:12px; color:var(--text-secondary)">${t('reset.request_intro')}</p>
+ <form onSubmit=${requestCode}>
+ <input type="text" placeholder="${t('login.username')}" value=${username}
+ onInput=${e => setUsername(e.target.value)}
+ autocomplete="username" required autofocus />
+ <input type="email" placeholder="${t('register.email')}" value=${email}
+ onInput=${e => setEmail(e.target.value)}
+ autocomplete="email" required />
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <button type="submit" disabled=${busy}>${t('reset.send_code')}</button>
+ </form>`}
+
+ ${phase === 'form' && html`
+ <p style="margin-bottom:12px; color:var(--text-secondary)">${t('reset.form_intro')}</p>
+ <form onSubmit=${doReset}>
+ <input type="text" placeholder="${t('reset.code')}" value=${code}
+ onInput=${e => setCode(e.target.value)}
+ inputmode="numeric" maxlength="6" required autofocus
+ style="text-align:center;font-size:1.3em;letter-spacing:0.3em" />
+ <textarea placeholder="${t('reset.recovery_key')}" value=${recovery}
+ onInput=${e => setRecovery(e.target.value)} rows="2"
+ style="width:100%;font-family:monospace;font-size:0.9em;
+ letter-spacing:0.08em;resize:vertical"></textarea>
+ <p style="font-size:0.8em;color:var(--text-dim);margin:-4px 0 8px">
+ ${t('reset.recovery_key_hint')}
+ </p>
+ <input type="password" placeholder="${t('reset.new_pass')}" value=${password}
+ onInput=${e => setPassword(e.target.value)}
+ autocomplete="new-password" required />
+ <input type="password" placeholder="${t('reset.new_pass_repeat')}" value=${confirm}
+ onInput=${e => setConfirm(e.target.value)}
+ autocomplete="new-password" required />
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <button type="submit" disabled=${busy}>${t('reset.submit')}</button>
+ </form>`}
+
+ <div class="login-footer">
+ <a href="#/login">${t('reset.back_to_login')}</a>
+ </div>
+ </div></div>
+ `;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index acfcfc8..d7c81ad 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -6,7 +6,8 @@ import { Icon } from './icon.js';
import { transfers } from './transfers.js';
import { downloadEntry } from './file-utils.js';
import {
- HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey,
+ HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken,
+ _loadBundleKey, _loadRecoveryKey, _storeBundleKey,
} from './hub-client.js';
import { visibleApps } from './apps.js';
import { GroupName } from './group-name.js';
@@ -114,6 +115,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [needsDevice, setNeedsDevice] = useState(false);
const [deviceCode, setDeviceCode] = useState('');
const [codeInput, setCodeInput] = useState('');
+ // This browser has never derived the passphrase-bundle key (fresh browser,
+ // cleared storage, or a device-key sign-in). Ask for the passphrase here
+ // rather than sending someone back to the browser they registered on.
+ const [needsPass, setNeedsPass] = useState(false);
+ const [passInput, setPassInput] = useState('');
+ const [passBusy, setPassBusy] = useState(false);
const [retryKey, setRetryKey] = useState(0);
const transportRef = useRef(null);
const gekRef = useRef(null);
@@ -125,7 +132,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// set to `true` while looking at one group would otherwise silently disable
// the retry for every group opened afterward in the same session, forever.
const refreshedRef = useRef(false);
- useEffect(() => { refreshedRef.current = false; }, [groupId]);
+ useEffect(() => {
+ refreshedRef.current = false;
+ setNeedsPass(false);
+ }, [groupId]);
const submitJoinCode = useCallback((e) => {
e.preventDefault();
@@ -138,6 +148,31 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setRetryKey(k => k + 1);
}, [codeInput]);
+ const submitPass = useCallback(async (e) => {
+ e.preventDefault();
+ const pass = passInput;
+ if (!pass || !window.MeshBayKeys) return;
+ setPassBusy(true);
+ setError('');
+ try {
+ // Same derivation as sign-in — the token is already ours, only the key
+ // that opens node bundles is missing here. Persisted so this browser is
+ // set up from now on.
+ session.bundleKey = {
+ v2: await window.MeshBayKeys.deriveEncryptionKey(pass, username),
+ v1: await window.MeshBayKeys.deriveEncryptionKeyV1(pass, username),
+ };
+ await _storeBundleKey(session.bundleKey);
+ setPassInput('');
+ setNeedsPass(false);
+ setRetryKey(k => k + 1);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setPassBusy(false);
+ }
+ }, [passInput, username]);
+
// One place that takes an index from the node and puts it everywhere it has to
// go. Deleting a file used to refresh the table and leave the cache alone, so
// the search page went on offering a file that no longer existed until the
@@ -191,6 +226,16 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setError('');
gekRef.current = null;
if (!session.bundleKey) session.bundleKey = await _loadBundleKey();
+ // Persisted (docs/auth-confirm.md §4.3) so a group joined in a later
+ // session still leaves a recovery-wrapped identity copy on its node.
+ if (!session.recoveryKey) session.recoveryKey = await _loadRecoveryKey();
+ if (!session.bundleKey && window.MeshBayKeys) {
+ // Nothing to sign or unwrap with in this browser yet — ask for the
+ // passphrase instead of failing into a "go back to your other browser"
+ // message.
+ if (!cancelled) { setNeedsPass(true); setStatus('idle'); }
+ return;
+ }
try {
const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
if (cancelled) return;
@@ -225,7 +270,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const ack = await transport.connect(
nodeId, live, groupId, null, sessionKeys, session.bundleKey, username,
- userId, session.pendingJoinCode);
+ userId, session.pendingJoinCode, session.recoveryKey);
session.pendingJoinCode = null;
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
@@ -281,8 +326,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// passphrase. It is this node's key and no other's.
if (transport.connected && transport.newNodeBundle) {
try {
- await transport.storeKeypairBundle(transport.newNodeBundle);
+ await transport.storeKeypairBundle(
+ transport.newNodeBundle, transport.newNodeBundleRecovery);
transport.newNodeBundle = null;
+ transport.newNodeBundleRecovery = null;
} catch (e) {
console.warn('[MeshBay] could not leave our key with the node:', e.message);
}
@@ -349,6 +396,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// one-time code from the operator before it will hand over the group
// key. Not an error to shout about — a step in joining.
if (err.reason === 'code_required') setNeedsCode(true);
+ // The node has no bundle for us and this browser derived no key to make
+ // one — the passphrase form below is the way in, not a support request.
+ if (err.reason === 'no_keys') setNeedsPass(true);
// A key this node has never pinned, for an account it knows. The way in
// is a device already trusted here, not an operator — which is the
// whole point of device linking: a second browser or a native client
@@ -549,6 +599,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
`}
</div>
`}
+ ${needsPass && html`
+ <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitPass}>
+ <h4>${t('group.pass_title')}</h4>
+ <p class="settings-hint">${t('group.pass_hint')}</p>
+ <div style="display:flex;gap:8px">
+ <input type="password" placeholder=${t('login.password')}
+ autocomplete="current-password"
+ value=${passInput} onInput=${e => setPassInput(e.target.value)} required />
+ <button class="admin-btn" type="submit" disabled=${passBusy}>
+ ${passBusy ? '…' : t('group.pass_btn')}
+ </button>
+ </div>
+ </form>
+ `}
${needsCode && html`
<form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}>
<h4>${t('group.join_code_title')}</h4>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index 16dce9f..e72961c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -89,7 +89,12 @@ async function clearAllCachedIndexes() {
// a code is single-use and short-lived. A plain object, not two bare `let`s,
// so importing modules can update either field without this module handing
// out a rebindable export.
-const session = { bundleKey: null, pendingJoinCode: null };
+//
+// `recoveryKey` (docs/auth-confirm.md §4.3) is the AES key that wraps the
+// *recovery* copy of an identity bundle. In-memory only, and set only when the
+// user has just generated or entered the recovery secret (registration, or the
+// Flow B screen) — it cannot be re-derived from the passphrase.
+const session = { bundleKey: null, pendingJoinCode: null, recoveryKey: null };
function _openKeyDB() {
return new Promise((resolve, reject) => {
@@ -99,25 +104,33 @@ function _openKeyDB() {
req.onerror = () => reject(req.error);
});
}
-async function _storeBundleKey(key) {
+async function _storeKey(slot, key) {
try {
const db = await _openKeyDB();
const tx = db.transaction('k', 'readwrite');
- tx.objectStore('k').put(key, 'bk');
+ tx.objectStore('k').put(key, slot);
await new Promise(r => { tx.oncomplete = r; });
db.close();
} catch {}
}
-async function _loadBundleKey() {
+async function _loadKey(slot) {
try {
const db = await _openKeyDB();
const tx = db.transaction('k', 'readonly');
- const g = tx.objectStore('k').get('bk');
+ const g = tx.objectStore('k').get(slot);
const val = await new Promise(r => { g.onsuccess = () => r(g.result); });
db.close();
return val || null;
} catch { return null; }
}
+// 'bk' = passphrase-derived bundle key; 'rk' = recovery key (docs/auth-confirm.md
+// §4.3). Persisting 'rk' is what lets a group joined in a *later* session still
+// get a recovery-wrapped identity copy, instead of only groups joined in the
+// unbroken session that generated it. Cleared with everything else on sign-out.
+const _storeBundleKey = (key) => _storeKey('bk', key);
+const _loadBundleKey = () => _loadKey('bk');
+const _storeRecoveryKey = (key) => _storeKey('rk', key);
+const _loadRecoveryKey = () => _loadKey('rk');
async function _clearKeyDB() {
try {
const db = await _openKeyDB();
@@ -142,6 +155,7 @@ function saveAuth(auth) {
} else {
localStorage.removeItem(AUTH_KEY);
session.bundleKey = null;
+ session.recoveryKey = null;
_clearKeyDB();
}
}
@@ -274,7 +288,7 @@ async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) {
export {
HUB, navigate, session,
cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes,
- _storeBundleKey, _loadBundleKey, _clearKeyDB,
+ _storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB,
loadAuth, saveAuth, setAuth, setAuthChangeListener,
tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index ce38d35..0aaa6a5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -155,6 +155,70 @@ async function deriveEncryptionKey(password, username) {
'raw', out.hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
+// ── Account recovery key ─────────────────────────────────────────────────────
+//
+// docs/auth-confirm.md §4.3. A full-entropy secret the user keeps outside the
+// passphrase — in their password manager, or (step 3) e-mailed to them. It
+// wraps a *second* copy of every per-node identity bundle, so a forgotten
+// passphrase does not strand the account's group identities.
+//
+// 256 bits of real entropy, so the derivation is HKDF, not Argon2: there is
+// nothing to brute-force and no reason to make the legitimate path slow. The
+// username domain-separates it, exactly as for the bundle key.
+
+const _B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // RFC 4648, no padding
+
+/** 32 random bytes, shown to the human as 13 groups of 4 Base32 chars. */
+function generateRecoveryKey() {
+ const R = crypto.getRandomValues(new Uint8Array(32));
+ return {
+ rawB64: btoa(String.fromCharCode(...R)),
+ mnemonic: _toMnemonic(R),
+ };
+}
+
+function _toMnemonic(bytes) {
+ let bits = 0, value = 0, out = '';
+ for (const b of bytes) {
+ value = (value << 8) | b;
+ bits += 8;
+ while (bits >= 5) { out += _B32[(value >>> (bits - 5)) & 31]; bits -= 5; }
+ }
+ if (bits > 0) out += _B32[(value << (5 - bits)) & 31];
+ return out.replace(/(.{4})(?=.)/g, '$1 ');
+}
+
+function _fromMnemonic(mnemonic) {
+ const clean = String(mnemonic).replace(/[^A-Za-z2-7]/g, '').toUpperCase();
+ let bits = 0, value = 0;
+ const out = [];
+ for (const ch of clean) {
+ const idx = _B32.indexOf(ch);
+ if (idx < 0) throw new Error('invalid recovery key');
+ value = (value << 5) | idx;
+ bits += 5;
+ if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
+ }
+ if (out.length < 32) throw new Error('recovery key too short');
+ return new Uint8Array(out.slice(0, 32));
+}
+
+/**
+ * Derive the AES-GCM key that wraps the recovery copy of a bundle.
+ * `R` is the raw Uint8Array(32) or its Base32 mnemonic string.
+ */
+async function deriveRecoveryKey(R, username) {
+ const raw = (typeof R === 'string') ? _fromMnemonic(R) : new Uint8Array(R);
+ const km = await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']);
+ return crypto.subtle.deriveKey(
+ {
+ name: 'HKDF', hash: 'SHA-256',
+ salt: new Uint8Array(0),
+ info: new TextEncoder().encode(`meshbay:recovery:v1:${username}`),
+ },
+ km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+}
+
// ── Bundle encryption ─────────────────────────────────────────────────────────
/**
@@ -212,7 +276,7 @@ async function decryptBundle(bundleB64, password, username) {
*
* Returns the raw private keys for immediate use after registration.
*/
-async function registerUser(username, email, password) {
+async function registerUser(username, email, password, recoveryMnemonic) {
// No keypair here any more. Identity keys are per node: one is generated the
// first time this account joins a given node, encrypted under the passphrase,
// and left with that node. So an operator who cracks what sits on their own
@@ -222,10 +286,16 @@ async function registerUser(username, email, password) {
// It also means the hub stores no user key to publish, which is what H3 read.
const authKey = await deriveAuthKey(password, username);
+ const payload = { username, email, auth_key: authKey };
+ // The recovery mnemonic, when the user opted to have it e-mailed: the hub
+ // appends it to the verification e-mail and stores it nowhere
+ // (docs/auth-confirm.md §4.4). Omitted when they chose to save it themselves.
+ if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic;
+
const resp = await hubCall('/v1/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, email, auth_key: authKey }),
+ body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`);
@@ -235,21 +305,27 @@ async function registerUser(username, email, password) {
/**
* A fresh identity for one node, encrypted under the passphrase-derived key.
*
- * Returns { skEdB64, skXB64, pkXB64, bundleEnc } — the bundle goes to that node
- * and nowhere else, and is what any other browser fetches to become the same
- * person there.
+ * Returns { skEdB64, skXB64, pkXB64, bundleEnc, bundleEncRecovery? } — the
+ * bundle goes to that node and nowhere else, and is what any other browser
+ * fetches to become the same person there. When `recoveryKey` is supplied a
+ * second copy wrapped under it rides along, so a forgotten passphrase does not
+ * strand this identity (docs/auth-confirm.md §4.3).
*/
-async function generateNodeIdentity(bundleKey) {
+async function generateNodeIdentity(bundleKey, recoveryKey) {
const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, { name: 'X25519' }, true, []);
const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto));
- return {
+ const out = {
skEdB64: b64(skEdRaw),
skXB64: b64(skXRaw),
pkXB64: b64(pkXBytes),
bundleEnc: await encryptBundleWithKey(skEdRaw, skXRaw, bundleKey.v2 || bundleKey),
};
+ if (recoveryKey) {
+ out.bundleEncRecovery = await encryptBundleWithKey(skEdRaw, skXRaw, recoveryKey);
+ }
+ return out;
}
/**
@@ -324,4 +400,10 @@ async function signBytes(skEdPkcs8B64, message) {
window.MeshBayKeys = {
registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes,
deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
+ // Exposed for the passphrase change (docs/auth-confirm.md §3): re-wrapping a
+ // node's identity bundle needs the old key (a {v2,v1} pair, since an old
+ // bundle may be v1) to read it and the new v2 key to write it back.
+ deriveEncryptionKey, deriveEncryptionKeyV1,
+ // Account recovery key (docs/auth-confirm.md §4.3).
+ generateRecoveryKey, deriveRecoveryKey,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 909fec5..363df86 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -29,6 +29,25 @@ export default {
'login.loading': 'Anmeldung läuft …',
'login.no_account': 'Noch kein Konto?',
'login.register_link': 'Konto erstellen',
+ 'login.forgot': 'Passphrase vergessen?',
+ 'reset.title': 'Passphrase zurücksetzen',
+ 'reset.request_intro': 'Geben Sie Ihren Benutzernamen und die E-Mail des Kontos ein. Stimmen sie überein, wird ein Zurücksetzungscode an diese Adresse gesendet.',
+ 'reset.send_code': 'Code senden',
+ 'reset.form_intro': 'Geben Sie den Code aus Ihrer E-Mail ein und wählen Sie eine neue Passphrase. Wenn Sie Ihren Wiederherstellungsschlüssel haben, fügen Sie ihn hinzu, um auch den Zugang zu Ihren Gruppen wiederherzustellen.',
+ 'reset.code': 'Zurücksetzungscode',
+ 'reset.recovery_key': 'Wiederherstellungsschlüssel (optional)',
+ 'reset.recovery_key_hint': 'Leer lassen, wenn Sie ihn nicht haben. Ohne ihn wird Ihre Anmeldung wiederhergestellt, aber jeder Gruppe müssen Sie erneut beitreten.',
+ 'reset.new_pass': 'Neue Passphrase',
+ 'reset.new_pass_repeat': 'Neue Passphrase wiederholen',
+ 'reset.submit': 'Passphrase zurücksetzen',
+ 'reset.working': 'Ihre Gruppenidentitäten werden wiederhergestellt…',
+ 'reset.signin_restored': 'Ihre Anmeldung wurde wiederhergestellt.',
+ 'reset.groups_restored': 'Ihre Gruppenidentitäten wurden auf jedem erreichbaren Knoten wiederhergestellt.',
+ 'reset.no_recovery': 'Ihre Gruppenidentitäten wurden nicht wiederhergestellt. Bitten Sie für jede Gruppe deren Betreiber, Ihre Anheftung zu entfernen und einen neuen Einladungscode zu senden, und treten Sie dann erneut bei.',
+ 'reset.needs_operator': 'Diese Gruppen erfordern noch eine Aktion — bitten Sie jeden Betreiber, Ihre Anheftung zu entfernen und einen neuen Code zu senden:',
+ 'reset.go_app': 'Zur App',
+ 'reset.back_to_login': 'Zurück zur Anmeldung',
+ 'reset.err_unsupported': 'Das Zurücksetzen der Passphrase benötigt das Krypto-Modul des Browsers, das hier nicht verfügbar ist.',
// Register
'register.title': 'Konto erstellen',
@@ -40,6 +59,16 @@ export default {
'register.loading': 'Konto wird erstellt …',
'register.has_account': 'Sie haben bereits ein Konto?',
'register.login_link': 'Anmelden',
+ 'register.recovery_title': 'Speichern Sie Ihren Wiederherstellungsschlüssel',
+ 'register.recovery_intro': 'Dieser Schlüssel stellt Ihre Gruppenidentitäten wieder her, falls Sie jemals Ihre Passphrase vergessen. Bewahren Sie ihn in einem Passwortmanager oder an einem sicheren Ort auf — er wird nur einmal angezeigt, und der Hub sieht ihn nie.',
+ 'register.recovery_copy': 'Kopieren',
+ 'register.recovery_copied': 'Kopiert',
+ 'register.recovery_warning': 'Wenn Sie sowohl Ihre Passphrase als auch diesen Schlüssel verlieren, bleiben Ihre Dateien auf ihren Knoten, aber Sie treten jeder Gruppe mit einer neuen Identität erneut bei.',
+ 'register.recovery_saved': 'Ich habe meinen Wiederherstellungsschlüssel an einem sicheren Ort gespeichert.',
+ 'register.recovery_continue': 'Weiter',
+ 'register.recovery_email_opt': 'Diesen Wiederherstellungsschlüssel zur Sicherung auch per E-Mail an mich senden',
+ 'register.recovery_emailed': 'Eine Kopie wurde auch an Ihre E-Mail-Adresse gesendet, in derselben Nachricht wie Ihr Bestätigungscode.',
+ 'register.recovery_not_emailed': 'Dies wurde nicht per E-Mail gesendet. Speichern Sie es jetzt — es wird nur dieses eine Mal angezeigt.',
'register.success_title': 'E-Mail bestätigen',
'register.success_msg': 'Ein Bestätigungscode wurde an Ihre E-Mail-Adresse gesendet. Geben Sie ihn unten ein, um Ihr Konto zu aktivieren.',
'register.go_login': 'Zur Anmeldung',
@@ -294,6 +323,36 @@ export default {
other: '{n} gemerkt',
},
'settings.node_pins_clear': 'Gemerkte Identitäten löschen',
+ 'settings.passphrase': 'Passphrase',
+ 'settings.passphrase_hint': 'Ändert die Passphrase dieses Kontos. Ihre Identitätsschlüssel werden in jeder Gruppe neu verpackt, deren Knoten online ist; einer Gruppe, deren Knoten offline ist, müssen Sie danach erneut beitreten.',
+ 'settings.passphrase_change': 'Passphrase ändern',
+ 'settings.passphrase_current': 'Aktuelle Passphrase',
+ 'settings.passphrase_new': 'Neue Passphrase',
+ 'settings.passphrase_new_repeat': 'Neue Passphrase wiederholen',
+ 'settings.continue': 'Weiter',
+ 'settings.done': 'Fertig',
+ 'settings.passphrase_confirm_intro': 'Ihre Identitätsschlüssel werden jetzt in jeder Gruppe neu verpackt, deren Knoten erreichbar ist:',
+ 'settings.passphrase_reachable': 'Jetzt aktualisiert',
+ 'settings.passphrase_unreachable': 'Nicht erreichbar',
+ 'settings.passphrase_fallback_note': 'Bitten Sie bei einer nicht erreichbaren Gruppe deren Betreiber, Ihre Anheftung zu entfernen und einen neuen Einladungscode zu senden, und treten Sie dann erneut bei. Ihre Dateien und Nachrichten gehen nicht verloren.',
+ 'settings.passphrase_confirm_btn': 'Ändern',
+ 'settings.passphrase_working': 'Ihre Schlüssel werden neu verpackt…',
+ 'settings.passphrase_done': 'Passphrase geändert.',
+ 'settings.passphrase_needs_operator': 'Diese Gruppen erfordern noch eine Aktion — bitten Sie jeden Betreiber, Ihre Anheftung zu entfernen und einen neuen Code zu senden:',
+ 'settings.pw_too_short': 'Die neue Passphrase muss mindestens 12 Zeichen lang sein.',
+ 'settings.pw_mismatch': 'Die beiden neuen Passphrasen stimmen nicht überein.',
+ 'settings.pw_same': 'Die neue Passphrase muss sich von der aktuellen unterscheiden.',
+ 'settings.pw_wrong_current': 'Die aktuelle Passphrase ist nicht korrekt. Es wurde nichts geändert.',
+ 'settings.recovery': 'Wiederherstellungsschlüssel',
+ 'settings.recovery_hint': 'Fügt jeder Gruppe eine mit Ihrem Wiederherstellungsschlüssel verschlüsselte Kopie Ihrer Identität hinzu. Machen Sie das einmal in jedem Browser, den Sie nutzen; ohne sie bedeutet eine verlorene Passphrase, jeder Gruppe von Hand erneut beizutreten.',
+ 'settings.recovery_loaded': 'In diesem Browser geladen — neue Gruppen sind automatisch abgedeckt.',
+ 'settings.recovery_open': 'Wiederherstellungsschlüssel eingeben',
+ 'settings.recovery_input_ph': 'Fügen Sie Ihren Wiederherstellungsschlüssel ein',
+ 'settings.recovery_submit': 'Sicherungskopien hinzufügen',
+ 'settings.recovery_working': 'Ihre Gruppen werden aktualisiert…',
+ 'settings.recovery_done': 'Ihre Gruppen haben jetzt eine Wiederherstellungskopie.',
+ 'settings.recovery_partial': 'Diese Gruppen waren nicht erreichbar. Öffnen Sie sie später oder führen Sie dies erneut aus:',
+ 'settings.recovery_need_relogin': 'Bitte melden Sie sich ab und wieder an und versuchen Sie es erneut.',
'settings.danger': 'Konto löschen',
'settings.delete_hint': 'Entfernt Ihr Konto, Ihre Gruppenmitgliedschaften und Ihre '
+ 'Benachrichtigungen vom Hub und gibt Ihren Benutzernamen frei. Was auf den Nodes '
@@ -527,6 +586,9 @@ export default {
+ 'Einmalcode und geben Sie ihn hier ein. Danach ist dieser Browser bekannt und Sie '
+ 'werden nicht erneut gefragt.',
'group.join_code_btn': 'Beitreten',
+ 'group.pass_title': 'Geben Sie Ihre Passphrase ein',
+ 'group.pass_hint': 'Dieser Browser hat Ihre Schlüssel noch nicht entsperrt. Geben Sie Ihre Passphrase einmal ein, um Ihre Gruppen hier zu öffnen.',
+ 'group.pass_btn': 'Entsperren',
'notif.purge': 'Alle löschen',
'notif.title': 'Benachrichtigungen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a6f77b3..14a42a9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -32,6 +32,25 @@ export default {
'login.loading': 'Logging in...',
'login.no_account': 'No account?',
'login.register_link': 'Register',
+ 'login.forgot': 'Forgot your passphrase?',
+ 'reset.title': 'Reset passphrase',
+ 'reset.request_intro': 'Enter your username and the email on the account. If they match, a reset code is sent to it.',
+ 'reset.send_code': 'Send code',
+ 'reset.form_intro': 'Enter the code from your email and choose a new passphrase. If you have your recovery key, add it to also restore access to your groups.',
+ 'reset.code': 'Reset code',
+ 'reset.recovery_key': 'Recovery key (optional)',
+ 'reset.recovery_key_hint': 'Leave blank if you do not have it. Without it your sign-in is restored, but each group must be rejoined.',
+ 'reset.new_pass': 'New passphrase',
+ 'reset.new_pass_repeat': 'Repeat new passphrase',
+ 'reset.submit': 'Reset passphrase',
+ 'reset.working': 'Restoring your group identities…',
+ 'reset.signin_restored': 'Your sign-in has been restored.',
+ 'reset.groups_restored': 'Your group identities were restored on every reachable node.',
+ 'reset.no_recovery': 'Your group identities were not restored. For each group, ask its operator to unpin you and send a new invitation code, then rejoin.',
+ 'reset.needs_operator': 'These groups still need action — ask each operator to unpin you and send a new code:',
+ 'reset.go_app': 'Go to the app',
+ 'reset.back_to_login': 'Back to sign in',
+ 'reset.err_unsupported': 'Passphrase reset needs the browser crypto module, which is not available here.',
// Register
'register.title': 'Register',
@@ -43,6 +62,16 @@ export default {
'register.loading': 'Creating account...',
'register.has_account': 'Already have an account?',
'register.login_link': 'Login',
+ 'register.recovery_title': 'Save your recovery key',
+ 'register.recovery_intro': 'This key restores your group identities if you ever forget your passphrase. Store it in a password manager or somewhere safe — it is shown only once, and the hub never sees it.',
+ 'register.recovery_copy': 'Copy',
+ 'register.recovery_copied': 'Copied',
+ 'register.recovery_warning': 'If you lose both your passphrase and this key, your files stay on their nodes but you rejoin each group with a new identity.',
+ 'register.recovery_saved': 'I have saved my recovery key somewhere safe.',
+ 'register.recovery_continue': 'Continue',
+ 'register.recovery_email_opt': 'Also email this recovery key to me as a backup',
+ 'register.recovery_emailed': 'A copy has also been sent to your email, in the same message as your verification code.',
+ 'register.recovery_not_emailed': 'This was not emailed. Save it now — this is the only time it is shown.',
'register.success_title': 'Verify your email',
'register.success_msg': 'A verification code has been sent to your email address. Enter it below to activate your account.',
'register.go_login': 'Go to login',
@@ -288,6 +317,36 @@ export default {
other: '{n} pinned',
},
'settings.node_pins_clear': 'Clear pinned identities',
+ 'settings.passphrase': 'Passphrase',
+ 'settings.passphrase_hint': "Change this account's passphrase. Your identity keys are re-wrapped on every group whose node is online; a group whose node is offline has to be rejoined afterwards.",
+ 'settings.passphrase_change': 'Change passphrase',
+ 'settings.passphrase_current': 'Current passphrase',
+ 'settings.passphrase_new': 'New passphrase',
+ 'settings.passphrase_new_repeat': 'Repeat new passphrase',
+ 'settings.continue': 'Continue',
+ 'settings.done': 'Done',
+ 'settings.passphrase_confirm_intro': 'Your identity keys will be re-wrapped now on every group whose node can be reached:',
+ 'settings.passphrase_reachable': 'Updated now',
+ 'settings.passphrase_unreachable': 'Cannot be reached',
+ 'settings.passphrase_fallback_note': 'For a group that cannot be reached, ask its operator to unpin you and send a fresh invitation code, then rejoin. Your files and messages are not lost.',
+ 'settings.passphrase_confirm_btn': 'Change it',
+ 'settings.passphrase_working': 'Re-wrapping your keys…',
+ 'settings.passphrase_done': 'Passphrase changed.',
+ 'settings.passphrase_needs_operator': 'These groups still need action — ask each operator to unpin you and send a new code:',
+ 'settings.pw_too_short': 'The new passphrase must be at least 12 characters.',
+ 'settings.pw_mismatch': 'The two new passphrases do not match.',
+ 'settings.pw_same': 'The new passphrase must differ from the current one.',
+ 'settings.pw_wrong_current': 'The current passphrase is not correct. Nothing was changed.',
+ 'settings.recovery': 'Recovery key',
+ 'settings.recovery_hint': 'Add a recovery-wrapped copy of your identity to every group. Do this once in each browser you use; without it, a lost passphrase means rejoining each group by hand.',
+ 'settings.recovery_loaded': 'Loaded in this browser — new groups are covered automatically.',
+ 'settings.recovery_open': 'Enter recovery key',
+ 'settings.recovery_input_ph': 'Paste your recovery key',
+ 'settings.recovery_submit': 'Add backup copies',
+ 'settings.recovery_working': 'Updating your groups…',
+ 'settings.recovery_done': 'Your groups now have a recovery copy.',
+ 'settings.recovery_partial': 'These groups could not be reached. Open them later, or run this again:',
+ 'settings.recovery_need_relogin': 'Please sign out and back in, then try again.',
'settings.danger': 'Delete account',
'settings.delete_hint': 'Removes your account, your group memberships and your '
+ 'notifications from the hub, and frees your username. It cannot reach what '
@@ -604,6 +663,9 @@ export default {
'group.join_code_hint': 'Ask whoever invited you for the one-time code, and enter '
+ 'it here. After that this browser is recognised and you will not be asked again.',
'group.join_code_btn': 'Join',
+ 'group.pass_title': 'Enter your passphrase',
+ 'group.pass_hint': 'This browser has not unlocked your keys yet. Enter your passphrase once to open your groups here.',
+ 'group.pass_btn': 'Unlock',
'notif.purge': 'Clear all',
'notif.title': 'Notifications',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 640eb91..ab1829a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -28,6 +28,25 @@ export default {
'login.loading': 'Iniciando sesión...',
'login.no_account': '¿No tiene cuenta?',
'login.register_link': 'Crear una cuenta',
+ 'login.forgot': '¿Olvidó su frase de contraseña?',
+ 'reset.title': 'Restablecer la frase de contraseña',
+ 'reset.request_intro': 'Introduzca su nombre de usuario y el correo de la cuenta. Si coinciden, se envía un código de restablecimiento a esa dirección.',
+ 'reset.send_code': 'Enviar código',
+ 'reset.form_intro': 'Introduzca el código de su correo y elija una nueva frase de contraseña. Si tiene su clave de recuperación, añádala para restaurar también el acceso a sus grupos.',
+ 'reset.code': 'Código de restablecimiento',
+ 'reset.recovery_key': 'Clave de recuperación (opcional)',
+ 'reset.recovery_key_hint': 'Déjelo en blanco si no la tiene. Sin ella, su inicio de sesión se restaura, pero hay que volver a unirse a cada grupo.',
+ 'reset.new_pass': 'Nueva frase de contraseña',
+ 'reset.new_pass_repeat': 'Repetir la nueva frase de contraseña',
+ 'reset.submit': 'Restablecer la frase de contraseña',
+ 'reset.working': 'Restaurando sus identidades de grupo…',
+ 'reset.signin_restored': 'Su inicio de sesión se ha restaurado.',
+ 'reset.groups_restored': 'Sus identidades de grupo se restauraron en todos los nodos alcanzables.',
+ 'reset.no_recovery': 'Sus identidades de grupo no se restauraron. Para cada grupo, pida a su operador que le quite la fijación y le envíe un nuevo código de invitación, y vuelva a unirse.',
+ 'reset.needs_operator': 'Estos grupos aún requieren acción: pida a cada operador que le quite la fijación y le envíe un nuevo código:',
+ 'reset.go_app': 'Ir a la aplicación',
+ 'reset.back_to_login': 'Volver al inicio de sesión',
+ 'reset.err_unsupported': 'El restablecimiento de la frase de contraseña necesita el módulo de cifrado del navegador, que no está disponible aquí.',
// Register
'register.title': 'Crear una cuenta',
@@ -39,6 +58,16 @@ export default {
'register.loading': 'Creando la cuenta...',
'register.has_account': '¿Ya tiene una cuenta?',
'register.login_link': 'Iniciar sesión',
+ 'register.recovery_title': 'Guarde su clave de recuperación',
+ 'register.recovery_intro': 'Esta clave restaura sus identidades de grupo si alguna vez olvida su frase de contraseña. Guárdela en un gestor de contraseñas o en un lugar seguro: solo se muestra una vez y el hub nunca la ve.',
+ 'register.recovery_copy': 'Copiar',
+ 'register.recovery_copied': 'Copiado',
+ 'register.recovery_warning': 'Si pierde tanto su frase de contraseña como esta clave, sus archivos permanecen en sus nodos pero se vuelve a unir a cada grupo con una nueva identidad.',
+ 'register.recovery_saved': 'He guardado mi clave de recuperación en un lugar seguro.',
+ 'register.recovery_continue': 'Continuar',
+ 'register.recovery_email_opt': 'Enviarme también esta clave de recuperación por correo como copia de seguridad',
+ 'register.recovery_emailed': 'También se ha enviado una copia a su correo electrónico, en el mismo mensaje que su código de verificación.',
+ 'register.recovery_not_emailed': 'Esto no se envió por correo. Guárdela ahora: es la única vez que se muestra.',
'register.success_title': 'Verifique su correo',
'register.success_msg': 'Se ha enviado un código de verificación a su dirección de correo electrónico. Introdúzcalo a continuación para activar su cuenta.',
'register.go_login': 'Ir al inicio de sesión',
@@ -291,6 +320,36 @@ export default {
other: '{n} fijadas',
},
'settings.node_pins_clear': 'Borrar las identidades fijadas',
+ 'settings.passphrase': 'Frase de contraseña',
+ 'settings.passphrase_hint': 'Cambia la frase de contraseña de esta cuenta. Sus claves de identidad se vuelven a cifrar en cada grupo cuyo nodo esté en línea; un grupo cuyo nodo esté fuera de línea tendrá que volver a unirse después.',
+ 'settings.passphrase_change': 'Cambiar la frase de contraseña',
+ 'settings.passphrase_current': 'Frase de contraseña actual',
+ 'settings.passphrase_new': 'Nueva frase de contraseña',
+ 'settings.passphrase_new_repeat': 'Repetir la nueva frase de contraseña',
+ 'settings.continue': 'Continuar',
+ 'settings.done': 'Listo',
+ 'settings.passphrase_confirm_intro': 'Sus claves de identidad se volverán a cifrar ahora en cada grupo cuyo nodo se pueda alcanzar:',
+ 'settings.passphrase_reachable': 'Actualizado ahora',
+ 'settings.passphrase_unreachable': 'No se puede alcanzar',
+ 'settings.passphrase_fallback_note': 'Para un grupo que no se pueda alcanzar, pida a su operador que le quite la fijación y le envíe un nuevo código de invitación, y vuelva a unirse. Sus archivos y mensajes no se pierden.',
+ 'settings.passphrase_confirm_btn': 'Cambiarla',
+ 'settings.passphrase_working': 'Volviendo a cifrar sus claves…',
+ 'settings.passphrase_done': 'Frase de contraseña cambiada.',
+ 'settings.passphrase_needs_operator': 'Estos grupos aún requieren acción: pida a cada operador que le quite la fijación y le envíe un nuevo código:',
+ 'settings.pw_too_short': 'La nueva frase de contraseña debe tener al menos 12 caracteres.',
+ 'settings.pw_mismatch': 'Las dos nuevas frases de contraseña no coinciden.',
+ 'settings.pw_same': 'La nueva frase de contraseña debe ser distinta de la actual.',
+ 'settings.pw_wrong_current': 'La frase de contraseña actual no es correcta. No se cambió nada.',
+ 'settings.recovery': 'Clave de recuperación',
+ 'settings.recovery_hint': 'Añade a cada grupo una copia de su identidad cifrada con su clave de recuperación. Hágalo una vez en cada navegador que use; sin ella, una frase de contraseña perdida obliga a volver a unirse a cada grupo a mano.',
+ 'settings.recovery_loaded': 'Cargada en este navegador: los grupos nuevos quedan cubiertos automáticamente.',
+ 'settings.recovery_open': 'Introducir la clave de recuperación',
+ 'settings.recovery_input_ph': 'Pegue su clave de recuperación',
+ 'settings.recovery_submit': 'Añadir copias de seguridad',
+ 'settings.recovery_working': 'Actualizando sus grupos…',
+ 'settings.recovery_done': 'Sus grupos ya tienen una copia de recuperación.',
+ 'settings.recovery_partial': 'No se pudo contactar con estos grupos. Ábralos más tarde o vuelva a ejecutar esto:',
+ 'settings.recovery_need_relogin': 'Cierre sesión y vuelva a entrar, luego inténtelo de nuevo.',
'settings.danger': 'Eliminar la cuenta',
'settings.delete_hint': 'Elimina su cuenta, sus pertenencias a grupos y sus '
+ 'notificaciones del hub, y libera su nombre de usuario. No alcanza lo que vive '
@@ -523,6 +582,9 @@ export default {
+ 'introdúzcalo aquí. Después, este navegador queda reconocido y no se le volverá a '
+ 'preguntar.',
'group.join_code_btn': 'Unirse',
+ 'group.pass_title': 'Introduzca su frase de contraseña',
+ 'group.pass_hint': 'Este navegador aún no ha desbloqueado sus claves. Introduzca su frase de contraseña una vez para abrir sus grupos aquí.',
+ 'group.pass_btn': 'Desbloquear',
'notif.purge': 'Borrar todo',
'notif.title': 'Notificaciones',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index de6b7c8..bfa707c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -28,6 +28,25 @@ export default {
'login.loading': 'Connexion en cours...',
'login.no_account': 'Pas encore de compte ?',
'login.register_link': 'Créer un compte',
+ 'login.forgot': 'Phrase secrète oubliée ?',
+ 'reset.title': 'Réinitialiser la phrase secrète',
+ 'reset.request_intro': 'Saisissez votre nom d’utilisateur et l’e-mail du compte. S’ils correspondent, un code de réinitialisation est envoyé à cette adresse.',
+ 'reset.send_code': 'Envoyer le code',
+ 'reset.form_intro': 'Saisissez le code reçu par e-mail et choisissez une nouvelle phrase secrète. Si vous avez votre clé de récupération, ajoutez-la pour rétablir aussi l’accès à vos groupes.',
+ 'reset.code': 'Code de réinitialisation',
+ 'reset.recovery_key': 'Clé de récupération (facultatif)',
+ 'reset.recovery_key_hint': 'Laissez vide si vous ne l’avez pas. Sans elle, votre connexion est rétablie, mais chaque groupe devra être rejoint.',
+ 'reset.new_pass': 'Nouvelle phrase secrète',
+ 'reset.new_pass_repeat': 'Répéter la nouvelle phrase secrète',
+ 'reset.submit': 'Réinitialiser la phrase secrète',
+ 'reset.working': 'Restauration de vos identités de groupe…',
+ 'reset.signin_restored': 'Votre connexion a été rétablie.',
+ 'reset.groups_restored': 'Vos identités de groupe ont été restaurées sur chaque nœud joignable.',
+ 'reset.no_recovery': 'Vos identités de groupe n’ont pas été restaurées. Pour chaque groupe, demandez à son opérateur de vous désépingler et de vous envoyer un nouveau code d’invitation, puis rejoignez-le.',
+ 'reset.needs_operator': 'Ces groupes nécessitent encore une action — demandez à chaque opérateur de vous désépingler et de vous envoyer un nouveau code :',
+ 'reset.go_app': 'Aller à l’application',
+ 'reset.back_to_login': 'Retour à la connexion',
+ 'reset.err_unsupported': 'La réinitialisation de la phrase secrète nécessite le module de chiffrement du navigateur, indisponible ici.',
// Register
'register.title': 'Créer un compte',
@@ -39,6 +58,16 @@ export default {
'register.loading': 'Création du compte...',
'register.has_account': 'Vous avez déjà un compte ?',
'register.login_link': 'Se connecter',
+ 'register.recovery_title': 'Enregistrez votre clé de récupération',
+ 'register.recovery_intro': "Cette clé restaure vos identités de groupe si vous oubliez un jour votre phrase secrète. Conservez-la dans un gestionnaire de mots de passe ou en lieu sûr — elle n'est affichée qu'une seule fois, et le hub ne la voit jamais.",
+ 'register.recovery_copy': 'Copier',
+ 'register.recovery_copied': 'Copié',
+ 'register.recovery_warning': "Si vous perdez à la fois votre phrase secrète et cette clé, vos fichiers restent sur leurs nœuds mais vous rejoignez chaque groupe avec une nouvelle identité.",
+ 'register.recovery_saved': "J'ai enregistré ma clé de récupération en lieu sûr.",
+ 'register.recovery_continue': 'Continuer',
+ 'register.recovery_email_opt': 'M’envoyer aussi cette clé de récupération par e-mail comme sauvegarde',
+ 'register.recovery_emailed': 'Une copie a également été envoyée à votre adresse e-mail, dans le même message que votre code de vérification.',
+ 'register.recovery_not_emailed': 'Elle n’a pas été envoyée par e-mail. Enregistrez-la maintenant — c’est la seule fois où elle est affichée.',
'register.success_title': 'Vérifiez votre e-mail',
'register.success_msg': 'Un code de vérification a été envoyé à votre adresse e-mail. Saisissez-le ci-dessous pour activer votre compte.',
'register.go_login': 'Aller à la connexion',
@@ -293,6 +322,36 @@ export default {
other: '{n} épinglées',
},
'settings.node_pins_clear': 'Effacer les identités épinglées',
+ 'settings.passphrase': 'Phrase secrète',
+ 'settings.passphrase_hint': "Change la phrase secrète de ce compte. Vos clés d'identité sont re-chiffrées sur chaque groupe dont le nœud est en ligne ; un groupe dont le nœud est hors ligne devra être rejoint ensuite.",
+ 'settings.passphrase_change': 'Changer la phrase secrète',
+ 'settings.passphrase_current': 'Phrase secrète actuelle',
+ 'settings.passphrase_new': 'Nouvelle phrase secrète',
+ 'settings.passphrase_new_repeat': 'Répéter la nouvelle phrase secrète',
+ 'settings.continue': 'Continuer',
+ 'settings.done': 'Terminé',
+ 'settings.passphrase_confirm_intro': "Vos clés d'identité vont être re-chiffrées maintenant sur chaque groupe dont le nœud est joignable :",
+ 'settings.passphrase_reachable': 'Mis à jour maintenant',
+ 'settings.passphrase_unreachable': 'Injoignable',
+ 'settings.passphrase_fallback_note': "Pour un groupe injoignable, demandez à son opérateur de vous désépingler et de vous envoyer un nouveau code d'invitation, puis rejoignez-le. Vos fichiers et messages ne sont pas perdus.",
+ 'settings.passphrase_confirm_btn': 'Changer',
+ 'settings.passphrase_working': 'Re-chiffrement de vos clés…',
+ 'settings.passphrase_done': 'Phrase secrète changée.',
+ 'settings.passphrase_needs_operator': 'Ces groupes nécessitent encore une action — demandez à chaque opérateur de vous désépingler et de vous envoyer un nouveau code :',
+ 'settings.pw_too_short': 'La nouvelle phrase secrète doit comporter au moins 12 caractères.',
+ 'settings.pw_mismatch': 'Les deux nouvelles phrases secrètes ne correspondent pas.',
+ 'settings.pw_same': "La nouvelle phrase secrète doit être différente de l'actuelle.",
+ 'settings.pw_wrong_current': "La phrase secrète actuelle est incorrecte. Rien n'a été changé.",
+ 'settings.recovery': 'Clé de récupération',
+ 'settings.recovery_hint': "Ajoute à chaque groupe une copie de votre identité chiffrée avec votre clé de récupération. À faire une fois dans chaque navigateur que vous utilisez ; sans cela, une phrase secrète perdue oblige à rejoindre chaque groupe à la main.",
+ 'settings.recovery_loaded': 'Chargée dans ce navigateur — les nouveaux groupes sont couverts automatiquement.',
+ 'settings.recovery_open': 'Saisir la clé de récupération',
+ 'settings.recovery_input_ph': 'Collez votre clé de récupération',
+ 'settings.recovery_submit': 'Ajouter les copies de secours',
+ 'settings.recovery_working': 'Mise à jour de vos groupes…',
+ 'settings.recovery_done': 'Vos groupes ont maintenant une copie de récupération.',
+ 'settings.recovery_partial': 'Ces groupes n’ont pas pu être joints. Ouvrez-les plus tard, ou relancez l’opération :',
+ 'settings.recovery_need_relogin': 'Veuillez vous déconnecter puis vous reconnecter, et réessayer.',
'settings.danger': 'Supprimer le compte',
'settings.delete_hint': 'Supprime votre compte, vos adhésions aux groupes et vos '
+ 'notifications du hub, et libère votre nom d’utilisateur. Cela n’atteint pas '
@@ -526,6 +585,9 @@ export default {
+ 'invité, et saisissez-le ici. Ensuite, ce navigateur est reconnu et la question '
+ 'ne vous sera plus posée.',
'group.join_code_btn': 'Rejoindre',
+ 'group.pass_title': 'Saisissez votre phrase secrète',
+ 'group.pass_hint': 'Ce navigateur n’a pas encore déverrouillé vos clés. Saisissez votre phrase secrète une fois pour ouvrir vos groupes ici.',
+ 'group.pass_btn': 'Déverrouiller',
'notif.purge': 'Tout effacer',
'notif.title': 'Notifications',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 52f6279..0f92692 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -29,6 +29,25 @@ export default {
'login.loading': 'Accesso in corso...',
'login.no_account': 'Non ha ancora un account?',
'login.register_link': 'Crea un account',
+ 'login.forgot': 'Ha dimenticato la passphrase?',
+ 'reset.title': 'Reimposta la passphrase',
+ 'reset.request_intro': 'Inserisca il suo nome utente e l’e-mail dell’account. Se corrispondono, un codice di reimpostazione viene inviato a quell’indirizzo.',
+ 'reset.send_code': 'Invia codice',
+ 'reset.form_intro': 'Inserisca il codice ricevuto per e-mail e scelga una nuova passphrase. Se ha la sua chiave di recupero, la aggiunga per ripristinare anche l’accesso ai suoi gruppi.',
+ 'reset.code': 'Codice di reimpostazione',
+ 'reset.recovery_key': 'Chiave di recupero (facoltativa)',
+ 'reset.recovery_key_hint': 'La lasci vuota se non la possiede. Senza di essa il suo accesso viene ripristinato, ma dovrà riunirsi a ogni gruppo.',
+ 'reset.new_pass': 'Nuova passphrase',
+ 'reset.new_pass_repeat': 'Ripeti la nuova passphrase',
+ 'reset.submit': 'Reimposta la passphrase',
+ 'reset.working': 'Ripristino delle sue identità di gruppo…',
+ 'reset.signin_restored': 'Il suo accesso è stato ripristinato.',
+ 'reset.groups_restored': 'Le sue identità di gruppo sono state ripristinate su ogni nodo raggiungibile.',
+ 'reset.no_recovery': 'Le sue identità di gruppo non sono state ripristinate. Per ogni gruppo, chieda al suo operatore di rimuovere il suo pin e di inviarle un nuovo codice di invito, poi si riunisca.',
+ 'reset.needs_operator': 'Questi gruppi richiedono ancora un intervento — chieda a ogni operatore di rimuovere il suo pin e di inviarle un nuovo codice:',
+ 'reset.go_app': 'Vai all’app',
+ 'reset.back_to_login': 'Torna all’accesso',
+ 'reset.err_unsupported': 'La reimpostazione della passphrase richiede il modulo di cifratura del browser, non disponibile qui.',
// Register
'register.title': 'Crea un account',
@@ -40,6 +59,16 @@ export default {
'register.loading': "Creazione dell'account...",
'register.has_account': 'Ha già un account?',
'register.login_link': 'Accedi',
+ 'register.recovery_title': 'Salvi la sua chiave di recupero',
+ 'register.recovery_intro': 'Questa chiave ripristina le sue identità di gruppo se dovesse dimenticare la passphrase. La conservi in un gestore di password o in un luogo sicuro: viene mostrata una sola volta e il hub non la vede mai.',
+ 'register.recovery_copy': 'Copia',
+ 'register.recovery_copied': 'Copiato',
+ 'register.recovery_warning': 'Se perde sia la passphrase sia questa chiave, i suoi file restano sui rispettivi nodi ma si riunisce a ogni gruppo con una nuova identità.',
+ 'register.recovery_saved': 'Ho salvato la mia chiave di recupero in un luogo sicuro.',
+ 'register.recovery_continue': 'Continua',
+ 'register.recovery_email_opt': 'Inviami anche questa chiave di recupero via e-mail come backup',
+ 'register.recovery_emailed': 'Una copia è stata inviata anche alla sua e-mail, nello stesso messaggio del codice di verifica.',
+ 'register.recovery_not_emailed': 'Non è stata inviata via e-mail. La salvi ora: è l’unica volta che viene mostrata.',
'register.success_title': 'Verifichi la sua e-mail',
'register.success_msg': 'Un codice di verifica è stato inviato al suo indirizzo e-mail. Lo inserisca qui sotto per attivare il suo account.',
'register.go_login': "Vai all'accesso",
@@ -293,6 +322,36 @@ export default {
other: '{n} fissate',
},
'settings.node_pins_clear': 'Cancella le identità fissate',
+ 'settings.passphrase': 'Passphrase',
+ 'settings.passphrase_hint': "Cambia la passphrase di questo account. Le tue chiavi di identità vengono ricodificate su ogni gruppo il cui nodo è online; a un gruppo il cui nodo è offline dovrai riunirti in seguito.",
+ 'settings.passphrase_change': 'Cambia la passphrase',
+ 'settings.passphrase_current': 'Passphrase attuale',
+ 'settings.passphrase_new': 'Nuova passphrase',
+ 'settings.passphrase_new_repeat': 'Ripeti la nuova passphrase',
+ 'settings.continue': 'Continua',
+ 'settings.done': 'Fatto',
+ 'settings.passphrase_confirm_intro': 'Le tue chiavi di identità verranno ora ricodificate su ogni gruppo il cui nodo è raggiungibile:',
+ 'settings.passphrase_reachable': 'Aggiornato ora',
+ 'settings.passphrase_unreachable': 'Non raggiungibile',
+ 'settings.passphrase_fallback_note': "Per un gruppo non raggiungibile, chiedi al suo operatore di rimuovere il tuo pin e di inviarti un nuovo codice di invito, poi riunisciti. I tuoi file e messaggi non vanno persi.",
+ 'settings.passphrase_confirm_btn': 'Cambia',
+ 'settings.passphrase_working': 'Ricodifica delle tue chiavi in corso…',
+ 'settings.passphrase_done': 'Passphrase cambiata.',
+ 'settings.passphrase_needs_operator': 'Questi gruppi richiedono ancora un intervento — chiedi a ogni operatore di rimuovere il tuo pin e di inviarti un nuovo codice:',
+ 'settings.pw_too_short': 'La nuova passphrase deve contenere almeno 12 caratteri.',
+ 'settings.pw_mismatch': 'Le due nuove passphrase non corrispondono.',
+ 'settings.pw_same': 'La nuova passphrase deve essere diversa da quella attuale.',
+ 'settings.pw_wrong_current': 'La passphrase attuale non è corretta. Non è stato cambiato nulla.',
+ 'settings.recovery': 'Chiave di recupero',
+ 'settings.recovery_hint': "Aggiunge a ogni gruppo una copia della sua identità cifrata con la sua chiave di recupero. Lo faccia una volta in ogni browser che usa; senza di essa, una passphrase persa costringe a riunirsi a ogni gruppo a mano.",
+ 'settings.recovery_loaded': 'Caricata in questo browser — i nuovi gruppi sono coperti automaticamente.',
+ 'settings.recovery_open': 'Inserire la chiave di recupero',
+ 'settings.recovery_input_ph': 'Incolli la sua chiave di recupero',
+ 'settings.recovery_submit': 'Aggiungi le copie di backup',
+ 'settings.recovery_working': 'Aggiornamento dei suoi gruppi…',
+ 'settings.recovery_done': 'I suoi gruppi ora hanno una copia di recupero.',
+ 'settings.recovery_partial': 'Non è stato possibile raggiungere questi gruppi. Li apra più tardi, o riesegua l’operazione:',
+ 'settings.recovery_need_relogin': 'Esca e rientri, poi riprovi.',
'settings.danger': "Elimina l'account",
'settings.delete_hint': "Rimuove il suo account, le sue adesioni ai gruppi e le sue "
+ "notifiche dall'hub, e libera il suo nome utente. Non arriva a ciò che si trova "
@@ -525,6 +584,9 @@ export default {
'group.join_code_hint': 'Chieda il codice monouso a chi l’ha invitata e lo inserisca '
+ 'qui. Dopodiché questo browser è riconosciuto e non le verrà più chiesto.',
'group.join_code_btn': 'Partecipa',
+ 'group.pass_title': 'Inserisca la sua passphrase',
+ 'group.pass_hint': 'Questo browser non ha ancora sbloccato le sue chiavi. Inserisca la sua passphrase una volta per aprire i suoi gruppi qui.',
+ 'group.pass_btn': 'Sblocca',
'notif.purge': 'Cancella tutto',
'notif.title': 'Notifiche',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 75880fa..1e5a9f5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -29,6 +29,25 @@ export default {
'login.loading': 'ログインしています…',
'login.no_account': 'アカウントをお持ちでないですか?',
'login.register_link': 'アカウントを作成',
+ 'login.forgot': 'パスフレーズをお忘れですか?',
+ 'reset.title': 'パスフレーズをリセット',
+ 'reset.request_intro': 'ユーザー名とアカウントのメールアドレスを入力してください。両方が一致する場合、そのアドレスにリセットコードを送信します。',
+ 'reset.send_code': 'コードを送信',
+ 'reset.form_intro': 'メールで届いたコードを入力し、新しいパスフレーズを選んでください。リカバリーキーをお持ちの場合は入力すると、グループへのアクセスも復元されます。',
+ 'reset.code': 'リセットコード',
+ 'reset.recovery_key': 'リカバリーキー(任意)',
+ 'reset.recovery_key_hint': 'お持ちでない場合は空欄のままにしてください。なしの場合、サインインは復元されますが、各グループには再参加が必要です。',
+ 'reset.new_pass': '新しいパスフレーズ',
+ 'reset.new_pass_repeat': '新しいパスフレーズをもう一度入力',
+ 'reset.submit': 'パスフレーズをリセット',
+ 'reset.working': 'グループの identity を復元しています…',
+ 'reset.signin_restored': 'サインインが復元されました。',
+ 'reset.groups_restored': '到達できたすべてのノードで、グループの identity が復元されました。',
+ 'reset.no_recovery': 'グループの identity は復元されませんでした。各グループについて、その運用者に固定の解除と新しい招待コードの送付を依頼し、再参加してください。',
+ 'reset.needs_operator': 'これらのグループにはまだ対応が必要です。各運用者に固定の解除と新しいコードの送付を依頼してください:',
+ 'reset.go_app': 'アプリへ',
+ 'reset.back_to_login': 'サインインに戻る',
+ 'reset.err_unsupported': 'パスフレーズのリセットにはブラウザの暗号モジュールが必要ですが、ここでは利用できません。',
// Register
'register.title': 'アカウントを作成',
@@ -40,6 +59,16 @@ export default {
'register.loading': 'アカウントを作成しています…',
'register.has_account': 'すでにアカウントをお持ちですか?',
'register.login_link': 'ログイン',
+ 'register.recovery_title': 'リカバリーキーを保存してください',
+ 'register.recovery_intro': 'このキーは、パスフレーズを忘れた場合にグループの identity を復元します。パスワードマネージャーや安全な場所に保管してください。表示されるのは一度だけで、ハブがこれを見ることはありません。',
+ 'register.recovery_copy': 'コピー',
+ 'register.recovery_copied': 'コピーしました',
+ 'register.recovery_warning': 'パスフレーズとこのキーの両方を失うと、ファイルは各ノードに残りますが、各グループには新しい identity で再参加することになります。',
+ 'register.recovery_saved': 'リカバリーキーを安全な場所に保存しました。',
+ 'register.recovery_continue': '続ける',
+ 'register.recovery_email_opt': 'バックアップとして、このリカバリーキーもメールで送る',
+ 'register.recovery_emailed': '確認コードと同じメールで、コピーもお使いのメールアドレスに送信されました。',
+ 'register.recovery_not_emailed': 'これはメールで送信されていません。今すぐ保存してください。表示されるのはこの一度だけです。',
'register.success_title': 'メールアドレスを確認してください',
'register.success_msg': '確認コードをメールアドレスに送信しました。以下に入力してアカウントを有効にしてください。',
'register.go_login': 'ログインへ進む',
@@ -288,6 +317,36 @@ export default {
other: '{n} 件を固定中',
},
'settings.node_pins_clear': '固定した識別情報を消去',
+ 'settings.passphrase': 'パスフレーズ',
+ 'settings.passphrase_hint': 'このアカウントのパスフレーズを変更します。ノードがオンラインのすべてのグループで identity キーが再ラップされます。ノードがオフラインのグループには、あとで再参加する必要があります。',
+ 'settings.passphrase_change': 'パスフレーズを変更',
+ 'settings.passphrase_current': '現在のパスフレーズ',
+ 'settings.passphrase_new': '新しいパスフレーズ',
+ 'settings.passphrase_new_repeat': '新しいパスフレーズをもう一度入力',
+ 'settings.continue': '続ける',
+ 'settings.done': '完了',
+ 'settings.passphrase_confirm_intro': '到達できるノードを持つすべてのグループで、identity キーが今すぐ再ラップされます:',
+ 'settings.passphrase_reachable': '今すぐ更新されます',
+ 'settings.passphrase_unreachable': '到達できません',
+ 'settings.passphrase_fallback_note': '到達できないグループについては、その運用者に固定の解除と新しい招待コードの送付を依頼し、再参加してください。ファイルとメッセージは失われません。',
+ 'settings.passphrase_confirm_btn': '変更する',
+ 'settings.passphrase_working': 'キーを再ラップしています…',
+ 'settings.passphrase_done': 'パスフレーズを変更しました。',
+ 'settings.passphrase_needs_operator': 'これらのグループにはまだ対応が必要です。各運用者に固定の解除と新しいコードの送付を依頼してください:',
+ 'settings.pw_too_short': '新しいパスフレーズは 12 文字以上にしてください。',
+ 'settings.pw_mismatch': '2 つの新しいパスフレーズが一致しません。',
+ 'settings.pw_same': '新しいパスフレーズは現在のものと異なる必要があります。',
+ 'settings.pw_wrong_current': '現在のパスフレーズが正しくありません。何も変更されていません。',
+ 'settings.recovery': 'リカバリーキー',
+ 'settings.recovery_hint': 'リカバリーキーで暗号化した identity のコピーを、すべてのグループに追加します。使用する各ブラウザで一度実行してください。これがないと、パスフレーズを失った場合は各グループに手作業で再参加することになります。',
+ 'settings.recovery_loaded': 'このブラウザに読み込み済み — 新しいグループは自動的に対象になります。',
+ 'settings.recovery_open': 'リカバリーキーを入力',
+ 'settings.recovery_input_ph': 'リカバリーキーを貼り付け',
+ 'settings.recovery_submit': 'バックアップコピーを追加',
+ 'settings.recovery_working': 'グループを更新しています…',
+ 'settings.recovery_done': 'グループにリカバリーコピーが作成されました。',
+ 'settings.recovery_partial': 'これらのグループに到達できませんでした。あとで開くか、もう一度実行してください:',
+ 'settings.recovery_need_relogin': '一度サインアウトして再度サインインしてから、もう一度お試しください。',
'settings.danger': 'アカウントを削除',
'settings.delete_hint': 'hub から、お客様のアカウント、グループへの参加、通知を'
+ '削除し、ユーザー名を解放します。node 上にあるものには及びません。'
@@ -514,6 +573,9 @@ export default {
'group.join_code_hint': '招待してくれた方にワンタイムコードを聞いて、ここに入力してください。'
+ 'それ以降このブラウザーは認識され、再び尋ねられることはありません。',
'group.join_code_btn': '参加',
+ 'group.pass_title': 'パスフレーズを入力してください',
+ 'group.pass_hint': 'このブラウザーではまだキーのロックが解除されていません。パスフレーズを一度入力して、ここでグループを開いてください。',
+ 'group.pass_btn': 'ロック解除',
'notif.purge': 'すべて消去',
'notif.title': '通知',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 5b72314..3cd34c3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -29,6 +29,25 @@ export default {
'login.loading': 'Bezig met aanmelden...',
'login.no_account': 'Nog geen account?',
'login.register_link': 'Account aanmaken',
+ 'login.forgot': 'Wachtwoordzin vergeten?',
+ 'reset.title': 'Wachtwoordzin opnieuw instellen',
+ 'reset.request_intro': 'Voer uw gebruikersnaam en het e-mailadres van het account in. Als ze overeenkomen, wordt een herstelcode naar dat adres gestuurd.',
+ 'reset.send_code': 'Code versturen',
+ 'reset.form_intro': 'Voer de code uit uw e-mail in en kies een nieuwe wachtwoordzin. Als u uw herstelsleutel hebt, voeg die toe om ook de toegang tot uw groepen te herstellen.',
+ 'reset.code': 'Herstelcode',
+ 'reset.recovery_key': 'Herstelsleutel (optioneel)',
+ 'reset.recovery_key_hint': 'Laat leeg als u die niet hebt. Zonder de sleutel wordt uw aanmelding hersteld, maar moet u tot elke groep opnieuw toetreden.',
+ 'reset.new_pass': 'Nieuwe wachtwoordzin',
+ 'reset.new_pass_repeat': 'Nieuwe wachtwoordzin herhalen',
+ 'reset.submit': 'Wachtwoordzin opnieuw instellen',
+ 'reset.working': 'Uw groepsidentiteiten worden hersteld…',
+ 'reset.signin_restored': 'Uw aanmelding is hersteld.',
+ 'reset.groups_restored': 'Uw groepsidentiteiten zijn hersteld op elke bereikbare node.',
+ 'reset.no_recovery': 'Uw groepsidentiteiten zijn niet hersteld. Vraag voor elke groep de operator ervan om u los te maken en een nieuwe uitnodigingscode te sturen, en treed dan opnieuw toe.',
+ 'reset.needs_operator': 'Deze groepen vereisen nog actie — vraag elke operator om u los te maken en een nieuwe code te sturen:',
+ 'reset.go_app': 'Naar de app',
+ 'reset.back_to_login': 'Terug naar aanmelden',
+ 'reset.err_unsupported': 'Het opnieuw instellen van de wachtwoordzin vereist de crypto-module van de browser, die hier niet beschikbaar is.',
// Register
'register.title': 'Account aanmaken',
@@ -40,6 +59,16 @@ export default {
'register.loading': 'Account wordt aangemaakt...',
'register.has_account': 'Hebt u al een account?',
'register.login_link': 'Aanmelden',
+ 'register.recovery_title': 'Bewaar uw herstelsleutel',
+ 'register.recovery_intro': 'Deze sleutel herstelt uw groepsidentiteiten als u ooit uw wachtwoordzin vergeet. Bewaar hem in een wachtwoordmanager of op een veilige plek — hij wordt maar één keer getoond en de hub ziet hem nooit.',
+ 'register.recovery_copy': 'Kopiëren',
+ 'register.recovery_copied': 'Gekopieerd',
+ 'register.recovery_warning': 'Als u zowel uw wachtwoordzin als deze sleutel verliest, blijven uw bestanden op hun nodes staan, maar treedt u tot elke groep opnieuw toe met een nieuwe identiteit.',
+ 'register.recovery_saved': 'Ik heb mijn herstelsleutel op een veilige plek bewaard.',
+ 'register.recovery_continue': 'Doorgaan',
+ 'register.recovery_email_opt': 'Stuur deze herstelsleutel ook naar mij per e-mail als back-up',
+ 'register.recovery_emailed': 'Er is ook een kopie naar uw e-mailadres gestuurd, in hetzelfde bericht als uw verificatiecode.',
+ 'register.recovery_not_emailed': 'Dit is niet per e-mail verstuurd. Sla het nu op — dit is de enige keer dat het wordt getoond.',
'register.success_title': 'Verifieer uw e-mail',
'register.success_msg': 'Er is een verificatiecode naar uw e-mailadres gestuurd. Voer deze hieronder in om uw account te activeren.',
'register.go_login': 'Naar het aanmelden',
@@ -294,6 +323,36 @@ export default {
other: '{n} vastgezet',
},
'settings.node_pins_clear': 'Vastgezette identiteiten wissen',
+ 'settings.passphrase': 'Wachtwoordzin',
+ 'settings.passphrase_hint': 'Wijzigt de wachtwoordzin van dit account. Uw identiteitssleutels worden opnieuw verpakt op elke groep waarvan de node online is; een groep waarvan de node offline is, moet daarna opnieuw worden betreden.',
+ 'settings.passphrase_change': 'Wachtwoordzin wijzigen',
+ 'settings.passphrase_current': 'Huidige wachtwoordzin',
+ 'settings.passphrase_new': 'Nieuwe wachtwoordzin',
+ 'settings.passphrase_new_repeat': 'Nieuwe wachtwoordzin herhalen',
+ 'settings.continue': 'Doorgaan',
+ 'settings.done': 'Klaar',
+ 'settings.passphrase_confirm_intro': 'Uw identiteitssleutels worden nu opnieuw verpakt op elke groep waarvan de node bereikbaar is:',
+ 'settings.passphrase_reachable': 'Nu bijgewerkt',
+ 'settings.passphrase_unreachable': 'Niet bereikbaar',
+ 'settings.passphrase_fallback_note': 'Vraag voor een onbereikbare groep de operator ervan om u los te maken en een nieuwe uitnodigingscode te sturen, en treed dan opnieuw toe. Uw bestanden en berichten gaan niet verloren.',
+ 'settings.passphrase_confirm_btn': 'Wijzigen',
+ 'settings.passphrase_working': 'Uw sleutels worden opnieuw verpakt…',
+ 'settings.passphrase_done': 'Wachtwoordzin gewijzigd.',
+ 'settings.passphrase_needs_operator': 'Deze groepen vereisen nog actie — vraag elke operator om u los te maken en een nieuwe code te sturen:',
+ 'settings.pw_too_short': 'De nieuwe wachtwoordzin moet minstens 12 tekens lang zijn.',
+ 'settings.pw_mismatch': 'De twee nieuwe wachtwoordzinnen komen niet overeen.',
+ 'settings.pw_same': 'De nieuwe wachtwoordzin moet verschillen van de huidige.',
+ 'settings.pw_wrong_current': 'De huidige wachtwoordzin is niet juist. Er is niets gewijzigd.',
+ 'settings.recovery': 'Herstelsleutel',
+ 'settings.recovery_hint': 'Voegt aan elke groep een met uw herstelsleutel versleutelde kopie van uw identiteit toe. Doe dit één keer in elke browser die u gebruikt; zonder deze kopie betekent een verloren wachtwoordzin dat u elke groep handmatig opnieuw moet betreden.',
+ 'settings.recovery_loaded': 'Geladen in deze browser — nieuwe groepen zijn automatisch gedekt.',
+ 'settings.recovery_open': 'Herstelsleutel invoeren',
+ 'settings.recovery_input_ph': 'Plak uw herstelsleutel',
+ 'settings.recovery_submit': 'Back-upkopieën toevoegen',
+ 'settings.recovery_working': 'Uw groepen worden bijgewerkt…',
+ 'settings.recovery_done': 'Uw groepen hebben nu een herstelkopie.',
+ 'settings.recovery_partial': 'Deze groepen konden niet worden bereikt. Open ze later, of voer dit opnieuw uit:',
+ 'settings.recovery_need_relogin': 'Meld u af en weer aan en probeer het opnieuw.',
'settings.danger': 'Account verwijderen',
'settings.delete_hint': 'Verwijdert uw account, uw groepslidmaatschappen en uw '
+ 'meldingen van de hub, en geeft uw gebruikersnaam vrij. Wat op de nodes staat, '
@@ -527,6 +586,9 @@ export default {
+ 'voer die hier in. Daarna is deze browser bekend en wordt het u niet opnieuw '
+ 'gevraagd.',
'group.join_code_btn': 'Deelnemen',
+ 'group.pass_title': 'Voer uw wachtwoordzin in',
+ 'group.pass_hint': 'Deze browser heeft uw sleutels nog niet ontgrendeld. Voer uw wachtwoordzin één keer in om uw groepen hier te openen.',
+ 'group.pass_btn': 'Ontgrendelen',
'notif.purge': 'Alles wissen',
'notif.title': 'Meldingen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index bb2361e..10fe306 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -32,6 +32,25 @@ export default {
'login.loading': 'Logowanie...',
'login.no_account': 'Nie ma jeszcze konta?',
'login.register_link': 'Załóż konto',
+ 'login.forgot': 'Nie pamiętasz hasła-frazy?',
+ 'reset.title': 'Zresetuj hasło-frazę',
+ 'reset.request_intro': 'Wpisz swoją nazwę użytkownika i adres e-mail konta. Jeśli się zgadzają, kod resetowania zostanie wysłany na ten adres.',
+ 'reset.send_code': 'Wyślij kod',
+ 'reset.form_intro': 'Wpisz kod z e-maila i wybierz nową hasło-frazę. Jeśli masz klucz odzyskiwania, dodaj go, aby przywrócić też dostęp do swoich grup.',
+ 'reset.code': 'Kod resetowania',
+ 'reset.recovery_key': 'Klucz odzyskiwania (opcjonalnie)',
+ 'reset.recovery_key_hint': 'Pozostaw puste, jeśli go nie masz. Bez niego Twoje logowanie zostanie przywrócone, ale do każdej grupy trzeba będzie dołączyć ponownie.',
+ 'reset.new_pass': 'Nowa hasło-fraza',
+ 'reset.new_pass_repeat': 'Powtórz nową hasło-frazę',
+ 'reset.submit': 'Zresetuj hasło-frazę',
+ 'reset.working': 'Przywracanie Twoich tożsamości grupowych…',
+ 'reset.signin_restored': 'Twoje logowanie zostało przywrócone.',
+ 'reset.groups_restored': 'Twoje tożsamości grupowe zostały przywrócone na każdym osiągalnym węźle.',
+ 'reset.no_recovery': 'Twoje tożsamości grupowe nie zostały przywrócone. W przypadku każdej grupy poproś jej operatora o odpięcie Cię i przesłanie nowego kodu zaproszenia, a następnie dołącz ponownie.',
+ 'reset.needs_operator': 'Te grupy nadal wymagają działania — poproś każdego operatora o odpięcie Cię i przesłanie nowego kodu:',
+ 'reset.go_app': 'Przejdź do aplikacji',
+ 'reset.back_to_login': 'Powrót do logowania',
+ 'reset.err_unsupported': 'Resetowanie hasła-frazy wymaga modułu kryptograficznego przeglądarki, który jest tu niedostępny.',
// Register
'register.title': 'Zakładanie konta',
@@ -43,6 +62,16 @@ export default {
'register.loading': 'Tworzenie konta...',
'register.has_account': 'Konto już istnieje?',
'register.login_link': 'Zaloguj',
+ 'register.recovery_title': 'Zapisz swój klucz odzyskiwania',
+ 'register.recovery_intro': 'Ten klucz przywraca Twoje tożsamości grupowe, jeśli kiedykolwiek zapomnisz hasło-frazę. Przechowuj go w menedżerze haseł lub w bezpiecznym miejscu — jest pokazywany tylko raz, a hub nigdy go nie widzi.',
+ 'register.recovery_copy': 'Kopiuj',
+ 'register.recovery_copied': 'Skopiowano',
+ 'register.recovery_warning': 'Jeśli stracisz zarówno hasło-frazę, jak i ten klucz, Twoje pliki pozostaną na swoich węzłach, ale do każdej grupy dołączysz ponownie z nową tożsamością.',
+ 'register.recovery_saved': 'Zapisałem/-am mój klucz odzyskiwania w bezpiecznym miejscu.',
+ 'register.recovery_continue': 'Kontynuuj',
+ 'register.recovery_email_opt': 'Wyślij mi też ten klucz odzyskiwania e-mailem jako kopię zapasową',
+ 'register.recovery_emailed': 'Kopia została również wysłana na Twój adres e-mail, w tej samej wiadomości co kod weryfikacyjny.',
+ 'register.recovery_not_emailed': 'To nie zostało wysłane e-mailem. Zapisz teraz — jest pokazywane tylko ten jeden raz.',
'register.success_title': 'Zweryfikuj swój e-mail',
'register.success_msg': 'Kod weryfikacyjny został wysłany na Twój adres e-mail. Wpisz go poniżej, aby aktywować konto.',
'register.go_login': 'Przejdź do logowania',
@@ -307,6 +336,36 @@ export default {
other: '{n} przypiętej',
},
'settings.node_pins_clear': 'Wyczyść przypięte tożsamości',
+ 'settings.passphrase': 'Hasło-fraza',
+ 'settings.passphrase_hint': 'Zmienia hasło-frazę tego konta. Twoje klucze tożsamości są ponownie pakowane w każdej grupie, której węzeł jest online; do grupy, której węzeł jest offline, trzeba będzie dołączyć ponownie później.',
+ 'settings.passphrase_change': 'Zmień hasło-frazę',
+ 'settings.passphrase_current': 'Bieżąca hasło-fraza',
+ 'settings.passphrase_new': 'Nowa hasło-fraza',
+ 'settings.passphrase_new_repeat': 'Powtórz nową hasło-frazę',
+ 'settings.continue': 'Kontynuuj',
+ 'settings.done': 'Gotowe',
+ 'settings.passphrase_confirm_intro': 'Twoje klucze tożsamości zostaną teraz ponownie spakowane w każdej grupie, której węzeł jest osiągalny:',
+ 'settings.passphrase_reachable': 'Zaktualizowano teraz',
+ 'settings.passphrase_unreachable': 'Nieosiągalne',
+ 'settings.passphrase_fallback_note': 'W przypadku grupy, której nie można osiągnąć, poproś jej operatora o odpięcie Cię i przesłanie nowego kodu zaproszenia, a następnie dołącz ponownie. Twoje pliki i wiadomości nie zostaną utracone.',
+ 'settings.passphrase_confirm_btn': 'Zmień',
+ 'settings.passphrase_working': 'Ponowne pakowanie kluczy…',
+ 'settings.passphrase_done': 'Hasło-fraza zmieniona.',
+ 'settings.passphrase_needs_operator': 'Te grupy nadal wymagają działania — poproś każdego operatora o odpięcie Cię i przesłanie nowego kodu:',
+ 'settings.pw_too_short': 'Nowa hasło-fraza musi mieć co najmniej 12 znaków.',
+ 'settings.pw_mismatch': 'Obie nowe hasło-frazy nie są zgodne.',
+ 'settings.pw_same': 'Nowa hasło-fraza musi różnić się od bieżącej.',
+ 'settings.pw_wrong_current': 'Bieżąca hasło-fraza jest nieprawidłowa. Nic nie zostało zmienione.',
+ 'settings.recovery': 'Klucz odzyskiwania',
+ 'settings.recovery_hint': 'Dodaje do każdej grupy kopię Twojej tożsamości zaszyfrowaną Twoim kluczem odzyskiwania. Zrób to raz w każdej używanej przeglądarce; bez tego utrata hasła-frazy oznacza ponowne dołączanie do każdej grupy ręcznie.',
+ 'settings.recovery_loaded': 'Wczytany w tej przeglądarce — nowe grupy są objęte automatycznie.',
+ 'settings.recovery_open': 'Wprowadź klucz odzyskiwania',
+ 'settings.recovery_input_ph': 'Wklej swój klucz odzyskiwania',
+ 'settings.recovery_submit': 'Dodaj kopie zapasowe',
+ 'settings.recovery_working': 'Aktualizowanie Twoich grup…',
+ 'settings.recovery_done': 'Twoje grupy mają teraz kopię odzyskiwania.',
+ 'settings.recovery_partial': 'Nie udało się połączyć z tymi grupami. Otwórz je później lub uruchom to ponownie:',
+ 'settings.recovery_need_relogin': 'Wyloguj się i zaloguj ponownie, a następnie spróbuj jeszcze raz.',
'settings.danger': 'Usunięcie konta',
'settings.delete_hint': 'Usuwa z huba konto, członkostwa w grupach i powiadomienia '
+ 'oraz zwalnia nazwę użytkownika. Nie sięga tego, co znajduje się na nodes: '
@@ -543,6 +602,9 @@ export default {
+ 'wpisać go tutaj. Od tej pory przeglądarka jest rozpoznawana i pytanie się nie '
+ 'powtórzy.',
'group.join_code_btn': 'Dołącz',
+ 'group.pass_title': 'Wprowadź swoją hasło-frazę',
+ 'group.pass_hint': 'Ta przeglądarka nie odblokowała jeszcze Twoich kluczy. Wprowadź hasło-frazę raz, aby otworzyć tutaj swoje grupy.',
+ 'group.pass_btn': 'Odblokuj',
'notif.purge': 'Wyczyść wszystko',
'notif.title': 'Powiadomienia',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 3fbbb0b..9d69b85 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -30,6 +30,25 @@ export default {
'login.loading': 'Entrando...',
'login.no_account': 'Ainda não tem conta?',
'login.register_link': 'Criar uma conta',
+ 'login.forgot': 'Esqueceu sua frase secreta?',
+ 'reset.title': 'Redefinir a frase secreta',
+ 'reset.request_intro': 'Digite seu nome de usuário e o e-mail da conta. Se coincidirem, um código de redefinição é enviado para esse endereço.',
+ 'reset.send_code': 'Enviar código',
+ 'reset.form_intro': 'Digite o código do seu e-mail e escolha uma nova frase secreta. Se você tem sua chave de recuperação, adicione-a para também restaurar o acesso aos seus grupos.',
+ 'reset.code': 'Código de redefinição',
+ 'reset.recovery_key': 'Chave de recuperação (opcional)',
+ 'reset.recovery_key_hint': 'Deixe em branco se não a tiver. Sem ela, seu acesso é restaurado, mas é preciso reingressar em cada grupo.',
+ 'reset.new_pass': 'Nova frase secreta',
+ 'reset.new_pass_repeat': 'Repetir a nova frase secreta',
+ 'reset.submit': 'Redefinir a frase secreta',
+ 'reset.working': 'Restaurando suas identidades de grupo…',
+ 'reset.signin_restored': 'Seu acesso foi restaurado.',
+ 'reset.groups_restored': 'Suas identidades de grupo foram restauradas em todos os nós alcançáveis.',
+ 'reset.no_recovery': 'Suas identidades de grupo não foram restauradas. Para cada grupo, peça ao operador dele para remover sua fixação e enviar um novo código de convite, e então reingresse.',
+ 'reset.needs_operator': 'Estes grupos ainda precisam de ação — peça a cada operador para remover sua fixação e enviar um novo código:',
+ 'reset.go_app': 'Ir para o aplicativo',
+ 'reset.back_to_login': 'Voltar para o login',
+ 'reset.err_unsupported': 'A redefinição da frase secreta precisa do módulo de criptografia do navegador, que não está disponível aqui.',
// Register
'register.title': 'Criar uma conta',
@@ -41,6 +60,16 @@ export default {
'register.loading': 'Criando a conta...',
'register.has_account': 'Já tem uma conta?',
'register.login_link': 'Entrar',
+ 'register.recovery_title': 'Guarde sua chave de recuperação',
+ 'register.recovery_intro': 'Esta chave restaura suas identidades de grupo caso você esqueça sua frase secreta. Guarde-a em um gerenciador de senhas ou em local seguro — ela é mostrada apenas uma vez, e o hub nunca a vê.',
+ 'register.recovery_copy': 'Copiar',
+ 'register.recovery_copied': 'Copiado',
+ 'register.recovery_warning': 'Se você perder tanto a frase secreta quanto esta chave, seus arquivos permanecem em seus nós, mas você reingressa em cada grupo com uma nova identidade.',
+ 'register.recovery_saved': 'Guardei minha chave de recuperação em um local seguro.',
+ 'register.recovery_continue': 'Continuar',
+ 'register.recovery_email_opt': 'Enviar também esta chave de recuperação para o meu e-mail como backup',
+ 'register.recovery_emailed': 'Uma cópia também foi enviada para o seu e-mail, na mesma mensagem que o seu código de verificação.',
+ 'register.recovery_not_emailed': 'Isto não foi enviado por e-mail. Guarde agora — esta é a única vez que é mostrada.',
'register.success_title': 'Verifique seu e-mail',
'register.success_msg': 'Um código de verificação foi enviado para o seu e-mail. Digite-o abaixo para ativar sua conta.',
'register.go_login': 'Ir para o login',
@@ -293,6 +322,36 @@ export default {
other: '{n} fixadas',
},
'settings.node_pins_clear': 'Limpar as identidades fixadas',
+ 'settings.passphrase': 'Frase secreta',
+ 'settings.passphrase_hint': 'Altera a frase secreta desta conta. Suas chaves de identidade são recriptografadas em cada grupo cujo nó esteja on-line; um grupo cujo nó esteja off-line terá de ser reingressado depois.',
+ 'settings.passphrase_change': 'Alterar a frase secreta',
+ 'settings.passphrase_current': 'Frase secreta atual',
+ 'settings.passphrase_new': 'Nova frase secreta',
+ 'settings.passphrase_new_repeat': 'Repetir a nova frase secreta',
+ 'settings.continue': 'Continuar',
+ 'settings.done': 'Concluído',
+ 'settings.passphrase_confirm_intro': 'Suas chaves de identidade serão recriptografadas agora em cada grupo cujo nó possa ser alcançado:',
+ 'settings.passphrase_reachable': 'Atualizado agora',
+ 'settings.passphrase_unreachable': 'Não pode ser alcançado',
+ 'settings.passphrase_fallback_note': 'Para um grupo que não pode ser alcançado, peça ao operador dele para remover sua fixação e enviar um novo código de convite, e então reingresse. Seus arquivos e mensagens não são perdidos.',
+ 'settings.passphrase_confirm_btn': 'Alterar',
+ 'settings.passphrase_working': 'Recriptografando suas chaves…',
+ 'settings.passphrase_done': 'Frase secreta alterada.',
+ 'settings.passphrase_needs_operator': 'Estes grupos ainda precisam de ação — peça a cada operador para remover sua fixação e enviar um novo código:',
+ 'settings.pw_too_short': 'A nova frase secreta deve ter pelo menos 12 caracteres.',
+ 'settings.pw_mismatch': 'As duas novas frases secretas não coincidem.',
+ 'settings.pw_same': 'A nova frase secreta deve ser diferente da atual.',
+ 'settings.pw_wrong_current': 'A frase secreta atual não está correta. Nada foi alterado.',
+ 'settings.recovery': 'Chave de recuperação',
+ 'settings.recovery_hint': 'Adiciona a cada grupo uma cópia da sua identidade criptografada com a sua chave de recuperação. Faça isso uma vez em cada navegador que usar; sem ela, uma frase secreta perdida obriga a reingressar em cada grupo manualmente.',
+ 'settings.recovery_loaded': 'Carregada neste navegador — grupos novos ficam cobertos automaticamente.',
+ 'settings.recovery_open': 'Inserir a chave de recuperação',
+ 'settings.recovery_input_ph': 'Cole a sua chave de recuperação',
+ 'settings.recovery_submit': 'Adicionar cópias de backup',
+ 'settings.recovery_working': 'Atualizando os seus grupos…',
+ 'settings.recovery_done': 'Os seus grupos agora têm uma cópia de recuperação.',
+ 'settings.recovery_partial': 'Não foi possível alcançar estes grupos. Abra-os mais tarde, ou execute isto de novo:',
+ 'settings.recovery_need_relogin': 'Saia e entre novamente, depois tente de novo.',
'settings.danger': 'Excluir a conta',
'settings.delete_hint': 'Remove sua conta, suas participações em grupos e suas '
+ 'notificações do hub, e libera seu nome de usuário. Não alcança o que está nos '
@@ -524,6 +583,9 @@ export default {
'group.join_code_hint': 'Peça o código de uso único a quem convidou você e digite-o '
+ 'aqui. Depois disso este navegador fica reconhecido e a pergunta não se repete.',
'group.join_code_btn': 'Participar',
+ 'group.pass_title': 'Digite a sua frase secreta',
+ 'group.pass_hint': 'Este navegador ainda não desbloqueou as suas chaves. Digite a sua frase secreta uma vez para abrir os seus grupos aqui.',
+ 'group.pass_btn': 'Desbloquear',
'notif.purge': 'Limpar tudo',
'notif.title': 'Notificações',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 8dff38f..fc141c2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -29,6 +29,25 @@ export default {
'login.loading': '正在登录…',
'login.no_account': '还没有账户?',
'login.register_link': '注册',
+ 'login.forgot': '忘记密码短语?',
+ 'reset.title': '重置密码短语',
+ 'reset.request_intro': '输入您的用户名和账户的邮箱。如果二者匹配,重置码将发送至该邮箱。',
+ 'reset.send_code': '发送验证码',
+ 'reset.form_intro': '输入邮件中的验证码并设置新的密码短语。如果您有恢复密钥,请一并输入,以同时恢复对群组的访问。',
+ 'reset.code': '重置码',
+ 'reset.recovery_key': '恢复密钥(可选)',
+ 'reset.recovery_key_hint': '如果没有请留空。没有它,您的登录会恢复,但每个群组都需要重新加入。',
+ 'reset.new_pass': '新密码短语',
+ 'reset.new_pass_repeat': '再次输入新密码短语',
+ 'reset.submit': '重置密码短语',
+ 'reset.working': '正在恢复您的群组身份…',
+ 'reset.signin_restored': '您的登录已恢复。',
+ 'reset.groups_restored': '您的群组身份已在所有可达节点上恢复。',
+ 'reset.no_recovery': '您的群组身份未恢复。对于每个群组,请让其运营者取消对你的固定并发送新的邀请码,然后重新加入。',
+ 'reset.needs_operator': '这些群组仍需处理——请让每位运营者取消对你的固定并发送新的代码:',
+ 'reset.go_app': '前往应用',
+ 'reset.back_to_login': '返回登录',
+ 'reset.err_unsupported': '重置密码短语需要浏览器的加密模块,此处不可用。',
// Register
'register.title': '注册',
@@ -40,6 +59,16 @@ export default {
'register.loading': '正在创建账户…',
'register.has_account': '已经有账户了?',
'register.login_link': '登录',
+ 'register.recovery_title': '保存您的恢复密钥',
+ 'register.recovery_intro': '如果您忘记了密码短语,此密钥可恢复您的群组身份。请将其保存在密码管理器或安全的地方——它只显示一次,且 hub 永远看不到它。',
+ 'register.recovery_copy': '复制',
+ 'register.recovery_copied': '已复制',
+ 'register.recovery_warning': '如果您同时丢失了密码短语和此密钥,您的文件仍会保留在各自的节点上,但您将以新身份重新加入每个群组。',
+ 'register.recovery_saved': '我已将恢复密钥保存在安全的地方。',
+ 'register.recovery_continue': '继续',
+ 'register.recovery_email_opt': '同时将此恢复密钥通过邮件发送给我作为备份',
+ 'register.recovery_emailed': '副本也已发送至您的邮箱,与验证码在同一封邮件中。',
+ 'register.recovery_not_emailed': '这未通过邮件发送。请立即保存——它只显示这一次。',
'register.success_title': '验证您的邮箱',
'register.success_msg': '验证码已发送至您的邮箱。请在下方输入以激活账户。',
'register.go_login': '前往登录',
@@ -284,6 +313,36 @@ export default {
other: '已固定 {n} 个',
},
'settings.node_pins_clear': '清除已固定的身份',
+ 'settings.passphrase': '密码短语',
+ 'settings.passphrase_hint': '更改此账户的密码短语。你的身份密钥会在其节点在线的每个群组上重新封装;对于节点离线的群组,之后需要重新加入。',
+ 'settings.passphrase_change': '更改密码短语',
+ 'settings.passphrase_current': '当前密码短语',
+ 'settings.passphrase_new': '新密码短语',
+ 'settings.passphrase_new_repeat': '再次输入新密码短语',
+ 'settings.continue': '继续',
+ 'settings.done': '完成',
+ 'settings.passphrase_confirm_intro': '你的身份密钥现在将在其节点可达的每个群组上重新封装:',
+ 'settings.passphrase_reachable': '现在更新',
+ 'settings.passphrase_unreachable': '无法连接',
+ 'settings.passphrase_fallback_note': '对于无法连接的群组,请让其运营者取消对你的固定并发送新的邀请码,然后重新加入。你的文件和消息不会丢失。',
+ 'settings.passphrase_confirm_btn': '更改',
+ 'settings.passphrase_working': '正在重新封装你的密钥…',
+ 'settings.passphrase_done': '密码短语已更改。',
+ 'settings.passphrase_needs_operator': '这些群组仍需处理——请让每位运营者取消对你的固定并发送新的代码:',
+ 'settings.pw_too_short': '新密码短语至少需要 12 个字符。',
+ 'settings.pw_mismatch': '两个新密码短语不一致。',
+ 'settings.pw_same': '新密码短语必须与当前的不同。',
+ 'settings.pw_wrong_current': '当前密码短语不正确。未做任何更改。',
+ 'settings.recovery': '恢复密钥',
+ 'settings.recovery_hint': '为每个群组添加一份用你的恢复密钥加密的身份副本。在你使用的每个浏览器中执行一次;没有它,一旦丢失密码短语就只能手动重新加入每个群组。',
+ 'settings.recovery_loaded': '已在此浏览器中加载——新群组会自动覆盖。',
+ 'settings.recovery_open': '输入恢复密钥',
+ 'settings.recovery_input_ph': '粘贴你的恢复密钥',
+ 'settings.recovery_submit': '添加备份副本',
+ 'settings.recovery_working': '正在更新你的群组…',
+ 'settings.recovery_done': '你的群组现在有了恢复副本。',
+ 'settings.recovery_partial': '无法连接这些群组。稍后打开它们,或再次运行此操作:',
+ 'settings.recovery_need_relogin': '请先退出登录再重新登录,然后重试。',
'settings.danger': '删除账户',
'settings.delete_hint': '这会从 hub 上删除您的账户、您的群组成员身份和您的通知,'
+ '并释放您的用户名。它无法触及存放在各个 node 上的内容:您上传的文件仍留在其运营者'
@@ -501,6 +560,9 @@ export default {
'group.join_code_hint': '向邀请您的人索取一次性代码,并在此输入。'
+ '之后此浏览器即被认可,不会再次询问。',
'group.join_code_btn': '加入',
+ 'group.pass_title': '输入你的密码短语',
+ 'group.pass_hint': '此浏览器尚未解锁你的密钥。输入一次密码短语即可在此打开你的群组。',
+ 'group.pass_btn': '解锁',
'notif.purge': '全部清除',
'notif.title': '通知',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index ce22116..10bcee7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -3,7 +3,10 @@ import {
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
-import { hubFetch } from './hub-client.js';
+import {
+ hubFetch, HUB, session, setAuth,
+ _storeBundleKey, _loadBundleKey, _storeRecoveryKey,
+} from './hub-client.js';
export function ProfilePage({ user, onLogout }) {
const [nodeKey, setNodeKey] = useState('');
@@ -47,6 +50,128 @@ export function ProfilePage({ user, onLogout }) {
setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0);
}, []);
+ // ── Passphrase change (docs/auth-confirm.md §3) ─────────────────────────
+ const [cpOpen, setCpOpen] = useState(false);
+ const [cpOld, setCpOld] = useState('');
+ const [cpNew, setCpNew] = useState('');
+ const [cpNew2, setCpNew2] = useState('');
+ const [cpPhase, setCpPhase] = useState('form'); // form | confirm | working | done
+ const [cpEstimate, setCpEstimate] = useState(null);
+ const [cpProgress, setCpProgress] = useState(null);
+ const [cpResult, setCpResult] = useState(null);
+ const [cpError, setCpError] = useState('');
+
+ const cpReset = useCallback(() => {
+ setCpOpen(false); setCpPhase('form');
+ setCpOld(''); setCpNew(''); setCpNew2('');
+ setCpEstimate(null); setCpProgress(null); setCpResult(null); setCpError('');
+ }, []);
+
+ const _label = (g) => (g.owner_username ? `${g.name}@${g.owner_username}` : g.name);
+
+ const cpBeginConfirm = useCallback(async (e) => {
+ e.preventDefault();
+ setCpError('');
+ if (cpNew.length < 12) { setCpError(t('settings.pw_too_short')); return; }
+ if (cpNew !== cpNew2) { setCpError(t('settings.pw_mismatch')); return; }
+ if (cpNew === cpOld) { setCpError(t('settings.pw_same')); return; }
+ try {
+ const mine = await hubFetch('/v1/groups/mine', { token: user.token });
+ const groups = mine.groups || [];
+ setCpEstimate({
+ reachable: groups.filter((g) => g.node_online).map(_label),
+ unreachable: groups.filter((g) => !g.node_online).map(_label),
+ });
+ setCpPhase('confirm');
+ } catch (err) {
+ setCpError(err.message);
+ }
+ }, [cpOld, cpNew, cpNew2, user.token]);
+
+ const cpConfirm = useCallback(async () => {
+ setCpPhase('working');
+ setCpError('');
+ setCpProgress({ done: 0, total: 0 });
+ try {
+ // Re-wrap every reachable node's identity bundle first — if this cannot
+ // run at all the account is left untouched.
+ const result = await window.MeshBayTransport.rewrapAllNodes({
+ hubUrl: HUB, token: user.token,
+ username: user.username, userId: user.userId,
+ oldPassphrase: cpOld, newPassphrase: cpNew,
+ onProgress: setCpProgress,
+ });
+
+ const oldAuthKey = await window.MeshBayKeys.deriveAuthKey(cpOld, user.username);
+ const newAuthKey = await window.MeshBayKeys.deriveAuthKey(cpNew, user.username);
+ const resp = await hubFetch('/v1/users/password', {
+ method: 'POST', token: user.token,
+ body: { old_auth_key: oldAuthKey, new_auth_key: newAuthKey },
+ });
+
+ // Keep this tab signed in with the fresh pair, and move the session's
+ // bundle key forward so the next node connection opens the new bundles.
+ setAuth({ ...user, token: resp.access_token, refreshToken: resp.refresh_token });
+ session.bundleKey = result.newBundleKey;
+ _storeBundleKey(result.newBundleKey);
+
+ setCpResult(result);
+ setCpPhase('done');
+ } catch (err) {
+ const msg = /403|does not match/i.test(err.message)
+ ? t('settings.pw_wrong_current') : err.message;
+ setCpError(msg);
+ setCpPhase('confirm');
+ }
+ }, [cpOld, cpNew, user]);
+
+ // ── Recovery key backfill (docs/auth-confirm.md §4.3) ───────────────────
+ // Enter the recovery key once per browser to add a recovery-wrapped copy of
+ // your identity to every group — covers groups joined before the key was
+ // loaded here.
+ const [rkOpen, setRkOpen] = useState(false);
+ const [rkInput, setRkInput] = useState('');
+ const [rkPhase, setRkPhase] = useState('form'); // form | working | done
+ const [rkProgress, setRkProgress] = useState(null);
+ const [rkResult, setRkResult] = useState(null);
+ const [rkError, setRkError] = useState('');
+ const rkLoaded = !!session.recoveryKey;
+
+ const rkReset = useCallback(() => {
+ setRkOpen(false); setRkPhase('form'); setRkInput('');
+ setRkProgress(null); setRkResult(null); setRkError('');
+ }, []);
+
+ const rkBackfill = useCallback(async (e) => {
+ e.preventDefault();
+ const mnemonic = rkInput.trim();
+ if (!mnemonic || !window.MeshBayKeys) return;
+ setRkError('');
+ if (!session.bundleKey) session.bundleKey = await _loadBundleKey();
+ if (!session.bundleKey) { setRkError(t('settings.recovery_need_relogin')); return; }
+ setRkPhase('working');
+ setRkProgress(null);
+ try {
+ // Derives the key (and validates the mnemonic — a bad one throws here).
+ const key = await window.MeshBayKeys.deriveRecoveryKey(mnemonic, user.username);
+ session.recoveryKey = key;
+ await _storeRecoveryKey(key);
+ const r = await window.MeshBayTransport.rewrapAllNodes({
+ hubUrl: HUB, token: user.token,
+ username: user.username, userId: user.userId,
+ bundleKey: session.bundleKey, // keep the current passphrase key
+ recoveryKey: mnemonic,
+ onProgress: setRkProgress,
+ });
+ setRkResult(r);
+ setRkPhase('done');
+ setRkInput('');
+ } catch (err) {
+ setRkError(err.message);
+ setRkPhase('form');
+ }
+ }, [rkInput, user]);
+
useEffect(() => {
hubFetch('/v1/users/me', { token: user.token })
.then(data => {
@@ -218,6 +343,121 @@ export function ProfilePage({ user, onLogout }) {
</div>
<div class="settings-section">
+ <h3 class="settings-heading">${t('settings.passphrase')}</h3>
+ <p class="settings-hint">${t('settings.passphrase_hint')}</p>
+ ${!cpOpen && html`
+ <button class="admin-btn" onClick=${() => setCpOpen(true)}>
+ ${t('settings.passphrase_change')}
+ </button>`}
+
+ ${cpOpen && cpPhase === 'form' && html`
+ <form onSubmit=${cpBeginConfirm} style="display:flex;flex-direction:column;gap:8px;max-width:340px">
+ <input type="password" autocomplete="current-password"
+ placeholder=${t('settings.passphrase_current')}
+ value=${cpOld} onInput=${e => setCpOld(e.target.value)} required />
+ <input type="password" autocomplete="new-password"
+ placeholder=${t('settings.passphrase_new')}
+ value=${cpNew} onInput=${e => setCpNew(e.target.value)} required />
+ <input type="password" autocomplete="new-password"
+ placeholder=${t('settings.passphrase_new_repeat')}
+ value=${cpNew2} onInput=${e => setCpNew2(e.target.value)} required />
+ <div style="display:flex;gap:8px">
+ <button class="admin-btn" type="submit">${t('settings.continue')}</button>
+ <button class="btn-secondary" type="button" onClick=${cpReset}>
+ ${t('settings.cancel')}
+ </button>
+ </div>
+ </form>`}
+
+ ${cpOpen && cpPhase === 'confirm' && cpEstimate && html`
+ <div style="max-width:420px">
+ <p class="settings-hint">${t('settings.passphrase_confirm_intro')}</p>
+ ${cpEstimate.reachable.length > 0 && html`
+ <p style="margin:8px 0 2px"><strong>${t('settings.passphrase_reachable')}</strong></p>
+ <ul style="margin:0 0 8px 18px">
+ ${cpEstimate.reachable.map(n => html`<li>${n}</li>`)}
+ </ul>`}
+ ${cpEstimate.unreachable.length > 0 && html`
+ <p style="margin:8px 0 2px"><strong>${t('settings.passphrase_unreachable')}</strong></p>
+ <ul style="margin:0 0 8px 18px">
+ ${cpEstimate.unreachable.map(n => html`<li>${n}</li>`)}
+ </ul>`}
+ <p class="settings-hint">${t('settings.passphrase_fallback_note')}</p>
+ ${cpError && html`<p class="error-msg">${cpError}</p>`}
+ <div style="display:flex;gap:8px;margin-top:8px">
+ <button class="btn-danger" onClick=${cpConfirm}>
+ ${t('settings.passphrase_confirm_btn')}
+ </button>
+ <button class="btn-secondary" onClick=${cpReset}>${t('settings.cancel')}</button>
+ </div>
+ </div>`}
+
+ ${cpOpen && cpPhase === 'working' && html`
+ <p class="settings-hint">
+ ${t('settings.passphrase_working')}
+ ${cpProgress && cpProgress.total ? ` (${cpProgress.done}/${cpProgress.total})` : ''}
+ </p>`}
+
+ ${cpOpen && cpPhase === 'done' && cpResult && html`
+ <div style="max-width:420px">
+ <p style="color:var(--success)">${t('settings.passphrase_done')}</p>
+ ${(cpResult.unreachable.length > 0 || cpResult.failed.length > 0) && html`
+ <p class="settings-hint" style="margin-top:8px">
+ ${t('settings.passphrase_needs_operator')}
+ </p>
+ <ul style="margin:0 0 8px 18px">
+ ${cpResult.unreachable.concat(cpResult.failed).map(g =>
+ html`<li>${g.name}${g.reason ? ` — ${g.reason}` : ''}</li>`)}
+ </ul>`}
+ <button class="admin-btn" onClick=${cpReset}>${t('settings.done')}</button>
+ </div>`}
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.recovery')}</h3>
+ <p class="settings-hint">${t('settings.recovery_hint')}</p>
+ ${rkLoaded && rkPhase !== 'done' && html`
+ <p class="settings-hint" style="color:var(--success)">${t('settings.recovery_loaded')}</p>`}
+
+ ${!rkOpen && html`
+ <button class="admin-btn" onClick=${() => setRkOpen(true)}>
+ ${t('settings.recovery_open')}
+ </button>`}
+
+ ${rkOpen && rkPhase === 'form' && html`
+ <form onSubmit=${rkBackfill} style="display:flex;flex-direction:column;gap:8px;max-width:360px">
+ <textarea placeholder=${t('settings.recovery_input_ph')}
+ value=${rkInput} onInput=${e => setRkInput(e.target.value)} rows="2"
+ style="font-family:monospace;font-size:0.9em;letter-spacing:0.08em;resize:vertical"></textarea>
+ ${rkError && html`<p class="error-msg">${rkError}</p>`}
+ <div style="display:flex;gap:8px">
+ <button class="admin-btn" type="submit">${t('settings.recovery_submit')}</button>
+ <button class="btn-secondary" type="button" onClick=${rkReset}>
+ ${t('settings.cancel')}
+ </button>
+ </div>
+ </form>`}
+
+ ${rkOpen && rkPhase === 'working' && html`
+ <p class="settings-hint">
+ ${t('settings.recovery_working')}
+ ${rkProgress && rkProgress.total ? ` (${rkProgress.done}/${rkProgress.total})` : ''}
+ </p>`}
+
+ ${rkOpen && rkPhase === 'done' && rkResult && html`
+ <div style="max-width:420px">
+ <p style="color:var(--success)">${t('settings.recovery_done')}</p>
+ ${(rkResult.unreachable.length > 0 || rkResult.failed.length > 0) && html`
+ <p class="settings-hint" style="margin-top:8px">${t('settings.recovery_partial')}</p>
+ <ul style="margin:0 0 8px 18px">
+ ${rkResult.unreachable.concat(rkResult.failed).map(g =>
+ html`<li>${g.name}${g.reason ? ` — ${g.reason}` : ''}</li>`)}
+ </ul>`}
+ <button class="admin-btn" onClick=${rkReset}>${t('settings.done')}</button>
+ </div>`}
+ </div>
+
+ <div class="settings-section">
<h3 class="settings-heading">${t('settings.danger')}</h3>
<p class="settings-hint">${t('settings.delete_hint')}</p>
${delError && html`<p class="error-msg">${delError}</p>`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 286bc9d..f7ae256 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -296,13 +296,19 @@ class MeshBayTransport {
get newNodeBundle() { return this._newNodeBundle || null; }
set newNodeBundle(v) { this._newNodeBundle = v; }
+ /** The recovery-wrapped copy of that same first-join identity, when a recovery key was in hand. */
+ get newNodeBundleRecovery() { return this._newNodeBundleRecovery || null; }
+ set newNodeBundleRecovery(v) { this._newNodeBundleRecovery = v; }
+
async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username,
- userId, joinCode) {
+ userId, joinCode, recoveryKey) {
// Remembered for _reconnectLoop, which calls connect() again with these
// same values (plus a freshly-fetched token and the identity connect()
// itself settles on below) after the WebRTC connection is declared
// "failed" — see the pc.onconnectionstatechange handler further down.
- this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode };
+ this._connectArgs = {
+ nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey,
+ };
this._lastToken = jwtToken;
// The constructor sets this once from whatever token the caller had at
// the time — and the signaling POST below reads *this*, not `jwtToken`.
@@ -316,9 +322,11 @@ class MeshBayTransport {
this._gekRaw = gekRaw || null;
this._sessionKeys = sessionKeys || null;
this._bundleKey = bundleKey || null;
+ this._recoveryKey = recoveryKey || null;
this._username = username || null;
this._userId = userId || null;
this._newNodeBundle = null;
+ this._newNodeBundleRecovery = null;
this._joinError = null;
this._pc = new RTCPeerConnection({
iceServers: [
@@ -548,20 +556,58 @@ class MeshBayTransport {
const kpResp = await this._sendAndWait({
type: 'keypair_bundle_fetch', v: '0.1',
});
+ let keys = null;
+ let openErr = null;
if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) {
- const keys = await window.MeshBayKeys.decryptBundleWithKey(
- kpResp.bundle_enc, this._bundleKey);
+ try {
+ keys = await window.MeshBayKeys.decryptBundleWithKey(
+ kpResp.bundle_enc, this._bundleKey);
+ } catch (e) {
+ openErr = e;
+ // The passphrase key did not open the bundle. If we hold a recovery
+ // key and the node kept a recovery copy, try that — Flow B
+ // (docs/auth-confirm.md §4.5): recovering an identity after a lost
+ // passphrase, before re-wrapping it under the new one.
+ if (this._recoveryKey && kpResp.bundle_enc_recovery) {
+ try {
+ keys = await window.MeshBayKeys.decryptBundleWithKey(
+ kpResp.bundle_enc_recovery, this._recoveryKey);
+ this._recoveredFromRecovery = true;
+ } catch { /* recovery copy did not open either */ }
+ }
+ }
+ }
+
+ if (!keys && this._rewrapOnly) {
+ // A passphrase-change / backfill run must recover the *existing*
+ // identity or report the node — never mint a new one. These strings
+ // are shown on the reset / backfill screens.
+ throw new Error(
+ !kpResp.found ? 'no identity on this node'
+ : this._recoveryKey
+ ? (kpResp.bundle_enc_recovery
+ ? "recovery key does not open this node's bundle"
+ : 'no recovery copy on this node')
+ : (openErr && openErr.message) || 'could not open the stored identity');
+ }
+
+ if (keys) {
const pkXB64 = await _pkFromSk(keys.skX);
this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 };
} else {
- // This node has never seen us. Generate the identity we will use here
- // and nowhere else; it is stored on this node once the join succeeds,
- // which is what lets another browser become the same person here.
- const id = await window.MeshBayKeys.generateNodeIdentity(this._bundleKey);
+ // Either the node has never seen us, or it holds a stale bundle we
+ // cannot open (wrapped under a passphrase we no longer use, with no
+ // usable recovery copy — e.g. an unpin that left the old bundle
+ // behind). Mint a fresh identity and let the join path take over; a
+ // successful join overwrites whatever was stored. A recovery-wrapped
+ // copy is left too when a recovery key is in hand (§4.3).
+ const id = await window.MeshBayKeys.generateNodeIdentity(
+ this._bundleKey, this._recoveryKey);
this._sessionKeys = {
skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64,
};
this._newNodeBundle = id.bundleEnc;
+ this._newNodeBundleRecovery = id.bundleEncRecovery || null;
fresh = true;
}
}
@@ -598,15 +644,11 @@ class MeshBayTransport {
}
if (!gekRaw && !this._sessionKeys) {
- // No identity keys in this browser and none recoverable from the node:
- // the keypair bundle is created where you register and only reaches a
- // node after a first successful connection, so a brand-new member opening
- // a second browser has nothing to sign or unwrap with. Say that, rather
- // than blaming the GEK — a code prompt here would be useless, since a
- // code proves who you are and we have no key to bind to.
- const err = new Error(
- 'This browser does not hold your keys. Open the group once from the '
- + 'browser where you registered — after that this one can recover them.');
+ // No key in this browser to sign or unwrap with — `bundleKey` was null.
+ // The caller (group-page.js) shows a passphrase prompt on this reason
+ // and retries; a code prompt would be useless, since a code proves who
+ // you are and there is no key to bind it to.
+ const err = new Error('Your passphrase is needed to unlock your keys in this browser.');
err.reason = 'no_keys';
throw err;
}
@@ -1777,11 +1819,14 @@ class MeshBayTransport {
return msg;
}
- async storeKeypairBundle(bundleEnc) {
+ async storeKeypairBundle(bundleEnc, recoveryEnc) {
const msg = await this._sendAndWait({
type: 'keypair_bundle_store',
v: '0.1',
bundle_enc: bundleEnc,
+ // MNP 0.14, optional: the recovery-wrapped copy. Omitted for a plain
+ // re-backup; the node keeps any copy it already holds.
+ ...(recoveryEnc ? { bundle_enc_recovery: recoveryEnc } : {}),
});
if (msg.type === 'error') throw new Error(msg.detail);
return msg;
@@ -2560,7 +2605,155 @@ function pinnedNodeCount() {
} catch { return 0; }
}
+// ── Passphrase change: re-wrap every reachable identity bundle ───────────────
+//
+// docs/auth-confirm.md §3.2. The passphrase-derived bundle_key encrypts this
+// account's per-node identity on every node it has joined. Changing the
+// passphrase changes that key, so each bundle must be read with the old key and
+// written back with the new one — on the node, while both keys are in hand.
+//
+// The reachable set is the online nodes of the account's current groups. A node
+// that is offline, or belongs to a group left since, cannot be reached here and
+// is reported so the caller can tell the user to ask that group's operator to
+// unpin them and issue a fresh code (§3.4).
+
+function _acHubFetch(hubUrl, path, init) {
+ const p = typeof window !== 'undefined' && window.MeshBayPlatform;
+ const url = (hubUrl || '') + path;
+ return (p && p.apiFetch) ? p.apiFetch(url, init) : fetch(url, init);
+}
+
+async function _acHubGet(hubUrl, token, path) {
+ const r = await _acHubFetch(hubUrl, path, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!r.ok) throw new Error(`${path} → ${r.status}`);
+ return r.json();
+}
+
+function _acWithTimeout(promise, ms, label) {
+ let timer;
+ return Promise.race([
+ promise.finally(() => clearTimeout(timer)),
+ new Promise((_, rej) => {
+ timer = setTimeout(() => rej(new Error(`${label} timed out`)), ms);
+ }),
+ ]);
+}
+
+/**
+ * @param {object} o
+ * @param {string} o.hubUrl same base the SPA uses for the hub
+ * @param {string} o.token a fresh access token
+ * @param {string} o.username
+ * @param {string} o.userId
+ * @param {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy
+ * @param {string} o.newPassphrase
+ * @param {string} [o.recoveryKey] the recovery mnemonic (Flow B, docs/auth-confirm.md §4.5).
+ * When given, the recovery-wrapped copy is read where the
+ * passphrase copy cannot be, and a fresh one is written back.
+ * @param {(p:{done:number,total:number})=>void} [o.onProgress]
+ * @returns {Promise<{updated:Array,unreachable:Array,failed:Array,newBundleKey:object}>}
+ */
+async function rewrapAllNodes(o) {
+ const K = window.MeshBayKeys;
+ if (!K || !K.deriveEncryptionKey) {
+ throw new Error('key module unavailable');
+ }
+ let oldKey, newKey;
+ if (o.bundleKey) {
+ // "Keep the current passphrase key, just add / refresh the recovery copy"
+ // — the Profile backfill (docs/auth-confirm.md §4.3). `o.bundleKey` is the
+ // live {v2,v1} session key, so no passphrase is needed.
+ oldKey = newKey = o.bundleKey;
+ } else {
+ // Flow B has no old passphrase; connect will fail the passphrase decrypt and
+ // fall back to the recovery copy, so a placeholder key is fine for `oldKey`.
+ const oldPass = o.oldPassphrase || o.newPassphrase;
+ oldKey = {
+ v2: await K.deriveEncryptionKey(oldPass, o.username),
+ v1: await K.deriveEncryptionKeyV1(oldPass, o.username),
+ };
+ newKey = {
+ v2: await K.deriveEncryptionKey(o.newPassphrase, o.username),
+ v1: await K.deriveEncryptionKeyV1(o.newPassphrase, o.username),
+ };
+ }
+ const recoveryKey = o.recoveryKey
+ ? await K.deriveRecoveryKey(o.recoveryKey, o.username)
+ : null;
+
+ const mine = await _acHubGet(o.hubUrl, o.token, '/v1/groups/mine');
+ const groups = mine.groups || (Array.isArray(mine) ? mine : []);
+ const updated = [], unreachable = [], failed = [];
+
+ for (const g of groups) {
+ const label = g.owner_username ? `${g.name}@${g.owner_username}` : g.name;
+ let nodes = [];
+ try {
+ const nd = await _acHubGet(o.hubUrl, o.token, `/v1/groups/${g.id}/nodes`);
+ nodes = nd.nodes || [];
+ } catch (e) {
+ failed.push({ groupId: g.id, name: label, reason: e.message });
+ if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
+ continue;
+ }
+ if (nodes.length === 0) {
+ unreachable.push({ groupId: g.id, name: label, reason: 'node offline' });
+ if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
+ continue;
+ }
+
+ let anyOk = false, lastErr = null;
+ for (const n of nodes) {
+ const tp = new MeshBayTransport(o.hubUrl, o.token);
+ // Recover the *existing* identity or report this node — never mint a new
+ // one just because the stored bundle would not open.
+ tp._rewrapOnly = true;
+ try {
+ await _acWithTimeout(
+ tp.connect(n.node_id, o.token, g.id, null, null, oldKey,
+ o.username, o.userId, null, recoveryKey),
+ 30000, 'connect');
+ if (tp.newNodeBundle) {
+ // No identity existed on this node — connect just minted one under
+ // the old key. Don't persist it: the next time this group is opened
+ // the normal flow creates one under the current key, and storing it
+ // here could also walk back a deliberate bundle withdrawal. Nothing
+ // is stranded, so this node needs no fix.
+ anyOk = true;
+ continue;
+ }
+ const sk = tp.sessionKeys;
+ if (!sk) { lastErr = new Error('identity not recovered'); continue; }
+ const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0));
+ const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0));
+ const reEnc = await K.encryptBundleWithKey(skEd, skX, newKey.v2);
+ // In Flow B, refresh the recovery copy too (same R) so the node's
+ // passphrase copy and recovery copy stay in step.
+ const reRecovery = recoveryKey
+ ? await K.encryptBundleWithKey(skEd, skX, recoveryKey)
+ : null;
+ await tp.storeKeypairBundle(reEnc, reRecovery);
+ anyOk = true;
+ } catch (e) {
+ lastErr = e;
+ } finally {
+ try { tp.close(); } catch { /* already gone */ }
+ }
+ }
+
+ if (anyOk) updated.push({ groupId: g.id, name: label });
+ else failed.push({ groupId: g.id, name: label,
+ reason: (lastErr && lastErr.message) || 'unreachable' });
+ if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
+ }
+
+ return { updated, unreachable, failed, newBundleKey: newKey };
+}
+
// Export
MeshBayTransport.clearNodePin = clearNodePin;
MeshBayTransport.pinnedNodeCount = pinnedNodeCount;
+MeshBayTransport.rewrapAllNodes = rewrapAllNodes;
window.MeshBayTransport = MeshBayTransport;
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py
index bf96e3d..2769f7e 100644
--- a/packages/meshbay-hub/tests/conftest.py
+++ b/packages/meshbay-hub/tests/conftest.py
@@ -90,7 +90,7 @@ async def db_session(app):
@pytest.fixture(autouse=True)
def _skip_email_verification(monkeypatch):
"""Skip email verification in tests — users are active immediately."""
- async def _noop(db, user, email, eh):
+ async def _noop(db, user, email, eh, recovery_key=None):
pass
monkeypatch.setattr(
diff --git a/packages/meshbay-hub/tests/test_password_change.py b/packages/meshbay-hub/tests/test_password_change.py
new file mode 100644
index 0000000..aba2d9a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_password_change.py
@@ -0,0 +1,140 @@
+"""
+Passphrase change — Flow A of docs/auth-confirm.md.
+
+The hub's part is small: re-prove the current passphrase, swap the auth_key
+verifier, invalidate every other session, keep the caller's. The re-wrapping of
+per-node identity bundles is the client's job and does not touch the hub, so it
+is not exercised here.
+"""
+
+import base64
+import hashlib
+
+import pytest
+from sqlalchemy import select
+
+from meshbay_hub.db.models import IPLog, RefreshToken, User
+
+
+def _auth_key(password: str, username: str) -> str:
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+async def _register(client, username, password="the-first-passphrase"):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": _auth_key(password, username),
+ })
+ assert r.status_code in (200, 201), r.text
+ login = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key(password, username)})
+ assert login.status_code == 200, login.text
+ return login.json()
+
+
+@pytest.mark.asyncio
+async def test_change_then_sign_in_with_the_new_passphrase(client):
+ old, new = "the-first-passphrase", "a-second-passphrase-entirely"
+ session = await _register(client, "alice", old)
+
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key(old, "alice"),
+ "new_auth_key": _auth_key(new, "alice"),
+ }, headers={"Authorization": f"Bearer {session['access_token']}"})
+ assert r.status_code == 200, r.text
+ assert r.json()["status"] == "changed"
+
+ assert (await client.post("/v1/users/login", json={
+ "username": "alice", "auth_key": _auth_key(old, "alice")})).status_code == 401
+ assert (await client.post("/v1/users/login", json={
+ "username": "alice", "auth_key": _auth_key(new, "alice")})).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_wrong_current_passphrase_is_refused_and_changes_nothing(client):
+ old = "the-first-passphrase"
+ session = await _register(client, "bob", old)
+
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key("not the passphrase", "bob"),
+ "new_auth_key": _auth_key("some-new-passphrase", "bob"),
+ }, headers={"Authorization": f"Bearer {session['access_token']}"})
+ assert r.status_code == 403
+
+ assert (await client.post("/v1/users/login", json={
+ "username": "bob", "auth_key": _auth_key(old, "bob")})).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_new_must_differ_from_old(client):
+ old = "the-first-passphrase"
+ session = await _register(client, "carol", old)
+
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key(old, "carol"),
+ "new_auth_key": _auth_key(old, "carol"),
+ }, headers={"Authorization": f"Bearer {session['access_token']}"})
+ assert r.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_unauthenticated_call_is_rejected(client):
+ """A session is required — the current passphrase alone is not a credential."""
+ await _register(client, "dave", "the-first-passphrase")
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key("the-first-passphrase", "dave"),
+ "new_auth_key": _auth_key("a-new-one", "dave"),
+ })
+ assert r.status_code in (401, 403, 422)
+
+
+@pytest.mark.asyncio
+async def test_other_sessions_are_invalidated_and_the_caller_keeps_one(
+ client, db_session):
+ old, new = "the-first-passphrase", "a-second-passphrase-entirely"
+ first = await _register(client, "erin", old)
+ # A second browser signs in before the change.
+ second = (await client.post("/v1/users/login", json={
+ "username": "erin", "auth_key": _auth_key(old, "erin")})).json()
+
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key(old, "erin"),
+ "new_auth_key": _auth_key(new, "erin"),
+ }, headers={"Authorization": f"Bearer {first['access_token']}"})
+ assert r.status_code == 200, r.text
+
+ # The other browser's refresh token is dead.
+ stale = await client.post("/v1/users/token/refresh", json={
+ "refresh_token": second["refresh_token"]})
+ assert stale.status_code == 401
+
+ # The caller was handed a fresh pair that still works.
+ fresh = await client.post("/v1/users/token/refresh", json={
+ "refresh_token": r.json()["refresh_token"]})
+ assert fresh.status_code == 200, fresh.text
+
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "erin"))).scalar_one()
+ live = (await db_session.execute(
+ select(RefreshToken).where(RefreshToken.user_id == uid,
+ RefreshToken.revoked.is_(False)))).scalars().all()
+ # Only the family issued to the caller (the refresh above rotated it once).
+ assert len(live) == 1
+
+
+@pytest.mark.asyncio
+async def test_the_change_is_logged(client, db_session):
+ old, new = "the-first-passphrase", "a-second-passphrase-entirely"
+ session = await _register(client, "frank", old)
+ await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key(old, "frank"),
+ "new_auth_key": _auth_key(new, "frank"),
+ }, headers={"Authorization": f"Bearer {session['access_token']}"})
+
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "frank"))).scalar_one()
+ events = {e.event for e in (await db_session.execute(
+ select(IPLog).where(IPLog.user_id == uid))).scalars().all()}
+ assert "password_change" in events
diff --git a/packages/meshbay-hub/tests/test_password_reset.py b/packages/meshbay-hub/tests/test_password_reset.py
new file mode 100644
index 0000000..80977c5
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_password_reset.py
@@ -0,0 +1,204 @@
+"""
+Passphrase reset by e-mail code — Flow B of docs/auth-confirm.md §4.2.
+
+The hub's part re-opens sign-in only: it swaps the auth_key verifier, kills
+every session, and drops every registered device key so a stored one cannot
+sign back in past the reset. Restoring group access is the client's job with
+the recovery key and is not exercised here.
+"""
+
+import base64
+import time
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import pk_to_b64
+from meshbay_hub.db.models import EmailVerification, IPLog, User
+from sqlalchemy import select
+
+
+def _email(username: str) -> str:
+ return f"{username}@example.com"
+
+
+async def _register(client, username, auth_key="k" * 44):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": _email(username),
+ "auth_key": auth_key})
+ assert r.status_code in (200, 201), r.text
+
+
+async def _request_reset(client, username, email=None):
+ return await client.post("/v1/users/password/reset-request", json={
+ "username": username, "email": email or _email(username)})
+
+
+async def _reset_code(db_session, username) -> str:
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == username))).scalar_one()
+ row = (await db_session.execute(
+ select(EmailVerification).where(
+ EmailVerification.user_id == uid,
+ EmailVerification.purpose == "password_reset",
+ EmailVerification.verified_at.is_(None),
+ ).order_by(EmailVerification.created_at.desc()))).scalars().first()
+ return row.code if row else None
+
+
+@pytest.mark.asyncio
+async def test_reset_lets_the_user_sign_in_with_a_new_passphrase(client, db_session):
+ await _register(client, "alice", "old" + "a" * 41)
+ r = await _request_reset(client, "alice")
+ assert r.status_code == 200 and r.json()["status"] == "sent_if_exists"
+
+ code = await _reset_code(db_session, "alice")
+ assert code
+
+ new = "new" + "b" * 41
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "alice", "code": code, "new_auth_key": new})
+ assert r.status_code == 200, r.text
+
+ assert (await client.post("/v1/users/login", json={
+ "username": "alice", "auth_key": "old" + "a" * 41})).status_code == 401
+ assert (await client.post("/v1/users/login", json={
+ "username": "alice", "auth_key": new})).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_reset_request_never_reveals_whether_an_account_exists(
+ client, db_session):
+ r = await _request_reset(client, "ghost")
+ assert r.status_code == 200
+ assert r.json()["status"] == "sent_if_exists"
+ rows = (await db_session.execute(select(EmailVerification))).scalars().all()
+ assert rows == []
+
+
+@pytest.mark.asyncio
+async def test_reset_request_needs_the_username_and_email_to_match(client, db_session):
+ await _register(client, "hank")
+
+ # Right username, wrong e-mail — answered exactly like an unknown account,
+ # and no code is created.
+ r = await _request_reset(client, "hank", email="someone.else@example.com")
+ assert r.status_code == 200
+ assert r.json()["status"] == "sent_if_exists"
+ assert (await db_session.execute(
+ select(EmailVerification))).scalars().all() == []
+
+ # The real pair does create one.
+ await _request_reset(client, "hank")
+ assert (await db_session.execute(
+ select(EmailVerification))).scalars().first() is not None
+
+
+@pytest.mark.asyncio
+async def test_reset_request_rejects_a_malformed_email(client):
+ await _register(client, "iris")
+ r = await client.post("/v1/users/password/reset-request", json={
+ "username": "iris", "email": "not-an-email"})
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_a_wrong_code_is_rejected_and_counts_against_the_limit(
+ client, db_session):
+ await _register(client, "bob")
+ await _request_reset(client, "bob")
+
+ for _ in range(10):
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "bob", "code": "000000", "new_auth_key": "x" * 44})
+ assert r.status_code == 400
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "bob", "code": "000000", "new_auth_key": "x" * 44})
+ assert r.status_code == 429
+
+
+@pytest.mark.asyncio
+async def test_an_expired_code_is_refused(client, db_session):
+ await _register(client, "carol")
+ await _request_reset(client, "carol")
+
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "carol"))).scalar_one()
+ row = (await db_session.execute(select(EmailVerification).where(
+ EmailVerification.user_id == uid))).scalars().one()
+ row.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
+ await db_session.commit()
+
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "carol", "code": row.code, "new_auth_key": "y" * 44})
+ assert r.status_code == 410
+
+
+@pytest.mark.asyncio
+async def test_a_reset_code_works_once(client, db_session):
+ await _register(client, "dave")
+ await _request_reset(client, "dave")
+ code = await _reset_code(db_session, "dave")
+
+ first = await client.post("/v1/users/password/reset", json={
+ "username": "dave", "code": code, "new_auth_key": "z" * 44})
+ assert first.status_code == 200
+ second = await client.post("/v1/users/password/reset", json={
+ "username": "dave", "code": code, "new_auth_key": "z" * 44})
+ assert second.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_reset_revokes_sessions_and_wipes_devices(client, db_session):
+ await _register(client, "erin", "erin" + "a" * 40)
+ login = await client.post("/v1/users/login", json={
+ "username": "erin", "auth_key": "erin" + "a" * 40})
+ refresh_token = login.json()["refresh_token"]
+ token = login.json()["access_token"]
+
+ sk = Ed25519PrivateKey.generate()
+ dev = await client.post(
+ "/v1/users/devices",
+ json={"pk_auth_ed25519": pk_to_b64(sk.public_key()), "label": "laptop"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert dev.status_code == 201, dev.text
+
+ await _request_reset(client, "erin")
+ code = await _reset_code(db_session, "erin")
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "erin", "code": code, "new_auth_key": "erin-new" + "b" * 36})
+ assert r.status_code == 200
+
+ # Old refresh token is dead.
+ assert (await client.post("/v1/users/token/refresh", json={
+ "refresh_token": refresh_token})).status_code == 401
+
+ # Every device key is gone; the stored one can no longer sign in.
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "erin"))).scalar_one()
+ from meshbay_hub.db.models import UserDevice
+ devices = (await db_session.execute(
+ select(UserDevice).where(UserDevice.user_id == uid))).scalars().all()
+ assert devices == []
+
+ ts = int(time.time())
+ msg = f"meshbay:user_auth:erin:{ts}".encode()
+ da = await client.post("/v1/users/auth", json={
+ "username": "erin", "timestamp": ts,
+ "signature": base64.b64encode(sk.sign(msg)).decode()})
+ assert da.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_the_request_and_the_reset_are_logged(client, db_session):
+ await _register(client, "frank")
+ await _request_reset(client, "frank")
+ code = await _reset_code(db_session, "frank")
+ await client.post("/v1/users/password/reset", json={
+ "username": "frank", "code": code, "new_auth_key": "f" * 44})
+
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "frank"))).scalar_one()
+ events = {e.event for e in (await db_session.execute(
+ select(IPLog).where(IPLog.user_id == uid))).scalars().all()}
+ assert {"password_reset_request", "password_reset"} <= events
diff --git a/packages/meshbay-hub/tests/test_recovery_email.py b/packages/meshbay-hub/tests/test_recovery_email.py
new file mode 100644
index 0000000..e4288a1
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_recovery_email.py
@@ -0,0 +1,99 @@
+"""
+The recovery key in the registration e-mail (docs/auth-confirm.md §4.4).
+
+When the client sends `recovery_key`, the hub appends it to the verification
+e-mail and stores it nowhere. When it does not, the e-mail carries only the
+code. `recovery_key` is a pass-through — no column, no log line beyond a
+boolean.
+"""
+
+import base64
+import hashlib
+
+import pytest
+from meshbay_hub import mail
+from meshbay_hub.db.models import EmailVerification, User
+from sqlalchemy import select
+
+RECOVERY = "ABCD EFGH JKLM NPQR STUV WXYZ 2345 6789 ABCD EFGH JKLM NPQR STUV"
+
+
+def _auth_key(password: str, username: str) -> str:
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+@pytest.fixture(autouse=True)
+def _skip_email_verification(monkeypatch):
+ """
+ Override conftest's skip: this module needs the real verification path to
+ run so the e-mail is actually built. Capture it instead of sending.
+ """
+ sent = []
+ monkeypatch.setattr("meshbay_hub.mail._send", lambda msg: sent.append(msg) or True)
+ return sent
+
+
+@pytest.mark.asyncio
+async def test_register_appends_the_recovery_key_to_the_email(
+ client, _skip_email_verification):
+ r = await client.post("/v1/users/register", json={
+ "username": "rk1", "email": "rk1@example.com",
+ "auth_key": _auth_key("a-long-enough-passphrase", "rk1"),
+ "recovery_key": RECOVERY,
+ })
+ assert r.status_code in (200, 201), r.text
+ assert len(_skip_email_verification) == 1
+ body = _skip_email_verification[0].get_content()
+ assert RECOVERY in body
+ assert "recovery key" in body.lower()
+
+
+@pytest.mark.asyncio
+async def test_register_without_recovery_key_sends_only_the_code(
+ client, _skip_email_verification):
+ r = await client.post("/v1/users/register", json={
+ "username": "rk2", "email": "rk2@example.com",
+ "auth_key": _auth_key("a-long-enough-passphrase", "rk2"),
+ })
+ assert r.status_code in (200, 201), r.text
+ body = _skip_email_verification[0].get_content()
+ assert "recovery key" not in body.lower()
+ assert "verification code is" in body.lower()
+
+
+@pytest.mark.asyncio
+async def test_the_recovery_key_is_not_persisted(
+ client, db_session, _skip_email_verification):
+ await client.post("/v1/users/register", json={
+ "username": "rk3", "email": "rk3@example.com",
+ "auth_key": _auth_key("a-long-enough-passphrase", "rk3"),
+ "recovery_key": RECOVERY,
+ })
+ rows = (await db_session.execute(select(EmailVerification))).scalars().all()
+ assert rows
+ for row in rows:
+ assert RECOVERY not in (row.code or "")
+ assert RECOVERY not in (row.email_encrypted or "")
+ user = (await db_session.execute(
+ select(User).where(User.username == "rk3"))).scalar_one()
+ assert RECOVERY not in repr(vars(user))
+
+
+def test_mail_body_with_and_without_the_key(monkeypatch):
+ captured = []
+ monkeypatch.setattr("meshbay_hub.mail._send",
+ lambda msg: captured.append(msg) or True)
+
+ mail.send_verification_code("x@example.com", "123456",
+ recovery_key="MY-RECOVERY-KEY")
+ body = captured[-1].get_content()
+ assert "123456" in body
+ assert "MY-RECOVERY-KEY" in body
+ assert "recovery key" in body.lower()
+
+ mail.send_verification_code("x@example.com", "123456")
+ body = captured[-1].get_content()
+ assert "123456" in body
+ assert "recovery key" not in body.lower()
diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py
new file mode 100644
index 0000000..378758a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_recovery_key.py
@@ -0,0 +1,136 @@
+"""
+The account recovery key (docs/auth-confirm.md §4.3).
+
+`generateRecoveryKey` / `deriveRecoveryKey` in keyderive.js are run here under
+node against the real WebCrypto, rather than reimplemented: the mnemonic has to
+round-trip its bytes exactly, and the derived key has to be deterministic per
+account and domain-separated between accounts, or a recovery would hand back a
+key that opens nothing.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+KEYDERIVE = STATIC / "keyderive.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not KEYDERIVE.exists(),
+ reason="node or keyderive.js is unavailable",
+)
+
+_HARNESS = r"""
+const fs = require('fs');
+const webcrypto = require('crypto').webcrypto;
+global.self = global;
+global.window = global;
+global.crypto = webcrypto;
+
+// keyderive.js is a classic script ending in `window.MeshBayKeys = {...}`.
+eval(fs.readFileSync(process.argv[2], 'utf8'));
+const K = window.MeshBayKeys;
+
+const hex = (buf) => Buffer.from(buf).toString('hex');
+
+// deriveRecoveryKey yields a non-extractable AES-GCM key, so two keys are
+// compared by encrypting a fixed block with a fixed IV: same key => same bytes.
+const fp = async (key) => hex(await webcrypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: new Uint8Array(12) }, key, new Uint8Array(16)));
+
+(async () => {
+ const out = {};
+
+ // 1. mnemonic round-trips the exact 32 bytes, 200 random draws: the key
+ // derived from the mnemonic string must match the key from the raw bytes.
+ let roundTripOk = true;
+ for (let i = 0; i < 200; i++) {
+ const rk = K.generateRecoveryKey(); // { rawB64, mnemonic }
+ const raw = Uint8Array.from(atob(rk.rawB64), c => c.charCodeAt(0));
+ const a = await fp(await K.deriveRecoveryKey(rk.mnemonic, 'u'));
+ const b = await fp(await K.deriveRecoveryKey(raw, 'u'));
+ if (a !== b) { roundTripOk = false; break; }
+ }
+ out.round_trip_ok = roundTripOk;
+
+ // 2. deterministic per account, different per account.
+ const rk = K.generateRecoveryKey();
+ const raw = Uint8Array.from(atob(rk.rawB64), c => c.charCodeAt(0));
+ const k1 = await fp(await K.deriveRecoveryKey(raw, 'alice'));
+ const k1again = await fp(await K.deriveRecoveryKey(raw, 'alice'));
+ const k2 = await fp(await K.deriveRecoveryKey(raw, 'bob'));
+ out.deterministic = (k1 === k1again);
+ out.domain_separated = (k1 !== k2);
+
+ // 3. mnemonic is grouped Base32, 52 significant chars for 32 bytes.
+ out.mnemonic_shape_ok =
+ /^[A-Z2-7]{4}( [A-Z2-7]{1,4})+$/.test(rk.mnemonic) &&
+ rk.mnemonic.replace(/ /g, '').length === 52;
+
+ // 4. a garbled key is rejected, not silently truncated.
+ let rejected = false;
+ try { await K.deriveRecoveryKey('too short', 'u'); }
+ catch { rejected = true; }
+ out.rejects_short = rejected;
+
+ // 5. the building block connect()'s Flow B fallback relies on: a bundle
+ // wrapped under one recovery key does not open under a wrong one, and does
+ // open under the right one.
+ {
+ const kA = await K.deriveRecoveryKey(raw, 'acc-A');
+ const kB = await K.deriveRecoveryKey(raw, 'acc-B');
+ const skEd = new Uint8Array([1, 2, 3]);
+ const skX = new Uint8Array([4, 5, 6]);
+ const blob = await K.encryptBundleWithKey(skEd, skX, kA);
+ let wrongRejected = false;
+ try { await K.decryptBundleWithKey(blob, kB); } catch { wrongRejected = true; }
+ const opened = await K.decryptBundleWithKey(blob, kA);
+ out.recovery_wrap_isolates = wrongRejected
+ && opened.skEd === btoa(String.fromCharCode(1, 2, 3))
+ && opened.skX === btoa(String.fromCharCode(4, 5, 6));
+ }
+
+ process.stdout.write(JSON.stringify(out));
+})().catch(e => { console.error(e); process.exit(1); });
+"""
+
+
+@pytest.fixture(scope="module")
+def result(tmp_path_factory):
+ d = tmp_path_factory.mktemp("recovery")
+ harness = d / "harness.cjs"
+ harness.write_text(_HARNESS)
+ proc = subprocess.run(
+ ["node", str(harness), str(KEYDERIVE)],
+ capture_output=True, text=True, timeout=120,
+ )
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
+ return json.loads(proc.stdout)
+
+
+def test_mnemonic_round_trips_the_exact_bytes(result):
+ assert result["round_trip_ok"]
+
+
+def test_derived_key_is_deterministic_per_account(result):
+ assert result["deterministic"]
+
+
+def test_derived_key_is_domain_separated_between_accounts(result):
+ assert result["domain_separated"]
+
+
+def test_mnemonic_is_grouped_base32(result):
+ assert result["mnemonic_shape_ok"]
+
+
+def test_a_malformed_recovery_key_is_rejected(result):
+ assert result["rejects_short"]
+
+
+def test_a_recovery_wrapped_bundle_only_opens_under_the_matching_key(result):
+ assert result["recovery_wrap_isolates"]
diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py
new file mode 100644
index 0000000..03d24dc
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py
@@ -0,0 +1,210 @@
+"""
+`MeshBayTransport.rewrapAllNodes` — the passphrase-change / recovery fan-out
+(docs/auth-confirm.md §3.2, §4.5).
+
+The real function is run under node with its two boundaries stubbed: the hub
+HTTP calls and the per-node `MeshBayTransport` handshake. What is exercised is
+the orchestration — which groups land in `updated` / `unreachable` / `failed`,
+which nodes get a `keypair_bundle_store`, and that Flow B also writes a
+recovery-wrapped copy. The WebRTC handshake itself and `connect()`'s
+recovery-copy fallback are integration territory with no harness here.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not TRANSPORT.exists(),
+ reason="node or transport.js is unavailable",
+)
+
+_HARNESS = r"""
+const fs = require('fs');
+global.self = global;
+global.window = global;
+global.location = { hash: '' };
+global.addEventListener = () => {};
+global.document = {
+ hidden: false, visibilityState: 'visible', addEventListener: () => {},
+};
+global.localStorage = {
+ getItem: () => null, setItem() {}, removeItem() {}, key: () => null, length: 0,
+};
+// connect() is stubbed on the prototype below, so no WebRTC shim is needed.
+global.RTCPeerConnection = function () { throw new Error('connect() not stubbed'); };
+
+eval(fs.readFileSync(process.argv[2], 'utf8'));
+const T = window.MeshBayTransport;
+
+let deriveEncCalls = 0;
+window.MeshBayKeys = {
+ deriveEncryptionKey: async (p) => { deriveEncCalls++; return { kind: 'enc', p }; },
+ deriveEncryptionKeyV1: async (p) => ({ kind: 'encv1', p }),
+ deriveRecoveryKey: async (r) => ({ kind: 'rec', r }),
+ encryptBundleWithKey: async (_skEd, _skX, key) => 'wrapped:' + key.kind,
+};
+
+const b64 = (s) => Buffer.from(s).toString('base64');
+
+const NODES = {
+ 'n-ok': { sessionKeys: { skEdB64: b64('ed'), skXB64: b64('x') } },
+ 'n-fresh': { newNodeBundle: 'fresh', sessionKeys: { skEdB64: b64('ed'), skXB64: b64('x') } },
+ 'n-throw': { throws: 'handshake failed' },
+ 'n-noident': { sessionKeys: null },
+};
+
+const stored = [];
+const rewrapOnlySeen = [];
+T.prototype.connect = async function (nodeId) {
+ this._nodeId = nodeId;
+ rewrapOnlySeen.push(this._rewrapOnly === true);
+ const s = NODES[nodeId] || {};
+ if (s.throws) throw new Error(s.throws);
+ this._sessionKeys = s.sessionKeys || null;
+ this._newNodeBundle = s.newNodeBundle || null;
+ return { ok: true };
+};
+T.prototype.storeKeypairBundle = async function (enc, rec) {
+ stored.push({ nodeId: this._nodeId, enc, rec: rec || null });
+};
+T.prototype.close = function () {};
+
+const MINE = { groups: [
+ { id: 'gA', name: 'a', owner_username: 'ann' }, // normal node
+ { id: 'gB', name: 'b', owner_username: 'ann' }, // no online node
+ { id: 'gC', name: 'c', owner_username: 'ann' }, // /nodes errors
+ { id: 'gD', name: 'd', owner_username: 'ann' }, // connect throws
+ { id: 'gE', name: 'e', owner_username: 'ann' }, // fresh identity, nothing stranded
+ { id: 'gF', name: 'f', owner_username: 'ann' }, // identity not recovered
+] };
+const NODES_FOR = {
+ gA: { nodes: [{ node_id: 'n-ok' }] },
+ gB: { nodes: [] },
+ gC: 'ERR',
+ gD: { nodes: [{ node_id: 'n-throw' }] },
+ gE: { nodes: [{ node_id: 'n-fresh' }] },
+ gF: { nodes: [{ node_id: 'n-noident' }] },
+};
+global.fetch = async (url) => {
+ const path = url.replace(/^.*?(\/v1\/)/, '$1');
+ if (path === '/v1/groups/mine') return { ok: true, json: async () => MINE };
+ const m = path.match(/^\/v1\/groups\/([^/]+)\/nodes$/);
+ if (m) {
+ const v = NODES_FOR[m[1]];
+ if (v === 'ERR') return { ok: false, status: 503 };
+ return { ok: true, json: async () => v };
+ }
+ return { ok: false, status: 404 };
+};
+
+const names = (a) => a.map((x) => x.name).sort();
+
+(async () => {
+ const A = await T.rewrapAllNodes({
+ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
+ oldPassphrase: 'old', newPassphrase: 'new',
+ });
+ const storeA = stored.splice(0);
+
+ const B = await T.rewrapAllNodes({
+ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
+ newPassphrase: 'new', recoveryKey: 'A RECOVERY MNEMONIC',
+ });
+ const storeB = stored.splice(0);
+
+ // Flow C — Profile backfill: keep the live passphrase key, just add the
+ // recovery copy. No passphrase strings, so deriveEncryptionKey is not called.
+ deriveEncCalls = 0;
+ const C = await T.rewrapAllNodes({
+ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
+ bundleKey: { v2: { kind: 'bk' }, v1: { kind: 'bkv1' } },
+ recoveryKey: 'A RECOVERY MNEMONIC',
+ });
+ const storeC = stored.splice(0);
+
+ process.stdout.write(JSON.stringify({
+ a_updated: names(A.updated),
+ a_unreachable: names(A.unreachable),
+ a_failed: names(A.failed),
+ a_stored_nodes: storeA.map((s) => s.nodeId).sort(),
+ a_recovery_always_null: storeA.every((s) => s.rec === null),
+ a_new_bundle_key_kind: A.newBundleKey && A.newBundleKey.v2 && A.newBundleKey.v2.kind,
+ b_stored: storeB.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
+ c_stored: storeC.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
+ c_derive_enc_calls: deriveEncCalls,
+ // Every transport the fan-out builds is flagged rewrap-only, so a stored
+ // bundle it cannot open is reported, not silently replaced with a new one.
+ all_rewrap_only: rewrapOnlySeen.length > 0 && rewrapOnlySeen.every(Boolean),
+ }));
+})().catch((e) => { console.error(e); process.exit(1); });
+"""
+
+
+@pytest.fixture(scope="module")
+def result(tmp_path_factory):
+ d = tmp_path_factory.mktemp("rewrap")
+ harness = d / "harness.cjs"
+ harness.write_text(_HARNESS)
+ proc = subprocess.run(
+ ["node", str(harness), str(TRANSPORT)],
+ capture_output=True, text=True, timeout=120,
+ )
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
+ return json.loads(proc.stdout)
+
+
+def test_a_reachable_node_with_an_identity_is_updated(result):
+ assert "a@ann" in result["a_updated"]
+ assert result["a_stored_nodes"] == ["n-ok"]
+
+
+def test_a_group_with_no_online_node_is_unreachable(result):
+ assert result["a_unreachable"] == ["b@ann"]
+
+
+def test_a_nodes_lookup_error_and_a_failed_handshake_land_in_failed(result):
+ assert "c@ann" in result["a_failed"] # /nodes returned 503
+ assert "d@ann" in result["a_failed"] # connect() threw
+
+
+def test_a_node_that_never_had_our_identity_is_not_written_but_not_a_failure(result):
+ # gE: connect minted a fresh identity — nothing is stranded, so the group is
+ # "updated", and no keypair_bundle_store is sent for it.
+ assert "e@ann" in result["a_updated"]
+ assert "n-fresh" not in result["a_stored_nodes"]
+
+
+def test_a_node_that_returns_no_identity_is_a_failure(result):
+ assert "f@ann" in result["a_failed"]
+
+
+def test_flow_a_writes_only_the_passphrase_copy(result):
+ assert result["a_recovery_always_null"] is True
+ assert result["a_new_bundle_key_kind"] == "enc"
+
+
+def test_flow_b_writes_both_the_passphrase_and_the_recovery_copy(result):
+ assert result["b_stored"] == [
+ {"node": "n-ok", "enc": "wrapped:enc", "rec": "wrapped:rec"},
+ ]
+
+
+def test_profile_backfill_keeps_the_live_key_and_adds_the_recovery_copy(result):
+ # bundleKey mode: the passphrase copy is re-wrapped with the same live key
+ # (kind "bk"), the recovery copy is added, and no passphrase is derived.
+ assert result["c_stored"] == [
+ {"node": "n-ok", "enc": "wrapped:bk", "rec": "wrapped:rec"},
+ ]
+ assert result["c_derive_enc_calls"] == 0
+
+
+def test_every_fanout_transport_is_rewrap_only(result):
+ assert result["all_rewrap_only"] is True
diff --git a/packages/meshbay-node/src/meshbay_node/bundle_store.py b/packages/meshbay-node/src/meshbay_node/bundle_store.py
index 4cf3236..7f03caa 100644
--- a/packages/meshbay-node/src/meshbay_node/bundle_store.py
+++ b/packages/meshbay-node/src/meshbay_node/bundle_store.py
@@ -3,7 +3,10 @@ Bundle store — SQLite-backed storage for GEK bundles and keypair bundles.
GEK bundles: ECIES-wrapped GEK targeted at a specific user's X25519 key.
Keypair bundles: AES-GCM encrypted (Ed25519 + X25519) private keys, encrypted
-with the user's password-derived bundle_key. Opaque to the node.
+with the user's password-derived bundle_key. Opaque to the node. An optional
+second copy (bundle_enc_recovery) is wrapped under the account's recovery key
+instead, so a forgotten passphrase does not strand the identity — see
+docs/auth-confirm.md §4.3.
Both are stored and served over the P2P DataChannel during MNP handshake.
"""
@@ -29,9 +32,10 @@ CREATE TABLE IF NOT EXISTS gek_bundles (
_SCHEMA_KEYPAIR = """\
CREATE TABLE IF NOT EXISTS keypair_bundles (
- user_id TEXT PRIMARY KEY,
- bundle_enc TEXT NOT NULL,
- stored_at TEXT NOT NULL DEFAULT (datetime('now'))
+ user_id TEXT PRIMARY KEY,
+ bundle_enc TEXT NOT NULL,
+ bundle_enc_recovery TEXT,
+ stored_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
@@ -46,8 +50,22 @@ class BundleStore:
self._db = await aiosqlite.connect(str(self._db_path))
await self._db.execute(_SCHEMA_GEK)
await self._db.execute(_SCHEMA_KEYPAIR)
+ await self._migrate_keypair_recovery()
await self._db.commit()
+ async def _migrate_keypair_recovery(self) -> None:
+ """
+ Add bundle_enc_recovery to a keypair_bundles table created before it
+ existed. SQLite has no ADD COLUMN IF NOT EXISTS, so check the columns
+ first — this table is node-only and has no Alembic history.
+ """
+ assert self._db
+ async with self._db.execute("PRAGMA table_info(keypair_bundles)") as cur:
+ cols = {row[1] for row in await cur.fetchall()}
+ if "bundle_enc_recovery" not in cols:
+ await self._db.execute(
+ "ALTER TABLE keypair_bundles ADD COLUMN bundle_enc_recovery TEXT")
+
async def store(
self,
group_id: str,
@@ -81,23 +99,46 @@ class BundleStore:
"wrapped_b64": row[2],
}
- async def store_keypair(self, user_id: str, bundle_enc: str) -> None:
+ async def store_keypair(
+ self,
+ user_id: str,
+ bundle_enc: str,
+ bundle_enc_recovery: str | None = None,
+ ) -> None:
+ """
+ Store the passphrase-wrapped keypair bundle, and optionally a second
+ copy wrapped under the account's recovery key.
+
+ A call that omits bundle_enc_recovery — a plain re-backup, or a
+ passphrase-change re-wrap (docs/auth-confirm.md §3.2) — must not erase a
+ recovery copy already stored, so the upsert keeps the existing value
+ when the new one is None.
+ """
assert self._db
await self._db.execute(
- "INSERT OR REPLACE INTO keypair_bundles "
- "(user_id, bundle_enc, stored_at) VALUES (?, ?, datetime('now'))",
- (user_id, bundle_enc),
+ "INSERT INTO keypair_bundles "
+ "(user_id, bundle_enc, bundle_enc_recovery, stored_at) "
+ "VALUES (?, ?, ?, datetime('now')) "
+ "ON CONFLICT(user_id) DO UPDATE SET "
+ " bundle_enc = excluded.bundle_enc, "
+ " bundle_enc_recovery = COALESCE(excluded.bundle_enc_recovery, "
+ " keypair_bundles.bundle_enc_recovery), "
+ " stored_at = excluded.stored_at",
+ (user_id, bundle_enc, bundle_enc_recovery),
)
await self._db.commit()
- async def fetch_keypair(self, user_id: str) -> str | None:
+ async def fetch_keypair(self, user_id: str) -> dict | None:
assert self._db
async with self._db.execute(
- "SELECT bundle_enc FROM keypair_bundles WHERE user_id = ?",
+ "SELECT bundle_enc, bundle_enc_recovery FROM keypair_bundles "
+ "WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
- return row[0] if row else None
+ if not row:
+ return None
+ return {"bundle_enc": row[0], "bundle_enc_recovery": row[1]}
async def delete_keypair(self, user_id: str) -> bool:
"""
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 07579cd..cd5dc32 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -219,6 +219,16 @@ async def unpin_member(state: dict, user_id: str) -> dict:
roster = _roster(state)
if not await roster.unpin(user_id):
raise OpError("No such pinned identity", status=404)
+ # Drop the stored keypair bundle too. Left behind, it is served to the next
+ # connection, which then cannot open it (the passphrase may have changed
+ # since) and dies in the identity step before it ever reaches the join the
+ # unpin was meant to enable.
+ bundle_store = state.get("bundle_store")
+ if bundle_store:
+ try:
+ await bundle_store.delete_keypair(user_id)
+ except Exception:
+ log.warning("unpin: could not drop keypair bundle for %s", user_id[:8])
log.info("Identity unpinned: user=%s", user_id[:8])
return {"status": "unpinned", "user_id": user_id}
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 15309cf..717a27a 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -868,14 +868,20 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "No pending handshake"})
return
- bundle_enc = await bundle_store.fetch_keypair(user_id)
- if bundle_enc:
- self._send({
+ kp = await bundle_store.fetch_keypair(user_id)
+ if kp and kp.get("bundle_enc"):
+ resp = {
"type": MNP.KEYPAIR_BUNDLE_RESP,
"v": MNP_VERSION,
"found": True,
- "bundle_enc": bundle_enc,
- })
+ "bundle_enc": kp["bundle_enc"],
+ }
+ # The recovery-wrapped copy (MNP 0.14) rides along when present, so a
+ # client holding the recovery key can re-wrap it under a new
+ # passphrase — docs/auth-confirm.md §4.5.
+ if kp.get("bundle_enc_recovery"):
+ resp["bundle_enc_recovery"] = kp["bundle_enc_recovery"]
+ self._send(resp)
else:
self._send({"type": MNP.KEYPAIR_BUNDLE_RESP, "v": MNP_VERSION, "found": False})
@@ -891,8 +897,14 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "Missing bundle_enc"})
return
- await bundle_store.store_keypair(self._user_id, bundle_enc)
- log.info("Keypair bundle stored for user=%s", self._user_id[:8])
+ # Optional second copy wrapped under the recovery key (MNP 0.14). Omitted
+ # by an older client and by a plain re-backup; the store keeps any
+ # existing recovery copy when this is absent.
+ recovery = msg.get("bundle_enc_recovery") or None
+
+ await bundle_store.store_keypair(self._user_id, bundle_enc, recovery)
+ log.info("Keypair bundle stored for user=%s (recovery=%s)",
+ self._user_id[:8], bool(recovery))
self._audit("keypair_bundle_store")
self._send({
"type": "ack", "v": MNP_VERSION,
diff --git a/packages/meshbay-node/tests/test_admin_ops_mnp.py b/packages/meshbay-node/tests/test_admin_ops_mnp.py
index 1bf8365..92e50b5 100644
--- a/packages/meshbay-node/tests/test_admin_ops_mnp.py
+++ b/packages/meshbay-node/tests/test_admin_ops_mnp.py
@@ -100,10 +100,15 @@ async def _session(tmp_path: Path, roster, *, operator: bool) -> WebRTCPeerSessi
class _FakeBundleStore:
def __init__(self):
self.stored = []
+ self.deleted_keypairs = []
async def store(self, *args):
self.stored.append(args)
+ async def delete_keypair(self, user_id):
+ self.deleted_keypairs.append(user_id)
+ return True
+
class _FakeHub:
class _S:
@@ -279,6 +284,9 @@ async def test_the_operator_unpins(tmp_path, roster):
assert _last(session)["type"] == MNP.MEMBER_UNPIN_ACK
assert await roster.get_identity("bob") is None
+ # The stored keypair bundle goes too — left behind it blocks the re-join
+ # the unpin exists to enable.
+ assert "bob" in session.state["bundle_store"].deleted_keypairs
async def test_unpinning_someone_unknown_says_so(tmp_path, roster):
diff --git a/packages/meshbay-node/tests/test_bundle_store_recovery.py b/packages/meshbay-node/tests/test_bundle_store_recovery.py
new file mode 100644
index 0000000..10a3400
--- /dev/null
+++ b/packages/meshbay-node/tests/test_bundle_store_recovery.py
@@ -0,0 +1,76 @@
+"""
+The recovery-wrapped keypair copy (docs/auth-confirm.md §4.3, MNP 0.14).
+
+`bundle_enc_recovery` is a second copy of the identity bundle wrapped under the
+account's recovery key. The store has to add the column to a database that
+predates it, and a plain re-backup that omits the recovery copy must not erase
+one already there.
+"""
+
+import aiosqlite
+import pytest
+from meshbay_node.bundle_store import BundleStore
+
+
+@pytest.mark.asyncio
+async def test_round_trip_with_and_without_recovery(tmp_path):
+ store = BundleStore(db_path=tmp_path / "bundles.db")
+ await store.open()
+
+ await store.store_keypair("u1", "pass-wrapped-1")
+ row = await store.fetch_keypair("u1")
+ assert row == {"bundle_enc": "pass-wrapped-1", "bundle_enc_recovery": None}
+
+ await store.store_keypair("u2", "pass-wrapped-2", "recovery-wrapped-2")
+ row = await store.fetch_keypair("u2")
+ assert row["bundle_enc"] == "pass-wrapped-2"
+ assert row["bundle_enc_recovery"] == "recovery-wrapped-2"
+
+ assert await store.fetch_keypair("nobody") is None
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_re_backup_without_recovery_keeps_the_existing_copy(tmp_path):
+ """A passphrase-change re-wrap sends only bundle_enc; the recovery copy stays."""
+ store = BundleStore(db_path=tmp_path / "bundles.db")
+ await store.open()
+
+ await store.store_keypair("u1", "v1", "recovery-v1")
+ await store.store_keypair("u1", "v2") # no recovery arg
+ row = await store.fetch_keypair("u1")
+ assert row["bundle_enc"] == "v2"
+ assert row["bundle_enc_recovery"] == "recovery-v1"
+
+ # An explicit new recovery copy does replace it.
+ await store.store_keypair("u1", "v3", "recovery-v3")
+ row = await store.fetch_keypair("u1")
+ assert row == {"bundle_enc": "v3", "bundle_enc_recovery": "recovery-v3"}
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_migration_adds_the_column_to_an_old_database(tmp_path):
+ db_path = tmp_path / "bundles.db"
+
+ # A keypair_bundles table as it looked before MNP 0.14.
+ async with aiosqlite.connect(str(db_path)) as db:
+ await db.execute(
+ "CREATE TABLE keypair_bundles ("
+ " user_id TEXT PRIMARY KEY,"
+ " bundle_enc TEXT NOT NULL,"
+ " stored_at TEXT NOT NULL DEFAULT (datetime('now')))")
+ await db.execute(
+ "INSERT INTO keypair_bundles (user_id, bundle_enc) VALUES ('old', 'legacy')")
+ await db.commit()
+
+ store = BundleStore(db_path=db_path)
+ await store.open() # runs the migration
+
+ row = await store.fetch_keypair("old")
+ assert row == {"bundle_enc": "legacy", "bundle_enc_recovery": None}
+
+ await store.store_keypair("old", "legacy", "recovery-now")
+ row = await store.fetch_keypair("old")
+ assert row["bundle_enc_recovery"] == "recovery-now"
+ await store.close()
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index ec6e987..159ba63 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -1359,7 +1359,8 @@ async def test_keypair_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir,
# Verify in DB
stored = await bundle_store.fetch_keypair("user-001")
- assert stored == "encrypted-keypair-data-base64"
+ assert stored["bundle_enc"] == "encrypted-keypair-data-base64"
+ assert stored["bundle_enc_recovery"] is None
await pc1.close()