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
+198
View File
@@ -0,0 +1,198 @@
// Refreshes the AAGUID metadata and authenticator icons from the community AAGUID list
package main
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"maps"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
)
const (
sourceURL = "https://raw.githubusercontent.com/pocket-id/passkey-aaguids/refs/heads/main/combined_aaguid.json"
metadataFile = "backend/resources/aaguids.json"
iconDir = "backend/resources/aaguid-icons"
svgDataPrefix = "data:image/svg+xml;base64,"
iconFilePattern = "*.svg"
iconDigestBytes = 8
)
// authenticator is the subset of an upstream entry this script cares about
// The icons are data URIs rather than links, so nothing beyond the combined JSON has to be fetched
type authenticator struct {
Name string `json:"name"`
IconLight string `json:"icon_light"`
IconDark string `json:"icon_dark"`
}
// authenticatorMetadata is the generated runtime representation of an authenticator
// Icon file names contain a 64-bit SHA-256 prefix of their decoded SVG so identical upstream icons share one asset
type authenticatorMetadata struct {
Name string `json:"name"`
IconLight string `json:"icon_light,omitempty"`
IconDark string `json:"icon_dark,omitempty"`
}
func main() {
if err := run(); err != nil {
log.Fatalln("error:", err)
}
}
// run writes the metadata manifest and its content-addressed icons from one upstream snapshot so their references stay consistent
func run() error {
authenticators, err := fetchAuthenticators(sourceURL)
if err != nil {
return fmt.Errorf("failed to fetch %s: %w", sourceURL, err)
}
if len(authenticators) == 0 {
return errors.New("upstream AAGUID list is empty")
}
metadata, icons, err := buildResources(authenticators)
if err != nil {
return fmt.Errorf("failed to build authenticator resources: %w", err)
}
if err := writeIcons(icons); err != nil {
return fmt.Errorf("failed to write icon files: %w", err)
}
if err := writeAuthenticatorMetadata(metadata); err != nil {
return fmt.Errorf("failed to write authenticator metadata: %w", err)
}
return nil
}
// fetchAuthenticators downloads the upstream authenticator list keyed by AAGUID
func fetchAuthenticators(url string) (authenticators map[string]authenticator, err error) {
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to get authenticator JSON: %w", err)
}
defer func() {
err = errors.Join(err, resp.Body.Close())
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("expected status 200, got %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(&authenticators); err != nil {
return nil, fmt.Errorf("failed to decode JSON: %w", err)
}
return authenticators, nil
}
// buildResources converts upstream data URIs into metadata references and a set of unique decoded SVGs
func buildResources(authenticators map[string]authenticator) (map[string]authenticatorMetadata, map[string][]byte, error) {
metadata := make(map[string]authenticatorMetadata, len(authenticators))
icons := make(map[string][]byte)
// The AAGUIDs are sorted so failures are deterministic when the upstream data contains an invalid icon
for _, aaguid := range slices.Sorted(maps.Keys(authenticators)) {
a := authenticators[aaguid]
entry := authenticatorMetadata{Name: a.Name}
// Entries without a usable light SVG retain their display names but do not reference any icon
lightName, err := addIcon(icons, a.IconLight)
if err != nil {
return nil, nil, fmt.Errorf("failed to process light SVG for %s: %w", aaguid, err)
}
if lightName == "" {
metadata[aaguid] = entry
continue
}
entry.IconLight = lightName
// A missing or identical dark icon falls back to the light asset at runtime
darkName, err := addIcon(icons, a.IconDark)
if err != nil {
return nil, nil, fmt.Errorf("failed to process dark SVG for %s: %w", aaguid, err)
}
if darkName != "" && darkName != lightName {
entry.IconDark = darkName
}
metadata[aaguid] = entry
}
return metadata, icons, nil
}
// addIcon decodes one SVG data URI and returns the content-addressed file name stored in icons
func addIcon(icons map[string][]byte, dataURI string) (string, error) {
payload, ok := strings.CutPrefix(dataURI, svgDataPrefix)
if !ok {
return "", nil
}
svg, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return "", fmt.Errorf("failed to decode base64: %w", err)
}
digest := sha256.Sum256(svg)
name := fmt.Sprintf("%x.svg", digest[:iconDigestBytes])
if existing, exists := icons[name]; exists && !bytes.Equal(existing, svg) {
return "", fmt.Errorf("SHA-256 prefix collision for %s", name)
}
icons[name] = svg
return name, nil
}
// writeIcons replaces the icon directory contents so assets dropped upstream do not linger
func writeIcons(icons map[string][]byte) error {
if err := os.MkdirAll(iconDir, 0o755); err != nil {
return fmt.Errorf("failed to create authenticator icons directory: %w", err)
}
existing, err := filepath.Glob(filepath.Join(iconDir, iconFilePattern))
if err != nil {
return fmt.Errorf("error gathering existing SVGs: %w", err)
}
for _, path := range existing {
if err := os.Remove(path); err != nil {
return fmt.Errorf("error clearing existing SVG: %w", err)
}
}
for _, name := range slices.Sorted(maps.Keys(icons)) {
if err := os.WriteFile(filepath.Join(iconDir, name), icons[name], 0o644); err != nil {
return fmt.Errorf("failed to write SVG %s: %w", name, err)
}
}
return nil
}
// writeAuthenticatorMetadata writes the AAGUID manifest consumed by the backend
func writeAuthenticatorMetadata(metadata map[string]authenticatorMetadata) (err error) {
if err := os.MkdirAll(filepath.Dir(metadataFile), 0o755); err != nil {
return fmt.Errorf("failed to create authenticator metadata directory: %w", err)
}
file, err := os.Create(metadataFile)
if err != nil {
return fmt.Errorf("failed to create authenticator metadata file: %w", err)
}
defer func() {
err = errors.Join(err, file.Close())
}()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
return encoder.Encode(metadata)
}