diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-10 03:57:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-10 03:57:55 +0200 |
| commit | 1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (patch) | |
| tree | 04a23f81d3eea49de7414bf973600d33913795f9 /packages/meshbay-hub/tests | |
| parent | 4b3e8c3b8b9d10c8ac333dd8db614a7569052472 (diff) | |
| download | meshbay-1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc.tar.gz | |
feat(hub): Phase 8 — Hub v2 security hardening + production readiness
8.1 Config-based admin authz (require_admin on all admin endpoints)
8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key)
8.3 Refresh token rotation with family-based reuse detection
8.4 Federation persistence (HubPeer model replaces in-memory dict)
8.5 Federation token verification now async (DB-backed)
8.6 CSAM hash check wired into swarm registration flow
8.7 Rate limiting on auth endpoints (5/10/20 per minute)
8.8 Healthcheck endpoint (GET /v1/health, no auth)
8.9 IP log cleanup background task (365-day retention)
8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login)
Deployed to meshbay.org — schema migrated, existing emails encrypted.
117 tests pass (29 hub, 88 common+node).
Resolves security review items S1, S2, S5.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/conftest.py | 8 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hub_api.py | 197 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_moderation.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_revocation.py | 4 |
4 files changed, 211 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py index b42aba1..ffd6c2e 100644 --- a/packages/meshbay-hub/tests/conftest.py +++ b/packages/meshbay-hub/tests/conftest.py @@ -48,10 +48,18 @@ async def app(hub_config): from meshbay_hub.app import create_app application = create_app(hub_config) + # Disable rate limiting in tests + from meshbay_hub.api.middleware import limiter + limiter.enabled = False + # Run lifespan startup manually async with application.router.lifespan_context(application): yield application + # Reset admin usernames after each test + from meshbay_hub.api.deps import set_admin_usernames + set_admin_usernames([]) + @pytest_asyncio.fixture async def client(app): diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 568d5d9..327158f 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -11,6 +11,7 @@ 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_hub.api.deps import set_admin_usernames def _gen_user_keys(): @@ -35,6 +36,16 @@ async def test_hub_info(client): @pytest.mark.asyncio +async def test_health(client): + r = await client.get("/v1/health") + assert r.status_code == 200 + data = r.json() + assert data["status"] == "ok" + assert "version" in data + assert "connected_nodes" in data + + +@pytest.mark.asyncio async def test_hub_pubkey(client): r = await client.get("/v1/hub/pubkey") assert r.status_code == 200 @@ -123,6 +134,33 @@ async def test_token_refresh(client): r2 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt}) assert r2.status_code == 200 assert r2.json()["access_token"] != at # new token (different jti) + assert "refresh_token" in r2.json() # rotated refresh token returned + + +@pytest.mark.asyncio +async def test_refresh_token_rotation_old_rejected(client): + """After rotation, old refresh token is rejected.""" + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "rot_user", "email": "rot@x.com", "password": "rotpass99", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + r = await client.post("/v1/users/login", json={ + "username": "rot_user", "password": "rotpass99"}) + rt1 = r.json()["refresh_token"] + + r2 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt1}) + assert r2.status_code == 200 + rt2 = r2.json()["refresh_token"] + assert rt2 != rt1 + + # Old token reuse → detected and family revoked + r3 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt1}) + assert r3.status_code == 401 + assert "reuse" in r3.json()["detail"].lower() + + # New token also revoked (entire family) + r4 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt2}) + assert r4.status_code == 401 @pytest.mark.asyncio @@ -311,3 +349,162 @@ async def test_jwt_contains_groups_claim(client): "username": "grp_alice", "password": "alicepass99"}) decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"]) assert group_id in decoded_alice["groups"] + + +# ── Admin authz (8.1) ─────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_non_admin_cannot_revoke(client): + """Non-admin user gets 403 on admin endpoints.""" + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "regular_user", "email": "ru@x.com", "password": "regularpass", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + r = await client.post("/v1/users/login", json={ + "username": "regular_user", "password": "regularpass"}) + token = r.json()["access_token"] + + set_admin_usernames(["someone_else"]) + r = await client.post("/v1/admin/revoke", json={ + "target": "user", "target_id": "fake-id", "reason": "test", + }, headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 403 + assert "Admin" in r.json()["detail"] + + +@pytest.mark.asyncio +async def test_admin_can_revoke(client): + """Admin user (in config) can access admin endpoints.""" + pk_ed_v, pk_x_v, _ = _gen_user_keys() + r = await client.post("/v1/users/register", json={ + "username": "victim_a", "email": "va@x.com", "password": "victimpass9", + "pk_user_ed25519": pk_ed_v, "pk_user_x25519": pk_x_v}) + victim_id = r.json()["user_id"] + + pk_ed_a, pk_x_a, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "the_admin", "email": "ta@x.com", "password": "adminpass99", + "pk_user_ed25519": pk_ed_a, "pk_user_x25519": pk_x_a}) + r = await client.post("/v1/users/login", json={ + "username": "the_admin", "password": "adminpass99"}) + admin_token = r.json()["access_token"] + + set_admin_usernames(["the_admin"]) + r = await client.post("/v1/admin/revoke", json={ + "target": "user", "target_id": victim_id, "reason": "test", + }, headers={"Authorization": f"Bearer {admin_token}"}) + assert r.status_code == 200 + assert r.json()["status"] == "revoked" + + +# ── Email encryption (8.2) ────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_email_encrypted_at_rest(client): + """Email stored in DB must not contain plaintext address.""" + from meshbay_hub.auth import encrypt_email, decrypt_email + encrypted = encrypt_email("test@example.com") + assert "@" not in encrypted + assert decrypt_email(encrypted) == "test@example.com" + + +@pytest.mark.asyncio +async def test_registered_email_not_plaintext(client, app): + """Registration stores encrypted email, not plaintext.""" + pk_ed, pk_x, _ = _gen_user_keys() + await client.post("/v1/users/register", json={ + "username": "email_test", "email": "secret@example.com", + "password": "emailpass9", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + + from meshbay_hub.db.engine import get_db + from meshbay_hub.db.models import User + from sqlalchemy import select + + async for db in get_db(): + result = await db.execute(select(User).where(User.username == "email_test")) + user = result.scalar_one() + assert "@" not in user.email + from meshbay_hub.auth import decrypt_email + assert decrypt_email(user.email) == "secret@example.com" + break + + +# ── Argon2id rehash (8.10) ────────────────────────────────────────────────── + +@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 + 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": "rehash_user", "email": "rh@x.com", "password": "rehashpass9", + "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + + # Force pw_version to 1 (simulating pre-upgrade user) + async for db in get_db(): + result = await db.execute(select(User).where(User.username == "rehash_user")) + user = result.scalar_one() + user.pw_version = 1 + # Re-hash with v1 params so verify_password(version=1) succeeds + from meshbay_hub.auth import _ARGON2_VERSIONS, _ARGON2_KEY_LEN, _ARGON2_LANES + from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + import os + salt = os.urandom(16) + params = _ARGON2_VERSIONS[1] + pw_hash = Argon2id( + salt=salt, length=_ARGON2_KEY_LEN, iterations=params["iterations"], + lanes=_ARGON2_LANES, memory_cost=params["memory_cost"], + ).derive(b"rehashpass9") + user.pw_hash = pw_hash + user.pw_salt = salt + await db.commit() + break + + # Login should succeed and trigger rehash + r = await client.post("/v1/users/login", json={ + "username": "rehash_user", "password": "rehashpass9"}) + assert r.status_code == 200 + + # Verify pw_version is now current + 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() + break + + # Login still works after rehash + r = await client.post("/v1/users/login", json={ + "username": "rehash_user", "password": "rehashpass9"}) + assert r.status_code == 200 + + +# ── IP log cleanup (8.9) ──────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_ip_log_cleanup(app): + """Old IP log entries are purged by cleanup task.""" + from datetime import datetime, timezone, timedelta + from meshbay_hub.db.engine import get_db + from meshbay_hub.db.models import IPLog + from meshbay_hub.tasks.cleanup import purge_old_ip_logs + + async for db in get_db(): + old_ts = datetime.now(timezone.utc) - timedelta(days=400) + db.add(IPLog(event="test_old", ip_address="1.2.3.4", timestamp=old_ts)) + db.add(IPLog(event="test_recent", ip_address="5.6.7.8")) + await db.commit() + + deleted = await purge_old_ip_logs(db, retention_days=365) + assert deleted == 1 + + from sqlalchemy import select, func + count = (await db.execute( + select(func.count()).where(IPLog.event.in_(["test_old", "test_recent"])) + )).scalar_one() + assert count == 1 + break diff --git a/packages/meshbay-hub/tests/test_moderation.py b/packages/meshbay-hub/tests/test_moderation.py index 68e08c9..93d23cd 100644 --- a/packages/meshbay-hub/tests/test_moderation.py +++ b/packages/meshbay-hub/tests/test_moderation.py @@ -4,6 +4,7 @@ import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from meshbay_common.crypto import pk_to_b64 +from meshbay_hub.api.deps import set_admin_usernames FAKE_HASH = "a" * 64 # valid blake3 hex @@ -20,6 +21,7 @@ async def auth_headers(client): }) r = await client.post("/v1/users/login", json={"username": "mod_admin", "password": "modpass99"}) + set_admin_usernames(["mod_admin"]) return {"Authorization": f"Bearer {r.json()['access_token']}"} diff --git a/packages/meshbay-hub/tests/test_revocation.py b/packages/meshbay-hub/tests/test_revocation.py index f5d6050..494d77d 100644 --- a/packages/meshbay-hub/tests/test_revocation.py +++ b/packages/meshbay-hub/tests/test_revocation.py @@ -7,6 +7,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from meshbay_common.crypto import pk_to_b64 +from meshbay_hub.api.deps import set_admin_usernames async def _register_and_login(client, username, pk_ed, pk_x): @@ -45,6 +46,7 @@ async def test_revoke_user_marks_db(client): pk_to_b64(sk_admin_ed.public_key()), pk_to_b64(sk_admin_x.public_key()), ) + set_admin_usernames(["admin1"]) r = await client.post("/v1/admin/revoke", json={ "target": "user", "target_id": victim_id, "reason": "spam", @@ -80,6 +82,7 @@ async def test_revocation_token_verifiable_offline(client): pk_to_b64(sk_admin_ed.public_key()), pk_to_b64(sk_admin_x.public_key()), ) + set_admin_usernames(["admin2"]) r = await client.post("/v1/admin/revoke", json={ "target": "user", "target_id": victim_id, "reason": "test", @@ -107,6 +110,7 @@ async def test_revoke_group(client): pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key()), ) + set_admin_usernames(["admin3"]) hdrs = {"Authorization": f"Bearer {admin_token}"} r = await client.post("/v1/groups", json={"name": "grp-to-revoke"}, headers=hdrs) |