Unverified Commit 7278cd7c authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(passkey): require and record user verification at enrollment

Enrollment set no AuthenticatorSelection, so user verification defaulted to preferred (not enforced), and the UV/backup flags the ceremony reported were discarded. Set UserVerification=required so a bound passkey always proves possession AND user (a UV-incapable device falls back to email-OTP), and capture user_verified/backup_eligible/backup_state through VerifiedCredential -> PasskeyCredential -> webauthn_credentials (migration 0009) so a future login path can enforce UV per credential. Adds a negative test proving a presence-only authenticator is rejected, and asserts the roundtrip records UV=true.
parent 20e31fb0
Loading
Loading
Loading
Loading
+10 −0
Changes for internal/api/handlers_passkey.go: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -109,6 +109,13 @@ type VerifiedCredential struct {
	PublicKey    string // base64(COSE public key bytes)
	SignCount    uint32
	AAGUID       string
	// Ceremony flags captured at enrollment. UserVerified records that a PIN/biometric
	// (not mere presence) was performed; BackupEligible/BackupState record whether the
	// credential is syncable/backed up. All are non-secret ceremony facts a future login
	// path can enforce or surface per credential.
	UserVerified   bool
	BackupEligible bool
	BackupState    bool
}

// VerifiedAssertion is the output of a finished LOGIN (assertion) ceremony: which of the
@@ -245,6 +252,9 @@ func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request
		AAGUID:         vc.AAGUID,
		Name:           req.Name,
		CreatedAt:      a.now(),
		UserVerified:   vc.UserVerified,
		BackupEligible: vc.BackupEligible,
		BackupState:    vc.BackupState,
	}
	if err := a.Repo.CreatePasskeyCredential(r.Context(), cred); err != nil {
		if errors.Is(err, ErrConflict) {
+10 −5
Changes for internal/api/pgrepo.go: 10 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -924,10 +924,13 @@ func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purp
// so an authenticator is never silently rebound. Empty aaguid/name land as SQL NULL.
func (p *PGRepo) CreatePasskeyCredential(ctx context.Context, c PasskeyCredential) error {
	res, err := p.db.ExecContext(ctx,
		`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, sign_count, aaguid, name, created_at)
		 VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''), $8)
		`INSERT INTO webauthn_credentials
		   (id, user_id, credential_id, public_key, sign_count, aaguid, name, created_at,
		    user_verified, backup_eligible, backup_state)
		 VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''), $8, $9, $10, $11)
		 ON CONFLICT (credential_id) DO NOTHING`,
		c.ID, c.UserID, c.CredentialID, c.PublicKey, int64(c.SignCount), c.AAGUID, c.Name, c.CreatedAt)
		c.ID, c.UserID, c.CredentialID, c.PublicKey, int64(c.SignCount), c.AAGUID, c.Name, c.CreatedAt,
		c.UserVerified, c.BackupEligible, c.BackupState)
	if err != nil {
		return err
	}
@@ -946,7 +949,8 @@ func (p *PGRepo) CreatePasskeyCredential(ctx context.Context, c PasskeyCredentia
// nullable last_used_at maps to a *time.Time (nil until an assertion is verified).
func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) ([]PasskeyCredential, error) {
	const q = `SELECT id, user_id, credential_id, public_key, sign_count,
		COALESCE(aaguid, ''), COALESCE(name, ''), created_at, last_used_at
		COALESCE(aaguid, ''), COALESCE(name, ''), created_at, last_used_at,
		user_verified, backup_eligible, backup_state
		FROM webauthn_credentials WHERE user_id = $1 ORDER BY created_at DESC`
	rows, err := p.db.QueryContext(ctx, q, userID)
	if err != nil {
@@ -961,7 +965,8 @@ func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) (
			lastUsed  sql.NullTime
		)
		if err := rows.Scan(&c.ID, &c.UserID, &c.CredentialID, &c.PublicKey, &signCount,
			&c.AAGUID, &c.Name, &c.CreatedAt, &lastUsed); err != nil {
			&c.AAGUID, &c.Name, &c.CreatedAt, &lastUsed,
			&c.UserVerified, &c.BackupEligible, &c.BackupState); err != nil {
			return nil, err
		}
		c.SignCount = uint32(signCount)
+7 −0
Changes for internal/api/repo.go: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -107,6 +107,13 @@ type PasskeyCredential struct {
	Name         string
	CreatedAt    time.Time
	LastUsedAt   *time.Time
	// Ceremony flags captured at enrollment (migration 0009). UserVerified records that a
	// PIN/biometric was performed at bind; BackupEligible/BackupState record whether the
	// credential is syncable/backed up. Persisted so a future login path can enforce UV
	// per credential and reason about single-device vs. synced authenticators.
	UserVerified   bool
	BackupEligible bool
	BackupState    bool
}

// SessionedUser is the projection resolved from a live session cookie: the
+18 −0
Changes for internal/passkey/verifier.go: 18 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -60,6 +60,15 @@ func New(rpID, displayName string, origins []string) (*Verifier, error) {
		RPID:          rpID,
		RPDisplayName: displayName,
		RPOrigins:     origins,
		// Require user verification (a PIN/biometric, not mere presence) at enrollment,
		// so a bound passkey always proves two factors — possession of the authenticator
		// AND the user. go-webauthn stamps this requirement into the SessionData at begin
		// and enforces the UV flag at CreateCredential, so an authenticator that only
		// tested presence is rejected. A device that cannot do UV simply falls back to the
		// email-OTP factor (migration 0004); no one is locked out.
		AuthenticatorSelection: protocol.AuthenticatorSelection{
			UserVerification: protocol.VerificationRequired,
		},
	})
	if err != nil {
		return nil, err
@@ -121,6 +130,15 @@ func (v *Verifier) FinishRegistration(user api.PasskeyUser, sessionData []byte,
		PublicKey:    base64.StdEncoding.EncodeToString(cred.PublicKey),
		SignCount:    cred.Authenticator.SignCount,
		AAGUID:       aaguidString(cred.Authenticator.AAGUID),
		// Record the ceremony flags go-webauthn derived from the authenticator data.
		// UserVerified is redundant with the required-UV policy today (a non-UV finish is
		// rejected before we get here) but persisting it makes the guarantee auditable and
		// survives a future policy that permits UV=preferred credentials. BackupEligible/
		// BackupState tell a later login path whether the passkey is a single-device key or
		// a syncable/multi-device one — a posture signal worth capturing at bind time.
		UserVerified:   cred.Flags.UserVerified,
		BackupEligible: cred.Flags.BackupEligible,
		BackupState:    cred.Flags.BackupState,
	}, nil
}

+35 −0
Changes for internal/passkey/verifier_test.go: 35 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -88,6 +88,41 @@ func TestRegisterRoundTrip(t *testing.T) {
	if _, err := base64.StdEncoding.DecodeString(vc.PublicKey); err != nil {
		t.Errorf("PublicKey is not valid base64: %v", err)
	}
	// The default virtual authenticator performs user verification, and enrollment now
	// requires it — so the recorded UserVerified flag must be true. This proves the flag is
	// captured from the ceremony (not left at its zero value) end to end.
	if !vc.UserVerified {
		t.Error("UserVerified = false; a verified enrollment must record UV=true")
	}
}

// TestRegisterRequiresUserVerification proves the required-UV policy is enforced, not just
// advertised: an authenticator that tests presence but does NOT verify the user (no
// PIN/biometric) must be rejected at finish. go-webauthn stamps UV=required into the
// SessionData at begin and checks the UV flag at CreateCredential; without this guard a
// silent, presence-only passkey could be bound. Pairs with TestRegisterRoundTrip (which
// proves a UV-capable authenticator still succeeds), so the policy neither over- nor
// under-blocks.
func TestRegisterRequiresUserVerification(t *testing.T) {
	v := newTestVerifier(t)
	rp := virtualRP()
	// UserNotVerified: the authenticator signs with the UV flag clear.
	authenticator := virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{UserNotVerified: true})
	cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)

	options, sessionData, err := v.BeginRegistration(testUser())
	if err != nil {
		t.Fatalf("BeginRegistration: %v", err)
	}
	attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
	if err != nil {
		t.Fatalf("ParseAttestationOptions: %v", err)
	}
	attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOpts)

	if _, err := v.FinishRegistration(testUser(), sessionData, strings.NewReader(attestationResponse)); err == nil {
		t.Fatal("FinishRegistration accepted a presence-only (no user verification) attestation; want rejection")
	}
}

// TestRegisterOriginMismatchRejected proves the adapter is really checking the origin: an
Loading