Merge pull request 'Keep passkey sign-in available during anonymous challenge floods' (#536) from timmy/535-passkey-flood-protection into main
This commit is contained in:
commit
3bad8a0198
22
README.md
22
README.md
|
|
@ -178,6 +178,11 @@ export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
|
||||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||||
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
|
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.
|
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/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.
|
# 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
|
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
|
Token and passkey sign-in failures are scoped to a hashed canonical client address and
|
||||||
workers and restarts. Once the budget is exhausted, the server returns `429` with
|
persisted across workers and restarts. Public passkey option issuance has a separate
|
||||||
`Retry-After`; the mobile login form disables retries for that interval. Expired
|
fixed-window admission budget in the same ledger, and live challenges are bounded per
|
||||||
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
|
source and globally in the session registry. Once either sign-in budget is exhausted,
|
||||||
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
|
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
|
||||||
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
|
failure state. Expired source records and challenges are pruned, and both ledgers are
|
||||||
that setting, a reverse proxy is safely treated as one shared source.
|
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
|
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`
|
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
|
||||||
|
|
|
||||||
|
|
@ -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
|
return connection
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
raise LoginAttemptStoreError(
|
raise LoginAttemptStoreError(
|
||||||
|
|
@ -97,6 +108,52 @@ class LoginAttemptStore:
|
||||||
remaining = row[1] + self.window_seconds - now
|
remaining = row[1] + self.window_seconds - now
|
||||||
return max(0, math.ceil(remaining))
|
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:
|
def record_failure(self, source: str) -> None:
|
||||||
now = self.clock()
|
now = self.clock()
|
||||||
source_hash = self._digest(source)
|
source_hash = self._digest(source)
|
||||||
|
|
|
||||||
92
src/main.py
92
src/main.py
|
|
@ -249,7 +249,14 @@ def _passkey_store() -> PasskeyStore:
|
||||||
database = os.getenv(
|
database = os.getenv(
|
||||||
"STACKCHAIN_SESSION_DB", os.path.join(state_dir, "sessions.sqlite3")
|
"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:
|
def _security_event_store() -> SecurityEventStore:
|
||||||
|
|
@ -1339,6 +1346,34 @@ async def create_passkey_authentication_options(request: Request):
|
||||||
credentials = await asyncio.to_thread(store.all)
|
credentials = await asyncio.to_thread(store.all)
|
||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(status_code=404, detail="No passkeys enrolled")
|
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)
|
rp_id, _origin = _passkey_relying_party(request)
|
||||||
options, challenge = passkeys.authentication_options(
|
options, challenge = passkeys.authentication_options(
|
||||||
rp_id=rp_id,
|
rp_id=rp_id,
|
||||||
|
|
@ -1351,6 +1386,7 @@ async def create_passkey_authentication_options(request: Request):
|
||||||
purpose="authentication",
|
purpose="authentication",
|
||||||
action="sign_in",
|
action="sign_in",
|
||||||
target="dashboard",
|
target="dashboard",
|
||||||
|
source=source,
|
||||||
)
|
)
|
||||||
return JSONResponse(options, headers={"Cache-Control": "no-store"})
|
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":
|
if payload.action != "sign_in" or payload.target != "dashboard":
|
||||||
raise HTTPException(status_code=400, detail="Invalid passkey sign-in target")
|
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:
|
try:
|
||||||
challenge = passkeys.decode(payload.challenge)
|
challenge = passkeys.decode(payload.challenge)
|
||||||
credential_id = passkeys.decode(str(payload.credential.get("id", "")))
|
credential_id = passkeys.decode(str(payload.credential.get("id", "")))
|
||||||
except (ValueError, TypeError):
|
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")
|
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
|
||||||
store = _passkey_store()
|
store = _passkey_store()
|
||||||
valid = await asyncio.to_thread(
|
valid = await asyncio.to_thread(
|
||||||
|
|
@ -1453,6 +1518,14 @@ async def verify_passkey_authentication(
|
||||||
)
|
)
|
||||||
stored = await asyncio.to_thread(store.get, credential_id)
|
stored = await asyncio.to_thread(store.get, credential_id)
|
||||||
if not valid or stored is None:
|
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")
|
raise HTTPException(status_code=401, detail="Passkey sign-in failed")
|
||||||
rp_id, origin = _passkey_relying_party(request)
|
rp_id, origin = _passkey_relying_party(request)
|
||||||
try:
|
try:
|
||||||
|
|
@ -1478,7 +1551,24 @@ async def verify_passkey_authentication(
|
||||||
except dashboard_auth.SessionStoreError:
|
except dashboard_auth.SessionStoreError:
|
||||||
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
||||||
except Exception as exc:
|
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
|
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:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_security_event_store().record,
|
_security_event_store().record,
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,18 @@ class StoredPasskey:
|
||||||
|
|
||||||
|
|
||||||
class PasskeyStore:
|
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.path = Path(path)
|
||||||
self.clock = clock
|
self.clock = clock
|
||||||
|
self.max_challenges = max(1, max_challenges)
|
||||||
|
self.max_challenges_per_source = max(1, max_challenges_per_source)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _digest(value: bytes | str) -> str:
|
def _digest(value: bytes | str) -> str:
|
||||||
|
|
@ -50,6 +59,7 @@ class PasskeyStore:
|
||||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||||
challenge_hash TEXT PRIMARY KEY,
|
challenge_hash TEXT PRIMARY KEY,
|
||||||
session_hash TEXT,
|
session_hash TEXT,
|
||||||
|
source_hash TEXT,
|
||||||
purpose TEXT NOT NULL,
|
purpose TEXT NOT NULL,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
target 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
|
return connection
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||||
|
|
@ -69,25 +87,50 @@ class PasskeyStore:
|
||||||
purpose: str,
|
purpose: str,
|
||||||
action: str,
|
action: str,
|
||||||
target: str,
|
target: str,
|
||||||
|
source: str | None = None,
|
||||||
ttl_seconds: int = 120,
|
ttl_seconds: int = 120,
|
||||||
) -> None:
|
) -> None:
|
||||||
now = int(self.clock())
|
now = int(self.clock())
|
||||||
|
source_hash = self._digest(source or session_id or f"{purpose}:{action}:{target}")
|
||||||
try:
|
try:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
|
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO passkey_challenges("
|
"INSERT INTO passkey_challenges("
|
||||||
"challenge_hash, session_hash, purpose, action, target, expires_at"
|
"challenge_hash, session_hash, source_hash, purpose, action, target, expires_at"
|
||||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
") VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
self._digest(challenge),
|
self._digest(challenge),
|
||||||
self._digest(session_id) if session_id else None,
|
self._digest(session_id) if session_id else None,
|
||||||
|
source_hash,
|
||||||
purpose,
|
purpose,
|
||||||
action,
|
action,
|
||||||
target,
|
target,
|
||||||
now + max(1, ttl_seconds),
|
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:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
@pytest.mark.anyio
|
||||||
async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||||
access_control, monkeypatch
|
access_control, monkeypatch
|
||||||
|
|
|
||||||
|
|
@ -72,3 +72,28 @@ def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
||||||
|
|
||||||
with sqlite3.connect(store.path) as connection:
|
with sqlite3.connect(store.path) as connection:
|
||||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
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
106
tests/test_passkey_store.py
Normal 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"),)])
|
||||||
Loading…
Reference in New Issue
Block a user