summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_hub_api.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 03:56:30 +0200
commitf0248975908ad670fa8a820f865bf22ea8d0172d (patch)
treef4af64d36cacaccb4f6d13436e001aeb57e861e3 /packages/meshbay-hub/tests/test_hub_api.py
parent35130e5528a52161630fd1c93572e1b2b7cd911b (diff)
downloadmeshbay-f0248975908ad670fa8a820f865bf22ea8d0172d.tar.gz
feat: Phase 12 — P2P crypto material, password split, node Ed25519 auth
Baseline commit capturing in-progress Phase 12 work that was already present in the working tree (uncommitted) before the Phase 11.5 security remediation begins. Committed as-is, without review or modification, so that remediation changes arrive as a separable diff. Contents: BundleStore (P2P GEK + keypair bundles), password split (auth_key / bundle_key), node Ed25519 auth (POST /v1/nodes/auth, node-scoped JWT), GEK-HMAC handshake proof with DTLS channel binding, Ed25519 admin challenge-response, node local admin UI rewrite, browser key persistence. Not authored in this session — captured to establish a baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_hub_api.py')
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py279
1 files changed, 226 insertions, 53 deletions
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index f7c499e..a8232c1 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -10,7 +10,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives import serialization
-from meshbay_common.crypto import generate_gek, pk_to_b64, wrap_gek
+from meshbay_common.crypto import pk_to_b64
from meshbay_hub.api.deps import set_admin_usernames
@@ -210,12 +210,8 @@ async def test_announce_and_get_node(client):
# ── Groups + GEK bundles ──────────────────────────────────────────────────────
@pytest.mark.asyncio
-async def test_group_gek_roundtrip(client):
- """Admin creates group, wraps GEK for member, member retrieves and can unwrap."""
- import jwt as pyjwt
- from meshbay_common.crypto import unwrap_gek
-
- # Register admin (alice2) and member (bob2)
+async def test_group_member_add(client):
+ """Admin creates group and adds member (GEK exchange happens P2P on node)."""
pk_ed_a, pk_x_a, _ = _gen_user_keys()
pk_ed_b, pk_x_b, sk_x_b = _gen_user_keys()
@@ -227,46 +223,29 @@ async def test_group_gek_roundtrip(client):
"username": uname, "email": email, "password": pwd,
"pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
- def _token(uname, pwd):
- async def _inner():
- r = await client.post("/v1/users/login",
- json={"username": uname, "password": pwd})
- return r.json()["access_token"]
- return _inner
-
alice_token = (await client.post("/v1/users/login",
json={"username": "alice2", "password": "alicepass99"})).json()["access_token"]
- bob_token = (await client.post("/v1/users/login",
- json={"username": "bob2", "password": "bobpass99"})).json()["access_token"]
a_hdrs = {"Authorization": f"Bearer {alice_token}"}
- b_hdrs = {"Authorization": f"Bearer {bob_token}"}
- # Alice creates group
r = await client.post("/v1/groups", json={"name": "mygroup"}, headers=a_hdrs)
assert r.status_code == 201
group_id = r.json()["group_id"]
- # Alice generates GEK and wraps it for bob
- gek = generate_gek()
- pk_bob_raw = base64.b64decode(pk_x_b)
- bundle = wrap_gek(gek, pk_bob_raw)
-
- r = await client.post(f"/v1/groups/{group_id}/members/bob2/gek",
- json=bundle, headers=a_hdrs)
+ # Add bob as member (hub handles membership only, GEK exchange is P2P)
+ r = await client.post(f"/v1/groups/{group_id}/members/bob2",
+ json={}, headers=a_hdrs)
assert r.status_code == 201
- # Bob retrieves his bundle
- r = await client.get(f"/v1/groups/{group_id}/gek", headers=b_hdrs)
+ # Verify bob is in the group
+ bob_token = (await client.post("/v1/users/login",
+ json={"username": "bob2", "password": "bobpass99"})).json()["access_token"]
+ b_hdrs = {"Authorization": f"Bearer {bob_token}"}
+ r = await client.get(f"/v1/groups/{group_id}/members", headers=b_hdrs)
assert r.status_code == 200
- retrieved = r.json()
-
- # Bob unwraps — must recover original GEK
- sk_b_raw = sk_x_b.private_bytes(
- serialization.Encoding.Raw, serialization.PrivateFormat.Raw,
- serialization.NoEncryption())
- recovered = unwrap_gek(retrieved, sk_b_raw, pk_bob_raw)
- assert recovered == gek
+ members = [m["username"] for m in r.json()["members"]]
+ assert "alice2" in members
+ assert "bob2" in members
@pytest.mark.asyncio
@@ -291,12 +270,9 @@ async def test_non_admin_cannot_add_member(client):
headers={"Authorization": f"Bearer {charlie_token}"})
group_id = r.json()["group_id"]
- gek = generate_gek()
- bundle = wrap_gek(gek, base64.b64decode(pk_x_b))
-
# Dan (non-admin) tries to add a member → 403
- r = await client.post(f"/v1/groups/{group_id}/members/charlie/gek",
- json=bundle,
+ r = await client.post(f"/v1/groups/{group_id}/members/charlie",
+ json={},
headers={"Authorization": f"Bearer {dan_token}"})
assert r.status_code == 403
@@ -331,10 +307,8 @@ async def test_jwt_contains_groups_claim(client):
headers={"Authorization": f"Bearer {alice_token}"})
group_id = r.json()["group_id"]
- gek = generate_gek()
- bundle = wrap_gek(gek, base64.b64decode(pk_x_b))
- await client.post(f"/v1/groups/{group_id}/members/grp_bob/gek",
- json=bundle,
+ await client.post(f"/v1/groups/{group_id}/members/grp_bob",
+ json={},
headers={"Authorization": f"Bearer {alice_token}"})
# Login again — groups should contain the new group
@@ -381,10 +355,8 @@ async def test_my_groups(client):
r = await client.post("/v1/groups", json={"name": "mg-group"},
headers={"Authorization": f"Bearer {alice_token}"})
group_id = r.json()["group_id"]
- gek = generate_gek()
- bundle = wrap_gek(gek, base64.b64decode(pk_x_b))
- await client.post(f"/v1/groups/{group_id}/members/mg_bob/gek",
- json=bundle,
+ await client.post(f"/v1/groups/{group_id}/members/mg_bob",
+ json={},
headers={"Authorization": f"Bearer {alice_token}"})
# Re-login to get fresh token with group claims
@@ -559,8 +531,7 @@ async def test_registered_email_not_plaintext(client, app):
@pytest.mark.asyncio
async def test_password_rehash_on_login(client, app):
- """Users with pw_version=1 get rehashed to current version on login."""
- from meshbay_hub.auth import current_pw_version
+ """Users with pw_version=1 get rehashed to v2 on legacy password login."""
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import User
from sqlalchemy import select
@@ -590,16 +561,16 @@ async def test_password_rehash_on_login(client, app):
await db.commit()
break
- # Login should succeed and trigger rehash
+ # Login should succeed and trigger legacy rehash (v1 -> v2)
r = await client.post("/v1/users/login", json={
"username": "rehash_user", "password": "rehashpass9"})
assert r.status_code == 200
- # Verify pw_version is now current
+ # Verify pw_version is now 2 (legacy rehash stays within password scheme)
async for db in get_db():
result = await db.execute(select(User).where(User.username == "rehash_user"))
user = result.scalar_one()
- assert user.pw_version == current_pw_version()
+ assert user.pw_version == 2
break
# Login still works after rehash
@@ -722,3 +693,205 @@ async def test_webapp_html_includes_scripts(client):
assert 'type="module"' in html
assert 'rel="stylesheet"' in html
assert 'href="/style.css"' in html
+
+
+# ── Password split (T1 fix) ─────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_register_with_auth_key(client, app):
+ """Registration with auth_key sets pw_version 3."""
+ from meshbay_hub.db.engine import get_db
+ from meshbay_hub.db.models import User
+ from sqlalchemy import select
+
+ pk_ed, pk_x, _ = _gen_user_keys()
+ r = await client.post("/v1/users/register", json={
+ "username": "authuser",
+ "email": "auth@test.com",
+ "auth_key": "dGVzdGF1dGhrZXl0ZXN0YXV0aGtleXRlc3RhdXRo",
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+ assert r.status_code == 201
+ assert "user_id" in r.json()
+
+ async for db in get_db():
+ result = await db.execute(select(User).where(User.username == "authuser"))
+ user = result.scalar_one()
+ assert user.pw_version == 3
+ break
+
+
+@pytest.mark.asyncio
+async def test_register_with_password_sets_v2(client, app):
+ """Registration with raw password (legacy) sets pw_version 2."""
+ from meshbay_hub.db.engine import get_db
+ from meshbay_hub.db.models import User
+ from sqlalchemy import select
+
+ pk_ed, pk_x, _ = _gen_user_keys()
+ r = await client.post("/v1/users/register", json={
+ "username": "legacyreg",
+ "email": "legacy@test.com",
+ "password": "legacypass99",
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+ assert r.status_code == 201
+
+ async for db in get_db():
+ result = await db.execute(select(User).where(User.username == "legacyreg"))
+ user = result.scalar_one()
+ assert user.pw_version == 2
+ break
+
+
+@pytest.mark.asyncio
+async def test_register_no_credentials_rejected(client):
+ """Registration without auth_key or password returns 400."""
+ pk_ed, pk_x, _ = _gen_user_keys()
+ r = await client.post("/v1/users/register", json={
+ "username": "nocred",
+ "email": "nocred@test.com",
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+ assert r.status_code == 400
+ assert "auth_key or password required" in r.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_login_with_auth_key(client, app):
+ """Login with auth_key for pw_version 3 account succeeds."""
+ pk_ed, pk_x, _ = _gen_user_keys()
+ auth_key = "dGVzdGF1dGhrZXl0ZXN0YXV0aGtleXRlc3RhdXRo"
+ await client.post("/v1/users/register", json={
+ "username": "authlogin",
+ "email": "authlogin@test.com",
+ "auth_key": auth_key,
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+
+ r = await client.post("/v1/users/login", json={
+ "username": "authlogin", "auth_key": auth_key})
+ assert r.status_code == 200
+ data = r.json()
+ assert "access_token" in data
+ assert "refresh_token" in data
+
+
+@pytest.mark.asyncio
+async def test_login_auth_key_wrong_rejected(client):
+ """Login with wrong auth_key returns 401."""
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "authwrong",
+ "email": "authwrong@test.com",
+ "auth_key": "dGVzdGF1dGhrZXl0ZXN0YXV0aGtleXRlc3RhdXRo",
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+
+ r = await client.post("/v1/users/login", json={
+ "username": "authwrong", "auth_key": "d3JvbmdrZXl3cm9uZ2tleXdyb25na2V5d3Jvbmc="})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_login_v3_account_password_only_rejected(client):
+ """Login with raw password to a v3 (auth_key) account returns 401."""
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "v3nopw",
+ "email": "v3nopw@test.com",
+ "auth_key": "dGVzdGF1dGhrZXl0ZXN0YXV0aGtleXRlc3RhdXRo",
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+
+ r = await client.post("/v1/users/login", json={
+ "username": "v3nopw", "password": "somepassword"})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_login_legacy_upgrade_required(client):
+ """Legacy account (pw_version 2) with auth_key only returns auth_upgrade_required."""
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "legacyupg",
+ "email": "legacyupg@test.com",
+ "password": "legacypass99",
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+
+ r = await client.post("/v1/users/login", json={
+ "username": "legacyupg", "auth_key": "dGVzdGF1dGhrZXl0ZXN0YXV0aGtleXRlc3RhdXRo"})
+ assert r.status_code == 401
+ assert r.json()["detail"] == "auth_upgrade_required"
+
+
+@pytest.mark.asyncio
+async def test_login_legacy_migration(client, app):
+ """Legacy account migrates to auth_key on login with both fields."""
+ from meshbay_hub.auth import current_pw_version
+ from meshbay_hub.db.engine import get_db
+ from meshbay_hub.db.models import User
+ from sqlalchemy import select
+
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "migrateuser",
+ "email": "migrate@test.com",
+ "password": "migratepass9",
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+
+ # Verify starts at pw_version 2
+ async for db in get_db():
+ result = await db.execute(select(User).where(User.username == "migrateuser"))
+ user = result.scalar_one()
+ assert user.pw_version == 2
+ break
+
+ auth_key = "bWlncmF0ZWF1dGhrZXltaWdyYXRlYXV0aGtleW1p"
+
+ # Login with password + auth_key → should succeed and migrate
+ r = await client.post("/v1/users/login", json={
+ "username": "migrateuser",
+ "password": "migratepass9",
+ "auth_key": auth_key,
+ })
+ assert r.status_code == 200
+
+ # Verify pw_version is now 3 (migrated)
+ async for db in get_db():
+ result = await db.execute(select(User).where(User.username == "migrateuser"))
+ user = result.scalar_one()
+ assert user.pw_version == current_pw_version()
+ assert user.pw_version == 3
+ break
+
+ # Login again with auth_key only → should succeed (migrated account)
+ r = await client.post("/v1/users/login", json={
+ "username": "migrateuser", "auth_key": auth_key})
+ assert r.status_code == 200
+ assert "access_token" in r.json()
+
+ # Old password no longer works (hash was replaced with auth_key hash)
+ r = await client.post("/v1/users/login", json={
+ "username": "migrateuser", "password": "migratepass9"})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_login_no_credentials_rejected(client):
+ """Login without auth_key or password returns 401."""
+ r = await client.post("/v1/users/login", json={"username": "nobody"})
+ assert r.status_code == 401
+ assert "No credentials" in r.json()["detail"]
+
+