feat(passkeys): name new passkeys based on agguids (#332)

Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-03-20 10:35:08 -05:00
committed by GitHub
parent e486dbd771
commit 041c565dc1
6 changed files with 240 additions and 2 deletions

34
.github/workflows/update-aaguids.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Update AAGUIDs
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
workflow_dispatch: # Allows manual triggering of the workflow
permissions:
contents: write
jobs:
update-aaguids:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch JSON data
run: |
curl -o data.json https://raw.githubusercontent.com/pocket-id/passkey-aaguids/refs/heads/main/combined_aaguid.json
- name: Process JSON data
run: |
mkdir -p backend/resources
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
- name: Commit changes
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add backend/resources/aaguids.json
git diff --staged --quiet || git commit -m "chore: update AAGUIDs"
git push

View File

@@ -95,8 +95,11 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
return model.WebauthnCredential{}, err
}
// Determine passkey name using AAGUID and User-Agent
passkeyName := s.determinePasskeyName(credential.Authenticator.AAGUID)
credentialToStore := model.WebauthnCredential{
Name: "New Passkey",
Name: passkeyName,
CredentialID: credential.ID,
AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey,
@@ -112,6 +115,16 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
return credentialToStore, nil
}
func (s *WebAuthnService) determinePasskeyName(aaguid []byte) string {
// First try to identify by AAGUID using a combination of builtin + MDS
authenticatorName := utils.GetAuthenticatorName(aaguid)
if authenticatorName != "" {
return authenticatorName
}
return "New Passkey" // Default fallback
}
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
options, session, err := s.webAuthn.BeginDiscoverableLogin()
if err != nil {

View File

@@ -0,0 +1,64 @@
package utils
import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"sync"
"github.com/pocket-id/pocket-id/backend/resources"
)
var (
aaguidMap map[string]string
aaguidMapOnce sync.Once
)
// FormatAAGUID converts an AAGUID byte slice to UUID string format
func FormatAAGUID(aaguid []byte) string {
if len(aaguid) == 0 {
return ""
}
// If exactly 16 bytes, format as UUID
if len(aaguid) == 16 {
return fmt.Sprintf("%x-%x-%x-%x-%x",
aaguid[0:4], aaguid[4:6], aaguid[6:8], aaguid[8:10], aaguid[10:16])
}
// Otherwise just return as hex
return hex.EncodeToString(aaguid)
}
// GetAuthenticatorName returns the name of the authenticator for the given AAGUID
func GetAuthenticatorName(aaguid []byte) string {
aaguidStr := FormatAAGUID(aaguid)
if aaguidStr == "" {
return ""
}
// Then check JSON-sourced map
aaguidMapOnce.Do(loadAAGUIDsFromFile)
if name, ok := aaguidMap[aaguidStr]; ok {
return name + " Passkey"
}
return ""
}
// loadAAGUIDsFromFile loads AAGUID data from the embedded file system
func loadAAGUIDsFromFile() {
// Read from embedded file system
data, err := resources.FS.ReadFile("aaguids.json")
if err != nil {
log.Printf("Error reading embedded AAGUID file: %v", err)
return
}
if err := json.Unmarshal(data, &aaguidMap); err != nil {
log.Printf("Error unmarshalling AAGUID data: %v", err)
return
}
}

View File

@@ -0,0 +1,126 @@
package utils
import (
"encoding/hex"
"sync"
"testing"
)
func TestFormatAAGUID(t *testing.T) {
tests := []struct {
name string
aaguid []byte
want string
}{
{
name: "empty byte slice",
aaguid: []byte{},
want: "",
},
{
name: "16 byte slice - standard UUID",
aaguid: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
want: "01020304-0506-0708-090a-0b0c0d0e0f10",
},
{
name: "non-16 byte slice",
aaguid: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
want: "0102030405",
},
{
name: "specific UUID example",
aaguid: mustDecodeHex("adce000235bcc60a648b0b25f1f05503"),
want: "adce0002-35bc-c60a-648b-0b25f1f05503",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatAAGUID(tt.aaguid)
if got != tt.want {
t.Errorf("FormatAAGUID() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetAuthenticatorName(t *testing.T) {
// Reset the aaguidMap for testing
originalMap := aaguidMap
originalOnce := aaguidMapOnce
defer func() {
aaguidMap = originalMap
aaguidMapOnce = originalOnce
}()
// Inject a test AAGUID map
aaguidMap = map[string]string{
"adce0002-35bc-c60a-648b-0b25f1f05503": "Test Authenticator",
"00000000-0000-0000-0000-000000000000": "Zero Authenticator",
}
aaguidMapOnce = sync.Once{}
aaguidMapOnce.Do(func() {}) // Mark as done to avoid loading from file
tests := []struct {
name string
aaguid []byte
want string
}{
{
name: "empty byte slice",
aaguid: []byte{},
want: "",
},
{
name: "known AAGUID",
aaguid: mustDecodeHex("adce000235bcc60a648b0b25f1f05503"),
want: "Test Authenticator Passkey",
},
{
name: "zero UUID",
aaguid: mustDecodeHex("00000000000000000000000000000000"),
want: "Zero Authenticator Passkey",
},
{
name: "unknown AAGUID",
aaguid: mustDecodeHex("ffffffffffffffffffffffffffffffff"),
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetAuthenticatorName(tt.aaguid)
if got != tt.want {
t.Errorf("GetAuthenticatorName() = %v, want %v", got, tt.want)
}
})
}
}
func TestLoadAAGUIDsFromFile(t *testing.T) {
// Reset the map and once flag for clean testing
aaguidMap = nil
aaguidMapOnce = sync.Once{}
// Trigger loading of AAGUIDs by calling GetAuthenticatorName
GetAuthenticatorName([]byte{0x01, 0x02, 0x03, 0x04})
if len(aaguidMap) == 0 {
t.Error("loadAAGUIDsFromFile() failed to populate aaguidMap")
}
// 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")
}
// Helper function to convert hex string to bytes
func mustDecodeHex(s string) []byte {
bytes, err := hex.DecodeString(s)
if err != nil {
panic("invalid hex in test: " + err.Error())
}
return bytes
}

File diff suppressed because one or more lines are too long

View File

@@ -4,5 +4,5 @@ import "embed"
// Embedded file systems for the project
//go:embed email-templates images migrations fonts
//go:embed email-templates images migrations fonts aaguids.json
var FS embed.FS