diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 14:28:03 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 17:24:16 +0200 |
| commit | 6b9b5394c5ec01c5de01b7bf23bc161792f38278 (patch) | |
| tree | 56536e2d30723aaec1852ba5cf19e24a886c815f | |
| parent | 43c72cf9e8853cd5b2f59d4a4acd87729b0ddae0 (diff) | |
| download | meshbay-6b9b5394c5ec01c5de01b7bf23bc161792f38278.tar.gz | |
fix(hub): require the passphrase to change the e-mail on file
A member hands its hub access token to every node it connects to (the MNP
handshake), so a node operator holds a live bearer token for that member.
PATCH /v1/users/me {email} needed only that token, and the confirmation code
goes to the new address — so an operator could point the account's e-mail at
their own inbox, confirm it, and then use the passphrase-reset path to take the
account over. This is the immediate mitigation of that chain; the full fix
(a node-audience token distinct from the API session token) follows.
Changing the address now requires the passphrase-derived auth_key, verified
through the same throttle as a passphrase change or an account deletion — the
hub still never sees the passphrase. A PATCH that does not change the address is
unaffected. The profile page prompts for the passphrase and derives auth_key
with the existing MeshBayKeys.deriveAuthKey, as the delete and change-password
flows already do.
test_email_change_requires_passphrase.py: refused without / with a wrong
passphrase, proceeds with the right one, and a no-email PATCH still works; red
before, green after. test_mail_is_not_a_relay.py updated to pass the auth_key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 files changed, 110 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index a994acb..7046c2f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -728,6 +728,14 @@ async def get_current_user_info( class UpdateProfileRequest(BaseModel): email: str | None = None + # Required to change the address on file. Changing it is the first step of + # an account takeover from a bare access token: the confirmation code goes + # to the new (attacker) address, and a verified address then unlocks the + # passphrase-reset path. A live access token is not enough for that — the + # passphrase is, exactly as for `change_password` and `delete_own_account`. + # This matters because a member hands its access token to every node it + # connects to (the MNP handshake), so a node operator holds one. + auth_key: str | None = None @field_validator("email") @classmethod @@ -768,6 +776,22 @@ async def update_profile( new_email = body.email.strip() eh = hash_email_blind(new_email) + # Changing the address on file requires the passphrase, not merely a + # live token. Same second factor, and the same throttle, as a passphrase + # change or an account deletion — the hub still never sees the + # passphrase, only the derived auth_key. + if not body.auth_key: + raise HTTPException( + status_code=403, + detail="Changing your e-mail requires your passphrase.") + await _take_login_attempt(db, current_user.username) + if not await verify_password_off_loop( + body.auth_key, current_user.pw_hash, current_user.pw_salt, + current_user.pw_version): + raise HTTPException(status_code=403, + detail="Passphrase does not match") + await login_throttle.clear(db, current_user.username) + # How often one account may point the hub at a *different* address. # Long, because this is the only path where a signed-in account chooses # who receives a message, and a short delay alone still allows one diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index ed7a2fa..d3d06d7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -37,6 +37,10 @@ export function ProfilePage({ user, onLogout }) { const [emailVerifyPending, setEmailVerifyPending] = useState(false); const [emailCode, setEmailCode] = useState(''); const [emailVerifying, setEmailVerifying] = useState(false); + // Changing the address on file now needs the passphrase (the hub verifies the + // derived auth_key): a bare access token — which every node this account + // connects to is handed — must not be able to start an account takeover. + const [emailPass, setEmailPass] = useState(''); const [pinCount, setPinCount] = useState( () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); const [delOpen, setDelOpen] = useState(false); @@ -232,12 +236,18 @@ export function ProfilePage({ user, onLogout }) { const saveEmail = useCallback(async () => { const val = emailDraft.trim(); if (!val || val === email) { setEmailEditing(false); return; } + if (!emailPass) return; // the Save button is disabled until it is entered setEmailSaving(true); setEmailStatus(''); try { + // The passphrase is the second factor here, exactly as for a passphrase + // change or an account deletion: the hub gets the derived auth_key, never + // the passphrase itself. + const authKey = await window.MeshBayKeys.deriveAuthKey(emailPass, user.username); const resp = await hubFetch('/v1/users/me', { - method: 'PATCH', token: user.token, body: { email: val }, + method: 'PATCH', token: user.token, body: { email: val, auth_key: authKey }, }); + setEmailPass(''); if (resp.email_verification_required) { setEmailVerifyPending(true); setEmailStatus(t('settings.email_code_sent')); @@ -248,11 +258,13 @@ export function ProfilePage({ user, onLogout }) { setTimeout(() => setEmailStatus(''), 3000); } } catch (e) { - setEmailStatus(e.message); + const msg = /403|does not match/i.test(e.message) + ? t('settings.pw_wrong_current') : e.message; + setEmailStatus(msg); } finally { setEmailSaving(false); } - }, [emailDraft, email, user.token]); + }, [emailDraft, email, emailPass, user.token, user.username]); const verifyEmailChange = useCallback(async () => { if (!emailCode.trim()) return; @@ -311,14 +323,18 @@ export function ProfilePage({ user, onLogout }) { ? html`<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> <input type="email" value=${emailDraft} onInput=${e => setEmailDraft(e.target.value)} - onKeyDown=${e => e.key === 'Enter' && saveEmail()} disabled=${emailVerifyPending} style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> ${!emailVerifyPending && html` + <input type="password" autocomplete="current-password" + placeholder=${t('login.password')} + value=${emailPass} onInput=${e => setEmailPass(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && saveEmail()} + style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> <button class="admin-btn" onClick=${saveEmail} - disabled=${emailSaving}>${t('settings.email_save')}</button> + disabled=${emailSaving || !emailPass}>${t('settings.email_save')}</button> <button class="btn-secondary" onClick=${() => { - setEmailEditing(false); setEmailDraft(email); + setEmailEditing(false); setEmailDraft(email); setEmailPass(''); }}>${t('settings.cancel')}</button> `} </span>` diff --git a/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py b/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py new file mode 100644 index 0000000..697e6f9 --- /dev/null +++ b/packages/meshbay-hub/tests/test_email_change_requires_passphrase.py @@ -0,0 +1,55 @@ +"""Changing the address on file requires the passphrase, not merely a token. + +A member hands its hub access token to every node it connects to (the MNP +handshake), so a node operator holds a live bearer token for that member. +`PATCH /v1/users/me {email}` used to need only that token, and the confirmation +code goes to the *new* address — so an operator could point the account's e-mail +at their own inbox, confirm it, and then use the passphrase-reset path to take +the account over. The passphrase (as the derived auth_key, which is all the hub +ever sees) is now required, exactly as for a passphrase change or an account +deletion. +""" + +import pytest + + +async def _account(client, username="mail_pass_test", auth_key="k" * 44): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@test.local", "auth_key": auth_key}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": auth_key}) + return r.json()["access_token"] + + +@pytest.mark.asyncio +async def test_email_change_without_passphrase_is_refused(client): + tok = await _account(client) + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={"email": "attacker@evil.invalid"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_email_change_with_wrong_passphrase_is_refused(client): + tok = await _account(client) + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={"email": "attacker@evil.invalid", "auth_key": "z" * 44}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_email_change_with_correct_passphrase_proceeds(client): + tok = await _account(client, username="mail_ok_test", auth_key="k" * 44) + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={"email": "new@real.invalid", "auth_key": "k" * 44}) + assert r.status_code == 200 + assert r.json().get("email_verification_required") is True + + +@pytest.mark.asyncio +async def test_profile_patch_without_email_needs_no_passphrase(client): + """Regression: a PATCH that does not change the address is unaffected.""" + tok = await _account(client, username="mail_noop_test") + r = await client.patch("/v1/users/me", headers={"Authorization": f"Bearer {tok}"}, + json={}) + assert r.status_code == 200 diff --git a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py index 157dbd5..040ff43 100644 --- a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py +++ b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py @@ -314,6 +314,9 @@ def test_a_refusal_never_names_the_address(): # ── The doors, driven through the API ──────────────────────────────────────── +_AK = base64.b64encode(b"k" * 32).decode() # the passphrase-derived auth_key these accounts use + + async def _register(client, username: str, email: str, captcha=None): sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate() return await client.post("/v1/users/register", json={ @@ -383,11 +386,11 @@ async def test_an_account_may_point_the_hub_at_one_stranger_then_wait( before = len(wire) r = await client.patch("/v1/users/me", headers=headers, - json={"email": "a-stranger@example.test"}) + json={"auth_key": _AK, "email": "a-stranger@example.test"}) assert r.status_code == 200, r.text r = await client.patch("/v1/users/me", headers=headers, - json={"email": "another-stranger@example.test"}) + json={"auth_key": _AK, "email": "another-stranger@example.test"}) assert r.status_code == 429, r.text assert len(wire) - before == 1, "the hub mailed a second stranger on demand" @@ -408,7 +411,7 @@ async def test_the_address_already_pending_may_be_asked_for_again( "relay_d@example.test") typo = "jean@gmial.test" - r = await client.patch("/v1/users/me", headers=headers, json={"email": typo}) + r = await client.patch("/v1/users/me", headers=headers, json={"auth_key": _AK, "email": typo}) assert r.status_code == 200, r.text # The recipient's own cooldown is not what is under test here. @@ -419,7 +422,7 @@ async def test_the_address_already_pending_may_be_asked_for_again( await db_session.commit() before = len(wire) - r = await client.patch("/v1/users/me", headers=headers, json={"email": typo}) + r = await client.patch("/v1/users/me", headers=headers, json={"auth_key": _AK, "email": typo}) assert r.status_code == 200, ( "a typo locked the account out of correcting it: " + r.text) assert len(wire) - before == 1 @@ -437,7 +440,7 @@ async def test_the_delay_survives_the_verification_row_being_deleted( "relay_c@example.test") r = await client.patch("/v1/users/me", headers=headers, - json={"email": "c-first@example.test"}) + json={"auth_key": _AK, "email": "c-first@example.test"}) assert r.status_code == 200, r.text from meshbay_hub.db.models import EmailVerification @@ -446,7 +449,7 @@ async def test_the_delay_survives_the_verification_row_being_deleted( await db_session.commit() r = await client.patch("/v1/users/me", headers=headers, - json={"email": "c-second@example.test"}) + json={"auth_key": _AK, "email": "c-second@example.test"}) assert r.status_code == 429, ( "the delay was counted from a table the handler empties") |