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 {