diff --git a/README.md b/README.md index edcc1d6..dcb1700 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/session.js b/frontend/session.js index df1d638..059b1a0 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -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', diff --git a/src/main.py b/src/main.py index 1e936ae..171dfd3 100644 --- a/src/main.py +++ b/src/main.py @@ -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"} ) diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index cd88af3..bb949fc 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -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 diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 832c411..63b726e 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -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() diff --git a/tests/test_security_activity.py b/tests/test_security_activity.py index 399ddad..4009bc2 100644 --- a/tests/test_security_activity.py +++ b/tests/test_security_activity.py @@ -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)