From b9b2a4fa12686b11faa78d4e4c1f919f5c22f8ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Apitzsch?= Date: Sat, 7 Aug 2021 14:50:35 +0200 Subject: [PATCH 1/5] Add libsecret backend --- keyring/backends/libsecret.py | 103 +++++++++++++++++++++++++++++++ setup.cfg | 1 + tests/backends/test_libsecret.py | 30 +++++++++ 3 files changed, 134 insertions(+) create mode 100644 keyring/backends/libsecret.py create mode 100644 tests/backends/test_libsecret.py diff --git a/keyring/backends/libsecret.py b/keyring/backends/libsecret.py new file mode 100644 index 00000000..9c31d362 --- /dev/null +++ b/keyring/backends/libsecret.py @@ -0,0 +1,103 @@ +from contextlib import closing +import logging + +from ..util import properties +from ..backend import KeyringBackend +from ..credentials import SimpleCredential +from ..errors import ( + PasswordDeleteError, + ExceptionRaisedContext, + KeyringLocked, +) + +available = False +try: + import gi + gi.require_version('Secret', '1') + from gi.repository import Secret + available = True +except ImportError: + pass + +log = logging.getLogger(__name__) + + +class Keyring(KeyringBackend): + """libsecret Keyring""" + + appid = 'Python keyring library' + if available: + schema = Secret.Schema.new( + "org.freedesktop.Secret.Generic", + Secret.SchemaFlags.NONE, + { + "application": Secret.SchemaAttributeType.STRING, + "service": Secret.SchemaAttributeType.STRING, + "username": Secret.SchemaAttributeType.STRING, + } + ) + + @properties.ClassProperty + @classmethod + def priority(cls): + with ExceptionRaisedContext() as exc: + Secret.__name__ + if exc: + raise RuntimeError("libsecret required") + return 5 + + def get_password(self, service, username): + """Get password of the username for the service""" + attributes = { + "application": self.appid, + "service": service, + "username": username, + } + items = Secret.password_search_sync(self.schema, attributes, + Secret.SearchFlags.UNLOCK, None) + for item in items: + return item.retrieve_secret_sync().get_text() + + def set_password(self, service, username, password): + """Set password for the username of the service""" + collection = Secret.COLLECTION_DEFAULT + attributes = { + "application": self.appid, + "service": service, + "username": username, + } + label = "Password for '{}' on '{}'".format(username, service) + Secret.password_store_sync(self.schema, attributes, collection, + label, password, None) + + def delete_password(self, service, username): + """Delete the stored password (only the first one)""" + attributes = { + "application": self.appid, + "service": service, + "username": username, + } + items = Secret.password_search_sync(self.schema, attributes, + Secret.SearchFlags.UNLOCK, None) + for item in items: + removed = Secret.password_clear_sync(self.schema, + item.get_attributes(), None) + return removed + raise PasswordDeleteError("No such password!") + + def get_credential(self, service, username): + """Get the first username and password for a service. + Return a Credential instance + + The username can be omitted, but if there is one, it will use get_password + and return a SimpleCredential containing the username and password + Otherwise, it will return the first username and password combo that it finds. + """ + query = {"service": service} + if username: + query["username"] = username + items = Secret.password_search_sync(self.schema, query, + Secret.SearchFlags.UNLOCK, None) + for item in items: + username = item.get_attributes().get("username") + return SimpleCredential(username, item.retrieve_secret_sync().get_text()) diff --git a/setup.cfg b/setup.cfg index 9fc11b13..6a2b8cd2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,6 +63,7 @@ devpi_client = keyring.backends = Windows = keyring.backends.Windows macOS = keyring.backends.macOS + LibSecret = keyring.backends.libsecret SecretService = keyring.backends.SecretService KWallet = keyring.backends.kwallet chainer = keyring.backends.chainer diff --git a/tests/backends/test_libsecret.py b/tests/backends/test_libsecret.py new file mode 100644 index 00000000..9b8a633a --- /dev/null +++ b/tests/backends/test_libsecret.py @@ -0,0 +1,30 @@ +import pytest + +from keyring.testing.backend import BackendBasicTests +from keyring.testing.util import NoNoneDictMutator +from keyring.backends import libsecret + + +@pytest.mark.skipif( + not libsecret.Keyring.viable, + reason="libsecret package is needed for LibSecretKeyring", +) +class TestLibSecretKeyring(BackendBasicTests): + __test__ = True + + def init_keyring(self): + print( + "Testing LibSecretKeyring; the following " + "password prompts are for this keyring" + ) + keyring = libsecret.Keyring() + return keyring + + +class TestUnits: + def test_supported_no_libsecret(self): + """ + LibSecretKeyring is not viable if Secret can't be imported. + """ + with NoNoneDictMutator(libsecret.__dict__, Secret=None): + assert not libsecret.Keyring.viable From dd882b5fa336e84d8fafa620ae92a5bded2a625c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Apitzsch?= Date: Sun, 8 Aug 2021 17:23:57 +0200 Subject: [PATCH 2/5] Fix reviewer comments --- keyring/backends/libsecret.py | 58 ++++++++++++++++++++++++++++------- setup.cfg | 2 +- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/keyring/backends/libsecret.py b/keyring/backends/libsecret.py index 9c31d362..7fc5a6a9 100644 --- a/keyring/backends/libsecret.py +++ b/keyring/backends/libsecret.py @@ -1,4 +1,3 @@ -from contextlib import closing import logging from ..util import properties @@ -6,6 +5,7 @@ from ..credentials import SimpleCredential from ..errors import ( PasswordDeleteError, + PasswordSetError, ExceptionRaisedContext, KeyringLocked, ) @@ -13,10 +13,12 @@ available = False try: import gi + from gi.repository import Gio + from gi.repository import GLib gi.require_version('Secret', '1') from gi.repository import Secret available = True -except ImportError: +except (AttributeError, ImportError, ValueError): pass log = logging.getLogger(__name__) @@ -53,10 +55,22 @@ def get_password(self, service, username): "service": service, "username": username, } - items = Secret.password_search_sync(self.schema, attributes, - Secret.SearchFlags.UNLOCK, None) + try: + items = Secret.password_search_sync(self.schema, attributes, + Secret.SearchFlags.UNLOCK, None) + except GLib.Error as error: + quark = GLib.quark_try_string('g-io-error-quark') + if error.matches(quark, Gio.IOErrorEnum.FAILED): + raise KeyringLocked('Failed to unlock the item!') + raise error for item in items: - return item.retrieve_secret_sync().get_text() + try: + return item.retrieve_secret_sync().get_text() + except GLib.Error as error: + quark = GLib.quark_try_string('secret-error') + if error.matches(quark, Secret.Error.IS_LOCKED): + raise KeyringLocked('Failed to unlock the item!') + raise error def set_password(self, service, username, password): """Set password for the username of the service""" @@ -67,8 +81,19 @@ def set_password(self, service, username, password): "username": username, } label = "Password for '{}' on '{}'".format(username, service) - Secret.password_store_sync(self.schema, attributes, collection, - label, password, None) + try: + stored = Secret.password_store_sync(self.schema, attributes, collection, + label, password, None) + except GLib.Error as error: + quark = GLib.quark_try_string('secret-error') + if error.matches(quark, Secret.Error.IS_LOCKED): + raise KeyringLocked("Failed to unlock the collection!") + quark = GLib.quark_try_string('g-io-error-quark') + if error.matches(quark, Gio.IOErrorEnum.FAILED): + raise KeyringLocked("Failed to unlock the collection!") + raise error + if not stored: + raise PasswordSetError("Failed to store password!") def delete_password(self, service, username): """Delete the stored password (only the first one)""" @@ -77,11 +102,22 @@ def delete_password(self, service, username): "service": service, "username": username, } - items = Secret.password_search_sync(self.schema, attributes, - Secret.SearchFlags.UNLOCK, None) + try: + items = Secret.password_search_sync(self.schema, attributes, + Secret.SearchFlags.UNLOCK, None) + except GLib.Error as error: + quark = GLib.quark_try_string('g-io-error-quark') + if error.matches(quark, Gio.IOErrorEnum.FAILED): + raise KeyringLocked('Failed to unlock the item!') for item in items: - removed = Secret.password_clear_sync(self.schema, - item.get_attributes(), None) + try: + removed = Secret.password_clear_sync(self.schema, + item.get_attributes(), None) + except GLib.Error as error: + quark = GLib.quark_try_string('secret-error') + if error.matches(quark, Secret.Error.IS_LOCKED): + raise KeyringLocked('Failed to unlock the item!') + raise error return removed raise PasswordDeleteError("No such password!") diff --git a/setup.cfg b/setup.cfg index 6a2b8cd2..616cfdb9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,7 +63,7 @@ devpi_client = keyring.backends = Windows = keyring.backends.Windows macOS = keyring.backends.macOS - LibSecret = keyring.backends.libsecret + libsecret = keyring.backends.libsecret SecretService = keyring.backends.SecretService KWallet = keyring.backends.kwallet chainer = keyring.backends.chainer From 6fe9cc7df3c38414f54346638632cfa543a9387b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Apitzsch?= Date: Sun, 15 Aug 2021 19:33:42 +0200 Subject: [PATCH 3/5] Fix more reviewer comments --- keyring/backends/libsecret.py | 66 +++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/keyring/backends/libsecret.py b/keyring/backends/libsecret.py index 7fc5a6a9..7df3f984 100644 --- a/keyring/backends/libsecret.py +++ b/keyring/backends/libsecret.py @@ -15,8 +15,10 @@ import gi from gi.repository import Gio from gi.repository import GLib + gi.require_version('Secret', '1') from gi.repository import Secret + available = True except (AttributeError, ImportError, ValueError): pass @@ -36,7 +38,7 @@ class Keyring(KeyringBackend): "application": Secret.SchemaAttributeType.STRING, "service": Secret.SchemaAttributeType.STRING, "username": Secret.SchemaAttributeType.STRING, - } + }, ) @properties.ClassProperty @@ -56,21 +58,22 @@ def get_password(self, service, username): "username": username, } try: - items = Secret.password_search_sync(self.schema, attributes, - Secret.SearchFlags.UNLOCK, None) + items = Secret.password_search_sync( + self.schema, attributes, Secret.SearchFlags.UNLOCK, None + ) except GLib.Error as error: quark = GLib.quark_try_string('g-io-error-quark') if error.matches(quark, Gio.IOErrorEnum.FAILED): - raise KeyringLocked('Failed to unlock the item!') - raise error + raise KeyringLocked('Failed to unlock the item!') from error + raise for item in items: try: return item.retrieve_secret_sync().get_text() except GLib.Error as error: quark = GLib.quark_try_string('secret-error') if error.matches(quark, Secret.Error.IS_LOCKED): - raise KeyringLocked('Failed to unlock the item!') - raise error + raise KeyringLocked('Failed to unlock the item!') from error + raise def set_password(self, service, username, password): """Set password for the username of the service""" @@ -82,16 +85,17 @@ def set_password(self, service, username, password): } label = "Password for '{}' on '{}'".format(username, service) try: - stored = Secret.password_store_sync(self.schema, attributes, collection, - label, password, None) + stored = Secret.password_store_sync( + self.schema, attributes, collection, label, password, None + ) except GLib.Error as error: quark = GLib.quark_try_string('secret-error') if error.matches(quark, Secret.Error.IS_LOCKED): - raise KeyringLocked("Failed to unlock the collection!") + raise KeyringLocked("Failed to unlock the collection!") from error quark = GLib.quark_try_string('g-io-error-quark') if error.matches(quark, Gio.IOErrorEnum.FAILED): - raise KeyringLocked("Failed to unlock the collection!") - raise error + raise KeyringLocked("Failed to unlock the collection!") from error + raise if not stored: raise PasswordSetError("Failed to store password!") @@ -103,21 +107,24 @@ def delete_password(self, service, username): "username": username, } try: - items = Secret.password_search_sync(self.schema, attributes, - Secret.SearchFlags.UNLOCK, None) + items = Secret.password_search_sync( + self.schema, attributes, Secret.SearchFlags.UNLOCK, None + ) except GLib.Error as error: quark = GLib.quark_try_string('g-io-error-quark') if error.matches(quark, Gio.IOErrorEnum.FAILED): - raise KeyringLocked('Failed to unlock the item!') + raise KeyringLocked('Failed to unlock the item!') from error + raise for item in items: try: - removed = Secret.password_clear_sync(self.schema, - item.get_attributes(), None) + removed = Secret.password_clear_sync( + self.schema, item.get_attributes(), None + ) except GLib.Error as error: quark = GLib.quark_try_string('secret-error') if error.matches(quark, Secret.Error.IS_LOCKED): - raise KeyringLocked('Failed to unlock the item!') - raise error + raise KeyringLocked('Failed to unlock the item!') from error + raise return removed raise PasswordDeleteError("No such password!") @@ -132,8 +139,23 @@ def get_credential(self, service, username): query = {"service": service} if username: query["username"] = username - items = Secret.password_search_sync(self.schema, query, - Secret.SearchFlags.UNLOCK, None) + try: + items = Secret.password_search_sync( + self.schema, query, Secret.SearchFlags.UNLOCK, None + ) + except GLib.Error as error: + quark = GLib.quark_try_string('g-io-error-quark') + if error.matches(quark, Gio.IOErrorEnum.FAILED): + raise KeyringLocked('Failed to unlock the item!') from error + raise for item in items: username = item.get_attributes().get("username") - return SimpleCredential(username, item.retrieve_secret_sync().get_text()) + try: + return SimpleCredential( + username, item.retrieve_secret_sync().get_text() + ) + except GLib.Error as error: + quark = GLib.quark_try_string('secret-error') + if error.matches(quark, Secret.Error.IS_LOCKED): + raise KeyringLocked('Failed to unlock the item!') from error + raise From fd296c059e4aec7d19452b85b673dd2fea27a285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Apitzsch?= Date: Tue, 17 Aug 2021 18:17:49 +0200 Subject: [PATCH 4/5] libsecret: Lower priority for deterministic behavior --- keyring/backends/libsecret.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keyring/backends/libsecret.py b/keyring/backends/libsecret.py index 7df3f984..9142fd3e 100644 --- a/keyring/backends/libsecret.py +++ b/keyring/backends/libsecret.py @@ -48,7 +48,7 @@ def priority(cls): Secret.__name__ if exc: raise RuntimeError("libsecret required") - return 5 + return 4.8 def get_password(self, service, username): """Get password of the username for the service""" From 389d778cdda2dd94d4d6c696013a2d3e0a134e72 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 11 Sep 2021 08:45:14 -0400 Subject: [PATCH 5/5] Update changelog. --- CHANGES.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index a83bf7aa..ec1984b4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,8 @@ +v23.1.0 +------- + +* #521: Add libsecret backend. + v23.0.1 -------