feat: audit passkey enrollment (Closes #513)
CI / lint (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 20:56:20 +00:00
1 parent 338b6ebbd0
commit b455dc6286
6 files changed
+224 -13

No files matched your search

+9
View File
@@ -154,6 +154,15 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
"created_at": activity.json()["events"][0]["created_at"],
"status": "completed",
}
assert activity.json()["events"][1] == {
"id": activity.json()["events"][1]["id"],
"kind": "passkey_enrolled",
"method": "passkey",
"device_label": "Phone",
"target": "passkey",
"created_at": activity.json()["events"][1]["created_at"],
"status": "completed",
}
assert "stackchain_session=" in signed_in.headers["set-cookie"]
assert "correct horse battery staple" not in signed_in.text
+8
View File
@@ -655,6 +655,14 @@ def test_security_activity_explains_pending_outcome_confirmation():
assert "event.status === 'pending'" in source
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
source = SESSION_JS.read_text()
assert "passkey_enrolled: 'Passkey enrolled'" in source
assert "title.textContent = labels[event.kind]" in source
assert "details.textContent =" in source
def test_security_activity_labels_consequential_pull_review_decisions():
source = SESSION_JS.read_text()
+166
View File
@@ -75,6 +75,172 @@ async def test_security_activity_limit_is_validated(security_access):
assert invalid_cursor.status_code == 422
@pytest.mark.anyio
async def test_passkey_enrollment_reservation_failure_preserves_registry(
security_access, monkeypatch
):
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
class UnavailableJournal:
def reserve(self, *_args, **_kwargs):
raise SecurityEventStoreError("unavailable")
monkeypatch.setattr(
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "enroll_passkey",
"target": "current_device",
},
headers=headers,
)
options = await client.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
enrolled = await client.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
headers=headers,
)
assert enrolled.status_code == 503
assert enrolled.json() == {"detail": "Security activity is temporarily unavailable"}
assert main._passkey_store().all() == []
@pytest.mark.anyio
async def test_passkey_enrollment_registry_failure_discards_reserved_event(
security_access, monkeypatch
):
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
monkeypatch.setattr(
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "enroll_passkey",
"target": "current_device",
},
headers=headers,
)
options = await client.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
store = main._passkey_store()
def fail_registration(**_kwargs):
raise main.dashboard_auth.SessionStoreError("unavailable")
monkeypatch.setattr(store, "register", fail_registration)
monkeypatch.setattr(main, "_passkey_store", lambda: store)
enrolled = await client.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
headers=headers,
)
events = SecurityEventStore(
security_access / "security.sqlite3", clock=lambda: 0
).list(limit=10).events
assert enrolled.status_code == 503
assert enrolled.json() == {"detail": "Passkey registry is temporarily unavailable"}
assert all(event.kind != "passkey_enrolled" for event in events)
@pytest.mark.anyio
async def test_passkey_enrollment_finalization_failure_retains_pending_event(
security_access, monkeypatch
):
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
monkeypatch.setattr(
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "enroll_passkey",
"target": "current_device",
},
headers=headers,
)
options = await client.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
journal = main._security_event_store()
def fail_finalization(_operation_id):
raise SecurityEventStoreError("unavailable")
monkeypatch.setattr(journal, "finalize", fail_finalization)
monkeypatch.setattr(main, "_security_event_store", lambda: journal)
enrolled = await client.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
headers=headers,
)
activity = await client.get("/api/v1/security-events")
event = activity.json()["events"][0]
assert enrolled.status_code == 201
assert enrolled.json() == {"enrolled": True}
assert event["kind"] == "passkey_enrolled"
assert event["status"] == "pending"
assert event["device_label"] == "Phone"
assert event["method"] == "passkey"
assert event["target"] == "passkey"
@pytest.mark.anyio
async def test_revoked_device_activity_survives_live_session_removal(security_access):
transport = httpx.ASGITransport(app=main.app)