feat: audit passkey enrollment (Closes #513)
All checks were successful
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 2026-08-10 20:56:20 +00:00
parent 338b6ebbd0
commit b455dc6286
6 changed files with 224 additions and 13 deletions

View File

@ -212,16 +212,17 @@ session's last explicit activity. Existing two-column registries are migrated in
place, their live sessions remain valid, and their idle clock starts at migration.
The same sheet includes **Security activity**, a reverse-chronological journal of
successful token/passkey sign-ins, sign-outs, remote device revocations, issue
closures, and pull-request merges. The separate SQLite journal retains at most
10,000 events for 90 days and stores only bounded device labels and action targets.
It never stores access tokens, cookies, session/CSRF values, credential IDs, raw
network addresses, request bodies, or comment content. Keep its database on the
same class of persistent, writable storage as the session registry. Before a
session revocation or issue closure, the journal durably reserves a pending event;
if that reservation fails, the destructive action does not begin. A successful
action remains truthfully reported even if its event cannot immediately be finalized,
and the activity sheet marks that durable record as **Outcome confirmation pending**.
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
revocations, issue closures, and pull-request merges. The separate SQLite journal
retains at most 10,000 events for 90 days and stores only bounded device labels and
action targets. It never stores access tokens, cookies, session/CSRF values,
credential IDs, public keys, challenges, attestation data, raw network addresses,
request bodies, or comment content. Keep its database on the same class of persistent,
writable storage as the session registry. Before passkey credential creation, session
revocation, or issue closure, the journal durably reserves a pending event; if that
reservation fails, the consequential action does not begin. A successful action
remains truthfully reported even if its event cannot immediately be finalized, and
the activity sheet marks that durable record as **Outcome confirmation pending**.
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
WebAuthn credential with required user verification. That device can then sign in

View File

@ -145,6 +145,7 @@
const labels = {
sign_in: 'Signed in',
sign_out: 'Signed out',
passkey_enrolled: 'Passkey enrolled',
device_revoked: 'Device access revoked',
all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed',

View File

@ -1158,6 +1158,24 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
)
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
current = next(device for device in devices if device.current)
except dashboard_auth.SessionStoreError:
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
except Exception as exc:
raise HTTPException(status_code=400, detail="Passkey verification failed") from exc
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"passkey_enrolled",
method="passkey",
device_label=current.device_label,
target="passkey",
)
except SecurityEventStoreError:
raise HTTPException(
status_code=503, detail="Security activity is temporarily unavailable"
)
try:
await asyncio.to_thread(
store.register,
credential_id=verified.credential_id,
@ -1167,9 +1185,17 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
management_id=current.management_id,
)
except dashboard_auth.SessionStoreError:
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
except Exception as exc:
raise HTTPException(status_code=400, detail="Passkey verification failed") from exc
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise HTTPException(
status_code=503, detail="Passkey registry is temporarily unavailable"
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"enrolled": True}, status_code=201, headers={"Cache-Control": "no-store"}
)

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

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()

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)