diff --git a/ios.go b/ios.go index 02c9b87..045ab84 100644 --- a/ios.go +++ b/ios.go @@ -11,6 +11,8 @@ package keychain */ import "C" +import "fmt" + var AccessibleKey = attrKey(C.CFTypeRef(C.kSecAttrAccessible)) var accessibleTypeRef = map[Accessible]C.CFTypeRef{ AccessibleWhenUnlocked: C.CFTypeRef(C.kSecAttrAccessibleWhenUnlocked), @@ -21,3 +23,77 @@ var accessibleTypeRef = map[Accessible]C.CFTypeRef{ AccessibleAfterFirstUnlockThisDeviceOnly: C.CFTypeRef(C.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly), AccessibleAccessibleAlwaysThisDeviceOnly: C.CFTypeRef(C.kSecAttrAccessibleAlwaysThisDeviceOnly), } + +// AccessControlKey is key for kSecAttrAccessControl +var AccessControlKey = attrKey(C.CFTypeRef(C.kSecAttrAccessControl)) + +// AccessControlFlag is a bitmask for SecAccessControlCreateFlags. +type AccessControlFlag = C.SecAccessControlCreateFlags + +// Access control flag constants for use with SetAccessControl. +var ( + // AccessControlUserPresence constrains access with either biometry or passcode. + AccessControlUserPresence = AccessControlFlag(C.kSecAccessControlUserPresence) + // AccessControlBiometryAny constrains access with Touch ID for any enrolled finger. + AccessControlBiometryAny = AccessControlFlag(C.kSecAccessControlBiometryAny) + // AccessControlBiometryCurrentSet constrains access with Touch ID for currently enrolled fingers. + AccessControlBiometryCurrentSet = AccessControlFlag(C.kSecAccessControlBiometryCurrentSet) + // AccessControlDevicePasscode constrains access with the device passcode. + AccessControlDevicePasscode = AccessControlFlag(C.kSecAccessControlDevicePasscode) + // AccessControlWatch constrains access with Apple Watch. + AccessControlWatch = AccessControlFlag(C.kSecAccessControlWatch) + // AccessControlOr allows satisfying any single constraint. + AccessControlOr = AccessControlFlag(C.kSecAccessControlOr) + // AccessControlAnd requires satisfying all constraints. + AccessControlAnd = AccessControlFlag(C.kSecAccessControlAnd) + // AccessControlPrivateKeyUsage enables using a private key for signing/decryption operations. + AccessControlPrivateKeyUsage = AccessControlFlag(C.kSecAccessControlPrivateKeyUsage) + // AccessControlApplicationPassword requires an application-provided password for access. + AccessControlApplicationPassword = AccessControlFlag(C.kSecAccessControlApplicationPassword) +) + +// SetAccessControl sets the access control for the item with a protection level and flags. +// This replaces SetAccessible when you need biometric protection. +// Protection should be one of the Accessible constants (e.g., AccessibleWhenPasscodeSetThisDeviceOnly). +// Flags should be a bitmask of AccessControlFlag values (e.g., AccessControlBiometryCurrentSet). +// +// Note: kSecAttrAccessControl and kSecAttrAccessible are mutually exclusive. +// This method removes any previously set kSecAttrAccessible value. +func (k *Item) SetAccessControl(accessible Accessible, flags AccessControlFlag) error { + protection, ok := accessibleTypeRef[accessible] + if !ok { + return fmt.Errorf("invalid accessible value: %d", accessible) + } + + var cerr C.CFErrorRef + access := C.SecAccessControlCreateWithFlags( + C.kCFAllocatorDefault, + C.CFTypeRef(protection), + C.SecAccessControlCreateFlags(flags), + &cerr, + ) + if access == 0 { + if cerr != 0 { + defer C.CFRelease(C.CFTypeRef(cerr)) + return fmt.Errorf("SecAccessControlCreateWithFlags failed: %d", C.CFErrorGetCode(cerr)) + } + return fmt.Errorf("SecAccessControlCreateWithFlags failed with unknown error") + } + + // kSecAttrAccessControl and kSecAttrAccessible are mutually exclusive. + delete(k.attr, AccessibleKey) + + // Release any previous SecAccessControlRef to avoid leaking CF objects. + if old, exists := k.attr[AccessControlKey]; exists { + C.CFRelease(C.CFTypeRef(old.(C.CFTypeRef))) + } + + k.attr[AccessControlKey] = C.CFTypeRef(access) + return nil +} + +// SetUseDataProtectionKeychain is a no-op on iOS since iOS always uses +// the data protection keychain. +func (k *Item) SetUseDataProtectionKeychain(b bool) { + // iOS always uses data protection keychain; this is a no-op for API compatibility. +} diff --git a/macos.go b/macos.go index f2597ee..5ddd3e4 100644 --- a/macos.go +++ b/macos.go @@ -10,6 +10,8 @@ package keychain */ import "C" +import "fmt" + // AccessibleKey is key for kSecAttrAccessible var ( AccessibleKey = attrKey(C.CFTypeRef(C.kSecAttrAccessible)) @@ -17,11 +19,87 @@ var ( AccessibleWhenUnlocked: C.CFTypeRef(C.kSecAttrAccessibleWhenUnlocked), AccessibleAfterFirstUnlock: C.CFTypeRef(C.kSecAttrAccessibleAfterFirstUnlock), AccessibleAlways: C.CFTypeRef(C.kSecAttrAccessibleAlways), + AccessibleWhenPasscodeSetThisDeviceOnly: C.CFTypeRef(C.kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly), AccessibleWhenUnlockedThisDeviceOnly: C.CFTypeRef(C.kSecAttrAccessibleWhenUnlockedThisDeviceOnly), AccessibleAfterFirstUnlockThisDeviceOnly: C.CFTypeRef(C.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly), AccessibleAccessibleAlwaysThisDeviceOnly: C.CFTypeRef(C.kSecAttrAccessibleAlwaysThisDeviceOnly), - - // Only available in 10.10 - // AccessibleWhenPasscodeSetThisDeviceOnly: C.CFTypeRef(C.kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly), } ) + +// AccessControlKey is key for kSecAttrAccessControl +var AccessControlKey = attrKey(C.CFTypeRef(C.kSecAttrAccessControl)) + +// UseDataProtectionKeychainKey is key for kSecUseDataProtectionKeychain +var UseDataProtectionKeychainKey = attrKey(C.CFTypeRef(C.kSecUseDataProtectionKeychain)) + +// AccessControlFlag is a bitmask for SecAccessControlCreateFlags. +type AccessControlFlag = C.SecAccessControlCreateFlags + +// Access control flag constants for use with SetAccessControl. +var ( + // AccessControlUserPresence constrains access with either biometry or passcode. + AccessControlUserPresence = AccessControlFlag(C.kSecAccessControlUserPresence) + // AccessControlBiometryAny constrains access with Touch ID for any enrolled finger. + AccessControlBiometryAny = AccessControlFlag(C.kSecAccessControlBiometryAny) + // AccessControlBiometryCurrentSet constrains access with Touch ID for currently enrolled fingers. + AccessControlBiometryCurrentSet = AccessControlFlag(C.kSecAccessControlBiometryCurrentSet) + // AccessControlDevicePasscode constrains access with the device passcode. + AccessControlDevicePasscode = AccessControlFlag(C.kSecAccessControlDevicePasscode) + // AccessControlWatch constrains access with Apple Watch. + AccessControlWatch = AccessControlFlag(C.kSecAccessControlWatch) + // AccessControlOr allows satisfying any single constraint. + AccessControlOr = AccessControlFlag(C.kSecAccessControlOr) + // AccessControlAnd requires satisfying all constraints. + AccessControlAnd = AccessControlFlag(C.kSecAccessControlAnd) + // AccessControlPrivateKeyUsage enables using a private key for signing/decryption operations. + AccessControlPrivateKeyUsage = AccessControlFlag(C.kSecAccessControlPrivateKeyUsage) + // AccessControlApplicationPassword requires an application-provided password for access. + AccessControlApplicationPassword = AccessControlFlag(C.kSecAccessControlApplicationPassword) +) + +// SetAccessControl sets the access control for the item with a protection level and flags. +// This replaces SetAccessible when you need biometric protection. +// Protection should be one of the Accessible constants (e.g., AccessibleWhenPasscodeSetThisDeviceOnly). +// Flags should be a bitmask of AccessControlFlag values (e.g., AccessControlBiometryCurrentSet). +// +// Note: kSecAttrAccessControl and kSecAttrAccessible are mutually exclusive. +// This method removes any previously set kSecAttrAccessible value. +func (k *Item) SetAccessControl(accessible Accessible, flags AccessControlFlag) error { + protection, ok := accessibleTypeRef[accessible] + if !ok { + return fmt.Errorf("invalid accessible value: %d", accessible) + } + + var cerr C.CFErrorRef + access := C.SecAccessControlCreateWithFlags( + C.kCFAllocatorDefault, + C.CFTypeRef(protection), + C.SecAccessControlCreateFlags(flags), + &cerr, + ) + if access == 0 { + if cerr != 0 { + defer C.CFRelease(C.CFTypeRef(cerr)) + return fmt.Errorf("SecAccessControlCreateWithFlags failed: %d", C.CFErrorGetCode(cerr)) + } + return fmt.Errorf("SecAccessControlCreateWithFlags failed with unknown error") + } + + // kSecAttrAccessControl and kSecAttrAccessible are mutually exclusive. + delete(k.attr, AccessibleKey) + + // Release any previous SecAccessControlRef to avoid leaking CF objects. + if old, exists := k.attr[AccessControlKey]; exists { + C.CFRelease(C.CFTypeRef(old.(C.CFTypeRef))) + } + + k.attr[AccessControlKey] = C.CFTypeRef(access) + return nil +} + +// SetUseDataProtectionKeychain forces use of the modern data protection keychain +// instead of the legacy file-based keychain on macOS. +// This is required when using SecAccessControl with biometry. +func (k *Item) SetUseDataProtectionKeychain(b bool) { + k.attr[UseDataProtectionKeychainKey] = b +} diff --git a/macos_test.go b/macos_test.go index 7b3e57f..a416cc4 100644 --- a/macos_test.go +++ b/macos_test.go @@ -5,6 +5,9 @@ package keychain import ( "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestUpdateItem(t *testing.T) { @@ -127,3 +130,124 @@ func TestInternetPassword(t *testing.T) { t.Errorf("expected comment 'this is the comment' but got %q", r.Comment) } } + +func TestSetAccessControl(t *testing.T) { + item := NewItem() + item.SetSecClass(SecClassGenericPassword) + item.SetService("TestAccessControl") + item.SetAccount("test-access-control") + item.SetLabel("TestSetAccessControl") + item.SetData([]byte("secret")) + + // SetAccessControl should succeed with valid accessible + flags. + err := item.SetAccessControl(AccessibleWhenPasscodeSetThisDeviceOnly, AccessControlDevicePasscode) + require.NoError(t, err) + + // kSecAttrAccessible should have been removed (mutually exclusive with kSecAttrAccessControl). + _, hasAccessible := item.attr[AccessibleKey] + assert.False(t, hasAccessible, "kSecAttrAccessible should be removed when kSecAttrAccessControl is set") + + // kSecAttrAccessControl should be set. + _, hasAccessControl := item.attr[AccessControlKey] + assert.True(t, hasAccessControl, "kSecAttrAccessControl should be set") +} + +func TestSetAccessControlRemovesAccessible(t *testing.T) { + item := NewItem() + item.SetSecClass(SecClassGenericPassword) + + // First set accessible. + item.SetAccessible(AccessibleWhenUnlocked) + _, hasAccessible := item.attr[AccessibleKey] + assert.True(t, hasAccessible, "kSecAttrAccessible should be set after SetAccessible") + + // Now set access control, which should remove accessible. + err := item.SetAccessControl(AccessibleWhenPasscodeSetThisDeviceOnly, AccessControlDevicePasscode) + require.NoError(t, err) + + _, hasAccessible = item.attr[AccessibleKey] + assert.False(t, hasAccessible, "kSecAttrAccessible should be removed after SetAccessControl") +} + +func TestSetAccessControlInvalidAccessible(t *testing.T) { + item := NewItem() + err := item.SetAccessControl(Accessible(999), AccessControlDevicePasscode) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid accessible value") +} + +func TestSetUseDataProtectionKeychain(t *testing.T) { + item := NewItem() + item.SetUseDataProtectionKeychain(true) + val, ok := item.attr[UseDataProtectionKeychainKey] + assert.True(t, ok) + assert.Equal(t, true, val) + + item.SetUseDataProtectionKeychain(false) + val, ok = item.attr[UseDataProtectionKeychainKey] + assert.True(t, ok) + assert.Equal(t, false, val) +} + +func TestAccessControlAddDeleteItem(t *testing.T) { + item := NewItem() + item.SetSecClass(SecClassGenericPassword) + item.SetService("TestAccessControlAddDelete") + item.SetAccount("test-ac-add-delete") + item.SetLabel("TestAccessControlAddDeleteItem") + item.SetData([]byte("biometry-secret")) + item.SetSynchronizable(SynchronizableNo) + item.SetUseDataProtectionKeychain(true) + + err := item.SetAccessControl(AccessibleWhenPasscodeSetThisDeviceOnly, AccessControlDevicePasscode) + require.NoError(t, err) + + defer func() { + dq := NewItem() + dq.SetSecClass(SecClassGenericPassword) + dq.SetService("TestAccessControlAddDelete") + dq.SetAccount("test-ac-add-delete") + dq.SetUseDataProtectionKeychain(true) + _ = DeleteItem(dq) + }() + + err = AddItem(item) + if err != nil { + // -34018 (errSecMissingEntitlement) occurs when the test binary is not + // code-signed with keychain-access-groups entitlement, which is required + // for the data protection keychain. Skip in that case. + if err == Error(-34018) { + t.Skip("skipping: test binary not code-signed with keychain entitlements (errSecMissingEntitlement)") + } + t.Fatal(err) + } + + // Query it back. + query := NewItem() + query.SetSecClass(SecClassGenericPassword) + query.SetService("TestAccessControlAddDelete") + query.SetAccount("test-ac-add-delete") + query.SetMatchLimit(MatchLimitOne) + query.SetReturnData(true) + query.SetUseDataProtectionKeychain(true) + + results, err := QueryItem(query) + if err != nil { + t.Fatal(err) + } + + require.Len(t, results, 1) + assert.Equal(t, []byte("biometry-secret"), results[0].Data) +} + +func TestAccessControlFlagConstants(t *testing.T) { + // Verify that flag constants are non-zero (they are bitmask flags). + assert.NotZero(t, AccessControlUserPresence, "AccessControlUserPresence should be non-zero") + assert.NotZero(t, AccessControlBiometryAny, "AccessControlBiometryAny should be non-zero") + assert.NotZero(t, AccessControlBiometryCurrentSet, "AccessControlBiometryCurrentSet should be non-zero") + assert.NotZero(t, AccessControlDevicePasscode, "AccessControlDevicePasscode should be non-zero") + assert.NotZero(t, AccessControlOr, "AccessControlOr should be non-zero") + assert.NotZero(t, AccessControlAnd, "AccessControlAnd should be non-zero") + assert.NotZero(t, AccessControlPrivateKeyUsage, "AccessControlPrivateKeyUsage should be non-zero") + assert.NotZero(t, AccessControlApplicationPassword, "AccessControlApplicationPassword should be non-zero") +}