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.
|
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, sign-outs, remote device revocations, issue
|
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
|
||||||
closures, and pull-request merges. The separate SQLite journal retains at most
|
revocations, issue closures, and pull-request merges. The separate SQLite journal
|
||||||
10,000 events for 90 days and stores only bounded device labels and action targets.
|
retains at most 10,000 events for 90 days and stores only bounded device labels and
|
||||||
It never stores access tokens, cookies, session/CSRF values, credential IDs, raw
|
action targets. It never stores access tokens, cookies, session/CSRF values,
|
||||||
network addresses, request bodies, or comment content. Keep its database on the
|
credential IDs, public keys, challenges, attestation data, raw network addresses,
|
||||||
same class of persistent, writable storage as the session registry. Before a
|
request bodies, or comment content. Keep its database on the same class of persistent,
|
||||||
session revocation or issue closure, the journal durably reserves a pending event;
|
writable storage as the session registry. Before passkey credential creation, session
|
||||||
if that reservation fails, the destructive action does not begin. A successful
|
revocation, or issue closure, the journal durably reserves a pending event; if that
|
||||||
action remains truthfully reported even if its event cannot immediately be finalized,
|
reservation fails, the consequential action does not begin. A successful action
|
||||||
and the activity sheet marks that durable record as **Outcome confirmation pending**.
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@
|
||||||
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',
|
||||||
|
|
|
||||||
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)
|
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,
|
||||||
|
|
@ -1167,9 +1185,17 @@ 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:
|
||||||
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
try:
|
||||||
except Exception as exc:
|
await asyncio.to_thread(journal.discard, operation_id)
|
||||||
raise HTTPException(status_code=400, detail="Passkey verification failed") from exc
|
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(
|
return JSONResponse(
|
||||||
{"enrolled": True}, status_code=201, headers={"Cache-Control": "no-store"}
|
{"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"],
|
"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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -655,6 +655,14 @@ 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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,172 @@ 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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user