From 8bc5f623acacf7811619ea6a57980911e38b5050 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 11 Aug 2026 03:02:42 +0000 Subject: [PATCH] feat: protect passkey sign-in from challenge floods (Closes #535) --- README.md | 22 ++++-- src/login_attempt_store.py | 57 ++++++++++++++ src/main.py | 92 +++++++++++++++++++++- src/passkey_store.py | 49 +++++++++++- tests/test_dashboard_auth.py | 122 ++++++++++++++++++++++++++++++ tests/test_login_attempt_store.py | 25 ++++++ tests/test_passkey_store.py | 106 ++++++++++++++++++++++++++ 7 files changed, 462 insertions(+), 11 deletions(-) create mode 100644 tests/test_passkey_store.py diff --git a/README.md b/README.md index 08f3583..d6162cb 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,11 @@ export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900 export STACKCHAIN_LOGIN_MAX_FAILURES=5 export STACKCHAIN_LOGIN_WINDOW_SECONDS=300 export STACKCHAIN_LOGIN_MAX_ENTRIES=10000 +# Public passkey ceremonies share the same window and durable source ledger. +export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10 +# Bound live one-time challenges even if anonymous clients rotate addresses. +export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10 +export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000 # Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3. export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3' # Trust forwarding headers only from these immediate reverse-proxy networks. @@ -185,13 +190,16 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8' uvicorn src.main:app --host 127.0.0.1 --port 8000 ``` -Sign-in failures are scoped to a hashed canonical client address and persisted across -workers and restarts. Once the budget is exhausted, the server returns `429` with -`Retry-After`; the mobile login form disables retries for that interval. Expired -source records are pruned and the ledger is size-bounded. Keep its SQLite file on -shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is -inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without -that setting, a reverse proxy is safely treated as one shared source. +Token and passkey sign-in failures are scoped to a hashed canonical client address and +persisted across workers and restarts. Public passkey option issuance has a separate +fixed-window admission budget in the same ledger, and live challenges are bounded per +source and globally in the session registry. Once either sign-in budget is exhausted, +the server returns `429` with `Retry-After`; a successful sign-in clears that source's +failure state. Expired source records and challenges are pruned, and both ledgers are +size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is +ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only +networks you operate. Without that setting, a reverse proxy is safely treated as one +shared source. Inbound API mutations are admitted through a body-size boundary before FastAPI parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH` diff --git a/src/login_attempt_store.py b/src/login_attempt_store.py index 4cd4427..795a4ba 100644 --- a/src/login_attempt_store.py +++ b/src/login_attempt_store.py @@ -74,6 +74,17 @@ class LoginAttemptStore: ) """ ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS login_admissions ( + bucket TEXT NOT NULL, + source_hash TEXT NOT NULL, + attempts INTEGER NOT NULL, + window_started_at REAL NOT NULL, + PRIMARY KEY (bucket, source_hash) + ) + """ + ) return connection except (OSError, sqlite3.Error) as exc: raise LoginAttemptStoreError( @@ -97,6 +108,52 @@ class LoginAttemptStore: remaining = row[1] + self.window_seconds - now return max(0, math.ceil(remaining)) + def admit(self, bucket: str, source: str, *, limit: int) -> int: + """Consume one fixed-window admission or return seconds until retry.""" + now = self.clock() + source_hash = self._digest(source) + limit = max(1, limit) + try: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + connection.execute( + "DELETE FROM login_admissions WHERE window_started_at + ? <= ?", + (self.window_seconds, now), + ) + row = connection.execute( + "SELECT attempts, window_started_at FROM login_admissions " + "WHERE bucket = ? AND source_hash = ?", + (bucket, source_hash), + ).fetchone() + if row is not None and row[0] >= limit: + return max(1, math.ceil(row[1] + self.window_seconds - now)) + if row is None: + connection.execute( + "INSERT INTO login_admissions VALUES (?, ?, 1, ?)", + (bucket, source_hash, now), + ) + else: + connection.execute( + "UPDATE login_admissions SET attempts = attempts + 1 " + "WHERE bucket = ? AND source_hash = ?", + (bucket, source_hash), + ) + connection.execute( + """ + DELETE FROM login_admissions + WHERE (bucket, source_hash) NOT IN ( + SELECT bucket, source_hash FROM login_admissions + ORDER BY window_started_at DESC, rowid DESC LIMIT ? + ) + """, + (self.max_entries,), + ) + return 0 + except (OSError, sqlite3.Error) as exc: + raise LoginAttemptStoreError( + "Sign-in throttling is temporarily unavailable" + ) from exc + def record_failure(self, source: str) -> None: now = self.clock() source_hash = self._digest(source) diff --git a/src/main.py b/src/main.py index 35b33cf..ce4f889 100644 --- a/src/main.py +++ b/src/main.py @@ -249,7 +249,14 @@ def _passkey_store() -> PasskeyStore: database = os.getenv( "STACKCHAIN_SESSION_DB", os.path.join(state_dir, "sessions.sqlite3") ) - return PasskeyStore(database, clock=time.time) + return PasskeyStore( + database, + clock=time.time, + max_challenges=int(os.getenv("STACKCHAIN_PASSKEY_MAX_CHALLENGES", "10000")), + max_challenges_per_source=int( + os.getenv("STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE", "10") + ), + ) def _security_event_store() -> SecurityEventStore: @@ -1339,6 +1346,34 @@ async def create_passkey_authentication_options(request: Request): credentials = await asyncio.to_thread(store.all) if not credentials: raise HTTPException(status_code=404, detail="No passkeys enrolled") + peer_host = request.client.host if request.client is not None else "unknown" + source = client_source( + peer_host, + request.headers.get("x-forwarded-for", ""), + os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""), + ) + attempts = _login_attempt_store() + try: + retry_after = await asyncio.to_thread(attempts.retry_after, source) + if not retry_after: + retry_after = await asyncio.to_thread( + attempts.admit, + "passkey_options", + source, + limit=int(os.getenv("STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS", "10")), + ) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + if retry_after: + return JSONResponse( + {"detail": "Too many passkey sign-in attempts"}, + status_code=429, + headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)}, + ) rp_id, _origin = _passkey_relying_party(request) options, challenge = passkeys.authentication_options( rp_id=rp_id, @@ -1351,6 +1386,7 @@ async def create_passkey_authentication_options(request: Request): purpose="authentication", action="sign_in", target="dashboard", + source=source, ) return JSONResponse(options, headers={"Cache-Control": "no-store"}) @@ -1437,10 +1473,39 @@ async def verify_passkey_authentication( ): if payload.action != "sign_in" or payload.target != "dashboard": raise HTTPException(status_code=400, detail="Invalid passkey sign-in target") + peer_host = request.client.host if request.client is not None else "unknown" + source = client_source( + peer_host, + request.headers.get("x-forwarded-for", ""), + os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""), + ) + attempts = _login_attempt_store() + try: + retry_after = await asyncio.to_thread(attempts.retry_after, source) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + if retry_after: + return JSONResponse( + {"detail": "Too many passkey sign-in attempts"}, + status_code=429, + headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)}, + ) try: challenge = passkeys.decode(payload.challenge) credential_id = passkeys.decode(str(payload.credential.get("id", ""))) except (ValueError, TypeError): + try: + await asyncio.to_thread(attempts.record_failure, source) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) raise HTTPException(status_code=400, detail="Invalid passkey ceremony") store = _passkey_store() valid = await asyncio.to_thread( @@ -1453,6 +1518,14 @@ async def verify_passkey_authentication( ) stored = await asyncio.to_thread(store.get, credential_id) if not valid or stored is None: + try: + await asyncio.to_thread(attempts.record_failure, source) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) raise HTTPException(status_code=401, detail="Passkey sign-in failed") rp_id, origin = _passkey_relying_party(request) try: @@ -1478,7 +1551,24 @@ async def verify_passkey_authentication( except dashboard_auth.SessionStoreError: raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable") except Exception as exc: + try: + await asyncio.to_thread(attempts.record_failure, source) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) raise HTTPException(status_code=401, detail="Passkey sign-in failed") from exc + try: + await asyncio.to_thread(attempts.clear, source) + except LoginAttemptStoreError: + await asyncio.to_thread(dashboard_auth.revoke_session, session) + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) try: await asyncio.to_thread( _security_event_store().record, diff --git a/src/passkey_store.py b/src/passkey_store.py index 5767d6d..6225606 100644 --- a/src/passkey_store.py +++ b/src/passkey_store.py @@ -20,9 +20,18 @@ class StoredPasskey: class PasskeyStore: - def __init__(self, path: str | Path, *, clock: Callable[[], float]) -> None: + def __init__( + self, + path: str | Path, + *, + clock: Callable[[], float], + max_challenges: int = 10_000, + max_challenges_per_source: int = 10, + ) -> None: self.path = Path(path) self.clock = clock + self.max_challenges = max(1, max_challenges) + self.max_challenges_per_source = max(1, max_challenges_per_source) @staticmethod def _digest(value: bytes | str) -> str: @@ -50,6 +59,7 @@ class PasskeyStore: CREATE TABLE IF NOT EXISTS passkey_challenges ( challenge_hash TEXT PRIMARY KEY, session_hash TEXT, + source_hash TEXT, purpose TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL, @@ -57,6 +67,14 @@ class PasskeyStore: ) """ ) + columns = { + row[1] + for row in connection.execute("PRAGMA table_info(passkey_challenges)") + } + if "source_hash" not in columns: + connection.execute( + "ALTER TABLE passkey_challenges ADD COLUMN source_hash TEXT" + ) return connection except (OSError, sqlite3.Error) as exc: raise SessionStoreError("Passkey registry is temporarily unavailable") from exc @@ -69,25 +87,50 @@ class PasskeyStore: purpose: str, action: str, target: str, + source: str | None = None, ttl_seconds: int = 120, ) -> None: now = int(self.clock()) + source_hash = self._digest(source or session_id or f"{purpose}:{action}:{target}") try: with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,)) connection.execute( "INSERT INTO passkey_challenges(" - "challenge_hash, session_hash, purpose, action, target, expires_at" - ") VALUES (?, ?, ?, ?, ?, ?)", + "challenge_hash, session_hash, source_hash, purpose, action, target, expires_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?)", ( self._digest(challenge), self._digest(session_id) if session_id else None, + source_hash, purpose, action, target, now + max(1, ttl_seconds), ), ) + connection.execute( + """ + DELETE FROM passkey_challenges + WHERE source_hash = ? AND challenge_hash NOT IN ( + SELECT challenge_hash FROM passkey_challenges + WHERE source_hash = ? + ORDER BY expires_at DESC, rowid DESC LIMIT ? + ) + """, + (source_hash, source_hash, self.max_challenges_per_source), + ) + connection.execute( + """ + DELETE FROM passkey_challenges + WHERE challenge_hash NOT IN ( + SELECT challenge_hash FROM passkey_challenges + ORDER BY expires_at DESC, rowid DESC LIMIT ? + ) + """, + (self.max_challenges,), + ) except (OSError, sqlite3.Error) as exc: raise SessionStoreError("Passkey registry is temporarily unavailable") from exc diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 7f6a538..6a2660d 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -167,6 +167,128 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token( assert "correct horse battery staple" not in signed_in.text +@pytest.mark.anyio +async def test_passkey_options_are_source_limited_before_challenge_generation( + access_control, monkeypatch +): + monkeypatch.setenv("STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS", "2") + await asyncio.to_thread( + main._passkey_store().register, + credential_id=b"phone-credential", + public_key=b"phone-public-key", + sign_count=0, + device_label="Phone", + management_id="phone-management-id", + ) + first_source = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234)) + other_source = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234)) + + async with httpx.AsyncClient(transport=first_source, base_url="https://test") as client: + first = await client.post("/api/v1/passkeys/authentication/options") + second = await client.post("/api/v1/passkeys/authentication/options") + blocked = await client.post("/api/v1/passkeys/authentication/options") + async with httpx.AsyncClient(transport=other_source, base_url="https://test") as client: + available = await client.post("/api/v1/passkeys/authentication/options") + + assert first.status_code == second.status_code == available.status_code == 200 + assert blocked.status_code == 429 + assert blocked.json() == {"detail": "Too many passkey sign-in attempts"} + assert blocked.headers["retry-after"] == "60" + with sqlite3.connect(main._passkey_store().path) as connection: + assert connection.execute( + "SELECT COUNT(*) FROM passkey_challenges WHERE purpose = 'authentication'" + ).fetchone() == (3,) + + +@pytest.mark.anyio +async def test_failed_passkey_verification_consumes_the_shared_sign_in_budget( + access_control, +): + await asyncio.to_thread( + main._passkey_store().register, + credential_id=b"phone-credential", + public_key=b"phone-public-key", + sign_count=0, + device_label="Phone", + management_id="phone-management-id", + ) + transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234)) + + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + failures = [] + for _attempt in range(3): + options = await client.post("/api/v1/passkeys/authentication/options") + failures.append( + await client.post( + "/api/v1/passkeys/authentication/verify", + json={ + "challenge": options.json()["challenge"], + "credential": {"id": "d3JvbmctY3JlZGVudGlhbA"}, + "device_label": "Phone", + "action": "sign_in", + "target": "dashboard", + }, + ) + ) + blocked = await client.post("/api/v1/passkeys/authentication/options") + + assert [response.status_code for response in failures] == [401, 401, 401] + assert blocked.status_code == 429 + assert blocked.headers["retry-after"] == "60" + + +@pytest.mark.anyio +async def test_successful_passkey_sign_in_clears_prior_source_failures( + access_control, monkeypatch +): + class VerifiedAuthentication: + new_sign_count = 1 + + monkeypatch.setattr( + main.passkeys, + "verify_authentication", + lambda **_kwargs: VerifiedAuthentication(), + ) + _signed, _managed_session = await asyncio.to_thread( + main.dashboard_auth.issue_session, + device_label="Phone", + management_id="phone-management-id", + ) + await asyncio.to_thread( + main._passkey_store().register, + credential_id=b"phone-credential", + public_key=b"phone-public-key", + sign_count=0, + device_label="Phone", + management_id="phone-management-id", + ) + source = "203.0.113.10" + attempts = main._login_attempt_store() + await asyncio.to_thread(attempts.record_failure, source) + await asyncio.to_thread(attempts.record_failure, source) + transport = httpx.ASGITransport(app=main.app, client=(source, 1234)) + + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + options = await client.post("/api/v1/passkeys/authentication/options") + signed_in = await client.post( + "/api/v1/passkeys/authentication/verify", + json={ + "challenge": options.json()["challenge"], + "credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"}, + "device_label": "Phone", + "action": "sign_in", + "target": "dashboard", + }, + ) + + assert signed_in.status_code == 200, signed_in.text + with sqlite3.connect(attempts.path) as connection: + assert connection.execute( + "SELECT COUNT(*) FROM login_attempts WHERE source_hash = ?", + (attempts._digest(source),), + ).fetchone() == (0,) + + @pytest.mark.anyio async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use( access_control, monkeypatch diff --git a/tests/test_login_attempt_store.py b/tests/test_login_attempt_store.py index 22bad2c..9be8d5b 100644 --- a/tests/test_login_attempt_store.py +++ b/tests/test_login_attempt_store.py @@ -72,3 +72,28 @@ def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path): with sqlite3.connect(store.path) as connection: assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,) + + +def test_named_admission_budget_is_atomic_across_instances_and_source_scoped(tmp_path): + now = [1_000.0] + database = tmp_path / "login-attempts.sqlite3" + first = LoginAttemptStore( + database, + clock=lambda: now[0], + max_failures=3, + window_seconds=60, + ) + second = LoginAttemptStore( + database, + clock=lambda: now[0], + max_failures=3, + window_seconds=60, + ) + + assert first.admit("passkey_options", "203.0.113.7", limit=2) == 0 + assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0 + assert first.admit("passkey_options", "203.0.113.7", limit=2) == 60 + assert first.admit("passkey_options", "203.0.113.8", limit=2) == 0 + + now[0] = 1_060.0 + assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0 diff --git a/tests/test_passkey_store.py b/tests/test_passkey_store.py new file mode 100644 index 0000000..45a1c4c --- /dev/null +++ b/tests/test_passkey_store.py @@ -0,0 +1,106 @@ +import sqlite3 + +from src.passkey_store import PasskeyStore + + +def test_active_challenges_are_bounded_per_source_and_globally_across_instances(tmp_path): + now = [1_000.0] + database = tmp_path / "passkeys.sqlite3" + first = PasskeyStore( + database, + clock=lambda: now[0], + max_challenges=3, + max_challenges_per_source=2, + ) + second = PasskeyStore( + database, + clock=lambda: now[0], + max_challenges=3, + max_challenges_per_source=2, + ) + + for challenge in (b"first", b"second", b"third"): + first.issue_challenge( + challenge, + session_id=None, + purpose="authentication", + action="sign_in", + target="dashboard", + source="203.0.113.7", + ) + second.issue_challenge( + b"other-one", + session_id=None, + purpose="authentication", + action="sign_in", + target="dashboard", + source="203.0.113.8", + ) + second.issue_challenge( + b"other-two", + session_id=None, + purpose="authentication", + action="sign_in", + target="dashboard", + source="203.0.113.8", + ) + + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (3,) + counts = connection.execute( + "SELECT source_hash, COUNT(*) FROM passkey_challenges GROUP BY source_hash" + ).fetchall() + assert sorted(count for _source, count in counts) == [1, 2] + + now[0] = 1_121.0 + first.issue_challenge( + b"after-expiry", + session_id=None, + purpose="authentication", + action="sign_in", + target="dashboard", + source="203.0.113.9", + ) + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (1,) + + +def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(tmp_path): + database = tmp_path / "passkeys.sqlite3" + with sqlite3.connect(database) as connection: + connection.execute( + """ + CREATE TABLE passkey_challenges ( + challenge_hash TEXT PRIMARY KEY, + session_hash TEXT, + purpose TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL, + expires_at INTEGER NOT NULL + ) + """ + ) + connection.execute( + "INSERT INTO passkey_challenges VALUES ('existing', NULL, " + "'authentication', 'sign_in', 'dashboard', 1120)" + ) + store = PasskeyStore(database, clock=lambda: 1_000.0) + + store.issue_challenge( + b"new", + session_id=None, + purpose="authentication", + action="sign_in", + target="dashboard", + source="203.0.113.7", + ) + + with sqlite3.connect(database) as connection: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(passkey_challenges)") + } + rows = connection.execute( + "SELECT challenge_hash FROM passkey_challenges ORDER BY challenge_hash" + ).fetchall() + assert "source_hash" in columns + assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)]) -- 2.43.0