feat: audit passkey enrollment (Closes #513)
This commit is contained in:
parent
338b6ebbd0
commit
b455dc6286
21
README.md
21
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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
32
src/main.py
32
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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user