iOS Keychain for Pythonista (WIP)
#!python3 | |
from ctypes import c_int, c_void_p, POINTER, byref, c_ulong | |
from objc_util import ( | |
load_framework, c, ns, ObjCInstance, nsdata_to_bytes, NSString, NSData, NSNumber, | |
ObjCClass, NSArray, NSDictionary | |
) | |
from enum import Enum, IntFlag | |
from typing import Union | |
import datetime | |
from os.path import basename | |
__all__ = [ | |
'get_password', 'set_password', 'delete_password', 'get_services', | |
'KeychainError', 'KeychainDuplicateItemError', 'KeychainItemNotFoundError', | |
'KeychainAuthFailedError', 'KeychainUserCanceledError', 'KeychainUserInteractionNotAllowedError', | |
'KeychainParamError', 'KeychainUnhandledError', | |
'ItemClass', 'AuthenticationPolicy', 'Accessibility', 'AuthenticationUI', 'AccessControl', | |
'GenericPassword', 'GenericPasswordAttributes' | |
] | |
# | |
# Core Foundation | |
# | |
# Memory management rules | |
# https://developer.apple.com/library/content/documentation/CoreFoundation/Conceptual/CFMemoryMgmt/CFMemoryMgmt.html | |
# | |
# Toll-free bridged types - we're not forced to play with CFDictionaryCreate - we can use ns(dict) -> NSDictionary directlyy | |
# https://developer.apple.com/library/content/documentation/CoreFoundation/Conceptual/CFDesignConcepts/Articles/tollFreeBridgedTypes.html | |
# | |
load_framework('Security') | |
NSDate = ObjCClass('NSDate') | |
def _from_nsstring(obj): | |
return obj.UTF8String().decode() | |
def _from_nsnumber(obj): # noqa: C901 | |
ctype = obj.objCType() | |
if ctype == b'c': | |
return obj.charValue() | |
elif ctype == b's': | |
return obj.shortValue() | |
elif ctype == b'i': | |
return obj.intValue() | |
elif ctype == b'l': | |
return obj.longValue() | |
elif ctype == b'q': | |
return obj.longLongValue() | |
elif ctype == b'C': | |
return obj.unsignedCharValue() | |
elif ctype == b'S': | |
return obj.unsignedShortValue() | |
elif ctype == b'I': | |
return obj.unsignedIntValue() | |
elif ctype == b'L': | |
return obj.unsignedLongValue() | |
elif ctype == b'Q': | |
return obj.unsignedLongLongValue() | |
elif ctype == b'f': | |
return obj.floatValue() | |
elif ctype == b'd': | |
return obj.doubleValue() | |
elif ctype == b'B': | |
return obj.boolValue() | |
raise ValueError(f'Unsupported objCType value {ctype}') | |
def _from_nsdata(obj): | |
return nsdata_to_bytes(obj) | |
def _from_nsdate(obj): | |
return datetime.datetime.fromtimestamp(obj.timeIntervalSince1970()) | |
def from_ns(obj): | |
if obj.isKindOfClass_(NSString): | |
return _from_nsstring(obj) | |
elif obj.isKindOfClass_(NSNumber): | |
return _from_nsnumber(obj) | |
elif obj.isKindOfClass_(NSData): | |
return _from_nsdata(obj) | |
elif obj.isKindOfClass_(NSDate): | |
return _from_nsdate(obj) | |
elif obj.isKindOfClass_(NSArray): | |
return [from_ns(obj.objectAtIndex_(i) for i in range(obj.count()))] | |
elif obj.isKindOfClass_(NSDictionary): | |
return {from_ns(k): from_ns(obj.objectForKey_(k)) for k in obj.allKeys()} | |
print(type(obj)) | |
return obj | |
def _symbol_ptr(name): | |
return c_void_p.in_dll(c, name) | |
def _str_symbol(name): | |
return ObjCInstance(_symbol_ptr(name)).UTF8String().decode() | |
# | |
# kSec* constants | |
# | |
# [TODO] Check if there's a way how to get them via from_address, ... because in_dll works for these symbols | |
# [TODO] Add other constants | |
# | |
# https://developer.apple.com/documentation/security/keychain_services/keychain_items/item_class_keys_and_values?language=objc | |
kSecClass = _str_symbol('kSecClass') | |
kSecClassGenericPassword = _str_symbol('kSecClassGenericPassword') | |
kSecClassInternetPassword = _str_symbol('kSecClassInternetPassword') | |
# https://developer.apple.com/documentation/security/keychain_services/keychain_items/item_attribute_keys_and_values | |
# General Item Attribute Keys | |
kSecAttrAccessControl = _str_symbol('kSecAttrAccessControl') | |
kSecAttrAccessible = _str_symbol('kSecAttrAccessible') | |
kSecAttrAccessGroup = _str_symbol('kSecAttrAccessGroup') | |
kSecAttrSynchronizable = _str_symbol('kSecAttrSynchronizable') | |
kSecAttrCreationDate = _str_symbol('kSecAttrCreationDate') | |
kSecAttrModificationDate = _str_symbol('kSecAttrModificationDate') | |
kSecAttrDescription = _str_symbol('kSecAttrDescription') | |
kSecAttrComment = _str_symbol('kSecAttrComment') | |
kSecAttrCreator = _str_symbol('kSecAttrCreator') | |
kSecAttrType = _str_symbol('kSecAttrType') | |
kSecAttrLabel = _str_symbol('kSecAttrLabel') | |
kSecAttrIsInvisible = _str_symbol('kSecAttrIsInvisible') | |
kSecAttrIsNegative = _str_symbol('kSecAttrIsNegative') | |
kSecAttrSyncViewHint = _str_symbol('kSecAttrSyncViewHint') | |
# Password Attribute Keys (generic & internet password) | |
kSecAttrAccount = _str_symbol('kSecAttrAccount') | |
# Password Attribute Keys (generic password only) | |
kSecAttrService = _str_symbol('kSecAttrService') | |
kSecAttrGeneric = _str_symbol('kSecAttrGeneric') | |
# Password Attribute Keys (internet password only) | |
kSecAttrSecurityDomain = _str_symbol('kSecAttrSecurityDomain') | |
kSecAttrServer = _str_symbol('kSecAttrServer') | |
kSecAttrProtocol = _str_symbol('kSecAttrProtocol') | |
kSecAttrAuthenticationType = _str_symbol('kSecAttrAuthenticationType') | |
kSecAttrPort = _str_symbol('kSecAttrPort') | |
kSecAttrPath = _str_symbol('kSecAttrPath') | |
# kSecAttrProtocol values | |
kSecAttrProtocolFTP = _str_symbol('kSecAttrProtocolFTP') | |
kSecAttrProtocolFTPAccount = _str_symbol('kSecAttrProtocolFTPAccount') | |
kSecAttrProtocolHTTP = _str_symbol('kSecAttrProtocolHTTP') | |
kSecAttrProtocolIRC = _str_symbol('kSecAttrProtocolIRC') | |
kSecAttrProtocolNNTP = _str_symbol('kSecAttrProtocolNNTP') | |
kSecAttrProtocolPOP3 = _str_symbol('kSecAttrProtocolPOP3') | |
kSecAttrProtocolSMTP = _str_symbol('kSecAttrProtocolSMTP') | |
kSecAttrProtocolSOCKS = _str_symbol('kSecAttrProtocolSOCKS') | |
kSecAttrProtocolIMAP = _str_symbol('kSecAttrProtocolIMAP') | |
kSecAttrProtocolLDAP = _str_symbol('kSecAttrProtocolLDAP') | |
kSecAttrProtocolAppleTalk = _str_symbol('kSecAttrProtocolAppleTalk') | |
kSecAttrProtocolAFP = _str_symbol('kSecAttrProtocolAFP') | |
kSecAttrProtocolTelnet = _str_symbol('kSecAttrProtocolTelnet') | |
kSecAttrProtocolSSH = _str_symbol('kSecAttrProtocolSSH') | |
kSecAttrProtocolFTPS = _str_symbol('kSecAttrProtocolFTPS') | |
kSecAttrProtocolHTTPS = _str_symbol('kSecAttrProtocolHTTPS') | |
kSecAttrProtocolHTTPProxy = _str_symbol('kSecAttrProtocolHTTPProxy') | |
kSecAttrProtocolHTTPSProxy = _str_symbol('kSecAttrProtocolHTTPSProxy') | |
kSecAttrProtocolFTPProxy = _str_symbol('kSecAttrProtocolFTPProxy') | |
kSecAttrProtocolSMB = _str_symbol('kSecAttrProtocolSMB') | |
kSecAttrProtocolRTSP = _str_symbol('kSecAttrProtocolRTSP') | |
kSecAttrProtocolRTSPProxy = _str_symbol('kSecAttrProtocolRTSPProxy') | |
kSecAttrProtocolDAAP = _str_symbol('kSecAttrProtocolDAAP') | |
kSecAttrProtocolEPPC = _str_symbol('kSecAttrProtocolEPPC') | |
kSecAttrProtocolIPP = _str_symbol('kSecAttrProtocolIPP') | |
kSecAttrProtocolNNTPS = _str_symbol('kSecAttrProtocolNNTPS') | |
kSecAttrProtocolLDAPS = _str_symbol('kSecAttrProtocolLDAPS') | |
kSecAttrProtocolTelnetS = _str_symbol('kSecAttrProtocolTelnetS') | |
kSecAttrProtocolIMAPS = _str_symbol('kSecAttrProtocolIMAPS') | |
kSecAttrProtocolIRCS = _str_symbol('kSecAttrProtocolIRCS') | |
kSecAttrProtocolPOP3S = _str_symbol('kSecAttrProtocolPOP3S') | |
# kSecAttrAuthenticationType values | |
kSecAttrAuthenticationTypeNTLM = _str_symbol('kSecAttrAuthenticationTypeNTLM') | |
kSecAttrAuthenticationTypeMSN = _str_symbol('kSecAttrAuthenticationTypeMSN') | |
kSecAttrAuthenticationTypeDPA = _str_symbol('kSecAttrAuthenticationTypeDPA') | |
kSecAttrAuthenticationTypeRPA = _str_symbol('kSecAttrAuthenticationTypeRPA') | |
kSecAttrAuthenticationTypeHTTPBasic = _str_symbol('kSecAttrAuthenticationTypeHTTPBasic') | |
kSecAttrAuthenticationTypeHTTPDigest = _str_symbol('kSecAttrAuthenticationTypeHTTPDigest') | |
kSecAttrAuthenticationTypeHTMLForm = _str_symbol('kSecAttrAuthenticationTypeHTMLForm') | |
kSecAttrAuthenticationTypeDefault = _str_symbol('kSecAttrAuthenticationTypeDefault') | |
# https://developer.apple.com/documentation/security/keychain_services/keychain_items/item_return_result_keys?language=objc | |
kSecReturnData = _str_symbol('kSecReturnData') | |
kSecReturnAttributes = _str_symbol('kSecReturnAttributes') | |
kSecReturnRef = _str_symbol('kSecReturnRef') | |
kSecReturnPersistentRef = _str_symbol('kSecReturnPersistentRef') | |
# https://developer.apple.com/documentation/security/keychain_services/keychain_items/item_return_result_keys?language=objc | |
kSecValueData = _str_symbol('kSecValueData') | |
kSecValueRef = _str_symbol('kSecValueRef') | |
kSecValuePersistentRef = _str_symbol('kSecValuePersistentRef') | |
# https://developer.apple.com/documentation/security/keychain_services/keychain_items/search_attribute_keys_and_values?language=objc | |
kSecMatchLimit = _str_symbol('kSecMatchLimit') | |
kSecMatchLimitAll = _str_symbol('kSecMatchLimitAll') | |
kSecMatchLimitOne = _str_symbol('kSecMatchLimitOne') | |
kSecMatchCaseInsensitive = _str_symbol('kSecMatchCaseInsensitive') | |
# https://developer.apple.com/documentation/security/keychain_services/keychain_items/item_attribute_keys_and_values#1679100?language=objc | |
kSecAttrAccessibleAlways = _str_symbol('kSecAttrAccessibleAlways') | |
kSecAttrAccessibleAlwaysThisDeviceOnly = _str_symbol('kSecAttrAccessibleAlwaysThisDeviceOnly') | |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly = _str_symbol('kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly') | |
kSecAttrAccessibleAfterFirstUnlock = _str_symbol('kSecAttrAccessibleAfterFirstUnlock') | |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly = _str_symbol('kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly') | |
kSecAttrAccessibleWhenUnlocked = _str_symbol('kSecAttrAccessibleWhenUnlocked') | |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly = _str_symbol('kSecAttrAccessibleWhenUnlockedThisDeviceOnly') | |
# https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/ksecaccesscontroluserpresence | |
kSecAccessControlUserPresence = 1 << 0 | |
kSecAccessControlTouchIDAny = 1 << 1 | |
kSecAccessControlTouchIDCurrentSet = 1 << 3 | |
kSecAccessControlDevicePasscode = 1 << 4 | |
kSecAccessControlOr = 1 << 14 | |
kSecAccessControlAnd = 1 << 15 | |
kSecAccessControlPrivateKeyUsage = 1 << 30 | |
kSecAccessControlApplicationPassword = 1 << 31 | |
# https://developer.apple.com/documentation/security/ksecuseauthenticationuiallow?language=objc | |
kSecUseAuthenticationUI = _str_symbol('kSecUseAuthenticationUI') | |
kSecUseAuthenticationUIAllow = _str_symbol('kSecUseAuthenticationUIAllow') | |
kSecUseAuthenticationUIFail = _str_symbol('kSecUseAuthenticationUIFail') | |
kSecUseAuthenticationUISkip = _str_symbol('kSecUseAuthenticationUISkip') | |
kSecUseOperationPrompt = _str_symbol('kSecUseOperationPrompt') | |
# | |
# Security framework functions | |
# | |
CFTypeRef = c_void_p | |
CFDictionaryRef = c_void_p | |
SecAccessControlRef = c_void_p | |
CFErrorRef = c_void_p | |
CFAllocatorRef = c_void_p | |
# void CFRelease(CFTypeRef cf) | |
# https://developer.apple.com/documentation/corefoundation/1521153-cfrelease | |
CFRelease = c.CFRelease | |
CFRelease.restype = None | |
CFRelease.argtypes = [CFTypeRef] | |
# OSStatus SecItemAdd(CFDictionaryRef attributes, CFTypeRef _Nullable *result); | |
# https://developer.apple.com/documentation/security/1401659-secitemadd?language=objc | |
SecItemAdd = c.SecItemAdd | |
SecItemAdd.restype = c_int | |
SecItemAdd.argtypes = [CFDictionaryRef, POINTER(CFTypeRef)] | |
# OSStatus SecItemUpdate(CFDictionaryRef query, CFDictionaryRef attributesToUpdate); | |
# https://developer.apple.com/documentation/security/1393617-secitemupdate?language=objc | |
SecItemUpdate = c.SecItemUpdate | |
SecItemUpdate.restype = c_int | |
SecItemUpdate.argtypes = [CFDictionaryRef, CFDictionaryRef] | |
# OSStatus SecItemCopyMatching(CFDictionaryRef query, CFTypeRef _Nullable *result); | |
# https://developer.apple.com/documentation/security/1398306-secitemcopymatching?language=objc | |
SecItemCopyMatching = c.SecItemCopyMatching | |
SecItemCopyMatching.restype = c_int | |
SecItemCopyMatching.argtypes = [CFDictionaryRef, POINTER(CFTypeRef)] | |
# OSStatus SecItemDelete(CFDictionaryRef query); | |
# https://developer.apple.com/documentation/security/1395547-secitemdelete?language=objc | |
SecItemDelete = c.SecItemDelete | |
SecItemDelete.restype = c_int | |
SecItemDelete.argtypes = [CFDictionaryRef] | |
# SecAccessControlRef SecAccessControlCreateWithFlags(CFAllocatorRef allocator, CFTypeRef protection, | |
# SecAccessControlCreateFlags flags, CFErrorRef _Nullable *error); | |
# https://developer.apple.com/documentation/security/1394452-secaccesscontrolcreatewithflags?language=objc | |
SecAccessControlCreateWithFlags = c.SecAccessControlCreateWithFlags | |
SecAccessControlCreateWithFlags.restype = SecAccessControlRef | |
SecAccessControlCreateWithFlags.argtypes = [CFAllocatorRef, CFTypeRef, c_ulong, POINTER(CFErrorRef)] | |
# | |
# Keychain errors | |
# | |
_status_error_classes = {} | |
def register_status_error(status=None): | |
def decorator(cls): | |
_status_error_classes[status] = cls | |
return cls | |
return decorator | |
class KeychainError(Exception): | |
def __init__(self, *args, status=None): | |
super().__init__(*args) | |
self.status = status | |
@register_status_error(-25299) | |
class KeychainDuplicateItemError(KeychainError): | |
pass | |
@register_status_error(-25300) | |
class KeychainItemNotFoundError(KeychainError): | |
pass | |
@register_status_error(-25293) | |
class KeychainAuthFailedError(KeychainError): | |
pass | |
@register_status_error(-128) | |
class KeychainUserCanceledError(KeychainError): | |
pass | |
@register_status_error(-25308) | |
class KeychainUserInteractionNotAllowedError(KeychainError): | |
pass | |
@register_status_error(-50) | |
class KeychainParamError(KeychainError): | |
pass | |
@register_status_error() | |
class KeychainUnhandledError(KeychainError): | |
pass | |
def error_class_with_status(status): | |
return _status_error_classes.get(status, _status_error_classes[None]) | |
def raise_status(status, *args): | |
if status: | |
raise error_class_with_status(status)(*args, status=status) | |
def sec_item_add(attributes: dict) -> None: | |
raise_status( | |
SecItemAdd(ns(attributes), None), | |
'Failed to add keychain item' | |
) | |
def sec_item_update(query_attributes, attributes_to_update) -> None: | |
raise_status( | |
SecItemUpdate(ns(query_attributes), ns(attributes_to_update)), | |
'Failed to update keychain item' | |
) | |
def sec_item_copy_matching(query_attributes) -> ObjCInstance: | |
ptr = CFTypeRef() | |
raise_status( | |
SecItemCopyMatching(ns(query_attributes), byref(ptr)), | |
'Failed to get keychain item' | |
) | |
assert(ptr.value is not None) | |
result = ObjCInstance(ptr) | |
CFRelease(ptr) | |
return result | |
def sec_item_copy_matching_data(query_attributes) -> bytes: | |
query = dict(query_attributes) | |
query[kSecReturnAttributes] = False | |
query[kSecReturnData] = True | |
return from_ns(sec_item_copy_matching(query)) | |
def sec_item_copy_matching_attributes(query_attributes) -> dict: | |
query = dict(query_attributes) | |
query[kSecReturnAttributes] = True | |
query[kSecReturnData] = False | |
return from_ns(sec_item_copy_matching(query)) | |
def sec_item_delete(query_attributes) -> None: | |
raise_status( | |
SecItemDelete(ns(query_attributes)), | |
'Failed to delete keychain item' | |
) | |
# | |
# Kind of human interface for security framework | |
# | |
class ItemClass(str, Enum): | |
GENERIC_PASSWORD = kSecClassGenericPassword | |
INTERNET_PASSWORD = kSecClassInternetPassword | |
class AuthenticationPolicy(IntFlag): | |
USER_PRESENCE = kSecAccessControlUserPresence | |
TOUCH_ID_ANY = kSecAccessControlTouchIDAny | |
TOUCH_ID_CURRENT_SET = kSecAccessControlTouchIDCurrentSet | |
DEVICE_PASSCODE = kSecAccessControlDevicePasscode | |
OR = kSecAccessControlOr | |
AND = kSecAccessControlAnd | |
PRIVATE_KEY_USAGE = kSecAccessControlPrivateKeyUsage | |
APPLICATION_PASSWORD = kSecAccessControlApplicationPassword | |
class Accessibility(str, Enum): | |
ALWAYS = kSecAttrAccessibleAlways | |
ALWAYS_THIS_DEVICE_ONLY = kSecAttrAccessibleAlwaysThisDeviceOnly | |
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY = kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | |
AFTER_FIRST_UNLOCK = kSecAttrAccessibleAfterFirstUnlock | |
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | |
WHEN_UNLOCKED = kSecAttrAccessibleWhenUnlocked | |
WHEN_UNLOCKED_THIS_DEVICE_ONLY = kSecAttrAccessibleWhenUnlockedThisDeviceOnly | |
class AuthenticationUI(str, Enum): | |
ALLOW = kSecUseAuthenticationUIAllow | |
FAIL = kSecUseAuthenticationUIFail | |
SKIP = kSecUseAuthenticationUISkip | |
class AccessControl: | |
def __init__(self, accessibility: Accessibility, flags: AuthenticationPolicy): | |
self._accessibility = accessibility | |
self._flags = flags | |
self._sac = None | |
@property | |
def accessibility(self): | |
return self._accessibility | |
@property | |
def flags(self): | |
return self._flags | |
@property | |
def value(self): | |
if not self._sac: | |
sac = SecAccessControlCreateWithFlags(None, ns(self._accessibility.value), self._flags, None) | |
if sac is None: | |
raise KeychainError('Failed to create SecAccessControl object') | |
self._sac = ObjCInstance(sac) | |
CFRelease(sac) | |
return self._sac | |
class _SecItem: | |
_ITEM_CLASS = None | |
def __init__(self, **kwargs): | |
self.accessibility = kwargs.get('accessibility', None) | |
self.access_control = kwargs.get('access_control', None) | |
self.description = kwargs.get('description', None) | |
self.label = kwargs.get('label', None) | |
self.comment = kwargs.get('comment', None) | |
self.is_invisible = kwargs.get('is_invisible', None) | |
self.is_negative = kwargs.get('is_negative', None) | |
@property | |
def item_class(self): | |
return self._ITEM_CLASS | |
def _query_attributes(self): | |
return { | |
kSecClass: self.item_class | |
} | |
def _item_attributes(self): | |
attrs = {} | |
if self.accessibility is not None: | |
attrs[kSecAttrAccessible] = self.accessibility.value | |
if self.access_control: | |
attrs[kSecAttrAccessControl] = self.access_control.value | |
if self.description: | |
attrs[kSecAttrDescription] = self.description | |
if self.label: | |
attrs[kSecAttrLabel] = self.label | |
if self.comment: | |
attrs[kSecAttrComment] = self.comment | |
if self.is_invisible: | |
attrs[kSecAttrIsInvisible] = self.is_invisible | |
if self.is_negative: | |
attrs[kSecAttrIsNegative] = self.is_negative | |
return attrs | |
def _get_attributes(self, *, prompt: Union[str, None] = None, | |
authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
query = self._query_attributes() | |
query[kSecReturnAttributes] = True | |
query[kSecReturnData] = False | |
query[kSecMatchLimit] = kSecMatchLimitOne | |
query[kSecUseAuthenticationUI] = authentication_ui | |
if prompt: | |
query[kSecUseOperationPrompt] = prompt | |
return sec_item_copy_matching_attributes(query) | |
def get_data(self, *, prompt: Union[str, None] = None, authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
query = self._query_attributes() | |
query[kSecReturnAttributes] = False | |
query[kSecReturnData] = True | |
query[kSecMatchLimit] = kSecMatchLimitOne | |
query[kSecUseAuthenticationUI] = authentication_ui | |
if prompt: | |
query[kSecUseOperationPrompt] = prompt | |
return sec_item_copy_matching_data(query) | |
def delete(self): | |
try: | |
sec_item_delete(self._query_attributes()) | |
except KeychainItemNotFoundError: | |
pass | |
def add(self, *, data: Union[bytes, None] = None, prompt: Union[str, None] = None, | |
authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
attrs = self._query_attributes() | |
attrs.update(self._item_attributes()) | |
attrs[kSecUseAuthenticationUI] = authentication_ui | |
if data: | |
attrs[kSecValueData] = data | |
if prompt: | |
attrs[kSecUseOperationPrompt] = prompt | |
sec_item_add(attrs) | |
def update(self, *, data: Union[bytes, None] = None, prompt: Union[str, None] = None, | |
authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
query = self._query_attributes() | |
attrs = self._item_attributes() | |
query[kSecUseAuthenticationUI] = authentication_ui | |
if data: | |
attrs[kSecValueData] = data | |
if prompt: | |
query[kSecUseOperationPrompt] = prompt | |
sec_item_update(query, attrs) | |
def save(self, *, data: Union[bytes, None] = None, prompt: Union[str, None] = None, | |
authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
try: | |
self.add(data=data, prompt=prompt, authentication_ui=authentication_ui) | |
except KeychainDuplicateItemError: | |
self.update(data=data, prompt=prompt, authentication_ui=authentication_ui) | |
@classmethod | |
def _query_items(cls, attributes=None, *, prompt: Union[str, None] = None, | |
authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
query = { | |
kSecClass: cls._ITEM_CLASS, | |
kSecReturnData: False, | |
kSecReturnAttributes: True, | |
kSecMatchLimit: kSecMatchLimitAll, | |
kSecUseAuthenticationUI: authentication_ui | |
} | |
if prompt: | |
query[kSecUseOperationPrompt] = prompt | |
if attributes: | |
query.update(attributes) | |
result = sec_item_copy_matching(query) | |
return [ | |
from_ns(result.objectAtIndex_(i)) | |
for i in range(result.count()) | |
] | |
class _SecItemAttributes: | |
def __init__(self, attrs): | |
self.modification_date = attrs.get(kSecAttrModificationDate, None) | |
self.creation_date = attrs.get(kSecAttrCreationDate, None) | |
self.description = attrs.get(kSecAttrDescription, None) | |
self.label = attrs.get(kSecAttrLabel, None) | |
self.comment = attrs.get(kSecAttrComment, None) | |
self.is_invisible = bool(attrs.get(kSecAttrIsInvisible, False)) | |
self.is_negative = bool(attrs.get(kSecAttrIsNegative, False)) | |
if kSecAttrAccessible in attrs: | |
self.accessibility = Accessibility(attrs[kSecAttrAccessible]) | |
class GenericPasswordAttributes(_SecItemAttributes): | |
def __init__(self, attrs): | |
super().__init__(attrs) | |
self.item_class = ItemClass.GENERIC_PASSWORD | |
self.service = attrs.get(kSecAttrService, None) | |
self.account = attrs.get(kSecAttrAccount, None) | |
self.generic = attrs.get(kSecAttrGeneric, None) | |
class GenericPassword(_SecItem): | |
_ITEM_CLASS = ItemClass.GENERIC_PASSWORD | |
def __init__(self, service: str, account: str): | |
super().__init__() | |
self._service = service | |
self._account = account | |
self.generic = None | |
@property | |
def service(self): | |
return self._service | |
@property | |
def account(self): | |
return self._account | |
def _query_attributes(self): | |
query = super()._query_attributes() | |
query[kSecAttrService] = self.service | |
query[kSecAttrAccount] = self.account | |
return query | |
def _item_attributes(self): | |
attrs = super()._item_attributes() | |
attrs[kSecAttrService] = self._service | |
attrs[kSecAttrAccount] = self._account | |
if self.generic: | |
attrs[kSecAttrGeneric] = self.generic | |
return attrs | |
def get_attributes(self, *, prompt: Union[str, None] = None, | |
authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
return GenericPasswordAttributes(self._get_attributes(prompt=prompt, authentication_ui=authentication_ui)) | |
def get_password(self, *, prompt: Union[str, None] = None, authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
return self.get_data(prompt=prompt, authentication_ui=authentication_ui).decode() | |
def set_password(self, password, *, prompt: Union[str, None] = None, | |
authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
self.save(data=password.encode(), prompt=prompt, authentication_ui=authentication_ui) | |
@classmethod | |
def query_items(cls, service: Union[str, None] = None, | |
prompt: Union[str, None] = None, authentication_ui: AuthenticationUI = AuthenticationUI.ALLOW): | |
attrs = {} | |
if service: | |
attrs[kSecAttrService] = service | |
return [ | |
GenericPasswordAttributes(x) | |
for x in cls._query_items(attrs, prompt=prompt, authentication_ui=authentication_ui) | |
] | |
# | |
# Pythonista keychain compatibility layer | |
# | |
# - all functions mimicks Pythonista keychain module functions behavior | |
# - not sure what Pythonista does about exceptions (aka status != 0), I'm raising | |
# | |
def delete_password(service, account): | |
"""Delete the password for the given service/account from the keychain.""" | |
try: | |
GenericPassword(service, account).delete() | |
except KeychainItemNotFoundError: | |
pass | |
def set_password(service, account, password): | |
"""Save a password for the given service and account in the keychain.""" | |
GenericPassword(service, account).set_password(password) | |
def get_password(service, account): | |
"""Get the password for the given service/account that was previously stored in the keychain.""" | |
try: | |
return GenericPassword(service, account).get_password() | |
except KeychainItemNotFoundError: | |
# Compatibility - Pythonista returns None if there's no password | |
return None | |
def get_services(): | |
"""Return a list of all services and accounts that are stored in the keychain (each item is a 2-tuple).""" | |
try: | |
return [ | |
(x.service, x.account) | |
for x in GenericPassword.query_items() | |
] | |
except KeychainItemNotFoundError: | |
# Compatibility - Pythonista returns empty List if there're no passwords | |
return [] | |
def reset_keychain(): | |
"""Delete all data from the keychain (including the master password) after showing a confirmation dialog.""" | |
# Not a fan of this method :) | |
raise NotImplementedError('Use Pythonista keychain.reset_keychain() if you really need it') | |
# | |
# Tests | |
# | |
def test_delete_password(): | |
set_password('s', 'a', 'password') | |
assert(get_password('s', 'a') == 'password') | |
delete_password('s', 'a') | |
assert(get_password('s', 'a') is None) | |
def test_pythonista_compatibility_delete_password_does_not_raise(): | |
delete_password('s', 'a') | |
delete_password('s', 'a') | |
def test_set_password(): | |
delete_password('s', 'a') | |
assert(get_password('s', 'a') is None) | |
set_password('s', 'a', 'password') | |
assert(get_password('s', 'a') == 'password') | |
delete_password('s', 'a') | |
def test_pythonista_compatibility_set_password_does_not_raise(): | |
set_password('s', 'a', 'password') | |
set_password('s', 'a', 'password2') | |
delete_password('s', 'a') | |
def test_get_password(): | |
set_password('s', 'a', 'password') | |
assert(get_password('s', 'a') == 'password') | |
delete_password('s', 'a') | |
def test_pythonista_compatibility_get_password_does_not_raise(): | |
delete_password('s', 'a') | |
assert(get_password('s', 'a') is None) | |
def test_against_pythonista_keychain(): | |
import keychain | |
set_password('s', 'a', 'password') | |
assert(keychain.get_password('s', 'a') == 'password') | |
keychain.set_password('s', 'a', 'anotherone') | |
assert(get_password('s', 'a') == 'anotherone') | |
keychain.delete_password('s', 'a') | |
assert(get_password('s', 'a') is None) | |
def test_get_services(): | |
# We do not want to delete all items in tests -> no test for [] | |
set_password('s', 'a', 'password') | |
set_password('s', 'a2', 'password') | |
services = get_services() | |
s_services = list(filter(lambda x: x[0] == 's', services)) | |
assert(len(s_services) == 2) | |
s_accounts = sorted([x[1] for x in s_services]) | |
assert(s_accounts == ['a', 'a2']) | |
delete_password('s', 'a') | |
delete_password('s', 'a2') | |
services = get_services() | |
s_services = list(filter(lambda x: x[0] == 's', services)) | |
assert(len(s_services) == 0) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This comment has been minimized.
Nice work !
For your information I have just tried it and all the tests pass on a pre iOS 11.3 version (probably iOS 11.2.x) but most fail (6 on 8) after upgrading to 11.3 (verified on two devices). A quick googling reveals that keychain API was broken during the 11.3 beta so maybe all problems were not solved for the release.