aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 14:28:03 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 17:24:16 +0200
commit6b9b5394c5ec01c5de01b7bf23bc161792f38278 (patch)
tree56536e2d30723aaec1852ba5cf19e24a886c815f /packages/meshbay-hub/src/meshbay_hub/api
parent43c72cf9e8853cd5b2f59d4a4acd87729b0ddae0 (diff)
downloadmeshbay-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>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py24
1 files changed, 24 insertions, 0 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