feat: audit passkey enrollment (Closes #513)
This commit is contained in:
1 parent
338b6ebbd0
commit
b455dc6286
6 files changed
+224
-13
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user