M1-40: Passkeys¶
Status: ready | Depends on: M1-37 | Milestone: M1
Goal¶
After this ticket the server supports WebAuthn passkeys for login of local accounts, for the first
enrollment with an enrollment code, for adding further passkeys and for step-up, for the web
console and for instances with a public domain. The state of every ceremony lives in the table
auth_challenges (03, R-DM-31; created by M1-35), so it works on several nodes; user verification
is always required, attestation is not requested, relying party ID and origins come from
configuration, and a falling signature counter is reported in the audit log. A virtual authenticator makes every path testable without a browser.
Read first¶
docs/spec/08-identity.md: "Local accounts" (WebAuthn paragraph), "Enrollment code", "Approval", "Step-up flow", "DPoP", "Login protection", "Web console: session following the backend-for-frontend pattern" (context only), "Configuration values of this chapter".docs/spec/03-data-model.md: tablespasskey_credentials,auth_challenges(R-DM-31),enrollment_codes,user_identities,identity_providers,auth_sessions(step_up_at,step_up_method),audit_events; principle 1 (UUIDv7).docs/spec/05-actions.md: actionsauth.login.begin,auth.login.complete,auth.step_up,passkey.register,passkey.revoke,identity.read; "Columns Owner, Step-up, Audit, Permissions".docs/spec/07-permissions.md: "Flow" step 8, SR5.docs/spec/06-api.md: "Login flows" point 3, "Step-up", "Endpoints" (Auth, Me, Users).docs/spec/02-architecture.md: "Layers within a module" (foreign libraries only in adapters), "Module register as the source", "Configuration loading".docs/spec/16-operations.md: "Relocation and cloud portability" (RP ID keeps passkeys on the same domain).docs/PLAN.md: P-03, P-11, row M1-40 (updated by M1-37), section 5 "Test strategy" (virtual WebAuthn authenticator).- ADR 0019, ADR 0020.
docs/api/openapi.yaml:beginPasskeyLogin,completePasskeyLogin,beginEnrollment,completeEnrollment,beginPasskeyRegistration,completePasskeyRegistration,revokePasskey,createStepUpChallenge,completeStepUpWithPasskey,getMyCredentials,getUserCredentials; schemasWebAuthnChallenge,PasskeyLoginBeginRequest,PasskeyLoginCompleteRequest,EnrollBeginRequest,EnrollCompleteRequest,PasskeyRegisterComplete,Passkey,StepUpChallenge,StepUpPasskeyRequest,StepUpResult.- W3C Web Authentication Level 3: https://www.w3.org/TR/webauthn-3/; go-webauthn: https://github.com/go-webauthn/webauthn (BSD-3-Clause; pin v0.18.2, released 2026-09-19 per the planning research; if the Go proxy does not serve that tag, pin the newest v0.18.x and report it).
- Code:
internal/identity(M1-35 step-up challenges, M1-36 credential overview, M1-37CompleteLoginandEnrollmentCodes),internal/authz(PrincipalSource, step 8).
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behavior change starts with a failing test (red), then code (green), then
cleanup. No en dash or em dash in any text. White label: examples use Alex, Sam, Robin and
example.com. Configuration values only through named keys listed indocs/operations/configuration.md. - WebAuthn with go-webauthn (pinned): RP ID is the domain of the instance, allowed origins as a
list,
userVerification = required,residentKey = preferred,attestation = none(08 "Local accounts"). - A decrease of
sign_countis reported in the audit log (03passkey_credentials). passkey.registerhas Step-upconditional(05 "Columns Owner, Step-up, Audit, Permissions"): step-up once an active passkey exists; the first passkey needs only a human session.- Passkeys serve login of local accounts and step-up (03
passkey_credentials, 08 "Step-up flow"); the enrollment code confirms the first device (08 "Enrollment code"). - Code stays node neutral (PLAN section 20, "Multi-node operation"): no ceremony state in
process memory; it lives in
auth_challenges(08 "2.5").
Scope¶
The specification is in place (planning, spec first): 08 "2.5" (configuration keys, ceremony
state in auth_challenges, single use, algorithms, passkey login only with a local identity, decoy,
sign counter), 08 "18" (auth.webauthn_rp_id, auth.webauthn_origins), 07 "4.2" step 8 with
has_credentials for the conditional step-up, and the descriptions of the passkey operations and
of WebAuthnChallenge in openapi.yaml. Change them only where the implementation proves them
wrong (spec first, make api-lint api-checks green), and regenerate docs/operations/configuration.md
if a key changes. docs/DECISIONS.md: rulings listed in Design notes.
Production code under services/server/:
migrations/<next sequence>_identity_passkey_credentials.sql: tablepasskey_credentialsexactly as in 03.internal/identity/adapters/postgres/queries/passkey_credentials.sql; regenerated sqlc code.internal/identity/domain/ceremony.go: ceremony state (webauthn_session.v1: RP ID, required user verification, allowed credential IDs, user handle), expiry and single-use decision, sign counter rule.- Repository of
auth_challengesfor the purposespasskey_login,passkey_enroll,passkey_register(the table and thestep_uppart come from M1-35). internal/identity/app/passkeys.go: use casesBeginPasskeyLogin,CompletePasskeyLogin,BeginEnrollment,CompleteEnrollment,BeginPasskeyRegistration,CompletePasskeyRegistration,RevokePasskey,CompleteStepUpWithPasskey; extendCreateStepUpChallenge(M1-35) withpasskey_options. Outbound portPasskeyVerifier.internal/identity/adapters/webauthn:PasskeyVerifierwith go-webauthn.internal/identity/adapters/webauthn/virtualauthn: software authenticator for tests (also used later by M1-71 and the console tests).internal/identity/adapters/httpapi: handlers for the eight operations.internal/authz: conditional step-up forpasskey.register(Design notes); identity adapter ofauthz.PrincipalSourcegetsHasActivePasskey.- Configuration schema (M1-07):
auth.webauthn_rp_id,auth.webauthn_originswith start validation. architecture/modules.yaml: vendorwebauthn: [github.com/go-webauthn/webauthn, github.com/go-webauthn/webauthn/**]allowed foridentityadapters; regenerate.go-arch-lint.yml.- Credential overview (M1-36):
passkeysfilled with active passkeys. services/server/api/operations.yaml: enablebeginPasskeyLogin,completePasskeyLogin,beginEnrollment,completeEnrollment,beginPasskeyRegistration,completePasskeyRegistration,revokePasskey,completeStepUpWithPasskey.internal/identity/README.md,services/server/CHANGELOG.md.
Outbound port (in internal/identity/app, names fixed; JSON in and out so the app layer stays
free of the library):
type CeremonyUser struct { ID ids.ID; Name, DisplayName string; Credentials []StoredPasskey }
type PasskeyVerifier interface {
CreationOptions(user CeremonyUser, challenge []byte) (optionsJSON []byte, err error)
VerifyCreation(user CeremonyUser, challenge []byte, responseJSON []byte) (NewPasskey, error)
RequestOptions(allow []StoredPasskey, challenge []byte) (optionsJSON []byte, err error)
// VerifyAssertion resolves the credential through lookup (discoverable or allow-listed).
VerifyAssertion(challenge []byte, responseJSON []byte, lookup func(credentialID, userHandle []byte) (CeremonyUser, error)) (AssertionResult, error)
}
Out of scope¶
- Console cookie session and the console step-up right after login (M6-01, P-03).
- Associated Domains files and Digital Asset Links for native apps (M5).
- Device approval of the pending devices that passkey login creates (M1-38).
- Redemption of enrollment codes without passkey (M1-37), password and TOTP (M1-39).
- Attestation verification against a metadata service, credential backup policies.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on branch
feat/M1-40-identity-passkeys. - Conventional Commits with module scope and DCO sign-off (
git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside Scope, except
docs/DECISIONS.mdand CHANGELOG entries. - Follow the host rules supplied by the launcher. Tests bind only
127.0.0.1and port 0; PostgreSQL only throughpgtest. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools.
- If an element of the core contract (00, 03, 04, 05) named here is missing or different, stop with
Verdict: BLOCKED(P-11).
Design notes¶
Relying party configuration: auth.webauthn_rp_id (empty means the host of
server.public_url), auth.webauthn_origins (empty means [origin of server.public_url];
entries may also be non-HTTP origins such as android:apk-key-hash:...). Start validation: the RP
ID equals the host of every HTTP(S) origin or is a registrable suffix of it; HTTP origins only for
localhost. RP display name = the RP ID. Reason: the product name lives in instance settings
(M1-46), and the display name is only a label. Public key algorithms offered and accepted: ES256
(COSE -7), EdDSA (-8), RS256 (-257, needed by Windows Hello); nothing else.
Ceremony state (03 auth_challenges, 08 "2.5"):
- Begin creates a row:
id= new UUIDv7 (APIchallenge_id), challenge value = 32 bytes fromcrypto/rand, stored only aschallenge_hash= HMAC-SHA-256(token pepper, value);webauthn_session=webauthn_session.v1without the challenge;expires_atper the table below. go-webauthn's begin functions create the options and session data: put the generated challenge into the options (or use the library's challenge option if the pinned version has one), store the session data fields named above, never the challenge itself. - Complete locks the row (
SELECT ... FOR UPDATE), accepts onlystatus = issuedand not expired, checks that the challenge inclientDataJSONhas the stored HMAC (hmac.Equal), rebuildswebauthn.SessionDatafrom the row and verifies; then setsused(success) orfailed(failed verification) withfinished_at. Unknown, used or expired rows:401 unauthenticatedfor login and enrollment,400 validation_failedfor registration.
Purpose (auth_challenges.purpose) |
Bound to | Lifetime |
|---|---|---|
passkey_login |
nothing (the credential names the person) | auth.login_flow_ttl |
passkey_enroll |
enrollment_code_id (checked at begin, redeemed at complete) |
auth.login_flow_ttl |
passkey_register |
session_id of the caller |
auth.login_flow_ttl |
step_up (M1-35) |
session_id; passkey_options use the same value |
auth.step_up_challenge_ttl |
Assertion checks for every purpose: origin in the list, RP ID hash, user present and user verified
flags set (else reject), signature with the stored COSE key, credential not revoked, person
active. Backup flags update backup_eligible and backup_state.
Sign counter: if stored and received counter are both above zero and received is not greater than
stored, write an audit entry (action of the ceremony, decision allow, details
{passkey_id, sign_count_regression: true, stored, received}), let the ceremony continue and keep
the higher value. Counters that stay zero (synced passkeys) are not a regression.
Login (beginPasskeyLogin, completePasskeyLogin):
- Begin:
login_nameoptional. With a name of a person who has a local identity and active passkeys,allowCredentialslists them. For an unknown name or a name without passkeys, return one decoy credential ID = first 32 bytes ofHMAC-SHA-256(pepper, "passkey-decoy/v1" || 0x00 || lowercase name)so the answer has the same shape; without a name, noallowCredentials(discoverable). Residual risk (document): the number of real passkeys is visible for known names. - Complete: DPoP login proof without
ath; verify the assertion; the person must have an active identity on the enabledlocalprovider (else401 unauthenticated). Reason: passkey login of an OIDC-only person would bypassauth.idp_recheck_interval; their passkeys serve step-up. ThenCompleteLogin(M1-37) withTrustUnconfirmed,AuthMethod = "passkey",IdentityID= the local identity. Every rejection gives the same401body.
Enrollment with passkey (beginEnrollment, completeEnrollment):
- Begin:
EnrollmentCodes.Match(code, first_login, recovery)read-only check (no attempt count on begin); invalid code401 unauthenticated. Creation options for the code's person:user.id= 16 bytes of the principal ID,user.name= local identity subject or handle,user.displayName= display name,excludeCredentials= active passkeys of the person. - Complete: load the challenge (row lock) and its
enrollment_code_id, lock and check the code again, verify the creation response; on failure roll back,RecordFailedAttempt(own transaction, 08 "3.2") and answer401. On success insert the passkey (name=passkey_name),CompleteLoginwithTrustConfirmed,AuthMethod = "enrollment_code", thenMarkUsed;201TokenResponse.EnrollCompleteRequestcarries no code: the challenge row holds it.
Registration (beginPasskeyRegistration, completePasskeyRegistration, action
passkey.register, self): options like enrollment with the calling person and session; complete
requires the session stored with the challenge; 201 Passkey. Duplicate credential_id: 409
conflict.
Revoke (revokePasskey, DELETE /users/{user_id}/passkeys/{passkey_id}, self or owner,
step-up): sets revoked_at; already revoked 204; not visible 404.
Step-up with passkey: createStepUpChallenge adds passkey_options (request options with the
person's active passkeys, the step-up challenge value as challenge, UV required) when the person
has at least one active passkey. completeStepUpWithPasskey consumes the step_up row of M1-35
(same lock and single use), verifies the assertion against the session's person (another person's passkey
fails and counts toward auth.step_up_fail_limit), then sets step_up_at and step_up_method =
'passkey' and updates last_used_at and sign_count. Works for local and OIDC people. A session
of a cli device gets 403 forbidden (audit step_up_not_possible) before any check of the
assertion (08 "6.4", ID-T29).
Conditional step-up (identical shape in M1-39; keep names exactly so the merge is clean):
- Add to
authz.PrincipalSource:HasActivePasskey(ctx context.Context, userID <UUID type of PrincipalSource>) (bool, error)(true if apasskey_credentialsrow withrevoked_at IS NULLexists); implement it in the identity adapter; update the authz fakes. - In the authz file that implements step 8 of 07 "Flow", add (or extend, if M1-39 already
created it)
var stepUpOnlyWithExistingCredential = map[authorize.Action]func(ctx context.Context, src PrincipalSource, userID <UUID type>) (bool, error){...}with the entry forpasskey.register. In step 8: if the action is in the map and the function returnsfalse, no step-up is required; "human with a session" stays required (elseforbidden,step_up_not_possible). Permission logic stays inauthz(B7).
Virtual authenticator (virtualauthn): holds ES256 keys in memory; produces creation responses
(attestation format none) and assertion responses as JSON exactly like a browser
(clientDataJSON with type, challenge, origin; authenticator data with RP ID hash, flags, counter);
options to drop the UV flag, set the origin, set the RP ID, set or freeze the counter, sign with
another key, mark backup flags and produce discoverable assertions with user handle.
Rulings to record in docs/DECISIONS.md (the spec decisions are in 03 R-DM-31 and 08 "2.5"; link
them): RP display name; sign counter handling; decoy credential derivation (passkey-decoy/v1).
Tests (write them first)¶
All WebAuthn tests use virtualauthn; integration tests use pgtest and the controllable clock.
Unit:
TestCeremonySingleUse:issuedand not expired accepted once;used,failedand expired rejected; the stored row never contains the challenge value.TestSignCountRule: table with zero counters, increase, equal, decrease.TestWebAuthnConfigValidation: RP ID suffix rule, HTTP origin only for localhost, defaults fromserver.public_url.TestVirtualAuthenticatorRoundTripagainst the adapter.
Integration:
TestEnrollmentWithPasskey: code frombootstrap-owner, begin, complete;201, deviceactive, passkey stored, code used; a second complete with the same code401.TestEnrollmentFailedVerificationCountsAttempt: wrong origin;attemptsincremented.TestPasskeyLoginWithNameandTestPasskeyLoginDiscoverable:201,auth_method: passkey, new devicepending, known active device stays active.TestPasskeyLoginRequiresUserVerification: UV flag missing401.TestPasskeyLoginWrongOriginOrRPID:401each.TestPasskeyAssertionReplayRejected: the same completion body sent twice, second401; concurrent pair gives one201.TestPasskeyLoginExpiredChallenge: clock pastauth.login_flow_ttl401.TestPasskeyLoginRevokedCredential:401.TestPasskeyLoginOnlyWithLocalIdentity: person without local identity401; the same passkey works for step-up.TestPasskeyLoginDecoy: unknown name returns exactly one credential ID of 32 bytes, stable for the same name, different for another name; no error status.TestSignCountRegressionAudited: lower counter; login succeeds, one audit entry withsign_count_regression, stored counter unchanged.TestPasskeyRegisterStepUpOnlyWhenOneExists: first registration without step-up201; second without fresh step-up401 step_up_required; with step-up201; personal token403.TestPasskeyRegisterBoundToSession: completion from another session400.TestPasskeyRegisterDuplicate:409 conflict.TestPasskeyAlgorithms: ES256, EdDSA and RS256 accepted, ES384 rejected.TestRevokePasskey: self with step-up204, owner204, other person404, repeat204.TestStepUpWithPasskey:createStepUpChallengecontainspasskey_options; completion setsstep_up_method: passkey; a challenge used twice fails; another person's passkey fails and counts towardauth.step_up_fail_limit; aclisession gets403 forbidden(ID-T29).TestCredentialOverviewListsPasskeys.TestNoCeremonyStateInMemory: begin on one server instance, complete on a second instance with the same database and secrets;201.
Commands that must pass:
make api-lint api-checks codegen-check arch migrations-lint config-doc-check docs licenses
cd services/server && go test -race ./internal/identity/... ./internal/authz/... ./internal/platform/config/...
make check
Acceptance criteria¶
- Ceremony rows in
auth_challengeswith the purposes of 03;make api-lint api-checks docsgreen; spec text changed only where proven wrong. -
userVerification = required,attestation = none, RP ID and origins from configuration, only ES256, EdDSA and RS256. - No ceremony state in process memory; completion on another node works.
- Every challenge works once and only within its TTL.
- A decreasing
sign_countwrites an audit entry. -
passkey.registerneeds step-up once a passkey exists, decided inauthz. - Passkey login creates
pendingdevices for unknown devices and only for people with a local identity. - go-webauthn pinned in
go.mod, license allowed (make licenses). -
make checkgreen, coverage at or abovequality.coverage.go_min_percent.
Definition of done¶
- Code and tests as above; doc comments on every exported symbol; comments explain why.
internal/identity/README.md(table, use cases, operations, ceremonies), README ofadapters/webauthnandvirtualauthn;services/server/CHANGELOG.mdentry under "Unreleased".- Specification, API and configuration changes in the same branch; generated files regenerated;
docs/DECISIONS.mdupdated. make checkgreen.make reviewand an independent code review over the diff, then the red team perdocs/quality/red-team-gate.md(focus: replay, origin confusion, account enumeration, step-up bypass); verdicts in the report.- Merge locally into the base branch only if review is clean, the red team verdict is
JAandmake checkis green; thengit worktree remove. If M1-39 merged first, keep both entries ofstepUpOnlyWithExistingCredentialand bothPrincipalSourcemethods.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, test results with counts, the pinned go-webauthn version, what
is not proven, and follow-ups that could not be done in scope with the reason.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.