aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 14:25:00 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 14:25:00 +0200
commit28c77b7648b2a44a18be64ec44344d91cccfd742 (patch)
tree72e0398306639817922cf292e4da6725182c23dd /packages/meshbay-hub
parentac4c3302783a3137e55dcede54c466bdf8aa55b7 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js48
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css8
-rw-r--r--packages/meshbay-hub/tests/conftest.py2
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py4
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py18
-rw-r--r--packages/meshbay-hub/tests/test_recovery_email.py93
-rw-r--r--packages/meshbay-hub/tests/test_recovery_mailto.py70
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]