Skip to content

Instantly share code, notes, and snippets.

@zrzka
Last active June 2, 2021 13:34
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save zrzka/d1da1dccd626643526747407a0e35135 to your computer and use it in GitHub Desktop.
Save zrzka/d1da1dccd626643526747407a0e35135 to your computer and use it in GitHub Desktop.
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)
@jmd
Copy link

jmd commented Apr 24, 2018

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment