From 217e2dc5a99a490ec82c1ab6105979c67f9df3db Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 16 Sep 2026 15:20:31 +0000 Subject: [PATCH] feat(auth): prefer rather than require passkey user verification Requiring user verification made password managers such as NordPass ask for their master password on every passkey use. Every ceremony now asks for it as preferred and no longer rejects a result without it. This is a deliberate trade-off: a passkey used without verification is possession-only, so passwordless sign-in and step-up rest on the device or vault being unlocked. The spec records the decision. --- .../2026-09-15-mfa-local-signin-design.md | 9 +++++--- server/internal/auth/mfa_enrol.go | 4 ---- server/internal/auth/passkey_login.go | 2 +- server/internal/auth/webauthn.go | 23 +++++++++---------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md b/docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md index cfcfaf3..e7c166a 100644 --- a/docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md +++ b/docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md @@ -17,7 +17,7 @@ In: - TOTP as a second factor, with 10 single-use recovery codes. - WebAuthn passkeys, both as a second factor after a password and as - passwordless sign-in (discoverable credentials, user verification required). + passwordless sign-in (discoverable credentials, user verification preferred). - An owner setting, `require_mfa`, that forces enrolment for password users. - Owner/admin reset of another member's MFA. - Step-up re-authentication before three sensitive actions: secret reveal, @@ -174,8 +174,11 @@ at their 24h TTL and the next sign-in enforces enrolment. `{instance_id, credential_id}`, verifies the assertion including the UV flag, and mints the session with `amr: ["webauthn"]`. -A user-verified passkey is possession plus biometric or PIN, so it satisfies -`require_mfa` on its own. +User verification is preferred, not required. Requiring it made password +managers such as NordPass prompt for their master password on every use, so it +was relaxed on 2026-09-16 at the product owner's decision. A passkey used +without verification is possession-only; passwordless sign-in and step-up then +rest on the device or vault being unlocked, and still satisfy `require_mfa`. The discoverable lookup is scoped by `instance_id` from the host, never by the credential alone - the same rule that makes `users` lookups instance-scoped. diff --git a/server/internal/auth/mfa_enrol.go b/server/internal/auth/mfa_enrol.go index 656bfa5..c5c10dc 100644 --- a/server/internal/auth/mfa_enrol.go +++ b/server/internal/auth/mfa_enrol.go @@ -216,10 +216,6 @@ func HandleEnrolPasskeyFinish(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"}) return } - if !cred.Flags.UserVerified { - c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"}) - return - } transports := make([]string, 0, len(parsed.Response.Transports)) for _, tr := range parsed.Response.Transports { transports = append(transports, string(tr)) diff --git a/server/internal/auth/passkey_login.go b/server/internal/auth/passkey_login.go index d6f966a..5eab043 100644 --- a/server/internal/auth/passkey_login.go +++ b/server/internal/auth/passkey_login.go @@ -44,7 +44,7 @@ func HandlePasskeyLoginBegin(c *gin.Context) { // Discoverable login: no allowCredentials, so the authenticator offers // whichever resident credential it holds for this RP ID. options, sessionData, err := w.BeginDiscoverableLogin( - webauthn.WithUserVerification(protocol.VerificationRequired)) + webauthn.WithUserVerification(protocol.VerificationPreferred)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"}) return diff --git a/server/internal/auth/webauthn.go b/server/internal/auth/webauthn.go index 929ea1e..b4536c6 100644 --- a/server/internal/auth/webauthn.go +++ b/server/internal/auth/webauthn.go @@ -67,8 +67,14 @@ func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) { RPID: rpID, RPOrigins: []string{origin}, AuthenticatorSelection: protocol.AuthenticatorSelection{ - ResidentKey: protocol.ResidentKeyRequirementRequired, - UserVerification: protocol.VerificationRequired, + ResidentKey: protocol.ResidentKeyRequirementRequired, + // Preferred, not required, and not checked on the result. Requiring + // it made password managers such as NordPass ask for their master + // password on every use, which other sites avoid by preferring it. + // The cost is deliberate: an authenticator that skips verification + // makes a passkey possession-only, so passwordless sign-in and + // step-up then rest on the device or vault being unlocked. + UserVerification: protocol.VerificationPreferred, }, }) } @@ -182,7 +188,7 @@ func HandleMFAWebAuthnBegin(c *gin.Context) { return } options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: t.Email, credentials: lib}, - webauthn.WithUserVerification(protocol.VerificationRequired)) + webauthn.WithUserVerification(protocol.VerificationPreferred)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) return @@ -328,10 +334,6 @@ func HandleRegisterPasskeyFinish(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"}) return } - if !cred.Flags.UserVerified { - c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"}) - return - } transports := make([]string, 0, len(parsed.Response.Transports)) for _, t := range parsed.Response.Transports { transports = append(transports, string(t)) @@ -388,7 +390,7 @@ func HandleStepUpWebAuthnBegin(c *gin.Context) { return } options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: sess.Email, credentials: lib}, - webauthn.WithUserVerification(protocol.VerificationRequired)) + webauthn.WithUserVerification(protocol.VerificationPreferred)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) return @@ -435,7 +437,7 @@ func HandleStepUpWebAuthnFinish(c *gin.Context) { } // finishAssertion is shared by second-factor sign-in, passwordless sign-in and -// step-up, so the verification rules (user verification, clone detection, +// step-up, so the verification rules (backup eligibility, clone detection, // instance scope) exist once. func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) { var body struct { @@ -490,9 +492,6 @@ func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauth if err != nil { return nil, err } - if !cred.Flags.UserVerified { - return nil, errors.New("user verification was not performed") - } // A counter that fails to advance is the library's clone signal. Zero on // both sides means the authenticator does not keep one, which is normal. if cred.Authenticator.CloneWarning {