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 --- .../src/meshbay_hub/static/auth-page.js | 48 ++++++++++++++-------- 1 file changed, 30 insertions(+), 18 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/auth-page.js') 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')}