From 934501f4ecfe801997321dbcb412695da3d66414 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 16 Sep 2026 15:03:54 +0000 Subject: [PATCH] fix(auth): store passkey backup flags so synced passkeys verify go-webauthn refuses an assertion whose Backup Eligible flag differs from the stored credential's. The flag was never stored, so it compared against false and every synced passkey (iCloud Keychain, Google Password Manager, 1Password) failed with "Backup Eligible flag inconsistency" - in passwordless sign-in, second-factor sign-in and step-up alike. Registration now stores BackupEligible and BackupState. Rows registered before this have no baseline, so their first verified assertion adopts the signed flag and records it; a recorded value always stands, so a genuine change is still refused. --- server/internal/auth/mfa_enrol.go | 2 +- server/internal/auth/passkey_login.go | 2 +- server/internal/auth/webauthn.go | 29 +++++++++++++++++--- server/internal/auth/webauthn_test.go | 38 +++++++++++++++++++++++++++ server/internal/models/user_mfa.go | 8 ++++++ server/internal/services/passkeys.go | 13 ++++++--- 6 files changed, 83 insertions(+), 9 deletions(-) diff --git a/server/internal/auth/mfa_enrol.go b/server/internal/auth/mfa_enrol.go index 566f9a5..656bfa5 100644 --- a/server/internal/auth/mfa_enrol.go +++ b/server/internal/auth/mfa_enrol.go @@ -226,7 +226,7 @@ func HandleEnrolPasskeyFinish(c *gin.Context) { } if err := services.SavePasskey(t.InstanceID, t.UserID, body.Name, cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount, - transports); err != nil { + transports, cred.Flags.BackupEligible, cred.Flags.BackupState); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"}) return } diff --git a/server/internal/auth/passkey_login.go b/server/internal/auth/passkey_login.go index c945795..d6f966a 100644 --- a/server/internal/auth/passkey_login.go +++ b/server/internal/auth/passkey_login.go @@ -105,6 +105,6 @@ func HandlePasskeyLoginFinish(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) return } - _ = services.TouchPasskey(instanceID, cred.ID, cred.Authenticator.SignCount) + _ = services.TouchPasskey(instanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState) c.JSON(http.StatusOK, gin.H{"ok": true}) } diff --git a/server/internal/auth/webauthn.go b/server/internal/auth/webauthn.go index 219e778..929ea1e 100644 --- a/server/internal/auth/webauthn.go +++ b/server/internal/auth/webauthn.go @@ -92,6 +92,10 @@ func toLibCredential(c models.WebAuthnCredential) webauthn.Credential { ID: c.CredentialID, PublicKey: c.PublicKey, AttestationType: "none", + Flags: webauthn.CredentialFlags{ + BackupEligible: c.BackupEligible != nil && *c.BackupEligible, + BackupState: c.BackupState, + }, Authenticator: webauthn.Authenticator{ AAGUID: c.AAGUID, SignCount: c.SignCount, @@ -99,6 +103,20 @@ func toLibCredential(c models.WebAuthnCredential) webauthn.Credential { } } +// backupEligibleFor is the eligibility to validate an assertion against. +// +// A recorded value always stands, so the library still refuses a credential +// whose eligibility changed. A row registered before the flag was stored has no +// baseline at all, and comparing against a zero value refused every synced +// passkey; for those the flag in the signed authenticator data is adopted, and +// TouchPasskey records it after the assertion verifies. +func backupEligibleFor(stored *bool, observed bool) bool { + if stored != nil { + return *stored + } + return observed +} + func saveCeremony(ctx context.Context, data *webauthn.SessionData) (string, error) { id, err := randomHex(32) if err != nil { @@ -206,7 +224,7 @@ func HandleMFAWebAuthnFinish(c *gin.Context) { }) return } - _ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount) + _ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState) u, err := services.GetUserInInstance(t.InstanceID, t.UserID) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) @@ -320,7 +338,7 @@ func HandleRegisterPasskeyFinish(c *gin.Context) { } if err := services.SavePasskey(sess.InstanceID, sess.UserID, body.Name, cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount, - transports); err != nil { + transports, cred.Flags.BackupEligible, cred.Flags.BackupState); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"}) return } @@ -407,7 +425,7 @@ func HandleStepUpWebAuthnFinish(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "that passkey could not be verified", "code": "invalid_assertion"}) return } - _ = services.TouchPasskey(sess.InstanceID, cred.ID, cred.Authenticator.SignCount) + _ = services.TouchPasskey(sess.InstanceID, cred.ID, cred.Authenticator.SignCount, cred.Flags.BackupEligible, cred.Flags.BackupState) if err := TouchStepUpFromRequest(c); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"}) return @@ -450,7 +468,10 @@ func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauth if err != nil { return nil, err } - user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{toLibCredential(*stored)}} + libCred := toLibCredential(*stored) + libCred.Flags.BackupEligible = backupEligibleFor(stored.BackupEligible, + parsed.Response.AuthenticatorData.Flags.HasBackupEligible()) + user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{libCred}} var cred *webauthn.Credential if userID == "" { // Discoverable (passwordless) ceremony: the session carries no user, diff --git a/server/internal/auth/webauthn_test.go b/server/internal/auth/webauthn_test.go index 2d6edfa..efd7c74 100644 --- a/server/internal/auth/webauthn_test.go +++ b/server/internal/auth/webauthn_test.go @@ -2,6 +2,7 @@ package auth import ( "bytes" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" "net/http/httptest" "testing" @@ -50,3 +51,40 @@ func TestDiscoverableHandleMatches(t *testing.T) { t.Fatal("an empty handle must never match") } } + +// Synced passkeys (iCloud Keychain, Google Password Manager, 1Password) report +// backup eligibility, and go-webauthn refuses any assertion whose flag differs +// from the stored credential's. Not storing it made every synced passkey fail +// with "Backup Eligible flag inconsistency". +func TestLibCredentialCarriesStoredBackupFlags(t *testing.T) { + yes := true + lc := toLibCredential(models.WebAuthnCredential{BackupEligible: &yes, BackupState: true}) + if !lc.Flags.BackupEligible || !lc.Flags.BackupState { + t.Fatalf("flags not carried: %+v", lc.Flags) + } +} + +func TestBackupEligibleFor(t *testing.T) { + yes, no := true, false + cases := []struct { + name string + stored *bool + observed bool + want bool + }{ + // Registered before the flag was recorded: no baseline, adopt it. + {"legacy row, synced passkey", nil, true, true}, + {"legacy row, hardware key", nil, false, false}, + // Recorded: the stored value stands, so the library still refuses a + // credential whose eligibility genuinely changed. + {"recorded eligible", &yes, true, true}, + {"recorded not eligible, now claims eligible", &no, true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := backupEligibleFor(tc.stored, tc.observed); got != tc.want { + t.Fatalf("backupEligibleFor = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/server/internal/models/user_mfa.go b/server/internal/models/user_mfa.go index 1b29c45..19122c3 100644 --- a/server/internal/models/user_mfa.go +++ b/server/internal/models/user_mfa.go @@ -55,6 +55,14 @@ type WebAuthnCredential struct { AAGUID []byte `bson:"aaguid" json:"-"` Transports []string `bson:"transports,omitempty" json:"transports,omitempty"` + // BackupEligible is whether the credential can sync between devices. It + // never changes for a real credential, and go-webauthn refuses an assertion + // whose flag differs from this one, so it must be stored. Nil only on rows + // registered before it was recorded; see auth.backupEligibleFor. + BackupEligible *bool `bson:"backup_eligible,omitempty" json:"-"` + // BackupState is whether it is currently synced. It may change. + BackupState bool `bson:"backup_state" json:"-"` + // CredentialIDHex is the browser-facing identifier for rename and delete. // The raw bytes never reach a URL. CredentialIDHex string `bson:"credential_id_hex" json:"id"` diff --git a/server/internal/services/passkeys.go b/server/internal/services/passkeys.go index a0aa3ec..42ff0a7 100644 --- a/server/internal/services/passkeys.go +++ b/server/internal/services/passkeys.go @@ -47,7 +47,7 @@ func GetPasskeyByCredentialID(instanceID string, credID []byte) (*models.WebAuth return &c, nil } -func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []byte, signCount uint32, transports []string) error { +func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []byte, signCount uint32, transports []string, backupEligible, backupState bool) error { ctx, cancel := mfaCtx() defer cancel() if name == "" { @@ -62,6 +62,8 @@ func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []by AAGUID: aaguid, SignCount: signCount, Transports: transports, + BackupEligible: &backupEligible, + BackupState: backupState, Name: name, CreatedAt: time.Now(), }) @@ -71,13 +73,18 @@ func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []by // TouchPasskey records use and the new signature counter. A counter that fails // to advance can mean a cloned authenticator, so the caller checks it before // calling this. -func TouchPasskey(instanceID string, credID []byte, signCount uint32) error { +func TouchPasskey(instanceID string, credID []byte, signCount uint32, backupEligible, backupState bool) error { ctx, cancel := mfaCtx() defer cancel() now := time.Now() _, err := db.Col("webauthn_credentials").UpdateOne(ctx, bson.M{"instance_id": instanceID, "credential_id": credID}, - bson.M{"$set": bson.M{"sign_count": signCount, "last_used_at": now}}) + bson.M{"$set": bson.M{ + "sign_count": signCount, "last_used_at": now, + // Records eligibility on rows that predate it; unchanged otherwise, + // because an assertion only succeeds when it matched. + "backup_eligible": backupEligible, "backup_state": backupState, + }}) return err }