Keep passkey sign-in available during anonymous challenge floods #536
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_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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
92
src/main.py
92
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
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