feat: protect passkey sign-in from challenge floods (Closes #535)
This commit is contained in:
1 parent
175817e6d8
commit
8bc5f623ac
7 files changed
+462
-11
No files matched your search
@@ -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
|
||||
@@ -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"),)])
|
||||
Reference in new issue
Block a user