diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index 691928c..e2c04dc 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -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) { diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 8d5c1a8..e8580a1 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -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) diff --git a/internal/api/repo.go b/internal/api/repo.go index aefc06d..3f7859a 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -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 diff --git a/internal/passkey/verifier.go b/internal/passkey/verifier.go index e35ad38..cc2f642 100644 --- a/internal/passkey/verifier.go +++ b/internal/passkey/verifier.go @@ -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 } diff --git a/internal/passkey/verifier_test.go b/internal/passkey/verifier_test.go index a88d3c1..ee3d25e 100644 --- a/internal/passkey/verifier_test.go +++ b/internal/passkey/verifier_test.go @@ -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 diff --git a/internal/store/migrations/0009_webauthn_credential_flags.sql b/internal/store/migrations/0009_webauthn_credential_flags.sql new file mode 100644 index 0000000..793fe31 --- /dev/null +++ b/internal/store/migrations/0009_webauthn_credential_flags.sql @@ -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;