feat: include passkey icons based on AAGUID (#1756)

This commit is contained in:
Mike Beaumont
2026-09-19 09:41:53 -07:00
committed by GitHub
parent c22003f6de
commit b5a07a29ef
90 changed files with 2934 additions and 49 deletions
+83 -11
View File
@@ -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
}
+121 -23
View File
@@ -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)
}
})
}