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)
|
PublicKey string // base64(COSE public key bytes)
|
||||||
SignCount uint32
|
SignCount uint32
|
||||||
AAGUID string
|
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
|
// 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
|
return
|
||||||
}
|
}
|
||||||
cred := PasskeyCredential{
|
cred := PasskeyCredential{
|
||||||
ID: id,
|
ID: id,
|
||||||
UserID: p.UserID,
|
UserID: p.UserID,
|
||||||
CredentialID: vc.CredentialID,
|
CredentialID: vc.CredentialID,
|
||||||
PublicKey: vc.PublicKey,
|
PublicKey: vc.PublicKey,
|
||||||
SignCount: vc.SignCount,
|
SignCount: vc.SignCount,
|
||||||
AAGUID: vc.AAGUID,
|
AAGUID: vc.AAGUID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
CreatedAt: a.now(),
|
CreatedAt: a.now(),
|
||||||
|
UserVerified: vc.UserVerified,
|
||||||
|
BackupEligible: vc.BackupEligible,
|
||||||
|
BackupState: vc.BackupState,
|
||||||
}
|
}
|
||||||
if err := a.Repo.CreatePasskeyCredential(r.Context(), cred); err != nil {
|
if err := a.Repo.CreatePasskeyCredential(r.Context(), cred); err != nil {
|
||||||
if errors.Is(err, ErrConflict) {
|
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.
|
// so an authenticator is never silently rebound. Empty aaguid/name land as SQL NULL.
|
||||||
func (p *PGRepo) CreatePasskeyCredential(ctx context.Context, c PasskeyCredential) error {
|
func (p *PGRepo) CreatePasskeyCredential(ctx context.Context, c PasskeyCredential) error {
|
||||||
res, err := p.db.ExecContext(ctx,
|
res, err := p.db.ExecContext(ctx,
|
||||||
`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, sign_count, aaguid, name, created_at)
|
`INSERT INTO webauthn_credentials
|
||||||
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''), $8)
|
(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`,
|
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 {
|
if err != nil {
|
||||||
return err
|
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).
|
// 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) {
|
func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) ([]PasskeyCredential, error) {
|
||||||
const q = `SELECT id, user_id, credential_id, public_key, sign_count,
|
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`
|
FROM webauthn_credentials WHERE user_id = $1 ORDER BY created_at DESC`
|
||||||
rows, err := p.db.QueryContext(ctx, q, userID)
|
rows, err := p.db.QueryContext(ctx, q, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -961,7 +965,8 @@ func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) (
|
|||||||
lastUsed sql.NullTime
|
lastUsed sql.NullTime
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&c.ID, &c.UserID, &c.CredentialID, &c.PublicKey, &signCount,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
c.SignCount = uint32(signCount)
|
c.SignCount = uint32(signCount)
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ type PasskeyCredential struct {
|
|||||||
Name string
|
Name string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastUsedAt *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
|
// SessionedUser is the projection resolved from a live session cookie: the
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ func New(rpID, displayName string, origins []string) (*Verifier, error) {
|
|||||||
RPID: rpID,
|
RPID: rpID,
|
||||||
RPDisplayName: displayName,
|
RPDisplayName: displayName,
|
||||||
RPOrigins: origins,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -121,6 +130,15 @@ func (v *Verifier) FinishRegistration(user api.PasskeyUser, sessionData []byte,
|
|||||||
PublicKey: base64.StdEncoding.EncodeToString(cred.PublicKey),
|
PublicKey: base64.StdEncoding.EncodeToString(cred.PublicKey),
|
||||||
SignCount: cred.Authenticator.SignCount,
|
SignCount: cred.Authenticator.SignCount,
|
||||||
AAGUID: aaguidString(cred.Authenticator.AAGUID),
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,41 @@ func TestRegisterRoundTrip(t *testing.T) {
|
|||||||
if _, err := base64.StdEncoding.DecodeString(vc.PublicKey); err != nil {
|
if _, err := base64.StdEncoding.DecodeString(vc.PublicKey); err != nil {
|
||||||
t.Errorf("PublicKey is not valid base64: %v", err)
|
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
|
// TestRegisterOriginMismatchRejected proves the adapter is really checking the origin: an
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Phase 6 passkey hardening: record the WebAuthn ceremony flags on each bound credential.
|
||||||
|
--
|
||||||
|
-- 0007 stored the credential material but discarded the authenticator-data flags. The
|
||||||
|
-- enrollment ceremony now requires user verification (verifier.go sets UV=required), and
|
||||||
|
-- we persist the flags the ceremony reported so the guarantee is auditable and a future
|
||||||
|
-- login/step-up path can enforce or reason about them per credential:
|
||||||
|
-- user_verified — a PIN/biometric (not mere presence) was performed at bind. With the
|
||||||
|
-- required-UV policy this is always true for new rows, but persisting
|
||||||
|
-- it survives a future policy that permits UV=preferred credentials.
|
||||||
|
-- backup_eligible — the credential is exportable/syncable across devices (a passkey that
|
||||||
|
-- lives in a cloud keychain), as opposed to a single-device key.
|
||||||
|
-- backup_state — the credential is currently backed up / synced.
|
||||||
|
--
|
||||||
|
-- DEFAULT false backfills any pre-existing row (none in practice: enrollment shipped in
|
||||||
|
-- 0007 with no production data yet) to the conservative "not verified, single-device"
|
||||||
|
-- reading; NOT NULL keeps the Go scan a plain bool with no nullable handling.
|
||||||
|
|
||||||
|
ALTER TABLE webauthn_credentials
|
||||||
|
ADD COLUMN user_verified boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN backup_eligible boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN backup_state boolean NOT NULL DEFAULT false;
|
||||||
Reference in new issue
Block a user