feat: protect passkey sign-in from challenge floods (Closes #535)
All checks were successful
CI / lint (pull_request) Successful in 1m9s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-11 03:02:42 +00:00
parent 175817e6d8
commit 8bc5f623ac
7 changed files with 462 additions and 11 deletions

View File

@ -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`

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

106
tests/test_passkey_store.py Normal file
View File

@ -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"),)])