mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-25 20:39:04 +02:00
feat: include passkey icons based on AAGUID (#1756)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user