aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py79
-rw-r--r--packages/meshbay-hub/tests/test_login_lockout.py256
-rw-r--r--packages/meshbay-hub/tests/test_migrations_reach_head.py2
-rw-r--r--packages/meshbay-hub/tests/test_unauthenticated_surface.py134
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"}]