Compare commits

..

No commits in common. "e5016d8b869a9fad857213cab8faa27de0cd358c" and "338b6ebbd0506bfbb7941d5f185eb54adc70c05f" have entirely different histories.

6 changed files with 13 additions and 224 deletions

View File

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

View File

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

View File

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

View File

@ -154,15 +154,6 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
"created_at": activity.json()["events"][0]["created_at"], "created_at": activity.json()["events"][0]["created_at"],
"status": "completed", "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 "stackchain_session=" in signed_in.headers["set-cookie"]
assert "correct horse battery staple" not in signed_in.text assert "correct horse battery staple" not in signed_in.text

View File

@ -655,14 +655,6 @@ def test_security_activity_explains_pending_outcome_confirmation():
assert "event.status === 'pending'" in source 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(): def test_security_activity_labels_consequential_pull_review_decisions():
source = SESSION_JS.read_text() source = SESSION_JS.read_text()

View File

@ -75,172 +75,6 @@ async def test_security_activity_limit_is_validated(security_access):
assert invalid_cursor.status_code == 422 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 @pytest.mark.anyio
async def test_revoked_device_activity_survives_live_session_removal(security_access): async def test_revoked_device_activity_survives_live_session_removal(security_access):
transport = httpx.ASGITransport(app=main.app) transport = httpx.ASGITransport(app=main.app)