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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user