diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 14:25:00 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 14:25:00 +0200 |
| commit | 28c77b7648b2a44a18be64ec44344d91cccfd742 (patch) | |
| tree | 72e0398306639817922cf292e4da6725182c23dd /packages/meshbay-hub/src/meshbay_hub/static/auth-page.js | |
| parent | ac4c3302783a3137e55dcede54c466bdf8aa55b7 (diff) | |
| download | meshbay-28c77b7648b2a44a18be64ec44344d91cccfd742.tar.gz | |
feat(auth): the recovery key never reaches the hub
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/auth-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/auth-page.js | 48 |
1 files changed, 30 insertions, 18 deletions
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() { <button class="btn-secondary" style="margin-top:8px" onClick=${copyRecovery}> ${recoveryCopied ? t('register.recovery_copied') : t('register.recovery_copy')} </button> + <a class="btn-secondary recovery-mail" role="button" + href=${recoveryMailto(email, username.trim(), recoveryMnemonic)} + target=${platform.isNative ? '_blank' : undefined} rel="noopener"> + ${t('register.recovery_mail_self')} + </a> + <p class="settings-hint" style="margin-top:6px">${t('register.recovery_mail_hint')}</p> <p style="margin-top:12px; color:var(--text-secondary)"> - ${emailRecovery ? t('register.recovery_emailed') : t('register.recovery_not_emailed')} + ${t('register.recovery_shown_once')} </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"> @@ -589,12 +607,6 @@ export function RegisterPage() { <${PasswordInput} placeholder=${t('register.confirm')} value=${confirm} onInput=${e => setConfirm(e.target.value)} autocomplete="new-password" /> - <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')} |