Making seed v0.12's second recovery mechanism — additional registered authenticators — real in the engine: three endpoints over the already-built add-passkey ceremony, so a person can register a backup passkey and list the passkeys they have.
Seed v0.12 names three ways to recover an account if you lose your authenticator. Two of them weren't real yet. Today a person has exactly one passkey and one set of recovery codes — lose both and they are permanently locked out. This change makes the seed's second recovery mechanism, "additional registered authenticators," real in the engine: an authenticated person can now register a backup passkey and see the passkeys they have registered.
The registration ceremony itself — the WebAuthn challenge, the duplicate-authenticator exclusion, the credential write, the schema that already allows many passkeys per person — was already built in the engine and verified. It simply had no way to be called over the web. This change adds that: three small HTTP endpoints over the existing ceremony. It writes no new ceremony logic.
It is not reachable by a person yet — there is no screen for it. That screen is a separate, later piece of work. For now the capability is real and proven through the test suite.
A new /me/passkeys route group, for the signed-in person only:
POST /me/passkeys/begin — starts registering a backup passkey. Returns the challenge the browser's passkey prompt needs, with the person's existing passkeys excluded so the same authenticator can't be registered twice. Returns an opaque handle to carry into the completion step.POST /me/passkeys/complete — verifies the browser's passkey result and saves the new passkey, bound to the signed-in person. Returns the new passkey's identifier.GET /me/passkeys — lists the person's registered passkeys: identifier, label, when it was registered, and how it connects.The router mirrors the existing /me/* routers and is mounted alongside them. All three require an authenticated, second-factor-verified session; an unauthenticated request is rejected with 401.
The add-passkey ceremony lives in persons/signup.py: add_passkey_begin and add_passkey_complete, the pending-registration store, the credential insert (credentials.add_credential → WebauthnCredentialRow), the excludeCredentials wiring, and the schema that allows multiple credentials per person (unique on the credential, not on the person). All of it was present and tested at HEAD.
So this change is route glue plus schemas plus tests — it calls the existing ceremony over HTTP and adds nothing to the ceremony itself. add_passkey_complete was not modified.
There is one piece of logic in the route beyond plain glue, and it is deliberate. At completion, before the new passkey is written, the route resolves the in-progress registration and refuses with 403 if it belongs to a different account than the authenticated caller.
The reasoning: the handle returned by begin (the add_id) is an unguessable, short-lived, single-use capability token, and on its own it is adequate today. But persisting a credential is a security-critical write — it decides which account an authenticator can sign into. Binding it on the token alone would mean that if an add_id ever leaked, the holder could bind their authenticator to someone else's account. The guard makes binding depend on the token and the authenticated session, so a future add_id leak cannot bind a passkey to the wrong account. This is defense-in-depth, and it is the route's own responsibility — the ceremony function only knows the token; only the route knows who is calling. The guard refuses without disturbing the pending registration (the rightful owner can still complete it).
In scope: add (begin + complete) and list. That is what the seed's mechanism (2) requires — a person can register a backup authenticator and see what they have.
Delete / revoke is out of scope, deliberately. Removing a passkey needs a "don't let someone remove their last authenticator" guard, and what counts as the last credential — passkeys alone, or passkeys-and-recovery-codes together — is a question the recovery arc has to settle first. Building a delete path before that question is answered would bake in the wrong answer. Delete waits for the recovery arc.
Every endpoint identifies a passkey by its row id (a UUID), and the list returns only a safe subset: { id, display_name, created_at, transports }. No key material, no raw credential bytes, no sign-count, no person id are exposed.
Using the row id as the single handle (rather than the raw WebAuthn credential id) keeps one identifier kind across create, list, and a future delete; gives a clean DELETE /me/passkeys/{id} key when delete is built; and avoids putting raw credential bytes into the API for a consumer that does not exist yet. The raw credential id is not secret and can be added later if a client genuinely needs it.
The capability is proven through the test harness (reusing the existing WebAuthn registration test double), not yet through a UI:
/me/passkeys tests: 5 passed. The broader auth suite is green; the full suite collects 2,932 with no import breakage. The three routes appear in the live engine's API.
This is engine-only. The Operator Layer surface — a security-settings screen where a person adds a backup passkey and sees their registered passkeys — is a later change request, and it merges with recovery-code surfacing (view / regenerate) into one screen, so a person manages all of their account-recovery means in one place. Until that screen lands, these endpoints are real and provable but not reachable by a person.
Delete / revoke and the last-credential guard — deferred to the recovery arc (see Scope).
The Operator Layer security-settings surface — a later CR, merged with recovery-code surfacing.
Pre-existing oddity, recorded for separate cleanup: get_webauthn_config is defined twice in deps.py (lines 337 and 468); Depends(get_webauthn_config) resolves to the last definition (line 468). This build uses it knowingly; the duplicate is not de-duplicated here — it is a pre-existing item for its own cleanup decision, recorded so it is not silently carried.