diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 03:56:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 03:56:30 +0200 |
| commit | f0248975908ad670fa8a820f865bf22ea8d0172d (patch) | |
| tree | f4af64d36cacaccb4f6d13436e001aeb57e861e3 /packages/meshbay-hub/tests/test_hub_api.py | |
| parent | 35130e5528a52161630fd1c93572e1b2b7cd911b (diff) | |
| download | meshbay-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.py | 279 |
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"] + + |