mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-02 15:59:04 +02:00
feat: include passkey icons based on AAGUID (#1756)
This commit is contained in:
@@ -15,6 +15,9 @@ type WebauthnCredentialDto struct {
|
||||
BackupEligible bool `json:"backupEligible"`
|
||||
BackupState bool `json:"backupState"`
|
||||
|
||||
AAGUID string `json:"aaguid"`
|
||||
HasIcon bool `json:"hasIcon"`
|
||||
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
func TestWebauthnCredentialDto_iconFields(t *testing.T) {
|
||||
withIcon := anyAAGUIDWithIcon(t)
|
||||
|
||||
credentials := []model.WebauthnCredential{
|
||||
{Name: "With icon", AAGUID: withIcon},
|
||||
{Name: "Unknown authenticator", AAGUID: "ffffffff-ffff-ffff-ffff-ffffffffffff"},
|
||||
{Name: "Authenticator that does not identify itself", AAGUID: utils.ZeroAAGUID},
|
||||
}
|
||||
|
||||
var dtos []WebauthnCredentialDto
|
||||
require.NoError(t, MapStructList(credentials, &dtos))
|
||||
require.Len(t, dtos, len(credentials))
|
||||
|
||||
require.Equal(t, withIcon, dtos[0].AAGUID)
|
||||
require.True(t, dtos[0].HasIcon)
|
||||
|
||||
require.False(t, dtos[1].HasIcon)
|
||||
|
||||
require.Equal(t, utils.ZeroAAGUID, dtos[2].AAGUID)
|
||||
require.False(t, dtos[2].HasIcon)
|
||||
}
|
||||
|
||||
func anyAAGUIDWithIcon(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile("../../resources/aaguids.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
var metadata map[string]struct {
|
||||
IconLight string `json:"icon_light"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(data, &metadata))
|
||||
|
||||
for aaguid, entry := range metadata {
|
||||
if entry.IconLight != "" && utils.HasAuthenticatorIcon(aaguid) {
|
||||
return aaguid
|
||||
}
|
||||
}
|
||||
|
||||
t.Skip("no authenticator icons are embedded")
|
||||
return ""
|
||||
}
|
||||
@@ -20,9 +20,17 @@ type WebauthnCredential struct {
|
||||
BackupEligible bool `json:"backupEligible"`
|
||||
BackupState bool `json:"backupState"`
|
||||
|
||||
AAGUID string `gorm:"column:aaguid;default:00000000-0000-0000-0000-000000000000"`
|
||||
|
||||
UserID string
|
||||
}
|
||||
|
||||
// HasIcon reports whether an icon is embedded for this credential's authenticator
|
||||
// It is mapped onto the DTO so the frontend knows upfront whether an icon is worth requesting
|
||||
func (c WebauthnCredential) HasIcon() bool {
|
||||
return utils.HasAuthenticatorIcon(c.AAGUID)
|
||||
}
|
||||
|
||||
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
|
||||
|
||||
// Scan and Value methods for GORM to handle the custom type
|
||||
|
||||
@@ -4,19 +4,39 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
"uuid"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
var (
|
||||
aaguidMap map[string]string
|
||||
aaguidMapOnce *sync.Once
|
||||
const (
|
||||
aaguidIconsDir = "aaguid-icons"
|
||||
authenticatorIconHashBytes = 8
|
||||
)
|
||||
|
||||
// ZeroAAGUID is the AAGUID reported by authenticators that do not want to identify themselves, and it is also the column default for credentials that were registered before AAGUIDs were tracked
|
||||
var ZeroAAGUID = uuid.Nil().String()
|
||||
|
||||
var (
|
||||
aaguidMetadata map[string]authenticatorMetadata
|
||||
aaguidMetadataOnce *sync.Once
|
||||
)
|
||||
|
||||
// authenticatorMetadata records the display name and content-addressed icon files for an AAGUID
|
||||
// IconDark is empty when the authenticator uses its light icon in both themes
|
||||
type authenticatorMetadata struct {
|
||||
Name string `json:"name"`
|
||||
IconLight string `json:"icon_light"`
|
||||
IconDark string `json:"icon_dark"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
aaguidMapOnce = &sync.Once{}
|
||||
aaguidMetadataOnce = &sync.Once{}
|
||||
}
|
||||
|
||||
// FormatAAGUID converts an AAGUID byte slice to UUID string format
|
||||
@@ -42,18 +62,18 @@ func GetAuthenticatorName(aaguid []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Then check JSON-sourced map
|
||||
aaguidMapOnce.Do(loadAAGUIDsFromFile)
|
||||
// Then check the embedded metadata manifest
|
||||
aaguidMetadataOnce.Do(loadAAGUIDMetadataFromFile)
|
||||
|
||||
if name, ok := aaguidMap[aaguidStr]; ok {
|
||||
return name + " Passkey"
|
||||
if metadata, ok := aaguidMetadata[aaguidStr]; ok && metadata.Name != "" {
|
||||
return metadata.Name + " Passkey"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadAAGUIDsFromFile loads AAGUID data from the embedded file system
|
||||
func loadAAGUIDsFromFile() {
|
||||
// loadAAGUIDMetadataFromFile loads AAGUID names and icon references from the embedded manifest
|
||||
func loadAAGUIDMetadataFromFile() {
|
||||
// Read from embedded file system
|
||||
data, err := resources.FS.ReadFile("aaguids.json")
|
||||
if err != nil {
|
||||
@@ -61,9 +81,61 @@ func loadAAGUIDsFromFile() {
|
||||
return
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &aaguidMap)
|
||||
err = json.Unmarshal(data, &aaguidMetadata)
|
||||
if err != nil {
|
||||
slog.Error("Error unmarshalling AAGUID data", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HasAuthenticatorIcon reports whether an icon is embedded for the given AAGUID
|
||||
// Callers use this to avoid pointing clients at an icon endpoint that would only answer with a 404
|
||||
func HasAuthenticatorIcon(aaguid string) bool {
|
||||
aaguidMetadataOnce.Do(loadAAGUIDMetadataFromFile)
|
||||
|
||||
metadata, ok := aaguidMetadata[aaguid]
|
||||
return ok && validAuthenticatorIconName(metadata.IconLight)
|
||||
}
|
||||
|
||||
// OpenAuthenticatorIcon opens the embedded icon for the given AAGUID and returns it together with its size
|
||||
// It returns os.ErrNotExist for every AAGUID without an icon, which is also what keeps caller-controlled input from ever reaching the embedded file system
|
||||
func OpenAuthenticatorIcon(aaguid string, light bool) (fs.File, int64, error) {
|
||||
aaguidMetadataOnce.Do(loadAAGUIDMetadataFromFile)
|
||||
|
||||
// Only AAGUIDs with a valid generated light reference are served, so caller input never becomes an embedded file path
|
||||
metadata, ok := aaguidMetadata[aaguid]
|
||||
if !ok || !validAuthenticatorIconName(metadata.IconLight) {
|
||||
return nil, 0, os.ErrNotExist
|
||||
}
|
||||
|
||||
// Fall back to the light icon when the authenticator does not ship a dark variant
|
||||
name := metadata.IconLight
|
||||
if !light && validAuthenticatorIconName(metadata.IconDark) {
|
||||
name = metadata.IconDark
|
||||
}
|
||||
|
||||
file, err := resources.FS.Open(path.Join(aaguidIconsDir, name))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// The size is resolved upfront so the caller can stream the icon with a Content-Length instead of buffering it
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return file, stat.Size(), nil
|
||||
}
|
||||
|
||||
// validAuthenticatorIconName accepts only the truncated SHA-256 file names emitted by the updater
|
||||
func validAuthenticatorIconName(name string) bool {
|
||||
const extension = ".svg"
|
||||
if len(name) != authenticatorIconHashBytes*2+len(extension) || name[len(name)-len(extension):] != extension {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := hex.DecodeString(name[:authenticatorIconHashBytes*2])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
func TestFormatAAGUID(t *testing.T) {
|
||||
@@ -45,21 +54,21 @@ func TestFormatAAGUID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetAuthenticatorName(t *testing.T) {
|
||||
// Reset the aaguidMap for testing
|
||||
originalMap := aaguidMap
|
||||
originalOnce := aaguidMapOnce
|
||||
// Preserve the package-level metadata cache so this test does not affect other tests
|
||||
originalMetadata := aaguidMetadata
|
||||
originalOnce := aaguidMetadataOnce
|
||||
defer func() {
|
||||
aaguidMap = originalMap
|
||||
aaguidMapOnce = originalOnce
|
||||
aaguidMetadata = originalMetadata
|
||||
aaguidMetadataOnce = originalOnce
|
||||
}()
|
||||
|
||||
// Inject a test AAGUID map
|
||||
aaguidMap = map[string]string{
|
||||
"adce0002-35bc-c60a-648b-0b25f1f05503": "Test Authenticator",
|
||||
"00000000-0000-0000-0000-000000000000": "Zero Authenticator",
|
||||
// Inject test metadata without loading the embedded manifest
|
||||
aaguidMetadata = map[string]authenticatorMetadata{
|
||||
"adce0002-35bc-c60a-648b-0b25f1f05503": {Name: "Test Authenticator"},
|
||||
"00000000-0000-0000-0000-000000000000": {Name: "Zero Authenticator"},
|
||||
}
|
||||
aaguidMapOnce = &sync.Once{}
|
||||
aaguidMapOnce.Do(func() {}) // Mark as done to avoid loading from file
|
||||
aaguidMetadataOnce = &sync.Once{}
|
||||
aaguidMetadataOnce.Do(func() {})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -98,25 +107,22 @@ func TestGetAuthenticatorName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAAGUIDsFromFile(t *testing.T) {
|
||||
// Reset the map and once flag for clean testing
|
||||
aaguidMap = nil
|
||||
aaguidMapOnce = &sync.Once{}
|
||||
func TestLoadAAGUIDMetadataFromFile(t *testing.T) {
|
||||
// Reset the metadata cache so this test exercises the embedded manifest
|
||||
aaguidMetadata = nil
|
||||
aaguidMetadataOnce = &sync.Once{}
|
||||
|
||||
// Trigger loading of AAGUIDs by calling GetAuthenticatorName
|
||||
// Trigger loading by resolving an arbitrary AAGUID
|
||||
GetAuthenticatorName([]byte{0x01, 0x02, 0x03, 0x04})
|
||||
|
||||
if len(aaguidMap) == 0 {
|
||||
t.Error("loadAAGUIDsFromFile() failed to populate aaguidMap")
|
||||
if len(aaguidMetadata) == 0 {
|
||||
t.Error("loadAAGUIDMetadataFromFile() failed to populate aaguidMetadata")
|
||||
}
|
||||
|
||||
// Check for a few known entries that should be in the embedded file
|
||||
// This test will be more brittle as it depends on the content of aaguids.json,
|
||||
// but it helps verify that the loading actually worked
|
||||
t.Log("AAGUID map loaded with", len(aaguidMap), "entries")
|
||||
t.Log("AAGUID metadata loaded with", len(aaguidMetadata), "entries")
|
||||
}
|
||||
|
||||
// Helper function to convert hex string to bytes
|
||||
// mustDecodeHex keeps the table fixtures readable
|
||||
func mustDecodeHex(s string) []byte {
|
||||
bytes, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
@@ -124,3 +130,95 @@ func mustDecodeHex(s string) []byte {
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
func TestAuthenticatorIcons(t *testing.T) {
|
||||
aaguidMetadataOnce.Do(loadAAGUIDMetadataFromFile)
|
||||
if len(aaguidMetadata) == 0 {
|
||||
t.Skip("no authenticator icons are embedded")
|
||||
}
|
||||
|
||||
var withDark, withoutDark string
|
||||
referencedIcons := make(map[string]struct{})
|
||||
for aaguid, metadata := range aaguidMetadata {
|
||||
if metadata.IconLight == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
require.True(t, validAuthenticatorIconName(metadata.IconLight), "icon %q has an invalid light reference", aaguid)
|
||||
referencedIcons[metadata.IconLight] = struct{}{}
|
||||
|
||||
if metadata.IconDark != "" {
|
||||
require.True(t, validAuthenticatorIconName(metadata.IconDark), "icon %q has an invalid dark reference", aaguid)
|
||||
referencedIcons[metadata.IconDark] = struct{}{}
|
||||
}
|
||||
|
||||
if metadata.IconDark != "" && withDark == "" {
|
||||
withDark = aaguid
|
||||
}
|
||||
if metadata.IconDark == "" && withoutDark == "" {
|
||||
withoutDark = aaguid
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, withDark, "expected at least one authenticator with a separate dark icon")
|
||||
require.NotEmpty(t, withoutDark, "expected at least one authenticator with a single icon")
|
||||
|
||||
readIcon := func(t *testing.T, aaguid string, light bool) []byte {
|
||||
t.Helper()
|
||||
|
||||
file, size, err := OpenAuthenticatorIcon(aaguid, light)
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, data, int(size))
|
||||
require.Contains(t, string(data), "<svg")
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
t.Run("known AAGUID", func(t *testing.T) {
|
||||
require.True(t, HasAuthenticatorIcon(withDark))
|
||||
require.NotEqual(t, readIcon(t, withDark, true), readIcon(t, withDark, false))
|
||||
})
|
||||
|
||||
t.Run("dark falls back to the light icon", func(t *testing.T) {
|
||||
require.Equal(t, readIcon(t, withoutDark, true), readIcon(t, withoutDark, false))
|
||||
})
|
||||
|
||||
t.Run("unknown AAGUID", func(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
"../aaguids.json",
|
||||
"..%2faaguids.json",
|
||||
"a/b",
|
||||
".",
|
||||
}
|
||||
|
||||
for _, aaguid := range tests {
|
||||
t.Run(aaguid, func(t *testing.T) {
|
||||
require.False(t, HasAuthenticatorIcon(aaguid))
|
||||
|
||||
_, _, err := OpenAuthenticatorIcon(aaguid, true)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("manifest references every content-addressed icon", func(t *testing.T) {
|
||||
entries, err := resources.FS.ReadDir(aaguidIconsDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, len(referencedIcons))
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
require.Contains(t, referencedIcons, name)
|
||||
|
||||
data, err := resources.FS.ReadFile(path.Join(aaguidIconsDir, name))
|
||||
require.NoError(t, err)
|
||||
digest := sha256.Sum256(data)
|
||||
require.Equal(t, fmt.Sprintf("%x.svg", digest[:authenticatorIconHashBytes]), name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
@@ -11,6 +15,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
@@ -206,3 +211,25 @@ func (h *handler) reauthenticate(c *gin.Context) error {
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getThemedAuthenticatorIcon serves the embedded icon of the authenticator behind an AAGUID
|
||||
// The route needs no authentication because the icons are static vendor branding that ships with the binary and reveals nothing about the instance or its users
|
||||
func (h *handler) getThemedAuthenticatorIcon(c *gin.Context) error {
|
||||
// An unparsable light parameter falls back to the light icon, which every authenticator with an icon has
|
||||
light, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
||||
|
||||
file, size, err := utils.OpenAuthenticatorIcon(c.Param("aaguid"), light)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return apperror.ImageNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Icons only change when a new version is deployed, so they can be cached aggressively
|
||||
utils.SetCacheControlHeader(c, 24*time.Hour, 7*24*time.Hour)
|
||||
|
||||
c.DataFromReader(http.StatusOK, size, "image/svg+xml", file, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, browserAuth
|
||||
apiGroup.GET("/webauthn/credentials", userAuth, httpserver.Handle(m.handler.listCredentials))
|
||||
apiGroup.PATCH("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.updateCredential))
|
||||
apiGroup.DELETE("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.deleteCredential))
|
||||
|
||||
apiGroup.GET("/webauthn/authenticator-icons/:aaguid", httpserver.Handle(m.handler.getThemedAuthenticatorIcon))
|
||||
}
|
||||
|
||||
// ConsumeReauthenticationToken implements the OIDC module's ReauthenticationTokenConsumer interface
|
||||
|
||||
@@ -183,6 +183,7 @@ func (s *Service) VerifyRegistration(ctx context.Context, dbConfig *appconfig.Ap
|
||||
|
||||
// Determine passkey name using AAGUID and User-Agent
|
||||
passkeyName := s.determinePasskeyName(credential.Authenticator.AAGUID)
|
||||
aaguid := utils.FormatAAGUID(credential.Authenticator.AAGUID)
|
||||
|
||||
credentialToStore := model.WebauthnCredential{
|
||||
Name: passkeyName,
|
||||
@@ -193,6 +194,7 @@ func (s *Service) VerifyRegistration(ctx context.Context, dbConfig *appconfig.Ap
|
||||
UserID: user.ID,
|
||||
BackupEligible: credential.Flags.BackupEligible,
|
||||
BackupState: credential.Flags.BackupState,
|
||||
AAGUID: aaguid,
|
||||
}
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
|
||||
@@ -375,3 +375,37 @@ func TestConsumeReauthenticationTokenReturnsTokenCreationTime(t *testing.T) {
|
||||
|
||||
require.Equal(t, storedToken.CreatedAt.UTC(), reauthenticatedAt)
|
||||
}
|
||||
|
||||
func TestUpdateCredentialKeepsAAGUID(t *testing.T) {
|
||||
const (
|
||||
userID = "icon-user"
|
||||
aaguid = "bada5566-a7aa-401f-bd96-45619a55120d"
|
||||
)
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
Username: userID,
|
||||
}).Error)
|
||||
|
||||
credential := model.WebauthnCredential{
|
||||
Name: "Original name",
|
||||
CredentialID: []byte("test-credential"),
|
||||
PublicKey: []byte("test-public-key"),
|
||||
UserID: userID,
|
||||
AAGUID: aaguid,
|
||||
}
|
||||
require.NoError(t, db.Create(&credential).Error)
|
||||
|
||||
service := &Service{db: db}
|
||||
|
||||
updated, err := service.UpdateCredential(t.Context(), userID, credential.ID, "New name")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "New name", updated.Name)
|
||||
assert.Equal(t, aaguid, updated.AAGUID)
|
||||
|
||||
var stored model.WebauthnCredential
|
||||
require.NoError(t, db.First(&stored, "id = ?", credential.ID).Error)
|
||||
assert.Equal(t, "New name", stored.Name)
|
||||
assert.Equal(t, aaguid, stored.AAGUID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user