mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-16 18:23:03 +03:00
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:
34
.github/workflows/update-aaguids.yml
vendored
Normal file
34
.github/workflows/update-aaguids.yml
vendored
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
64
backend/internal/utils/aaguid_util.go
Normal file
64
backend/internal/utils/aaguid_util.go
Normal 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
|
||||
}
|
||||
}
|
||||
126
backend/internal/utils/aaguid_util_test.go
Normal file
126
backend/internal/utils/aaguid_util_test.go
Normal 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
|
||||
}
|
||||
1
backend/resources/aaguids.json
Normal file
1
backend/resources/aaguids.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user