aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/mail.py
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 /packages/meshbay-hub/src/meshbay_hub/mail.py
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
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/mail.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py63
1 files changed, 56 insertions, 7 deletions
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: