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 }