|
| 1 | +"""Implements a Linux specific TokenCache, and provides auxiliary helper types. |
| 2 | +
|
| 3 | +This module depends on PyGObject. But `pip install pygobject` would typically fail, |
| 4 | +until you install its dependencies first. For example, on a Debian Linux, you need:: |
| 5 | +
|
| 6 | + sudo apt install libgirepository1.0-dev libcairo2-dev python3-dev gir1.2-secret-1 |
| 7 | + pip install pygobject |
| 8 | +
|
| 9 | +Alternatively, you could skip Cairo & PyCairo, but you still need to do all these |
| 10 | +(derived from https://gitlab.gnome.org/GNOME/pygobject/-/issues/395):: |
| 11 | +
|
| 12 | + sudo apt install libgirepository1.0-dev python3-dev gir1.2-secret-1 |
| 13 | + pip install wheel |
| 14 | + PYGOBJECT_WITHOUT_PYCAIRO=1 pip install --no-build-isolation pygobject |
| 15 | +""" |
| 16 | +import logging |
| 17 | + |
| 18 | +import gi # https://pygobject.readthedocs.io/en/latest/getting_started.html |
| 19 | + |
| 20 | +# pylint: disable=no-name-in-module |
| 21 | +gi.require_version("Secret", "1") # Would require a package gir1.2-secret-1 |
| 22 | +# pylint: disable=wrong-import-position |
| 23 | +from gi.repository import Secret # Would require a package gir1.2-secret-1 |
| 24 | + |
| 25 | + |
| 26 | +logger = logging.getLogger(__name__) |
| 27 | + |
| 28 | +class LibSecretAgent(object): |
| 29 | + """A loader/saver built on top of low-level libsecret""" |
| 30 | + # Inspired by https://developer.gnome.org/libsecret/unstable/py-examples.html |
| 31 | + def __init__( # pylint: disable=too-many-arguments |
| 32 | + self, |
| 33 | + schema_name, |
| 34 | + attributes, # {"name": "value", ...} |
| 35 | + label="", # Helpful when visualizing secrets by other viewers |
| 36 | + attribute_types=None, # {name: SchemaAttributeType, ...} |
| 37 | + collection=None, # None means default collection |
| 38 | + ): # pylint: disable=bad-continuation |
| 39 | + """This agent is built on top of lower level libsecret API. |
| 40 | +
|
| 41 | + Content stored via libsecret is associated with a bunch of attributes. |
| 42 | +
|
| 43 | + :param string schema_name: |
| 44 | + Attributes would conceptually follow an existing schema. |
| 45 | + But this class will do it in the other way around, |
| 46 | + by automatically deriving a schema based on your attributes. |
| 47 | + However, you will still need to provide a schema_name. |
| 48 | + load() and save() will only operate on data with matching schema_name. |
| 49 | +
|
| 50 | + :param dict attributes: |
| 51 | + Attributes are key-value pairs, represented as a Python dict here. |
| 52 | + They will be used to filter content during load() and save(). |
| 53 | + Their arbitrary keys are strings. |
| 54 | + Their arbitrary values can MEAN strings, integers and booleans, |
| 55 | + but are always represented as strings, according to upstream sample: |
| 56 | + https://developer.gnome.org/libsecret/0.18/py-store-example.html |
| 57 | +
|
| 58 | + :param string label: |
| 59 | + It will not be used during data lookup and filtering. |
| 60 | + It is only helpful when/if you visualize secrets by other viewers. |
| 61 | +
|
| 62 | + :param dict attribute_types: |
| 63 | + Each key is the name of your each attribute. |
| 64 | + The corresponding value will be one of the following three: |
| 65 | +
|
| 66 | + * Secret.SchemaAttributeType.STRING |
| 67 | + * Secret.SchemaAttributeType.INTEGER |
| 68 | + * Secret.SchemaAttributeType.BOOLEAN |
| 69 | +
|
| 70 | + But if all your attributes are Secret.SchemaAttributeType.STRING, |
| 71 | + you do not need to provide this types definition at all. |
| 72 | +
|
| 73 | + :param collection: |
| 74 | + The default value `None` means default collection. |
| 75 | + """ |
| 76 | + self._collection = collection |
| 77 | + self._attributes = attributes or {} |
| 78 | + self._label = label |
| 79 | + self._schema = Secret.Schema.new(schema_name, Secret.SchemaFlags.NONE, { |
| 80 | + k: (attribute_types or {}).get(k, Secret.SchemaAttributeType.STRING) |
| 81 | + for k in self._attributes}) |
| 82 | + |
| 83 | + def save(self, data): |
| 84 | + """Store data. Returns a boolean of whether operation was successful.""" |
| 85 | + return Secret.password_store_sync( |
| 86 | + self._schema, self._attributes, self._collection, self._label, |
| 87 | + data, None) |
| 88 | + |
| 89 | + def load(self): |
| 90 | + """Load a password in the secret service, return None when found nothing""" |
| 91 | + return Secret.password_lookup_sync(self._schema, self._attributes, None) |
| 92 | + |
| 93 | + def clear(self): |
| 94 | + """Returns a boolean of whether any passwords were removed""" |
| 95 | + return Secret.password_clear_sync(self._schema, self._attributes, None) |
| 96 | + |
| 97 | + |
| 98 | +def trial_run(): |
| 99 | + """This trial run will raise an exception if libsecret is not functioning. |
| 100 | +
|
| 101 | + Even after you installed all the dependencies so that your script can start, |
| 102 | + or even if your previous run was successful, your script could fail next time, |
| 103 | + for example when it will be running inside a headless SSH session. |
| 104 | +
|
| 105 | + You do not have to do trial_run. The exception would also be raised by save(). |
| 106 | + """ |
| 107 | + try: |
| 108 | + agent = LibSecretAgent("Test Schema", {"attr1": "foo", "attr2": "bar"}) |
| 109 | + payload = "Test Data" |
| 110 | + agent.save(payload) # It would fail when running inside an SSH session |
| 111 | + assert agent.load() == payload # This line is probably not reachable |
| 112 | + agent.clear() |
| 113 | + except (gi.repository.GLib.Error, AssertionError): |
| 114 | + message = ( |
| 115 | + "libsecret did not perform properly. Please refer to " |
| 116 | + "https://github.com/AzureAD/microsoft-authentication-extensions-for-python/wiki/Encryption-on-Linux") # pylint: disable=line-too-long |
| 117 | + logger.exception(message) # This log contains trace stack for debugging |
| 118 | + logger.warning(message) # This is visible by default |
| 119 | + raise |
| 120 | + |
0 commit comments