Passkey security overhaul: step-up, recovery, login history, USD session caps
Backend v1.3.0 ships action-bound step-up for high-risk operations, a recovery flow, login history with first-passkey risk holds, and USD-denominated session caps.
v1.3.0What changed
High-risk actions now require a fresh passkey assertion bound to the exact operation. wallet.send, agent_session.approve, passkey.register.additional, and passkey.remove each have a /stepup preflight that issues an HMAC token committing to action type, target resource, and a canonical parameter hash; the WebAuthn challenge is set to SHA-256(token) and a persisted step-up session prevents replay. Each enforcement point has a Disable*StepUp config flag for operator rollback, and all four step-ups emit activity events that drive a new GET /v1/security/stepup-log audit endpoint (filterable by all, agent_approve, wallet, passkey).
Agent session caps are now USD-denominated. The Assets token allowlist is gone from session delegation — the agent never names a settlement token. Sessions carry a server-stamped Currency field (default USD), and the merchant chooses the settlement stablecoin at transaction time. Tool params and swagger have been reworded to USD/budget language; CLI and skill USD changes follow separately.
Account recovery for the lost-passkey case ships behind POST /v1/recovery/{start,cancel,complete} and GET /v1/recovery/status. start flips the account to recovery_pending (which GetUserPasskeyReadiness exposes, naturally blocking high-risk actions), complete enforces a configurable delay (72h prod default, 10m in nonprod) and atomically revokes credentials plus other auth sessions in a single transaction. The recovery-started email carries a one-shot HMAC cancel link bound to the user and RecoveryStartedAt; clicking it lands on a confirmation page and cancellation happens via explicit POST so link previews and prefetch can't silently cancel. All five lifecycle events (passkey added/disabled, cooldown started, recovery started/completed) now render through a shared branded HTML template with a plain-text fallback.
A login_events table now records every /v1/login/verify attempt with derived device, browser, and MaxMind-resolved city/country (IP is not stored). Users can read their own history via GET /v1/security/login-log with since/until/limit/offset and a 90-day default. On top of this, first-passkey enrollment now evaluates risk: when the registering session's device, browser, and country are all unseen versus the user's prior successful logins, enrollment is held (403 ErrPasskeyEnrollmentRiskHold at POST /v1/passkey/register/options, before WebAuthn options are returned) and a security email is sent. The hold lifts on a familiar re-login or after the cooldown window. GET /v1/passkey lists a user's credentials with a richer display name (Platform passkey (synced, built-in) @ Mac · Chrome), and POST /v1/passkey/delete soft-disables credentials so they stay in the audit trail but no longer count toward readiness. GET /v1/me now surfaces passkey_readiness so clients can drive gating from a single call. Both login_events and first_passkey_risk are gated by config flags and ship enabled in base.yaml; the GeoLite2-City database is bundled into the Docker image.
Included changes:
- refactor(agent): make session caps USD-denominated, drop token allowlist
- chore(config): shorten recovery & first-passkey-risk cooldowns to 10m in nonprod
- fix: address security review findings
- feat(passkey): richer display name with humanized transports
- docs(passkey): align login-history web surface with implemented columns
- docs(recovery): align cancel-email subject with confirm flow + document GET landing
- fix(recovery): require explicit POST to cancel recovery via email link
- docs(passkey): correct stale step-up challenge comment and risk-hold endpoint
- feat(passkey): derive and expose passkey display metadata
- fix(recovery): accept cancel links without started_at
- feat(security): brand security notification emails with shared HTML template
- fix(passkey): use singular login_event table name in partial index migration
- fix(passkey): review nits — empty cancel-URL fallback, doc comment, docs
- fix(passkey): run first-passkey risk gate before the step-up check
- fix(passkey): bind first-passkey risk to the exact session + gate at begin
- docs(passkey): align remaining design docs with implemented risk model
- feat(passkey): implement first-passkey enrollment risk hold + enable flags
- fix(recovery): claim recovery atomically before destructive revoke
- login_events: fix dev startup - default tag must quote the literal, make migration self-healing
- config: add stepup + recovery default stanzas to base.yaml
- login_events: review followups - close geoip on stop, partial email index, rune-safe truncate
- login_events: P1/P2 review fixes (defaults, EOF whitespace, evaluator ctx)
- go mod tidy: promote useragent + geoip2 to direct deps
- login_events: schema + ingestion + GET /v1/security/login-log + first-passkey-risk flag
- docs(passkey): add login-history + first-passkey-risk design + bundle GeoLite2
- passkey: soft-disable credentials on delete + load exclusions from DB
- swagger: fix /mcp empty-responses + enum-tag ActivityEvent fields
- address Round 12 review findings on passkey-improve
- address 8 review findings on passkey-improve
- release agent_session request claim on post-claim error
- fix two findings: premature approved status, swagger drift
- fix three race + state-persistence findings from 2026-05-26 review
- fix three verified bugs from review 2026-05-26
- add DisableAgentSessionApproveStepUp config flag
- send email notifications on passkey lifecycle + recovery, with cancel link
- backend fixes from passkey-improve review (H1-3, M1, M5, L2-3)
- bind passkey.register.additional and passkey.remove to WebAuthn
- bind wallet.send to a persisted WebAuthn step-up session
- enable wallet.send and additional-passkey step-up by default
- add GET /v1/security/stepup-log for the Security page audit panel
- add recovery flow for lost-passkey + email-in-hand case
- add POST /v1/passkey/delete with passkey.remove step-up
- persist passkey cooldown state on the user and consult it in readiness
- require step-up to add another passkey when one already exists
- add wallet.send step-up token flow
- bind agent_session.approve to action-bound step-up token
- add GET /v1/passkey to list user's registered passkeys
- add step-up activity kinds and recordStepUpEvent helper
- add pkg/stepup for action-bound HMAC tokens
- add passkey trust state and account readiness computation
- add prod design doc for passkey-improvement
- refine syntax
- add thread-model for current email-otp + passkey design
- refine Account States
- add passkey-based security evolution plan