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 --- 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 ++++ 15 files changed, 112 insertions(+), 83 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub') 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')}