From 28c77b7648b2a44a18be64ec44344d91cccfd742 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 14:25:00 +0200 Subject: feat(auth): the recovery key never reaches the hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticking "also e-mail it to me" at registration sent the recovery key R in the register body, and the hub appended it to the verification e-mail: the hub saw R in clear on its way out, and the resend door — no captcha — let anyone who knew a pending username and address have the hub mail a "recovery key" of their choosing. The box is gone. The recovery screen offers "E-mail it to myself", a mailto: link that opens the person's own mail application with the key filled in; the desktop shell hands mailto: to the system (http, https and mailto only) and the Android shell to a mail application (ACTION_SENDTO). The CLI drops --email-recovery-key and prints the key, as before. The hub mails the code alone, and refuses a register that still carries a recovery_key rather than dropping it, so an older client offering the option cannot tell someone their key was sent when it was not. A mailbox holding R remains, with the reset code that reaches the same mailbox, enough to recover the account's identities; the link says so, and it is the person's choice. Checked in Chrome and Firefox (headless, the real page against a stubbed hub) and by the CLI's end-to-end registration against a local hub. The Android change was not built here, and the desktop link was not clicked in the application. Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/org/meshbay/client/MainActivity.kt | 8 ++ packages/meshbay-cli/src/meshbay_cli/client/hub.py | 6 +- .../src/meshbay_cli/commands/account.py | 17 +--- packages/meshbay-client/src/main.js | 4 +- packages/meshbay-hub/src/meshbay_hub/api/users.py | 24 ++++-- packages/meshbay-hub/src/meshbay_hub/mail.py | 27 ++---- .../src/meshbay_hub/static/auth-page.js | 48 +++++++---- .../src/meshbay_hub/static/keyderive.js | 8 +- .../src/meshbay_hub/static/locales/de.js | 8 +- .../src/meshbay_hub/static/locales/en.js | 8 +- .../src/meshbay_hub/static/locales/es.js | 8 +- .../src/meshbay_hub/static/locales/fr.js | 8 +- .../src/meshbay_hub/static/locales/it.js | 8 +- .../src/meshbay_hub/static/locales/ja.js | 8 +- .../src/meshbay_hub/static/locales/nl.js | 8 +- .../src/meshbay_hub/static/locales/pl.js | 8 +- .../src/meshbay_hub/static/locales/pt-BR.js | 8 +- .../src/meshbay_hub/static/locales/zh-CN.js | 8 +- .../meshbay-hub/src/meshbay_hub/static/style.css | 8 ++ packages/meshbay-hub/tests/conftest.py | 2 +- packages/meshbay-hub/tests/test_android_shell.py | 4 + packages/meshbay-hub/tests/test_desktop_shell.py | 18 ++++ packages/meshbay-hub/tests/test_recovery_email.py | 99 +++++++++------------- packages/meshbay-hub/tests/test_recovery_mailto.py | 70 +++++++++++++++ 24 files changed, 265 insertions(+), 158 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_recovery_mailto.py (limited to 'packages') diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index 6bd2a3f..d523da7 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -210,6 +210,9 @@ class MainActivity : Activity() { // The hub must never become the document origin. A link out // opens in the person's browser, not in a window holding keys. if (request.isForMainFrame && (url.scheme == "https" || url.scheme == "http")) openExternally(url) + // The recovery screen's "e-mail it to myself" (docs/MESHBAY_DESIGN.md + // §3.6): the person's own mail application sends it, never the hub. + if (request.isForMainFrame && url.scheme == "mailto") openMail(url) return true } } @@ -391,6 +394,11 @@ class MainActivity : Activity() { private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream()) + private fun openMail(url: Uri) { + try { startActivity(Intent(Intent.ACTION_SENDTO, url)) } + catch (e: android.content.ActivityNotFoundException) { Log.w(Bridge.TAG, "no mail application") } + } + private fun openExternally(url: Uri) { try { startActivity(Intent(Intent.ACTION_VIEW, url).addCategory(Intent.CATEGORY_BROWSABLE)) } catch (e: android.content.ActivityNotFoundException) { Log.w(Bridge.TAG, "no browser for $url") } diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py index 5b301f8..5726816 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/hub.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py @@ -132,10 +132,10 @@ class Hub: raise async def register(self, username: str, email: str, auth_key: str, - recovery_key: str | None, captcha_token: str | None) -> dict: + captcha_token: str | None) -> dict: + # Never the recovery key: the hub does not see it and refuses a + # register that carries one (docs/MESHBAY_DESIGN.md §3.6). body = {"username": username, "email": email, "auth_key": auth_key} - if recovery_key: - body["recovery_key"] = recovery_key if captcha_token: body["captcha_token"] = captcha_token return await self._call("POST", "/v1/users/register", json=body) diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/account.py b/packages/meshbay-cli/src/meshbay_cli/commands/account.py index 2137995..4499f0b 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/account.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/account.py @@ -66,9 +66,6 @@ def add_parser(sub, parent) -> None: p.add_argument("--recovery-key-file", metavar="FILE", help="reset: read the recovery key from FILE, to restore your keys " "on the nodes") - p.add_argument("--email-recovery-key", action="store_true", - help="register: also send the recovery key with the verification " - "e-mail") p.add_argument("--no-remember", action="store_true", help="reset: sign in afterwards without storing the session") p.add_argument("-y", "--yes", action="store_true", @@ -206,29 +203,23 @@ async def _register(ctx: Context) -> int: raw, mnemonic = generate_recovery() try: try: - reg = await ctx.hub.register(username, email, ak, - mnemonic if a.email_recovery_key else None, - await _captcha(ctx)) + reg = await ctx.hub.register(username, email, ak, await _captcha(ctx)) except HubError as e: if e.detail not in ("captcha_failed", "captcha_required"): raise # An answer is spent once checked; a fresh one, once. - reg = await ctx.hub.register(username, email, ak, - mnemonic if a.email_recovery_key else None, - await _captcha(ctx)) + reg = await ctx.hub.register(username, email, ak, await _captcha(ctx)) except HubError as e: raise _hub_refusal(e, "Not registered") from e if ctx.args.json: print(json.dumps({"user_id": reg.get("user_id"), "username": username, "recovery_key": mnemonic, - "recovery_key_emailed": bool(a.email_recovery_key), "email_verification_required": True})) else: print(f"Account {safe(username)} created on {ctx.hub.url}.\n\n" - "Your recovery key — shown this once, kept nowhere by MeshBay" - + (" (it is also in the verification e-mail)" if a.email_recovery_key - else "") + ":\n\n" + "Your recovery key — shown this once, kept nowhere by MeshBay, never " + "sent to the hub:\n\n" f" {mnemonic}\n\n" "Keep it somewhere safe, apart from your passphrase. If you forget the " "passphrase, it is what gives you your groups back.") diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index b602950..b77022e 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -682,8 +682,10 @@ function createWindow() { win.webContents.on('will-navigate', (event, target) => { if (!isOurs(target)) event.preventDefault(); }); + // `mailto:` goes to the system's mail application too: the recovery screen + // has the person mail their key to themselves (docs/MESHBAY_DESIGN.md §3.6). win.webContents.setWindowOpenHandler(({ url }) => { - if (/^https?:$/.test(new URL(url).protocol)) shell.openExternal(url); + if (/^(https?|mailto):$/.test(new URL(url).protocol)) shell.openExternal(url); return { action: 'deny' }; }); // Deny by default, with one exception, and the exception is the point of the diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index eb053b7..2d4e3cb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -131,9 +131,9 @@ 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/MESHBAY_DESIGN.md §3.6). 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. + # Refused when present (see `register`): the hub no longer mails the + # recovery key. Kept as a field only so an older client that still sends it + # is told so, rather than having it silently dropped. recovery_key: str | None = None captcha_token: str | None = None @@ -193,6 +193,16 @@ async def register( db: AsyncSession = Depends(get_db), ): """Create an account. It stays inactive until its e-mail address is verified.""" + # The recovery key never reaches the hub (docs/MESHBAY_DESIGN.md §3.6): the + # person mails it to themselves from their own mail application. An older + # client still offers "e-mail it to me" and would tell them it was sent; + # dropping the field quietly would leave them believing that, so the + # registration is refused with the reason instead. + if body.recovery_key: + raise HTTPException( + status_code=400, + detail="This hub no longer e-mails the recovery key. Update the " + "application, or untick the option to e-mail it.") eh = hash_email_blind(body.email) # Unique regardless of case: invitations and member management name people @@ -229,8 +239,7 @@ async def register( - timedelta(seconds=resend_cooldown), )) if not recent.first(): - await _create_and_send_verification( - db, found, body.email, eh, body.recovery_key) + await _create_and_send_verification(db, found, body.email, eh) await db.commit() return {"user_id": found.id, "email_verification_required": True} raise HTTPException(status_code=409, detail="Username already taken") @@ -275,7 +284,7 @@ async def register( detail=body.username, )) - await _create_and_send_verification(db, user, body.email, eh, body.recovery_key) + await _create_and_send_verification(db, user, body.email, eh) await db.commit() return {"user_id": user.id, "email_verification_required": True} @@ -283,7 +292,6 @@ 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 @@ -307,7 +315,7 @@ async def _create_and_send_verification( await db.flush() await mail.send_off_loop( db, mail.send_verification_code, email, code, - purpose="registration", recovery_key=recovery_key) + purpose="registration") class VerifyEmailRequest(BaseModel): diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index 6980da9..768b0d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -344,33 +344,17 @@ async def send_off_loop(db, fn, *args, purpose: str, sender: str = "", **kwargs) return True -def send_verification_code(to: str, code: str, recovery_key: str | None = None) -> None: +def send_verification_code(to: str, code: str) -> 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/MESHBAY_DESIGN.md §3.6). - - `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. + Registration verification e-mail: the code and nothing else. The recovery + key is never in it — the hub does not see that key (docs/MESHBAY_DESIGN.md + §3.6); the person mails it to themselves from their own application. """ 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" @@ -382,8 +366,7 @@ def send_verification_code(to: str, code: str, recovery_key: str | None = None) msg["Subject"] = f"MeshBay — Your verification code: {code}" msg.set_content(body) _send(msg, purpose="registration") - log.info("Verification code sent to %s (recovery_key=%s)", - _mask_email(to), bool(recovery_key)) + log.info("Verification code sent to %s", _mask_email(to)) def send_email_change_code(to: str, code: str) -> None: 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 c5374f8..4b8ed7c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -339,6 +339,23 @@ function WelcomePitch() { `; } +/** + * A `mailto:` link that has the person's own mail application send the + * recovery key to their own address. The hub never sees the key (§3.6): it + * used to append it to the verification e-mail, which put it in clear on the + * hub's way out. Opened in the browser by the link itself, and by the desktop + * and Android shells, which hand `mailto:` to the system. + */ +export function recoveryMailto(email, username, mnemonic) { + // RFC 6068: the address is percent-encoded like the rest, but '@' stays — + // several mail applications do not decode it back. + const to = encodeURIComponent(email).replace(/%40/g, '@'); + const subject = encodeURIComponent(t('register.recovery_mail_subject')); + const body = encodeURIComponent( + t('register.recovery_mail_body', { user: username, key: mnemonic })); + return `mailto:${to}?subject=${subject}&body=${body}`; +} + // The sign-in page's dark gradient backdrop and frosted card, without the // pitch beside it — so Register (and its verify/recovery/done steps) sits on // the same background and reads in the same dark theme as Login. A lone @@ -370,9 +387,6 @@ export function RegisterPage() { const [recoveryMnemonic, setRecoveryMnemonic] = useState(''); const [recoverySaved, setRecoverySaved] = useState(false); const [recoveryCopied, setRecoveryCopied] = useState(false); - // Off by default: mailing the recovery key is opt-in — the key is shown on - // screen to save, and sending a copy is the user's own choice to make. - const [emailRecovery, setEmailRecovery] = useState(false); const captcha = useCaptcha(); const onSubmit = async (e) => { @@ -393,18 +407,16 @@ export function RegisterPage() { try { if (window.MeshBayKeys) { // The account recovery key (docs/MESHBAY_DESIGN.md §3.6): 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. + // here, shown once on the next screen, and never sent to the hub — + // the person may mail it to themselves from their own application. + // 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. const reg = await window.MeshBayKeys.registerUser( - name, email, password, emailRecovery ? rk.mnemonic : null, - await captcha.take()); + name, email, password, await captcha.take()); // An account made in the desktop application starts without browser // access: its identities stay on this device and nothing of them is // left on nodes until the person turns it on, here, natively. @@ -510,8 +522,14 @@ export function RegisterPage() { + + ${t('register.recovery_mail_self')} + +

${t('register.recovery_mail_hint')}

- ${emailRecovery ? t('register.recovery_emailed') : t('register.recovery_not_emailed')} + ${t('register.recovery_shown_once')}

${t('register.recovery_warning')}