jaraco / keyring

MIT License
1.26k stars 161 forks source link

MacOS "Internet password" keychain items #624

Open YKdvd opened 1 year ago

YKdvd commented 1 year ago

I was hoping to use keyring to retrieve existing internet passwords on the MacOS keychain for things like ssh server passwords, etc. It looks like keyring deals with "application password" items ("kSecClassGenericPassword"), and can't retrieve "Internet password" items ("kSecClassInternetPassword"). I came up with a variation on your routine, and while I was at it, thought I'd try and retrieve the attributes on the item as well, by setting "kSecReturnAttributes" in the query as well.
It seemed to work - I apparently get the promised CFDictionary back, and I cobbled together a CFDictionaryGetValue() routine from other sources. I can get the item data value (the password) from the dictionary entry "kSecValueData", and convert it as you do with cfstr_to_str(). But while I can successfully retrieve other keys like "kSecAttrServer", which should be the name of the server and also a CFString, cfstr_to_str() crashes with "[__NSCFString bytes]: unrecognized selector sent to instance", so it doesn't seem to be a CFString as expected, or something?

I don't really know the MacOS APIs (at least since MacOS 9 or so), and I may be missing something obvious between that and the whole coercing into Python, so I thought I'd check if anyone here might have an idea.

Also, while the keyring API doesn't seem to be set up to handle multiple flavours of passwords like this, would there be any interest in having the macOS.api have superset functions that can handle some of the other types as a convenience for MacOS folks?

from keyring.backends.macOS import api
def find_internet_password(server, username, protocol="ssh ", not_found_ok=False):
    # https://developer.apple.com/documentation/security/keychain_services/keychain_items/searching_for_keychain_items?language=objc
    q = api.create_query(
        kSecClass=api.k_('kSecClassInternetPassword'),
        kSecMatchLimit=api.k_('kSecMatchLimitOne'),
        kSecAttrServer=server,
        kSecAttrProtocol=protocol,
        kSecAttrAccount=username,
        kSecReturnAttributes=api.create_cfbool(True),
        kSecReturnData=api.create_cfbool(True),
    )
    data = api.c_void_p()
    status = api.SecItemCopyMatching(q, api.byref(data))
    if status == api.error.item_not_found and not_found_ok:
        return
    api.Error.raise_for_status(status)
    password = CFDictionaryGetValue(data, api.k_("kSecValueData"))  # should be a CFString
    password = api.cfstr_to_str(password)   # and the conversion works
    if True:    # now try the attributes
        retServer = CFDictionaryGetValue(data, api.k_("kSecAttrServer"))
        retServer = api.cfstr_to_str(retServer)    # should also be a CFString?   but crashes
        account = CFDictionaryGetValue(data, api.k_("kSecAttrAccount"))
        account = api.cfstr_to_str(account) # should also be a CFString?   but crashes
        lastmod = CFDictionaryGetValue(data, api.k_("kSecAttrModificationDate"))
        #lastmodd = api.ctypes.cast(api.CFDataGetBytePtr(lastmod), ctypes.c_double)
        #lastmod = CFDateGetAbsoluteTime(lastmod)

    return password

password = find_internet_password("myserver.mydomain.org", "myusername")
YKdvd commented 1 year ago

Actually, I see the problem - cfstr_to_str is actually dealing with converting the CFData that is represented by "kSecValueData", not CFstring, and the name mislead me.

I found some ancient but still useful stuff at https://github.com/mountainstorm/MobileDevice/blob/41645fd0e7e3e674f0e963daaa374e3f44f18d67/CoreFoundation.py and have something that can get the attributes of a "kSecClassInternetPassword". I might try and clean it up and submit something as a suggestion if I ever get a chance.

vToMy commented 1 year ago

Here is a working stand-alone version of the above code:

from keyring.backends.macOS import api

CFTypeRef = api.c_void_p
CFDictionaryRef = api.c_void_p

CFDictionaryGetValue = api._found.CFDictionaryGetValue
CFDictionaryGetValue.restype = CFTypeRef
CFDictionaryGetValue.argtypes = [CFDictionaryRef, CFTypeRef]

def find_internet_password(server, protocol, username, not_found_ok=False):
    # https://developer.apple.com/documentation/security/keychain_services/keychain_items/searching_for_keychain_items?language=objc
    q = api.create_query(
        kSecClass=api.k_('kSecClassInternetPassword'),
        kSecMatchLimit=api.k_('kSecMatchLimitOne'),
        kSecAttrServer=server,
        kSecAttrProtocol=protocol,
        kSecAttrAccount=username,
        kSecReturnAttributes=api.create_cfbool(True),
        kSecReturnData=api.create_cfbool(True),
    )
    data = api.c_void_p()
    status = api.SecItemCopyMatching(q, api.byref(data))
    if status == api.error.item_not_found and not_found_ok:
        return
    api.Error.raise_for_status(status)
    password = CFDictionaryGetValue(data, api.k_('kSecValueData'))
    password = api.cfstr_to_str(password)
    return password

password = find_internet_password('http://proxy.com', 'htpx', 'username')
print(password)
vToMy commented 1 year ago

This is very useful in automatically getting proxy credentials from the machine. Ideally, I'd like to be able to query only for the protocol (htpx is an http proxy), and then retrieve the server name, port, user and password. It is possible to query just by the protocol type by removing the server and username from the create_query parameters (simply passing null does not work). I still did not manage to parse the server, port, and username from the result though... if anyone is up to the task.

As a workaround, one can simply call: security find-internet-password -r htpx And parse the results.

jaraco commented 1 year ago

Nice work. I'm very interested in providing a more complete interface. I'd very much like to build up the API module and possibly expose some of that through the Keyring (there's a mechanism by which environment variables can set properties on a keyring and thus affect behavior, so e.g. something like KEYRING_PROPERTY_KEY_CLASS="internet" would cause macOS.Keyring.key_class = "internet" and thus could signal this different kSecClass.

I recommend to start with expanding the APIs to support the functionality as best as possible and second to capture what are the use-cases that we're trying to support (do we want this to work with the CLI (keyring get ...), the API (keyring.get/set_password), or something else.

shirakaba commented 10 months ago

For anyone who ends up here on a similar search: just a big heads-up that the underlying macOS CLI tool, security find-internet-password, searches all local keychains but not, bafflingly, iCloud Keychain. So as far as I can tell, there is no headless way to get internet passwords stored in iCloud Keychain.

The implementation above does provide value as it finds internet passwords from the login keychain (or any other local keychain), but if we were ever to support iCloud Keychain as well, it sounds like it would have to involve some GUI-based flow.