aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
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/tests
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/tests')
-rw-r--r--packages/meshbay-hub/tests/test_email_change_requires_passphrase.py55
-rw-r--r--packages/meshbay-hub/tests/test_mail_is_not_a_relay.py15
2 files changed, 64 insertions, 6 deletions
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")