diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
4 files changed, 469 insertions, 2 deletions
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py index bfbfcc0..d1a4dcb 100644 --- a/packages/meshbay-hub/tests/test_availability_between_members.py +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -338,6 +338,9 @@ async def test_a_relay_must_prove_it_holds_the_approved_key(client, monkeypatch) """ from meshbay_hub.api import relay as relay_mod + # The registry ships closed (`relay.RELAYS_ENABLED`); the proof it demands + # is still what will be wanted the day it opens. + monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True) sk = Ed25519PrivateKey.generate() pk = pk_to_b64(sk.public_key()) relay_mod._relays["r1"] = {"pk": pk, "active": False} @@ -370,10 +373,84 @@ async def test_a_relay_must_prove_it_holds_the_approved_key(client, monkeypatch) @pytest.mark.asyncio -async def test_a_captured_relay_registration_is_not_replayable(client): +async def test_every_relay_route_is_closed_as_the_hub_ships(client): + """Nothing in the tree uses the registry, and two of its routes take no account. + + A dependency on the router, so a route added later is closed too. The flag + is read as shipped, not set here — a test that closes the gate itself + would keep passing the day somebody opens it. + """ + admin = await _make_user(client, "relayadmin") + from meshbay_hub.api.deps import set_admin_usernames + set_admin_usernames(["relayadmin"]) + auth = {"Authorization": f"Bearer {admin['token']}"} + + for method, path in (("get", "/v1/relays"), + ("post", "/v1/relays/register"), + ("post", "/v1/relays/approve")): + kwargs = {"headers": auth} if method == "get" else {"json": {}, "headers": auth} + r = await getattr(client, method)(path, **kwargs) + assert r.status_code == 503, (path, r.status_code, r.text) + + +@pytest.mark.asyncio +async def test_a_stranger_who_locks_your_name_does_not_sign_you_out(client, db_session): + """AV26. The sign-in lockout is keyed by username, and usernames are public. + + So anyone can spend your attempts, and the design has to make that cost as + little as possible: a lockout refuses *passphrase* sign-in and nothing else. + The session you already have keeps working and keeps renewing, and a reset + code sent to your own address ends the lockout at once. + """ + victim_key = base64.b64encode(b"k" * 32).decode() + r = await client.post("/v1/users/register", json={ + "username": "victim26", "email": "victim26@example.test", "auth_key": victim_key}) + assert r.status_code == 201, r.text + session = (await client.post("/v1/users/login", json={ + "username": "victim26", "auth_key": victim_key})).json() + + # The stranger needs no account at all — only the name. + for _ in range(4): + r = await client.post("/v1/users/login", json={ + "username": "victim26", "auth_key": "guess" + "x" * 39}) + assert r.status_code == 401 + r = await client.post("/v1/users/login", json={ + "username": "victim26", "auth_key": victim_key}) + assert r.status_code == 429, r.text + + # Still signed in, and still able to stay signed in. + auth = {"Authorization": f"Bearer {session['access_token']}"} + assert (await client.get("/v1/users/me", headers=auth)).status_code == 200 + r = await client.post("/v1/users/token/refresh", + json={"refresh_token": session["refresh_token"]}) + assert r.status_code == 200, r.text + + # The way out that needs nobody's help: a code to the address on file. + from meshbay_hub.db.models import EmailVerification, User + from sqlalchemy import select + r = await client.post("/v1/users/password/reset-request", json={ + "username": "victim26", "email": "victim26@example.test"}) + assert r.status_code == 200, r.text + uid = (await db_session.execute( + select(User.id).where(User.username == "victim26"))).scalar_one() + code = (await db_session.execute(select(EmailVerification.code).where( + EmailVerification.user_id == uid, + EmailVerification.purpose == "password_reset"))).scalar_one() + new_key = base64.b64encode(b"n" * 32).decode() + r = await client.post("/v1/users/password/reset", json={ + "username": "victim26", "code": code, "new_auth_key": new_key}) + assert r.status_code == 200, r.text + r = await client.post("/v1/users/login", json={ + "username": "victim26", "auth_key": new_key}) + assert r.status_code == 200, r.text + + +@pytest.mark.asyncio +async def test_a_captured_relay_registration_is_not_replayable(client, monkeypatch): """Same reason /v1/nodes/announce bounds its timestamp.""" from meshbay_hub.api import relay as relay_mod + monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True) sk = Ed25519PrivateKey.generate() pk = pk_to_b64(sk.public_key()) relay_mod._relays["r2"] = {"pk": pk, "active": False} diff --git a/packages/meshbay-hub/tests/test_login_lockout.py b/packages/meshbay-hub/tests/test_login_lockout.py new file mode 100644 index 0000000..43300d0 --- /dev/null +++ b/packages/meshbay-hub/tests/test_login_lockout.py @@ -0,0 +1,256 @@ +""" +Per-account sign-in lockout (`login_throttle.py`). + +The per-IP limit bounds one address, and IPv6 gives every subscriber a /64 of +them; an online guess targets an account, so the account is what is counted. +The properties pinned here are the ones that make that safe to ship: + + * the Nth wrong passphrase locks, and a locked account is refused **before** + the passphrase is checked — the right one is refused too + * an unknown username locks exactly like a real one, so the 429 says nothing + the 401 did not (M1) + * a right passphrase clears the count, and failures age out of the window + * `change_password` checks the same passphrase, so it counts on the same row + * the two numbers are the admin's to change, and zero turns it off + +What one account costs another through this — a stranger locking your name — +is `test_availability_between_members.py`, because it takes two accounts. +""" + +import asyncio +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select, update + +from meshbay_hub.api.deps import set_admin_usernames +from meshbay_hub.db.models import LoginThrottle +from meshbay_hub.login_throttle import _key + +RIGHT = "r" * 44 +WRONG = "w" * 44 + + +async def _register(client, username, auth_key=RIGHT): + r = await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.com", + "auth_key": auth_key}) + assert r.status_code == 201, r.text + + +async def _login(client, username, auth_key): + return await client.post("/v1/users/login", json={ + "username": username, "auth_key": auth_key}) + + +async def _fail(client, username, times): + for _ in range(times): + r = await _login(client, username, WRONG) + assert r.status_code == 401, r.text + + +@pytest.mark.asyncio +async def test_the_fourth_failure_locks_and_the_right_passphrase_is_refused(client): + await _register(client, "alice") + await _fail(client, "alice", 4) + + r = await _login(client, "alice", RIGHT) + assert r.status_code == 429, r.text + assert r.json()["detail"] == "account_locked" + # An hour, give or take the time the four failures took. + assert 3500 <= int(r.headers["retry-after"]) <= 3600 + + +@pytest.mark.asyncio +async def test_an_unknown_name_locks_exactly_like_a_real_one(client): + """M1: the lockout must not become the enumeration oracle `login` avoids.""" + await _register(client, "bob") + await _fail(client, "bob", 4) + await _fail(client, "nobody-by-this-name", 4) + + real = await _login(client, "bob", WRONG) + ghost = await _login(client, "nobody-by-this-name", WRONG) + assert (real.status_code, real.json()) == (ghost.status_code, ghost.json()) + assert real.status_code == 429 + + +@pytest.mark.asyncio +async def test_the_right_passphrase_clears_the_count(client): + await _register(client, "carol") + await _fail(client, "carol", 3) + r = await _login(client, "carol", RIGHT) + assert r.status_code == 200, r.text + + # Three more would have been seven in a row without the reset. + await _fail(client, "carol", 3) + assert (await _login(client, "carol", RIGHT)).status_code == 200 + + +@pytest.mark.asyncio +async def test_a_lockout_ends_when_its_window_does(client, db_session): + await _register(client, "dave") + await _fail(client, "dave", 4) + assert (await _login(client, "dave", RIGHT)).status_code == 429 + + await db_session.execute( + update(LoginThrottle).where(LoginThrottle.key == _key("dave")) + .values(last_failure_at=datetime.now(timezone.utc) - timedelta(minutes=61))) + await db_session.commit() + + assert (await _login(client, "dave", RIGHT)).status_code == 200 + + +@pytest.mark.asyncio +async def test_old_failures_do_not_carry_into_a_new_window(client, db_session): + await _register(client, "erin") + await _fail(client, "erin", 3) + await db_session.execute( + update(LoginThrottle).where(LoginThrottle.key == _key("erin")) + .values(last_failure_at=datetime.now(timezone.utc) - timedelta(minutes=61))) + await db_session.commit() + + # One stale window of three, then one fresh failure: a count of one, not four. + await _fail(client, "erin", 1) + assert (await _login(client, "erin", RIGHT)).status_code == 200 + + +@pytest.mark.asyncio +async def test_a_burst_of_concurrent_guesses_gets_no_more_than_the_limit(client): + """The attempt is taken before the check, in one statement. + + Read-then-write would let every request in a burst read "no failures yet" + and be checked. On SQLite writes serialise anyway, so this pins the + behaviour rather than proving the statement under PostgreSQL's concurrency; + the statement is an `ON CONFLICT DO UPDATE … WHERE`, which both evaluate + against the row as locked. + """ + await _register(client, "frank") + results = await asyncio.gather(*[_login(client, "frank", WRONG) for _ in range(10)]) + codes = sorted(r.status_code for r in results) + assert codes.count(401) == 4, codes + assert codes.count(429) == 6, codes + + +@pytest.mark.asyncio +async def test_change_password_counts_on_the_same_row(client): + """It checks the same passphrase, so it is the same oracle.""" + await _register(client, "grace") + token = (await _login(client, "grace", RIGHT)).json()["access_token"] + auth = {"Authorization": f"Bearer {token}"} + + for _ in range(4): + r = await client.post("/v1/users/password", headers=auth, json={ + "old_auth_key": WRONG, "new_auth_key": "n" * 44}) + assert r.status_code == 403, r.text + + r = await client.post("/v1/users/password", headers=auth, json={ + "old_auth_key": RIGHT, "new_auth_key": "n" * 44}) + assert r.status_code == 429, r.text + assert (await _login(client, "grace", RIGHT)).status_code == 429 + + +@pytest.mark.asyncio +async def test_a_signed_in_session_is_told_its_own_lockout(client): + """A passphrase change re-wraps every node's bundle before the hub accepts + it, so the client must know not to start one the hub would then refuse.""" + await _register(client, "olivia") + token = (await _login(client, "olivia", RIGHT)).json()["access_token"] + auth = {"Authorization": f"Bearer {token}"} + assert (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"] == 0 + + await _fail(client, "olivia", 4) + left = (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"] + assert 3500 <= left <= 3600 + + +@pytest.mark.asyncio +async def test_an_attempt_that_checks_no_passphrase_is_not_counted(client, db_session): + """A legacy account asked to upgrade has been told nothing about its passphrase.""" + from meshbay_hub.db.models import User + + await _register(client, "heidi") + await db_session.execute( + update(User).where(User.username == "heidi").values(pw_version=2)) + await db_session.commit() + + for _ in range(6): + r = await _login(client, "heidi", RIGHT) + assert r.status_code == 401 and r.json()["detail"] == "auth_upgrade_required" + failures = await db_session.scalar( + select(LoginThrottle.failures).where(LoginThrottle.key == _key("heidi"))) + assert not failures + + +@pytest.mark.asyncio +async def test_the_table_never_holds_what_was_typed(client, db_session): + """People type passphrases into the username field.""" + await _login(client, "my-secret-passphrase-typed-in-the-wrong-box", WRONG) + keys = (await db_session.execute(select(LoginThrottle.key))).scalars().all() + assert keys and all("secret" not in k for k in keys) + + +# ── The admin's two numbers ────────────────────────────────────────────────── + +async def _admin_headers(client, username="root"): + await _register(client, username) + set_admin_usernames([username]) + token = (await _login(client, username, RIGHT)).json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +async def test_the_admin_sets_the_limit_and_the_hub_applies_it(client): + admin = await _admin_headers(client) + + r = await client.get("/v1/admin/settings", headers=admin) + assert r.json()["login"] == {"max_failures": 4, "lockout_minutes": 60} + assert r.json()["login_defaults"] == {"max_failures": 4, "lockout_minutes": 60} + + r = await client.patch("/v1/admin/settings", headers=admin, + json={"login": {"max_failures": 2, "lockout_minutes": 5}}) + assert r.status_code == 200, r.text + assert r.json()["login"] == {"max_failures": 2, "lockout_minutes": 5} + + await _register(client, "ivan") + await _fail(client, "ivan", 2) + r = await _login(client, "ivan", RIGHT) + assert r.status_code == 429 + assert int(r.headers["retry-after"]) <= 300 + + +@pytest.mark.asyncio +async def test_zero_failures_turns_the_lockout_off(client): + admin = await _admin_headers(client) + await client.patch("/v1/admin/settings", headers=admin, + json={"login": {"max_failures": 0}}) + + await _register(client, "judy") + await _fail(client, "judy", 8) + assert (await _login(client, "judy", RIGHT)).status_code == 200 + + +@pytest.mark.asyncio +async def test_values_are_clamped_and_unknown_keys_refused(client): + admin = await _admin_headers(client) + + r = await client.patch("/v1/admin/settings", headers=admin, + json={"login": {"max_failures": -3, "lockout_minutes": 10**9}}) + assert r.status_code == 200 + low, _ = r.json()["login_bounds"]["max_failures"] + _, high = r.json()["login_bounds"]["lockout_minutes"] + assert r.json()["login"] == {"max_failures": low, "lockout_minutes": high} + + r = await client.patch("/v1/admin/settings", headers=admin, + json={"login": {"lockout_hours": 1}}) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_only_an_admin_changes_them(client): + await _admin_headers(client) # an admin exists; this is someone else + await _register(client, "mallory") + token = (await _login(client, "mallory", RIGHT)).json()["access_token"] + r = await client.patch("/v1/admin/settings", + headers={"Authorization": f"Bearer {token}"}, + json={"login": {"max_failures": 0}}) + assert r.status_code == 403 diff --git a/packages/meshbay-hub/tests/test_migrations_reach_head.py b/packages/meshbay-hub/tests/test_migrations_reach_head.py index 3631438..13c5590 100644 --- a/packages/meshbay-hub/tests/test_migrations_reach_head.py +++ b/packages/meshbay-hub/tests/test_migrations_reach_head.py @@ -52,7 +52,7 @@ def test_the_chain_reaches_head(migrated): assert "alembic_version" in insp.get_table_names() # A table from the newest revision, so "head" means head and not "as far as # the last revision anybody happened to run". - assert "mail_quota" in insp.get_table_names() + assert "login_throttle" in insp.get_table_names() def test_the_migrated_schema_is_the_one_the_models_expect(migrated): diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py new file mode 100644 index 0000000..2c9541a --- /dev/null +++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py @@ -0,0 +1,134 @@ +""" +What the hub answers to somebody with no token. + +Every route either depends on an authentication dependency, or is on the list +below with the reason it must not. The list is the review, written down: a new +route that takes no account fails here until somebody adds it and says why, +which is the moment the question "should this be public?" gets asked. + +Routes that authenticate in their own body (a signature, an MHP token, a JWT +inside the first WebSocket message) are listed too, with what they check — +"no `Depends`" is not the same as "open", and the list says which is which. +""" + +import asyncio +import json + +import pytest +from fastapi.routing import APIRoute, APIWebSocketRoute + +AUTH_DEPENDENCIES = { + "_decode_token", "get_current_user", "require_user_scope", + "require_moderator", "require_admin", +} + +# (method, path) → why it takes no hub account. +PUBLIC = { + ("GET", "/"): "the application shell", + ("GET", "/app"): "the application shell", + ("GET", "/app/{path:path}"): "the application shell", + ("GET", "/v1/hub/info"): "read before sign-in: captcha key, instance policy", + ("GET", "/v1/hub/version"): "an installed client checks its minimum version first", + ("GET", "/v1/hub/pubkey"): "nodes cache the hub key on first contact", + ("GET", "/v1/health"): "supervision", + ("POST", "/v1/users/register"): "obtains an account — captcha, per-IP limit", + ("POST", "/v1/users/login"): "obtains a session — per-IP limit, per-name lockout", + ("POST", "/v1/users/auth"): "device sign-in — Ed25519 signature over a fresh timestamp", + ("POST", "/v1/users/token/refresh"): "the refresh token is the credential", + ("POST", "/v1/users/verify-email"): "the e-mailed code is the credential, attempts capped", + ("POST", "/v1/users/password/reset-request"): "captcha, per-IP and per-account limits", + ("POST", "/v1/users/password/reset"): "the e-mailed code is the credential, attempts capped", + ("POST", "/v1/nodes/auth"): "node sign-in — Ed25519 signature over a fresh timestamp", + ("WS", "/v1/nodes/ws"): "a node-scoped JWT in the first message, within a timeout", + ("GET", "/v1/groups"): "the public directory — empty when public groups are off", + ("GET", "/v1/blocklist"): "nodes sync it on their own behalf — hashes only", + ("GET", "/v1/blocklist/check"): "nodes consult it on their own behalf", + ("GET", "/v1/relays"): "closed: 503 while relay.RELAYS_ENABLED is False", + ("POST", "/v1/relays/register"): "closed; when open, approved key + signature", + ("GET", "/mhp/info"): "closed: 503 while federation.FEDERATION_ENABLED is False", + ("GET", "/mhp/directory"): "closed; when open, an MHP token", + ("POST", "/mhp/directory"): "closed; when open, an MHP token", + ("POST", "/mhp/revoke"): "closed; when open, an MHP token", +} + + +def _dependency_names(dependant, acc): + if dependant.call is not None: + acc.add(getattr(dependant.call, "__name__", "")) + for d in dependant.dependencies: + _dependency_names(d, acc) + return acc + + +def _routes(routes): + for r in routes: + # FastAPI wraps an included router rather than copying its routes. + inner = getattr(r, "original_router", None) + if inner is not None: + yield from _routes(inner.routes) + elif isinstance(r, (APIRoute, APIWebSocketRoute)): + yield r + + +@pytest.mark.asyncio +async def test_every_route_without_an_account_is_one_somebody_chose(app): + found = set() + for route in _routes(app.routes): + if _dependency_names(route.dependant, set()) & AUTH_DEPENDENCIES: + continue + for method in (getattr(route, "methods", None) or {"WS"}): + found.add((method, route.path)) + + assert found - set(PUBLIC) == set(), ( + "open route(s) nobody has reviewed — add an auth dependency, or list " + "them in PUBLIC with the reason") + assert set(PUBLIC) - found == set(), "PUBLIC lists routes that are gone or now authenticated" + + +@pytest.mark.asyncio +async def test_the_walk_sees_the_whole_api(app): + """The test above passes vacuously if the walk finds nothing.""" + assert sum(1 for _ in _routes(app.routes)) > 80 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/docs", "/redoc", "/openapi.json"]) +async def test_the_api_describes_itself_to_nobody(client, path): + r = await client.get(path) + assert r.status_code == 404 + assert "swagger" not in r.text.lower() and "openapi" not in r.text.lower() + + +class _SilentSocket: + """A client that connects and never says anything.""" + + def __init__(self): + self.sent, self.closed_with = [], None + + async def accept(self): + pass + + async def receive_text(self): + await asyncio.Event().wait() + + async def send_text(self, text): + self.sent.append(json.loads(text)) + + async def close(self, code=1000): + self.closed_with = code + + +@pytest.mark.asyncio +async def test_a_socket_that_never_authenticates_is_closed(monkeypatch): + """The node socket is accepted before anyone is known; silence is not a lease. + + Driven directly: the suite's transport has no WebSocket support. + """ + from meshbay_hub.api import revocation + + monkeypatch.setattr(revocation, "NODE_WS_AUTH_TIMEOUT", 0.05) + ws = _SilentSocket() + await asyncio.wait_for(revocation.node_websocket(ws), timeout=5) + + assert ws.closed_with == 4001 + assert ws.sent == [{"type": "error", "detail": "Authentication timed out"}] |