From 702fce514ab3b441d591eddc416fcc0006223190 Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:58:22 +0200 Subject: [PATCH 1/2] Implement kSecAttrGeneric to encode additional credential metadata This patch adds the `kSecAttrGeneric` key so that credentials can store additional publicly available metadata on kSecClassGenericPassword credentials. The caller can add any generic metadata they'd like to store as long as it does not exceed the size of math.MaxUint32. This allows for a wider variety of use cases where many secrets are fetched from the keychain without fetching the underlying secret. Additional filtering rules could apply on the fetch secrets based on the metadata. More information about `kSecAttrGeneric` can be found here https://developer.apple.com/documentation/security/ksecattrgeneric Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- corefoundation.go | 15 +++++++++++ keychain.go | 63 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/corefoundation.go b/corefoundation.go index b7ee544..2c4a840 100644 --- a/corefoundation.go +++ b/corefoundation.go @@ -28,7 +28,10 @@ CFArrayRef CFArrayCreateSafe2(CFAllocatorRef allocator, const uintptr_t *values, } */ import "C" + import ( + "bytes" + "encoding/gob" "errors" "fmt" "math" @@ -217,6 +220,18 @@ func ConvertMapToCFDictionary(attr map[string]interface{}) (C.CFDictionaryRef, e } valueRef = convertedRef defer Release(valueRef) + case map[string]any: + var b bytes.Buffer + enc := gob.NewEncoder(&b) + if err := enc.Encode(val); err != nil { + return 0, err + } + mapRef, err := BytesToCFData(b.Bytes()) + if err != nil { + return 0, err + } + valueRef = C.CFTypeRef(mapRef) + defer Release(valueRef) } keyRef, err := StringToCFString(key) if err != nil { diff --git a/keychain.go b/keychain.go index 7d0a1ac..368bd71 100644 --- a/keychain.go +++ b/keychain.go @@ -14,7 +14,10 @@ package keychain #include */ import "C" + import ( + "bytes" + "encoding/gob" "fmt" "time" ) @@ -148,11 +151,13 @@ var ( ) // SecClassKey is the key type for SecClass -var SecClassKey = attrKey(C.CFTypeRef(C.kSecClass)) -var secClassTypeRef = map[SecClass]C.CFTypeRef{ - SecClassGenericPassword: C.CFTypeRef(C.kSecClassGenericPassword), - SecClassInternetPassword: C.CFTypeRef(C.kSecClassInternetPassword), -} +var ( + SecClassKey = attrKey(C.CFTypeRef(C.kSecClass)) + secClassTypeRef = map[SecClass]C.CFTypeRef{ + SecClassGenericPassword: C.CFTypeRef(C.kSecClassGenericPassword), + SecClassInternetPassword: C.CFTypeRef(C.kSecClassInternetPassword), + } +) var ( // ServiceKey is for kSecAttrService @@ -185,6 +190,8 @@ var ( CreationDateKey = attrKey(C.CFTypeRef(C.kSecAttrCreationDate)) // ModificationDateKey is for kSecAttrModificationDate ModificationDateKey = attrKey(C.CFTypeRef(C.kSecAttrModificationDate)) + + AttrGenericKey = attrKey(C.CFTypeRef(C.kSecAttrGeneric)) ) // Synchronizable is the items synchronizable status @@ -202,12 +209,14 @@ const ( ) // SynchronizableKey is the key type for Synchronizable -var SynchronizableKey = attrKey(C.CFTypeRef(C.kSecAttrSynchronizable)) -var syncTypeRef = map[Synchronizable]C.CFTypeRef{ - SynchronizableAny: C.CFTypeRef(C.kSecAttrSynchronizableAny), - SynchronizableYes: C.CFTypeRef(C.kCFBooleanTrue), - SynchronizableNo: C.CFTypeRef(C.kCFBooleanFalse), -} +var ( + SynchronizableKey = attrKey(C.CFTypeRef(C.kSecAttrSynchronizable)) + syncTypeRef = map[Synchronizable]C.CFTypeRef{ + SynchronizableAny: C.CFTypeRef(C.kSecAttrSynchronizableAny), + SynchronizableYes: C.CFTypeRef(C.kCFBooleanTrue), + SynchronizableNo: C.CFTypeRef(C.kCFBooleanFalse), + } +) // Accessible is the items accessibility type Accessible int @@ -244,11 +253,13 @@ const ( ) // MatchLimitKey is key type for MatchLimit -var MatchLimitKey = attrKey(C.CFTypeRef(C.kSecMatchLimit)) -var matchTypeRef = map[MatchLimit]C.CFTypeRef{ - MatchLimitOne: C.CFTypeRef(C.kSecMatchLimitOne), - MatchLimitAll: C.CFTypeRef(C.kSecMatchLimitAll), -} +var ( + MatchLimitKey = attrKey(C.CFTypeRef(C.kSecMatchLimit)) + matchTypeRef = map[MatchLimit]C.CFTypeRef{ + MatchLimitOne: C.CFTypeRef(C.kSecMatchLimitOne), + MatchLimitAll: C.CFTypeRef(C.kSecMatchLimitAll), + } +) // ReturnAttributesKey is key type for kSecReturnAttributes var ReturnAttributesKey = attrKey(C.CFTypeRef(C.kSecReturnAttributes)) @@ -348,6 +359,13 @@ func (k *Item) SetData(b []byte) { } } +func (k *Item) SetGenericMetadata(m map[string]any) { + if m == nil { + return + } + k.attr[AttrGenericKey] = m +} + // SetAccessGroup sets the access group attribute func (k *Item) SetAccessGroup(ag string) { k.SetString(AccessGroupKey, ag) @@ -463,6 +481,8 @@ type QueryResult struct { Data []byte CreationDate time.Time ModificationDate time.Time + + Attributes map[string]interface{} } // QueryItemRef returns query result as CFTypeRef. You must release it when you are done. @@ -575,6 +595,17 @@ func convertResult(d C.CFDictionaryRef) (*QueryResult, error) { result.CreationDate = CFDateToTime(C.CFDateRef(v)) case ModificationDateKey: result.ModificationDate = CFDateToTime(C.CFDateRef(v)) + case AttrGenericKey: + b, err := CFDataToBytes(C.CFDataRef(v)) + if err != nil { + return nil, err + } + dec := gob.NewDecoder(bytes.NewReader(b)) + attributes := make(map[string]any) + if err := dec.Decode(&attributes); err != nil { + return nil, err + } + result.Attributes = attributes // default: // fmt.Printf("Unhandled key in conversion: %v = %v\n", cfTypeValue(k), cfTypeValue(v)) } From 9959547fb7f1f5a3bb5b8ae7883e87dae652254b Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:27:37 +0200 Subject: [PATCH 2/2] Test generic attributes on macOS Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- macos_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/macos_test.go b/macos_test.go index 7b3e57f..f5bad7e 100644 --- a/macos_test.go +++ b/macos_test.go @@ -4,7 +4,11 @@ package keychain import ( + "bytes" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestUpdateItem(t *testing.T) { @@ -127,3 +131,76 @@ func TestInternetPassword(t *testing.T) { t.Errorf("expected comment 'this is the comment' but got %q", r.Comment) } } + +func TestGenericAttributes(t *testing.T) { + t.Run("generic password can have generic attributes", func(t *testing.T) { + service, account, label, accessGroup, password := "TestGenericPasswordRef", "test2", "generic-password", "TestGenericAttributes", "toomanysecrets" + item := NewGenericPassword(service, account, label, []byte(password), accessGroup) + attributes := map[string]any{ + "color": "green", + "large": string(bytes.Repeat([]byte{'a'}, 1024*1024)), + "score": 10, + } + t.Cleanup(func() { + queryDelete := NewItem() + queryDelete.SetAccessGroup(accessGroup) + queryDelete.SetAccount(account) + queryDelete.SetService(service) + queryDelete.SetSecClass(SecClassGenericPassword) + assert.NoError(t, DeleteItem(queryDelete)) + }) + + item.SetGenericMetadata(attributes) + require.NoError(t, AddItem(item)) + + query := NewItem() + query.SetReturnAttributes(true) + query.SetSecClass(SecClassGenericPassword) + query.SetMatchLimit(MatchLimitOne) + query.SetService(service) + query.SetAccount(account) + query.SetAccessGroup(accessGroup) + query.SetLabel(label) + + results, err := QueryItem(query) + require.NoError(t, err) + assert.Len(t, results, 1) + assert.EqualValues(t, attributes, results[0].Attributes) + }) + + t.Run("internet password cannot set generic attributes", func(t *testing.T) { + item := NewItem() + item.SetSecClass(SecClassInternetPassword) + + // Internet password-specific attributes + item.SetProtocol("htps") + item.SetServer("8xs8h5x5dfc0AI5EzT81l.com") + item.SetPort(1234) + item.SetPath("/this/is/the/path") + + item.SetAccount("this-is-the-username") + item.SetLabel("this is the label") + item.SetData([]byte("this is the password")) + item.SetGenericMetadata(map[string]any{ + "anything": "really", + }) + t.Cleanup(func() { + assert.NoError(t, DeleteItem(item)) + }) + require.NoError(t, AddItem(item)) + + query := NewItem() + query.SetSecClass(SecClassInternetPassword) + query.SetProtocol("htps") + query.SetServer("8xs8h5x5dfc0AI5EzT81l.com") + query.SetPort(1234) + query.SetPath("/this/is/the/path") + query.SetAccount("this-is-the-username") + query.SetLabel("this is the label") + query.SetReturnAttributes(true) + result, err := QueryItem(query) + assert.Len(t, result, 1) + assert.NoError(t, err) + assert.Empty(t, result[0].Attributes) + }) +}