Skip to content

M1-40 Passkeys

No Date Ruling Reason
M1-40/1 2026-10-14 Base int/m1-wave13 at 82693e5, branch feat/M1-40-identity-passkeys in its own worktree; not merged locally and not pushed, the PR lane ports it. Instructions of the launcher, which override the brief's merge step (as M1-36/1 and M1-37/1).
M1-40/2 2026-10-14 The RP display name is the RP ID. The product name lives in the instance settings (M1-46); the display name is only a label, and a fixed value keeps the options independent of a setting that changes.
M1-40/3 2026-10-14 Sign counter: when the stored and the received counter are both above zero and the received one is not greater, the ceremony continues, the higher value is kept and one audit entry is written with the action of the ceremony (auth.login.complete or auth.step_up), decision allow, resource passkey and details {passkey_id, sign_count_regression: true, stored, received}. Counters that stay zero (synced passkeys) are no regression. The passkey row is locked before the rule is applied, so two parallel uses keep the higher value. 03 passkey_credentials, 08 section 2.5 and R-DM-31: a regression is reported, not refused, because synced and cloned authenticators cannot be told apart.
M1-40/4 2026-10-14 The decoy credential ID for a login name without passkeys is the first 32 bytes of HMAC-SHA-256(token pepper, "passkey-decoy/v1" \|\| 0x00 \|\| lower case name). Residual risk: for a known name the number of real passkeys is visible, and a decoy and a real list differ in their transports. 08 section 2.5: the answer must not reveal accounts; the context string separates this use of the pepper from codes and tokens, and the HMAC keeps the decoy stable for a name.
M1-40/5 2026-10-14 When auth.webauthn_rp_id is empty and the host of server.public_url is an IP address, or when both passkey keys are empty and server.public_url is http on a host other than localhost, or when auth.webauthn_rp_id is empty and the host of server.public_url is no valid RP ID (a single label such as bothy), the instance has no passkeys: the start does not fail, the passkey operations answer 403 feature_disabled (reason passkeys_disabled) and createStepUpChallenge has no passkey_options. An explicit RP ID or origin that breaks the rules of 08 section 2.5 fails the start (cross-check webauthn_relying_party). An IP address is never a valid RP ID (WebAuthn Level 3, "RP ID"), and an http origin outside localhost is no secure context, so browsers offer no passkeys there; installations reached by IP or plain http (tests, a private network) must keep starting; passkeys serve the web console and instances with a domain (08 section 6). Spec 08 section 2.5 names the rule.
M1-40/6 2026-10-14 Every completion that opened an issued ceremony row and then failed (refused response, rejected device, server failure) sets the row to failed in an own short unit of work after the rollback. Only a registration whose challenge belongs to another session leaves it untouched. A row must work once (03 auth_challenges, redemption); a rollback alone would leave it issued, and the same assertion could be sent again until auth.login_flow_ttl ends.
M1-40/7 2026-10-14 A refused registration response (unknown, used, expired or foreign challenge, wrong challenge, rejected attestation, algorithm outside ES256, EdDSA and RS256) answers 400 validation_failed with pointer /credential and writes no deny entry; a duplicate credential ID answers 409 conflict (pointer /credential). The brief fixes 400 for registration; a refused response is a bad request, not a denial of 07 section 4.1, whose closed list of reasons has none for it.
M1-40/8 2026-10-14 completeStepUpWithPasskey counts like a wrong signature (failed, toward auth.step_up_fail_limit) every response that does not confirm the challenge: another challenge in clientDataJSON, a passkey of another person, a revoked or unknown credential, a refused assertion, and a passkey completion of a challenge that was issued without passkey_options. An unknown, foreign, finished or expired challenge does not count, as in M1-35. 08 section 6.2: a wrong confirmation counts, a challenge the caller could not have used does not.
M1-40/9 2026-10-14 passkey.register is authorized at begin and again at complete; the conditional step-up is decided in authz through stepUpOnlyWithExistingCredential and PrincipalSource.HasActivePasskey, so a person who registered the first passkey between begin and complete needs a step-up at complete. 07 section 4.2 step 8: rights logic stays in authz (B7); the second check closes the window between the two calls.
M1-40/10 2026-10-14 An active passkey counts as a remaining sign-in method when an identity is unlinked (PasskeySource) only while the person keeps an active identity at the enabled local provider other than the one being unlinked; the credential overview lists the active passkeys oldest first. 08 section 3.3 names the passkey as a way in, but a passkey logs in only with a local identity (08 section 2.5); counting it without one would let a person unlink the last usable identity (review finding).
M1-40/11 2026-10-14 user.name of the creation options is the subject of the person's local identity, else the handle; user.id is the 16 bytes of the principal ID; a person who is not active gets no options (401 for an enrollment, 400 for a registration). Brief M1-40 "Enrollment with passkey"; the user handle carries no personal data.
M1-40/12 2026-10-14 go-webauthn is pinned at v0.18.2 (the Go proxy serves the tag); it is the only package that imports the library (identity/adapters/webauthn, vendor group webauthn in the module register). The adapter rebuilds the session data from the checked challenge, the RP ID of the configuration and required user verification; the allow list of the stored webauthn_session.v1 and the person are enforced by the use case through the credential lookup. Brief M1-40 fixes the port with JSON in and out and no ceremony state in the adapter.
M1-40/13 2026-10-15 Port onto main: the migration is 00019_identity_passkey_credentials.sql as in the integration branch (INT-W14/2; M1-40/1 was written with the number 18 of its base), its comments, the code comments and these rulings name the English chapter files (new checksum recorded with make migrations-lock). auth.login_flow_ttl and EnrollmentCodes.MatchID (over the query LockEnrollmentCode) come with this ticket, in the form INT-W14/3 kept; M1-41 reuses them. go.mod keeps kin-openapi at the newer version of main and adds go-webauthn v0.18.2. The split of the REST package (INT-W14/4) comes with this ticket, not with M1-41. main holds M1-38 with 00018, so 00019 follows without a gap. On main api/rest/rest.gen.go was already larger than on the integration branch (English descriptions of the contract), and with the operations of this ticket alone it reached 1030 KiB, above repo.max_file_kb; the hook check-added-large-files would refuse the commit. No behaviour changes.