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.
This commit is contained in:
6 files changed
+109
-13
No files matched your search
@@ -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
|
||||
@@ -237,14 +244,17 @@ func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
cred := PasskeyCredential{
|
||||
ID: id,
|
||||
UserID: p.UserID,
|
||||
CredentialID: vc.CredentialID,
|
||||
PublicKey: vc.PublicKey,
|
||||
SignCount: vc.SignCount,
|
||||
AAGUID: vc.AAGUID,
|
||||
Name: req.Name,
|
||||
CreatedAt: a.now(),
|
||||
ID: id,
|
||||
UserID: p.UserID,
|
||||
CredentialID: vc.CredentialID,
|
||||
PublicKey: vc.PublicKey,
|
||||
SignCount: vc.SignCount,
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user