fix(auth): store passkey backup flags so synced passkeys verify
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 3m4s

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.
This commit is contained in:
2026-09-16 15:03:54 +00:00
parent b1193c59e3
commit 934501f4ec
6 changed files with 83 additions and 9 deletions
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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})
}
+25 -4
View File
@@ -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,
+38
View File
@@ -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)
}
})
}
}