Compare commits
No commits in common. "3bad8a019817bb985627afc1a3174e7d6ba3f8e7" and "175817e6d81cc1b60c3960843bff94e9e54815cd" have entirely different histories.
3bad8a0198
...
175817e6d8
22
README.md
22
README.md
|
|
@ -178,11 +178,6 @@ 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.
|
||||||
|
|
@ -190,16 +185,13 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Token and passkey sign-in failures are scoped to a hashed canonical client address and
|
Sign-in failures are scoped to a hashed canonical client address and persisted across
|
||||||
persisted across workers and restarts. Public passkey option issuance has a separate
|
workers and restarts. Once the budget is exhausted, the server returns `429` with
|
||||||
fixed-window admission budget in the same ledger, and live challenges are bounded per
|
`Retry-After`; the mobile login form disables retries for that interval. Expired
|
||||||
source and globally in the session registry. Once either sign-in budget is exhausted,
|
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
|
||||||
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
|
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
|
||||||
failure state. Expired source records and challenges are pruned, and both ledgers are
|
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
|
||||||
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
|
that setting, a reverse proxy is safely treated as one shared source.
|
||||||
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,17 +74,6 @@ 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(
|
||||||
|
|
@ -108,52 +97,6 @@ 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,14 +249,7 @@ 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(
|
return PasskeyStore(database, clock=time.time)
|
||||||
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:
|
||||||
|
|
@ -1346,34 +1339,6 @@ 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,
|
||||||
|
|
@ -1386,7 +1351,6 @@ 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"})
|
||||||
|
|
||||||
|
|
@ -1473,39 +1437,10 @@ 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(
|
||||||
|
|
@ -1518,14 +1453,6 @@ 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:
|
||||||
|
|
@ -1551,24 +1478,7 @@ 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,18 +20,9 @@ class StoredPasskey:
|
||||||
|
|
||||||
|
|
||||||
class PasskeyStore:
|
class PasskeyStore:
|
||||||
def __init__(
|
def __init__(self, path: str | Path, *, clock: Callable[[], float]) -> None:
|
||||||
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:
|
||||||
|
|
@ -59,7 +50,6 @@ 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,
|
||||||
|
|
@ -67,14 +57,6 @@ 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
|
||||||
|
|
@ -87,50 +69,25 @@ 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, source_hash, purpose, action, target, expires_at"
|
"challenge_hash, session_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,128 +167,6 @@ 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,28 +72,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
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