Forum Archive

Keychain & TouchID

zrzka

Hi,

not sure if anyone else is interested, but here's an update of the Pythonista keychain module drop-in replacement. Available as gist now, will be included in the Black Mamba later.

It's a proof of concept, will probably change API, add more things, ... Don't use this for serious work now.

What's this all about? iOS keychain is powerful and allows you to specify things like:

  • when the password is accessible (unlocked device, after first unlock, ...),
  • if the password should be synchronised to other devices as well,
  • if the user presence is required whenever script wants to access password,
  • etc.

Unfortunately, Pythonista keychain module doesn't allow us to control this. It's just a simple text password storage. Module does use system keychain, but there're no options to control all these things. That's the reason why I started to work on this enhancement.

Why? The reason is quite simple. I want to store sensitive data, like our production keys on the iPad and I do not want to use Pythonista keychain module. Because then any other script can silently retrieve my keychain items. One can say, which script? As the author of Black Mamba, I'm going to say Black Mamba for example, just not to offend authors of other scripts. Did you install Black Mamba? Did you read the source code? How can you be sure that I'm not silently calling get_services, get_password for every service and then sending all these passwords to my server? Of course that I'm not doing this. It's just an example. But these things happen. Google for PyPI, npm, ... issues and you'll see. You can say that I shouldn't store production passwords, ... on my iPad within Pythonista when I'm using 3rd party modules. Yes, I shouldn't via keychain module. I wanted to solve this somehow and thus here's this new module allowing me to set user presence requirement, disable syncing, ...

Here's an example:

gp = GenericPassword('s', 'a2')
gp.set_password('hallo3', accessible=kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, access_control=kSecAccessControlTouchIDAny)
p = get_password('s', 'a2')
print(p)

What it does? It stores password hallo3 for the service s and account a2. Whenever you want to retrieve it, iOS system dialog appears (every single time) requiring you to place your finger on the touch ID sensor. Also you can control what happens if fingerprints are changed (you can get your password deleted), if you require same fingerprints set or any, if you require biometrics or passcode is enough, etc.

This is system level stuff. Even when you change code to this one ...

gp = GenericPassword('s', 'a2')
gp.set_password('hallo3', accessible=kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, access_control=kSecAccessControlTouchIDAny)

import keychain
p = keychain.get_password('s', 'a2')
print(p)

... you'll still be asked for your fingerprint. Also you'll be asked for your fingerprint if you'd like to change password for existing service & account which is already protected with biometrics, etc.

I'll finish this one even even if no one is interested. But as I would like to include this in the Black Mamba, comments are appreciated.

shaun-h

@zrzka I am very interested in this, I think the ability to lock 'secrets' behind TouchID (and I assume FaceID) will be great, so thanks for creating this.

I haven't had a chance to have play with this but I assume that you copy keychain.py in to site-packages and it will be used instead of the built in keychain module?

To keep the compatibility layer is a great thing but I think it would be beneficial if it either has a helper method to say set_password_with_touchid(.....) or at least optional parameters to set_password etc that allowed you to set accessible and access_control this way you don't have to interact with the GenericPassword class?

omz

@zrzka This looks very interesting, and I'm sure that it'll give me a few ideas about how to improve Pythonista's built-in keychain module.

But I'd appreciate if you could rename your module to something else (better_keychain, keychain2, or whatever). Conflicting module names are a pretty frequent support issue for Pythonista. All kinds of weird things can happen when people have modules in site-packages that have the same name as a built-in module.

zrzka

@shaun-h yep, FaceID is biometrics as well. This shared gist is a proof of concept. If you'd like to play with it, rename it to security.py, place in to site-packages-3 and then import security. This will be included in BM as blackmamba.framework.security. iOS keychain support comes from the Security.framework and I'll put all wrappers in to the blackmamba.framework & framework name. Do not use keychain.py, just to avoid clashes. If you do not want to rewrite existing code, you can do import security as keychain :)

I'll introduce more convenience funcs later when I'll finish it. Would like to add InternetPassword, probably certificates as well, ... Then will have to think again about classes, methods, ... again, refactor little bit and then convenience funcs. Do not want to refactor all these convenience funcs as well when the underlying GenericPassword will be modified for sure. Saving some time :)

zrzka

@omz yup, will rename it in the gist as well. As I wrote, it will be blackmamba.framework.security. It's just WIP for now.

Edit: Done.

zrzka

BTW for ctypes users, found a way how to extract symbols. Example for kSecClass:

load_framework('Security')

def _symbol_ptr(name):
    return c_void_p.in_dll(c, name)

def _str_symbol(name):
    return ObjCInstance(_symbol_ptr(name)).UTF8String().decode()

kSecClass = _str_symbol('kSecClass')

And kSecClass now contains class, which is kSecClass symbol value in Security framework. Was extracting them on Mac and I no longer need it now :)

dgelessus

(@zrzka You probably found this out already, but I'll explain it just in case, and for others reading the thread)

in_dll only works for variables declared as extern in the headers, which is often the case for NSStrings and other object constants. On the other hand, integer constants are almost always preprocessor macros or static variables, which are not accessible at runtime. Those constants' values cannot be looked up using in_dll and have to be copied from the appropriate headers.

Sometimes the values of extern const variables are written in the headers, so they could be copied from there, but it's better to use in_dll where possible. That way, if Apple changes the value of a string constant in a new iOS version, the new value will be used automatically (since in_dll loads it at runtime from the respective library/framework). If the value is copy-pasted from the headers, it may become incorrect in future iOS versions, and the code will break.

zrzka

@dgelessus thanks for pointing this out. I'll add one more - ObjCInstance with symbol pointer works in this case because of toll-free bridging. One can be confused how CFStringRef can be used with ObjCInstance and then treated as NSString. Here's the documentation quote:

There are a number of data types in the Core Foundation framework and the Foundation framework that can be used interchangeably. Data types that can be used interchangeably are also referred to as toll-free bridged data types. This means that you can use the same data structure as the argument to a Core Foundation function call or as the receiver of an Objective-C message invocation.

You can find all supported types at the end of the linked page.

zrzka

Okay, some news, Friday, something to play with over weekend. Did update the gist.

Exceptions

They're pretty self explanatory, but ...

  • KeychainUserInteractionNotAllowedError - this exception is raised whenever you try to get keychain item which is protected and authentication_ui is set to .FAIL
  • KeychainItemNotFoundError - this exception is raised whenever you try to get existing keychain item which is protected and authentication_ui is set to .SKIP
  • KeychainUserCanceledError - this exception is raised whenever you try to get keychain item which is protected and you tap on the Cancel button (system dialog)

Enums

AuthenticationPolicy

It controls how user should authenticate to gain access. It's IntFlag (Python 3.6) and you can combine them.

Accessibility

It controls when the keychain item is available. You can't combine them. Basically it's about always, after first unlock, when unlocked and combined with this device only. Special value WHEN_PASSCODE_SET_THIS_DEVICE_ONLY says that the item will be stored when you have a passcode, fingerprints, ... Whenever you remove fingerprints and/or passcode, item will be automatically deleted.

AuthenticationUI

  • ALLOW - is allowed (default) and if item is protected, UI will appear
  • SKIP - if item is protected, it behaves like it doesn't exist
  • FAIL - if item is protected, KeychainUserInteractionNotAllowedError is raised

Classes

AccessControl

Here you can combine accessibility & authentication policy.

GenericPasswordAttributes

Almost all available attributes of generic password keychain items. You can get it via GenericPassword instance get_attributes method. Or you can get a list of them via GenericPassword class query_items method.

GenericPassword

Class for manipulation with generic passwords.

Goals

  • Provide a way to protect password items
  • Prepare for other keychain item classes (like internet password)
  • Raise for errors, silent errors are evil
  • Provide Python enums, classes, ... to hide CF* stuff
  • Provide compatibility layer with Pythonista keychain module

If you open the gist, you'll see lot of stuff there. You should use only and only things defined in __all__. Nothing else. At least now :)

Tried to hide complexity, but still pretty complex :) Will think about it more. Enjoy :)

Examples

First line of any example below should be from security import *.

Pythonista way (compatibility)

set_password('s', 'a', 'p')
assert get_password('s', 'a') == 'p'
delete_password('s', 'a')
assert get_password('s', 'a') is None

GenericPassword way

p = GenericPassword('s', 'a')
p.set_password('p')
assert p.get_password() == 'p'
p.delete()
try:
    p.get_password()
except KeychainItemNotFoundError:
    pass
else:
    assert False    

Password attributes

p = GenericPassword('s', 'a')
p.comment = 'Comment'
p.label = 'Label'
p.description = 'Description'
p.is_invisible = False
p.is_negative = False
p.generic = b'custom data'  # not a password
p.accessibility = Accessibility.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY
p.set_password('p')

a = p.get_attributes()
assert a.comment == p.comment
assert a.label == p.label
assert a.description == p.description
assert a.is_invisible == p.is_invisible
assert a.is_negative == p.is_negative
assert a.generic == p.generic
assert a.accessibility is p.accessibility

Protect with user presence (pass code, touch id, ...)

p = GenericPassword('s', 'a')
p.access_control = AccessControl(
    Accessibility.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
    AuthenticationPolicy.USER_PRESENCE
)
p.set_password('p')

# System UI will appear, if you hit Cancel, KeychainUserCanceledError is raised
assert p.get_password() == 'p'

Access protected with prompt

assert p.get_password(prompt='Zrzka wants your password') == 'p'

Disable authentication UI and auto fail for these items

# We have lost our finger, just ask system to fail automatically if it's protected
try:
    p.get_password(
        prompt='Zrzka wants your password',
        authentication_ui=AuthenticationUI.FAIL
    )
except KeychainUserInteractionNotAllowedError:
    print('Ooops, my finger is lost and I cannot retrieve my password')
else:
    assert False
````

#### Skip protected items

```python
# We have lost our finger, just ask system to skip all these items
try:
    p.get_password(
        prompt='Zrzka wants your password',
        authentication_ui=AuthenticationUI.SKIP
    )
except KeychainItemNotFoundError:
    pass
else:
    assert False

Query for services & account

attrs = GenericPassword.query_items()
for x in attrs:
    print(f'{x.service} {x.account} {x.creation_date}')

Query for accounts for specific service

attrs = GenericPassword.query_items(service='service')
for x in attrs:
    print(f'{x.service} {x.account} {x.creation_date}')

Skip protected accounts

attrs = GenericPassword.query_items(authentication_ui=AuthenticationUI.SKIP)
for x in attrs:
    print(f'{x.service} {x.account} {x.creation_date}')

Use prompt for protected accounts

attrs = GenericPassword.query_items(prompt='Your finger boy!')
for x in attrs:
    print(f'{x.service} {x.account} {x.creation_date}')
zrzka

Two more things ...

  • all methods / funcs can show system UI except delete, this one is not protected,
  • you should never call get_attributes, get_data, get_password, set_data, set_password, query_items on the main thread if authentication_ui is set to .ALLOW (default in iOS),
  • you can if you explicitly pass .FAIL or .SKIP.
zrzka

Enhanced & documented gist part of the Black Mamba as the blackmamba.framework.security package. Basically did add InternetPassword support, polished it little bit, tried to document everything what can be used, etc.

Several notes:

  • Not yet released, just in the master branch.
  • Because not yet released, you have to use latest documentation to see documentation for this module.
gudezhi

very interesting.
I just tried it on my iPhone X, but get an error like "failed to get keychain item". any suggestion?