Last active
June 2, 2021 13:34
-
-
Save zrzka/d1da1dccd626643526747407a0e35135 to your computer and use it in GitHub Desktop.
iOS Keychain for Pythonista (WIP)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!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
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.