mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-21 09:15:55 +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
|
return model.WebauthnCredential{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine passkey name using AAGUID and User-Agent
|
||||||
|
passkeyName := s.determinePasskeyName(credential.Authenticator.AAGUID)
|
||||||
|
|
||||||
credentialToStore := model.WebauthnCredential{
|
credentialToStore := model.WebauthnCredential{
|
||||||
Name: "New Passkey",
|
Name: passkeyName,
|
||||||
CredentialID: credential.ID,
|
CredentialID: credential.ID,
|
||||||
AttestationType: credential.AttestationType,
|
AttestationType: credential.AttestationType,
|
||||||
PublicKey: credential.PublicKey,
|
PublicKey: credential.PublicKey,
|
||||||
@@ -112,6 +115,16 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
|
|||||||
return credentialToStore, nil
|
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) {
|
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
|
||||||
options, session, err := s.webAuthn.BeginDiscoverableLogin()
|
options, session, err := s.webAuthn.BeginDiscoverableLogin()
|
||||||
if err != nil {
|
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
|
// 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
|
var FS embed.FS
|
||||||
|
|||||||
Reference in New Issue
Block a user