Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions keyring/backends/macOS/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
121 changes: 108 additions & 13 deletions keyring/backends/macOS/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -155,15 +207,15 @@ 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):
with contextlib.suppress(NotFound):
delete_generic_password(name, service, username)

q = create_query(
kSecClass=k_('kSecClassGenericPassword'),
kSecClass=k_("kSecClassGenericPassword"),
kSecAttrService=service,
kSecAttrAccount=username,
kSecValueData=password,
Expand All @@ -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
19 changes: 19 additions & 0 deletions tests/backends/test_macOS.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import keyring
from keyring.backends import macOS
from keyring.testing.util import random_string
from keyring.testing.backend import BackendBasicTests


Expand All @@ -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)