diff --git a/keyring/backends/macOS/__init__.py b/keyring/backends/macOS/__init__.py index c3734e29..1119d114 100644 --- a/keyring/backends/macOS/__init__.py +++ b/keyring/backends/macOS/__init__.py @@ -40,6 +40,13 @@ def priority(cls): raise RuntimeError("Security API unavailable") return 5 + @warn_keychain + def list_passwords(self): + try: + return api.list_generic_passwords() + except api.Error as e: + raise KeyringError(f"Can't list passwords in keychain: {e}") from e + @warn_keychain def set_password(self, service, username, password): if username is None: diff --git a/keyring/backends/macOS/api.py b/keyring/backends/macOS/api.py index d837308a..2713f4ca 100644 --- a/keyring/backends/macOS/api.py +++ b/keyring/backends/macOS/api.py @@ -22,9 +22,35 @@ class error: sec_interaction_not_allowed = -25308 -_sec = ctypes.CDLL(find_library('Security')) -_core = ctypes.CDLL(find_library('CoreServices')) -_found = ctypes.CDLL(find_library('Foundation')) +_sec = ctypes.CDLL(find_library("Security")) +_core = ctypes.CDLL(find_library("CoreServices")) +_found = ctypes.CDLL(find_library("Foundation")) + +CFGetTypeID = _found.CFGetTypeID +CFGetTypeID.restype = c_uint32 +CFGetTypeID.argtypes = [c_void_p] + +CFArrayGetTypeID = _found.CFArrayGetTypeID +CFArrayGetTypeID.restype = c_uint32 + +CFAttributedStringGetTypeID = _found.CFAttributedStringGetTypeID +CFAttributedStringGetTypeID.restype = c_uint32 + +CFCopyTypeIDDescription = _found.CFCopyTypeIDDescription +CFCopyTypeIDDescription.restype = c_void_p +CFCopyTypeIDDescription.argtypes = [c_uint32] + +CFArrayGetCount = _found.CFArrayGetCount +CFArrayGetCount.restype = c_int32 +CFArrayGetCount.argtypes = [c_void_p] + +CFArrayGetValueAtIndex = _found.CFArrayGetValueAtIndex +CFArrayGetValueAtIndex.restype = c_void_p +CFArrayGetValueAtIndex.argtypes = [c_void_p, c_int32] + +CFDictionaryGetValue = _found.CFDictionaryGetValue +CFDictionaryGetValue.restype = c_void_p +CFDictionaryGetValue.argtypes = [c_void_p, c_void_p] CFDictionaryCreate = _found.CFDictionaryCreate CFDictionaryCreate.restype = c_void_p @@ -37,6 +63,15 @@ class error: c_void_p, ) +CFStringGetCStringPtr = _found.CFStringGetCStringPtr +CFStringGetCStringPtr.restype = ctypes.c_char_p +CFStringGetCStringPtr.argtypes = [c_void_p, c_uint32] +kCFStringEncodingUTF8 = 0x08000100 + +CFStringGetCString = _found.CFStringGetCString +CFStringGetCString.restype = ctypes.c_bool +CFStringGetCString.argtypes = [c_void_p, ctypes.c_char_p, c_int32, c_uint32] + CFStringCreateWithCString = _found.CFStringCreateWithCString CFStringCreateWithCString.restype = c_void_p CFStringCreateWithCString.argtypes = [c_void_p, c_void_p, c_uint32] @@ -88,7 +123,7 @@ def _(val: bool | int): @create_cf.register def _(s: str): kCFStringEncodingUTF8 = 0x08000100 - return CFStringCreateWithCString(None, s.encode('utf8'), kCFStringEncodingUTF8) + return CFStringCreateWithCString(None, s.encode("utf8"), kCFStringEncodingUTF8) def create_query(**kwargs): @@ -102,10 +137,27 @@ def create_query(**kwargs): ) -def cfstr_to_str(data): - return ctypes.string_at(CFDataGetBytePtr(data), CFDataGetLength(data)).decode( - 'utf-8' - ) +# Convert CFDataRef to Python str +def cfdata_to_str(data): + b = ctypes.string_at(CFDataGetBytePtr(data), CFDataGetLength(data)) + try: + return b.decode("utf-8") + except UnicodeDecodeError: + # Return hex string if not valid UTF-8 + return b.hex() + + +# Convert CFStringRef to Python str +def cfstring_to_str(cfstring): + if not cfstring: + return None + cstr = CFStringGetCStringPtr(cfstring, kCFStringEncodingUTF8) + if cstr: + return cstr.decode("utf-8") + buf = ctypes.create_string_buffer(1024) + if CFStringGetCString(cfstring, buf, 1024, kCFStringEncodingUTF8): + return buf.value.decode("utf-8") + return None class Error(Exception): @@ -140,8 +192,8 @@ class SecAuthFailure(Error): def find_generic_password(kc_name, service, username, not_found_ok=False): q = create_query( - kSecClass=k_('kSecClassGenericPassword'), - kSecMatchLimit=k_('kSecMatchLimitOne'), + kSecClass=k_("kSecClassGenericPassword"), + kSecMatchLimit=k_("kSecMatchLimitOne"), kSecAttrService=service, kSecAttrAccount=username, kSecReturnData=True, @@ -155,7 +207,7 @@ def find_generic_password(kc_name, service, username, not_found_ok=False): Error.raise_for_status(status) - return cfstr_to_str(data) + return cfdata_to_str(data) def set_generic_password(name, service, username, password): @@ -163,7 +215,7 @@ def set_generic_password(name, service, username, password): delete_generic_password(name, service, username) q = create_query( - kSecClass=k_('kSecClassGenericPassword'), + kSecClass=k_("kSecClassGenericPassword"), kSecAttrService=service, kSecAttrAccount=username, kSecValueData=password, @@ -175,10 +227,53 @@ def set_generic_password(name, service, username, password): def delete_generic_password(name, service, username): q = create_query( - kSecClass=k_('kSecClassGenericPassword'), + kSecClass=k_("kSecClassGenericPassword"), kSecAttrService=service, kSecAttrAccount=username, ) status = SecItemDelete(q) Error.raise_for_status(status) + + +def list_generic_passwords(): + q = create_query( + kSecClass=k_("kSecClassGenericPassword"), + kSecMatchLimit=k_("kSecMatchLimitAll"), + kSecReturnAttributes=True, + ) + + result = ctypes.pointer(c_void_p()) + status = SecItemCopyMatching(q, result) + + if status == error.item_not_found: + return [] + + Error.raise_for_status(status) + + kSecAttrService = k_("kSecAttrService") + kSecAttrAccount = k_("kSecAttrAccount") + + cf_result = result.contents.value + if not cf_result: + return [] + + if CFGetTypeID(cf_result) == CFArrayGetTypeID(): + count = CFArrayGetCount(cf_result) + dicts = [CFArrayGetValueAtIndex(cf_result, i) for i in range(count)] + else: + dicts = [cf_result] + + items: list[dict[str, str | None]] = [] + for d in dicts: + service_cf = CFDictionaryGetValue(d, kSecAttrService) + account_cf = CFDictionaryGetValue(d, kSecAttrAccount) + + items.append( + { + "service": cfstring_to_str(service_cf), + "account": cfstring_to_str(account_cf), + } + ) + + return items diff --git a/tests/backends/test_macOS.py b/tests/backends/test_macOS.py index 9fda4df1..d384094a 100644 --- a/tests/backends/test_macOS.py +++ b/tests/backends/test_macOS.py @@ -2,6 +2,7 @@ import keyring from keyring.backends import macOS +from keyring.testing.util import random_string from keyring.testing.backend import BackendBasicTests @@ -12,3 +13,21 @@ class Test_macOSKeychain(BackendBasicTests): def init_keyring(self): return macOS.Keyring() + + def test_list_generic_passwords(self): + service = random_string(20) + account = random_string(20) + password = "non-blank" + + macOS.api.set_generic_password(None, service, account, password) + + items = macOS.api.list_generic_passwords() + + found = [ + item + for item in items + if item["service"] == service and item["account"] == account + ] + assert found, f"No item found for service={service} and account={account}" + + macOS.api.delete_generic_password(None, service, account)