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 | |
| 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')
20 files changed, 244 insertions, 138 deletions
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() { <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')} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index bd5282c..bfbb94f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -386,7 +386,7 @@ async function resealLegacyIdentity(keys, sessionKey, recoveryKey, { userId, nod * * Returns the raw private keys for immediate use after registration. */ -async function registerUser(username, email, password, recoveryMnemonic, captchaToken) { +async function registerUser(username, email, password, captchaToken) { // No keypair here any more. Identity keys are per node: one is generated the // first time this account joins a given node, encrypted under the passphrase, // and left with that node. So an operator who cracks what sits on their own @@ -397,10 +397,8 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha const authKey = await deriveAuthKey(password, username); const payload = { username, email, auth_key: authKey }; - // The recovery mnemonic, when the user opted to have it e-mailed: the hub - // appends it to the verification e-mail and stores it nowhere - // (docs/MESHBAY_DESIGN.md §3.6). Omitted when they chose to save it themselves. - if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic; + // Never the recovery mnemonic: the hub does not see it (docs/MESHBAY_DESIGN.md + // §3.6), and refuses a register that carries one. // The proof-of-work answer, when the hub asks for one. RegisterPage // (auth-page.js) works it out; this function just forwards it. A hub with // the check turned off gets nothing and does not look. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index a7aca2c..c0135b0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -75,9 +75,11 @@ export default { 'register.recovery_warning': 'Wenn Sie sowohl Ihre Passphrase als auch diesen Schlüssel verlieren, bleiben Ihre Dateien auf ihren Knoten, aber Sie treten jeder Gruppe mit einer neuen Identität erneut bei.', 'register.recovery_saved': 'Ich habe meinen Wiederherstellungsschlüssel an einem sicheren Ort gespeichert.', 'register.recovery_continue': 'Weiter', - 'register.recovery_email_opt': 'Diesen Wiederherstellungsschlüssel zur Sicherung auch per E-Mail an mich senden', - 'register.recovery_emailed': 'Eine Kopie wurde auch an Ihre E-Mail-Adresse gesendet, in derselben Nachricht wie Ihr Bestätigungscode.', - 'register.recovery_not_emailed': 'Dies wurde nicht per E-Mail gesendet. Speichern Sie es jetzt — es wird nur dieses eine Mal angezeigt.', + 'register.recovery_mail_self': 'Per E-Mail an mich senden', + 'register.recovery_mail_hint': 'Öffnet Ihre eigene Mail-App mit eingetragenem Schlüssel. Er läuft nicht über den Hub; wer dieses Postfach lesen kann, kann ihn verwenden.', + 'register.recovery_shown_once': 'Speichern Sie ihn jetzt — er wird nur dieses eine Mal angezeigt.', + 'register.recovery_mail_subject': 'MeshBay-Wiederherstellungsschlüssel', + 'register.recovery_mail_body': 'Wiederherstellungsschlüssel für das MeshBay-Konto {user}:\n\n{key}\n\nEr stellt Ihre Gruppenidentitäten wieder her, wenn Sie Ihre Passphrase vergessen. Halten Sie diese Nachricht privat.', 'register.success_title': 'E-Mail bestätigen', 'register.success_msg': 'Ein Bestätigungscode wurde an Ihre E-Mail-Adresse gesendet. Geben Sie ihn unten ein, um Ihr Konto zu aktivieren.', 'register.go_login': 'Zur Anmeldung', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index fd56d68..b32c569 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -78,9 +78,11 @@ export default { 'register.recovery_warning': 'If you lose both your passphrase and this key, your files stay on their nodes but you rejoin each group with a new identity.', 'register.recovery_saved': 'I have saved my recovery key somewhere safe.', 'register.recovery_continue': 'Continue', - 'register.recovery_email_opt': 'Also email this recovery key to me as a backup', - 'register.recovery_emailed': 'A copy has also been sent to your email, in the same message as your verification code.', - 'register.recovery_not_emailed': 'This was not emailed. Save it now — this is the only time it is shown.', + 'register.recovery_mail_self': 'E-mail it to myself', + 'register.recovery_mail_hint': 'Opens your own mail app with the key filled in. It does not pass through the hub; whoever can read that mailbox can use it.', + 'register.recovery_shown_once': 'Save it now — this is the only time it is shown.', + 'register.recovery_mail_subject': 'MeshBay recovery key', + 'register.recovery_mail_body': 'Recovery key for the MeshBay account {user}:\n\n{key}\n\nIt restores your group identities if you forget your passphrase. Keep this message private.', 'register.success_title': 'Verify your email', 'register.success_msg': 'A verification code has been sent to your email address. Enter it below to activate your account.', 'register.go_login': 'Go to login', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 908dca4..6a96f4d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -74,9 +74,11 @@ export default { 'register.recovery_warning': 'Si pierde tanto su frase de contraseña como esta clave, sus archivos permanecen en sus nodos pero se vuelve a unir a cada grupo con una nueva identidad.', 'register.recovery_saved': 'He guardado mi clave de recuperación en un lugar seguro.', 'register.recovery_continue': 'Continuar', - 'register.recovery_email_opt': 'Enviarme también esta clave de recuperación por correo como copia de seguridad', - 'register.recovery_emailed': 'También se ha enviado una copia a su correo electrónico, en el mismo mensaje que su código de verificación.', - 'register.recovery_not_emailed': 'Esto no se envió por correo. Guárdela ahora: es la única vez que se muestra.', + 'register.recovery_mail_self': 'Enviármela por correo', + 'register.recovery_mail_hint': 'Abre su propia aplicación de correo con la clave ya escrita. No pasa por el hub; quien pueda leer ese buzón podrá usarla.', + 'register.recovery_shown_once': 'Guárdela ahora: es la única vez que se muestra.', + 'register.recovery_mail_subject': 'Clave de recuperación de MeshBay', + 'register.recovery_mail_body': 'Clave de recuperación de la cuenta de MeshBay {user}:\n\n{key}\n\nRestaura sus identidades de grupo si olvida su frase de contraseña. Mantenga este mensaje en privado.', 'register.success_title': 'Verifique su correo', 'register.success_msg': 'Se ha enviado un código de verificación a su dirección de correo electrónico. Introdúzcalo a continuación para activar su cuenta.', 'register.go_login': 'Ir al inicio de sesión', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 4b4e036..92ab189 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -74,9 +74,11 @@ export default { 'register.recovery_warning': "Si vous perdez à la fois votre phrase secrète et cette clé, vos fichiers restent sur leurs nœuds mais vous rejoignez chaque groupe avec une nouvelle identité.", 'register.recovery_saved': "J'ai enregistré ma clé de récupération en lieu sûr.", 'register.recovery_continue': 'Continuer', - 'register.recovery_email_opt': 'M’envoyer aussi cette clé de récupération par e-mail comme sauvegarde', - 'register.recovery_emailed': 'Une copie a également été envoyée à votre adresse e-mail, dans le même message que votre code de vérification.', - 'register.recovery_not_emailed': 'Elle n’a pas été envoyée par e-mail. Enregistrez-la maintenant — c’est la seule fois où elle est affichée.', + 'register.recovery_mail_self': 'Me l’envoyer par e-mail', + 'register.recovery_mail_hint': 'Ouvre votre propre application de messagerie avec la clé déjà saisie. Elle ne passe pas par le hub ; quiconque peut lire cette boîte mail pourra l’utiliser.', + 'register.recovery_shown_once': 'Enregistrez-la maintenant — c’est la seule fois où elle est affichée.', + 'register.recovery_mail_subject': 'Clé de récupération MeshBay', + 'register.recovery_mail_body': 'Clé de récupération du compte MeshBay {user} :\n\n{key}\n\nElle restaure vos identités de groupe si vous oubliez votre phrase secrète. Gardez ce message privé.', 'register.success_title': 'Vérifiez votre e-mail', 'register.success_msg': 'Un code de vérification a été envoyé à votre adresse e-mail. Saisissez-le ci-dessous pour activer votre compte.', 'register.go_login': 'Aller à la connexion', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 27692b8..47538ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -75,9 +75,11 @@ export default { 'register.recovery_warning': 'Se perde sia la passphrase sia questa chiave, i suoi file restano sui rispettivi nodi ma si riunisce a ogni gruppo con una nuova identità.', 'register.recovery_saved': 'Ho salvato la mia chiave di recupero in un luogo sicuro.', 'register.recovery_continue': 'Continua', - 'register.recovery_email_opt': 'Inviami anche questa chiave di recupero via e-mail come backup', - 'register.recovery_emailed': 'Una copia è stata inviata anche alla sua e-mail, nello stesso messaggio del codice di verifica.', - 'register.recovery_not_emailed': 'Non è stata inviata via e-mail. La salvi ora: è l’unica volta che viene mostrata.', + 'register.recovery_mail_self': 'Inviarmela via e-mail', + 'register.recovery_mail_hint': 'Apre la sua app di posta con la chiave già inserita. Non passa dall’hub; chiunque possa leggere quella casella potrà usarla.', + 'register.recovery_shown_once': 'La salvi ora: è l’unica volta che viene mostrata.', + 'register.recovery_mail_subject': 'Chiave di recupero MeshBay', + 'register.recovery_mail_body': 'Chiave di recupero dell’account MeshBay {user}:\n\n{key}\n\nRipristina le sue identità di gruppo se dimentica la passphrase. Tenga privato questo messaggio.', 'register.success_title': 'Verifichi la sua e-mail', 'register.success_msg': 'Un codice di verifica è stato inviato al suo indirizzo e-mail. Lo inserisca qui sotto per attivare il suo account.', 'register.go_login': "Vai all'accesso", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 1b2c230..815435a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -75,9 +75,11 @@ export default { 'register.recovery_warning': 'パスフレーズとこのキーの両方を失うと、ファイルは各ノードに残りますが、各グループには新しい identity で再参加することになります。', 'register.recovery_saved': 'リカバリーキーを安全な場所に保存しました。', 'register.recovery_continue': '続ける', - 'register.recovery_email_opt': 'バックアップとして、このリカバリーキーもメールで送る', - 'register.recovery_emailed': '確認コードと同じメールで、コピーもお使いのメールアドレスに送信されました。', - 'register.recovery_not_emailed': 'これはメールで送信されていません。今すぐ保存してください。表示されるのはこの一度だけです。', + 'register.recovery_mail_self': '自分にメールで送る', + 'register.recovery_mail_hint': 'キーを入力済みの状態でお使いのメールアプリを開きます。ハブは経由しません。そのメールボックスを読める人は誰でもこのキーを使えます。', + 'register.recovery_shown_once': '今すぐ保存してください。表示されるのはこの一度だけです。', + 'register.recovery_mail_subject': 'MeshBay リカバリーキー', + 'register.recovery_mail_body': 'MeshBay アカウント {user} のリカバリーキー:\n\n{key}\n\nパスフレーズを忘れた場合、グループの identity を復元します。このメッセージは他人に見せないでください。', 'register.success_title': 'メールアドレスを確認してください', 'register.success_msg': '確認コードをメールアドレスに送信しました。以下に入力してアカウントを有効にしてください。', 'register.go_login': 'ログインへ進む', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 971b046..fde0941 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -75,9 +75,11 @@ export default { 'register.recovery_warning': 'Als u zowel uw wachtwoordzin als deze sleutel verliest, blijven uw bestanden op hun nodes staan, maar treedt u tot elke groep opnieuw toe met een nieuwe identiteit.', 'register.recovery_saved': 'Ik heb mijn herstelsleutel op een veilige plek bewaard.', 'register.recovery_continue': 'Doorgaan', - 'register.recovery_email_opt': 'Stuur deze herstelsleutel ook naar mij per e-mail als back-up', - 'register.recovery_emailed': 'Er is ook een kopie naar uw e-mailadres gestuurd, in hetzelfde bericht als uw verificatiecode.', - 'register.recovery_not_emailed': 'Dit is niet per e-mail verstuurd. Sla het nu op — dit is de enige keer dat het wordt getoond.', + 'register.recovery_mail_self': 'Naar mezelf e-mailen', + 'register.recovery_mail_hint': 'Opent uw eigen mail-app met de sleutel al ingevuld. Hij gaat niet via de hub; wie die mailbox kan lezen, kan hem gebruiken.', + 'register.recovery_shown_once': 'Sla hem nu op — dit is de enige keer dat hij wordt getoond.', + 'register.recovery_mail_subject': 'MeshBay-herstelsleutel', + 'register.recovery_mail_body': 'Herstelsleutel voor het MeshBay-account {user}:\n\n{key}\n\nHij herstelt uw groepsidentiteiten als u uw wachtwoordzin vergeet. Houd dit bericht privé.', 'register.success_title': 'Verifieer uw e-mail', 'register.success_msg': 'Er is een verificatiecode naar uw e-mailadres gestuurd. Voer deze hieronder in om uw account te activeren.', 'register.go_login': 'Naar het aanmelden', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 5092e5c..b8dc1cd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -78,9 +78,11 @@ export default { 'register.recovery_warning': 'Jeśli stracisz zarówno hasło-frazę, jak i ten klucz, Twoje pliki pozostaną na swoich węzłach, ale do każdej grupy dołączysz ponownie z nową tożsamością.', 'register.recovery_saved': 'Zapisałem/-am mój klucz odzyskiwania w bezpiecznym miejscu.', 'register.recovery_continue': 'Kontynuuj', - 'register.recovery_email_opt': 'Wyślij mi też ten klucz odzyskiwania e-mailem jako kopię zapasową', - 'register.recovery_emailed': 'Kopia została również wysłana na Twój adres e-mail, w tej samej wiadomości co kod weryfikacyjny.', - 'register.recovery_not_emailed': 'To nie zostało wysłane e-mailem. Zapisz teraz — jest pokazywane tylko ten jeden raz.', + 'register.recovery_mail_self': 'Wyślij mi e-mailem', + 'register.recovery_mail_hint': 'Otwiera Twoją aplikację pocztową z wpisanym kluczem. Nie przechodzi przez hub; każdy, kto może czytać tę skrzynkę, może go użyć.', + 'register.recovery_shown_once': 'Zapisz go teraz — jest pokazywany tylko ten jeden raz.', + 'register.recovery_mail_subject': 'Klucz odzyskiwania MeshBay', + 'register.recovery_mail_body': 'Klucz odzyskiwania konta MeshBay {user}:\n\n{key}\n\nPrzywraca Twoje tożsamości w grupach, jeśli zapomnisz hasła. Zachowaj tę wiadomość w tajemnicy.', 'register.success_title': 'Zweryfikuj swój e-mail', 'register.success_msg': 'Kod weryfikacyjny został wysłany na Twój adres e-mail. Wpisz go poniżej, aby aktywować konto.', 'register.go_login': 'Przejdź do logowania', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 075cc31..d517387 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -76,9 +76,11 @@ export default { 'register.recovery_warning': 'Se você perder tanto a frase secreta quanto esta chave, seus arquivos permanecem em seus nós, mas você reingressa em cada grupo com uma nova identidade.', 'register.recovery_saved': 'Guardei minha chave de recuperação em um local seguro.', 'register.recovery_continue': 'Continuar', - 'register.recovery_email_opt': 'Enviar também esta chave de recuperação para o meu e-mail como backup', - 'register.recovery_emailed': 'Uma cópia também foi enviada para o seu e-mail, na mesma mensagem que o seu código de verificação.', - 'register.recovery_not_emailed': 'Isto não foi enviado por e-mail. Guarde agora — esta é a única vez que é mostrada.', + 'register.recovery_mail_self': 'Enviar para o meu e-mail', + 'register.recovery_mail_hint': 'Abre o seu próprio app de e-mail com a chave já preenchida. Ela não passa pelo hub; quem puder ler essa caixa de correio poderá usá-la.', + 'register.recovery_shown_once': 'Guarde agora — esta é a única vez que é mostrada.', + 'register.recovery_mail_subject': 'Chave de recuperação do MeshBay', + 'register.recovery_mail_body': 'Chave de recuperação da conta MeshBay {user}:\n\n{key}\n\nEla restaura suas identidades de grupo se você esquecer sua frase secreta. Mantenha esta mensagem privada.', 'register.success_title': 'Verifique seu e-mail', 'register.success_msg': 'Um código de verificação foi enviado para o seu e-mail. Digite-o abaixo para ativar sua conta.', 'register.go_login': 'Ir para o login', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 0ef354e..42c56f0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -75,9 +75,11 @@ export default { 'register.recovery_warning': '如果您同时丢失了密码短语和此密钥,您的文件仍会保留在各自的节点上,但您将以新身份重新加入每个群组。', 'register.recovery_saved': '我已将恢复密钥保存在安全的地方。', 'register.recovery_continue': '继续', - 'register.recovery_email_opt': '同时将此恢复密钥通过邮件发送给我作为备份', - 'register.recovery_emailed': '副本也已发送至您的邮箱,与验证码在同一封邮件中。', - 'register.recovery_not_emailed': '这未通过邮件发送。请立即保存——它只显示这一次。', + 'register.recovery_mail_self': '通过邮件发给自己', + 'register.recovery_mail_hint': '打开您自己的邮件应用并填好密钥。它不经过 hub;任何能读取该邮箱的人都能使用它。', + 'register.recovery_shown_once': '请立即保存——它只显示这一次。', + 'register.recovery_mail_subject': 'MeshBay 恢复密钥', + 'register.recovery_mail_body': 'MeshBay 账户 {user} 的恢复密钥:\n\n{key}\n\n如果您忘记密码短语,它可恢复您的群组身份。请勿将此邮件透露给他人。', 'register.success_title': '验证您的邮箱', 'register.success_msg': '验证码已发送至您的邮箱。请在下方输入以激活账户。', 'register.go_login': '前往登录', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 07780a2..627f2ca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2661,6 +2661,14 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } } .btn-secondary:hover { border-color: var(--border); color: var(--text); } .btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; } +/* The recovery screen's "e-mail it to myself": a mailto: link, shaped like the + Copy button beside it. */ +a.recovery-mail { + display: inline-block; + margin: 8px 0 0 8px; + border-color: var(--border); + text-decoration: none; +} /* Text that acts as a button: the "add a description" affordance should not compete with the group's name for attention. */ diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py index 5104ae2..79a9e9a 100644 --- a/packages/meshbay-hub/tests/conftest.py +++ b/packages/meshbay-hub/tests/conftest.py @@ -100,7 +100,7 @@ async def db_session(app): @pytest.fixture(autouse=True) def _skip_email_verification(monkeypatch): """Skip email verification in tests — users are active immediately.""" - async def _noop(db, user, email, eh, recovery_key=None): + async def _noop(db, user, email, eh): pass monkeypatch.setattr( diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index 1049a5f..c68234d 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -62,6 +62,10 @@ def test_the_webview_loads_the_package_and_nothing_else(): assert "loadDataWithBaseURL" not in activity and "loadData(" not in activity # The hub never becomes the document origin; a link out leaves the app. assert "shouldOverrideUrlLoading" in activity and "openExternally" in activity + # The recovery screen's "e-mail it to myself" (§3.6) goes to a mail + # application, and only as a mail address: SENDTO, not a VIEW of anything. + assert 'url.scheme == "mailto") openMail(url)' in activity + assert "Intent(Intent.ACTION_SENDTO, url)" in activity def test_the_policy_is_the_desktop_policy_sent_as_a_header(): diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index 7a26dfb..587103f 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -72,6 +72,24 @@ def test_navigation_away_from_the_package_is_refused(): assert "event.preventDefault()" in source +def test_only_web_and_mail_links_reach_the_system(): + """`shell.openExternal` hands a URL to whatever the system registered for + its scheme, so the handler names the schemes it passes: the web, and + `mailto:` for the recovery screen's "e-mail it to myself" (§3.6). A + `file:` or a custom scheme from the page would run a local program.""" + import subprocess + source = _main() + block = source[source.index("setWindowOpenHandler"):] + block = block[:block.index("});")] + match = re.search(r"if \((/[^/]+/)\.test\(new URL\(url\)\.protocol\)\) shell\.openExternal\(url\)", block) + assert match, block + probe = ("const re = " + match.group(1) + ";" + "process.stdout.write(JSON.stringify(['https:','http:','mailto:','file:'," + "'javascript:','smb:','ms-settings:','vbscript:'].filter(p => re.test(p))))") + out = subprocess.run(["node", "-e", probe], capture_output=True, text=True, check=True) + assert json.loads(out.stdout) == ["https:", "http:", "mailto:"] + + def _granted_permissions() -> set[str]: """The allowlist, read out of the source rather than described here.""" match = re.search(r"GRANTED_PERMISSIONS = new Set\(\[([^\]]*)\]\)", _main()) diff --git a/packages/meshbay-hub/tests/test_recovery_email.py b/packages/meshbay-hub/tests/test_recovery_email.py index 96dde5d..0e19be9 100644 --- a/packages/meshbay-hub/tests/test_recovery_email.py +++ b/packages/meshbay-hub/tests/test_recovery_email.py @@ -1,10 +1,12 @@ """ -The recovery key in the registration e-mail (docs/MESHBAY_DESIGN.md §3.6). +The recovery key never reaches the hub (docs/MESHBAY_DESIGN.md §3.6). -When the client sends `recovery_key`, the hub appends it to the verification -e-mail and stores it nowhere. When it does not, the e-mail carries only the -code. `recovery_key` is a pass-through — no column, no log line beyond a -boolean. +It used to: a client that ticked "e-mail it to me" sent it in the register body +and the hub appended it to the verification e-mail, so the hub saw it in clear +and so did anyone who could make the hub mail one (the resend door). The person +now mails it to themselves from their own application. The hub refuses the +field rather than dropping it, so an older client that still offers the option +cannot tell somebody their key was sent when it was not. """ import base64 @@ -12,7 +14,7 @@ import hashlib import pytest from meshbay_hub import mail -from meshbay_hub.db.models import EmailVerification, User +from meshbay_hub.db.models import User from sqlalchemy import select RECOVERY = "ABCD EFGH JKLM NPQR STUV WXYZ 2345 6789 ABCD EFGH JKLM NPQR STUV" @@ -31,74 +33,57 @@ def _skip_email_verification(monkeypatch): run so the e-mail is actually built. Capture it instead of sending. """ sent = [] - # `**_` swallows the `purpose` keyword `_send` now requires — the gate - # that keeps this hub off the open-relay list. What this module is - # about is the body of the message, so the gate is stubbed away with - # the socket; `test_mail_is_not_a_relay.py` is where it is exercised. + # `**_` swallows the `purpose` keyword `_send` requires — the gate that + # keeps this hub off the open-relay list, exercised in + # `test_mail_is_not_a_relay.py`. What this module is about is the body. monkeypatch.setattr("meshbay_hub.mail._send", lambda msg, **_: sent.append(msg) or True) return sent @pytest.mark.asyncio -async def test_register_appends_the_recovery_key_to_the_email( - client, _skip_email_verification): +async def test_a_register_carrying_the_recovery_key_is_refused( + client, db_session, _skip_email_verification): r = await client.post("/v1/users/register", json={ "username": "rk1_test", "email": "rk1@example.com", "auth_key": _auth_key("a-long-enough-passphrase", "rk1_test"), "recovery_key": RECOVERY, }) - assert r.status_code in (200, 201), r.text - assert len(_skip_email_verification) == 1 - body = _skip_email_verification[0].get_content() - assert RECOVERY in body - assert "recovery key" in body.lower() + assert r.status_code == 400, r.text + assert "recovery key" in r.json()["detail"] + assert _skip_email_verification == [], "nothing was mailed" + assert (await db_session.execute( + select(User).where(User.username == "rk1_test"))).scalar_one_or_none() is None @pytest.mark.asyncio -async def test_register_without_recovery_key_sends_only_the_code( +async def test_a_resend_carrying_the_recovery_key_is_refused( client, _skip_email_verification): - r = await client.post("/v1/users/register", json={ - "username": "rk2_test", "email": "rk2@example.com", - "auth_key": _auth_key("a-long-enough-passphrase", "rk2_test"), - }) - assert r.status_code in (200, 201), r.text - body = _skip_email_verification[0].get_content() - assert "recovery key" not in body.lower() - assert "verification code is" in body.lower() + # The resend door has no captcha: it was how a stranger who knew a pending + # username and address could have the hub mail a "recovery key" of theirs. + body = {"username": "rk2_test", "email": "rk2@example.com", + "auth_key": _auth_key("a-long-enough-passphrase", "rk2_test")} + assert (await client.post("/v1/users/register", json=body)).status_code == 201 + r = await client.post("/v1/users/register", json={**body, "recovery_key": RECOVERY}) + assert r.status_code == 400, r.text + for msg in _skip_email_verification: + assert RECOVERY not in msg.get_content() @pytest.mark.asyncio -async def test_the_recovery_key_is_not_persisted( - client, db_session, _skip_email_verification): - await client.post("/v1/users/register", json={ +async def test_the_verification_mail_carries_only_the_code( + client, _skip_email_verification): + r = await client.post("/v1/users/register", json={ "username": "rk3_test", "email": "rk3@example.com", "auth_key": _auth_key("a-long-enough-passphrase", "rk3_test"), - "recovery_key": RECOVERY, }) - rows = (await db_session.execute(select(EmailVerification))).scalars().all() - assert rows - for row in rows: - assert RECOVERY not in (row.code or "") - assert RECOVERY not in (row.email_encrypted or "") - user = (await db_session.execute( - select(User).where(User.username == "rk3_test"))).scalar_one() - assert RECOVERY not in repr(vars(user)) - - -def test_mail_body_with_and_without_the_key(monkeypatch): - captured = [] - monkeypatch.setattr("meshbay_hub.mail._send", - lambda msg, **_: captured.append(msg) or True) + assert r.status_code == 201, r.text + (msg,) = _skip_email_verification + body = msg.get_content() + assert "verification code is" in body.lower() + assert "recovery" not in body.lower() - mail.send_verification_code("x@example.com", "123456", - recovery_key="MY-RECOVERY-KEY") - body = captured[-1].get_content() - assert "123456" in body - assert "MY-RECOVERY-KEY" in body - assert "recovery key" in body.lower() - mail.send_verification_code("x@example.com", "123456") - body = captured[-1].get_content() - assert "123456" in body - assert "recovery key" not in body.lower() +def test_the_mail_function_takes_no_recovery_key(): + with pytest.raises(TypeError): + mail.send_verification_code("x@example.com", "123456", recovery_key=RECOVERY) diff --git a/packages/meshbay-hub/tests/test_recovery_mailto.py b/packages/meshbay-hub/tests/test_recovery_mailto.py new file mode 100644 index 0000000..401a69f --- /dev/null +++ b/packages/meshbay-hub/tests/test_recovery_mailto.py @@ -0,0 +1,70 @@ +""" +"E-mail it to myself" on the recovery screen (docs/MESHBAY_DESIGN.md §3.6). + +The recovery key never reaches the hub; a `mailto:` link has the person's own +mail application send it to their own address. This runs the page's +`recoveryMailto` under Node, with the English strings, and reads the link back +the way a mail application would. +""" + +import json +import shutil +import subprocess +from urllib.parse import parse_qs, unquote, urlsplit + +import pytest +from spa_source import STATIC + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="no node") + +MNEMONIC = "ABCD EFGH JKLM NPQR STUV WXYZ 2345 6789 ABCD EFGH JKLM NPQR STUV" + +HARNESS = r""" +const fs = require('fs'); +const [page, locale, email, user, key] = process.argv.slice(1); +const src = fs.readFileSync(page, 'utf8'); +const start = src.indexOf('export function recoveryMailto('); +if (start < 0) throw new Error('recoveryMailto not found'); +const end = src.indexOf('\n}\n', start); +const fn = src.slice(start, end + 2).replace(/^export /, ''); +(async () => { + const strings = (await import(locale)).default; + const t = (k, params) => { + let s = strings[k]; + if (s == null) throw new Error('missing string ' + k); + for (const [n, v] of Object.entries(params || {})) s = s.split(`{${n}}`).join(v); + return s; + }; + const recoveryMailto = new Function('t', fn + '\nreturn recoveryMailto;')(t); + process.stdout.write(JSON.stringify(recoveryMailto(email, user, key))); +})(); +""" + + +def _link(email: str, user: str = "alice_tester") -> str: + out = subprocess.run( + ["node", "-e", HARNESS, str(STATIC / "auth-page.js"), + (STATIC / "locales" / "en.js").as_uri(), email, user, MNEMONIC], + capture_output=True, text=True, check=True) + return json.loads(out.stdout) + + +def test_the_link_sends_the_key_to_the_person_and_nowhere_else(): + link = _link("alice+mesh@example.org") + parts = urlsplit(link) + assert parts.scheme == "mailto" + assert unquote(parts.path) == "alice+mesh@example.org" + assert "@" in parts.path, "an encoded '@' is not decoded by every mail app" + query = parse_qs(parts.query) + assert set(query) == {"subject", "body"}, "no cc, no bcc, nothing else" + body = query["body"][0] + assert MNEMONIC in body and "alice_tester" in body + assert "\n\n" in body, "line breaks survive the encoding" + + +def test_an_address_cannot_add_recipients_or_headers(): + # An address is the person's own, but it is still text put into a URL. + link = _link("a@example.org?bcc=x@evil.example&body=") + query = parse_qs(urlsplit(link).query) + assert set(query) == {"subject", "body"} + assert MNEMONIC in query["body"][0] |