From 870b5f3a9c74c6e528f37b242201a335e2e8ef50 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Tue, 2 Aug 2022 18:18:00 -0400 Subject: [PATCH 01/10] Add support for a scheme property in SecretService backend. Set to 'KeypassXC' to use the username/system convention from KeypassXC, either with KEYRING_PROPERTY_SCHEME or keyring.with_properties(scheme='KeypassXC'). Fixes #448. --- keyring/backend.py | 6 +++++ keyring/backends/SecretService.py | 41 +++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/keyring/backend.py b/keyring/backend.py index be393a7e..5dfe46a4 100644 --- a/keyring/backend.py +++ b/keyring/backend.py @@ -6,6 +6,7 @@ import abc import logging import operator +import copy from typing import Optional @@ -151,6 +152,11 @@ def parse(item): for name, value in props: setattr(self, name, value) + def with_properties(self, **kwargs): + alt = copy.copy(self) + vars(alt).update(kwargs) + return alt + class Crypter: """Base class providing encryption and decryption""" diff --git a/keyring/backends/SecretService.py b/keyring/backends/SecretService.py index cf1a3d4d..fa32d0e0 100644 --- a/keyring/backends/SecretService.py +++ b/keyring/backends/SecretService.py @@ -27,6 +27,12 @@ class Keyring(KeyringBackend): """Secret Service Keyring""" appid = 'Python keyring library' + scheme = 'default' + + schemes = dict( + default=dict(username='username', service='service'), + KeypassXC=dict(username='UserName', service='Title'), + ) @properties.ClassProperty @classmethod @@ -73,11 +79,24 @@ def unlock(self, item): if item.is_locked(): # User dismissed the prompt raise KeyringLocked('Failed to unlock the item!') + def _query(self, service, username): + scheme = self.schemes[self.scheme] + return ( + { + scheme['username']: username, + scheme['service']: service, + } + if username + else { + scheme['service']: service, + } + ) + def get_password(self, service, username): """Get password of the username for the service""" collection = self.get_preferred_collection() with closing(collection.connection): - items = collection.search_items({"username": username, "service": service}) + items = collection.search_items(self._query(service, username)) for item in items: self.unlock(item) return item.get_secret().decode('utf-8') @@ -85,11 +104,10 @@ def get_password(self, service, username): def set_password(self, service, username, password): """Set password for the username of the service""" collection = self.get_preferred_collection() - attributes = { - "application": self.appid, - "service": service, - "username": username, - } + attributes = dict( + self._query(service, username), + application=self.appid, + ) label = "Password for '{}' on '{}'".format(username, service) with closing(collection.connection): collection.create_item(label, attributes, password, replace=True) @@ -98,7 +116,7 @@ def delete_password(self, service, username): """Delete the stored password (only the first one)""" collection = self.get_preferred_collection() with closing(collection.connection): - items = collection.search_items({"username": username, "service": service}) + items = collection.search_items(self._query(service, username)) for item in items: return item.delete() raise PasswordDeleteError("No such password!") @@ -111,16 +129,13 @@ def get_credential(self, service, username): 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 - + scheme = self.schemes[self.scheme] + query = self._query(service, username) collection = self.get_preferred_collection() with closing(collection.connection): items = collection.search_items(query) for item in items: self.unlock(item) - username = item.get_attributes().get("username") + username = item.get_attributes().get(scheme['username']) return SimpleCredential(username, item.get_secret().decode('utf-8')) From 22cf8bd83eb0647d52dd9836ca6e284dfccaa1bc Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Tue, 2 Aug 2022 18:26:45 -0400 Subject: [PATCH 02/10] Add test for with_properties helper. --- keyring/testing/backend.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/keyring/testing/backend.py b/keyring/testing/backend.py index 9aee15e4..db60affd 100644 --- a/keyring/testing/backend.py +++ b/keyring/testing/backend.py @@ -163,3 +163,10 @@ def test_set_properties(self, monkeypatch): monkeypatch.setattr(os, 'environ', env) self.keyring.set_properties_from_env() assert self.keyring.foo_bar == 'fizz buzz' + + def test_new_with_properties(self): + alt = self.keyring.with_properties(foo='bar') + assert alt is not self.keyring + assert alt.foo == 'bar' + with pytest.raises(AttributeError): + self.keyring.foo From d06b4520de8dac8ad24faf1a19d0abf2d114e0e3 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Tue, 2 Aug 2022 18:27:52 -0400 Subject: [PATCH 03/10] Re-use with_properties in with_keychain. --- keyring/backends/macOS/__init__.py | 4 +--- tests/backends/test_macOS.py | 5 ----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/keyring/backends/macOS/__init__.py b/keyring/backends/macOS/__init__.py index e69b6ae9..429683ea 100644 --- a/keyring/backends/macOS/__init__.py +++ b/keyring/backends/macOS/__init__.py @@ -68,6 +68,4 @@ def delete_password(self, service, username): ) def with_keychain(self, keychain): - alt = Keyring() - alt.keychain = keychain - return alt + return self.with_properties(keychain=keychain) diff --git a/tests/backends/test_macOS.py b/tests/backends/test_macOS.py index 2965c478..5cbbad6a 100644 --- a/tests/backends/test_macOS.py +++ b/tests/backends/test_macOS.py @@ -12,8 +12,3 @@ class Test_macOSKeychain(BackendBasicTests): def init_keyring(self): return macOS.Keyring() - - def test_alternate_keychain(self): - alt = self.keyring.with_keychain('abcd') - assert alt.keychain == 'abcd' - assert self.keyring.keychain != 'abcd' From 6c263f615070d15543a2e697b55bd6b06c39373d Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Tue, 2 Aug 2022 18:33:37 -0400 Subject: [PATCH 04/10] Deprecate macOS.Keyring.with_keychain, superseded by with_properties. --- keyring/backends/macOS/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/keyring/backends/macOS/__init__.py b/keyring/backends/macOS/__init__.py index 429683ea..aeea6321 100644 --- a/keyring/backends/macOS/__init__.py +++ b/keyring/backends/macOS/__init__.py @@ -1,5 +1,6 @@ import platform import os +import warnings from ...backend import KeyringBackend from ...errors import PasswordSetError @@ -68,4 +69,9 @@ def delete_password(self, service, username): ) def with_keychain(self, keychain): + warnings.warn( + "macOS.Keyring.with_keychain is deprecated. Use with_properties instead.", + DeprecationWarning, + stacklevel=2, + ) return self.with_properties(keychain=keychain) From 78000873b0b2eadf6bb46046b1d9de04008f7015 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 4 Aug 2022 19:33:14 -0400 Subject: [PATCH 05/10] Extract SchemeSelectable mixin --- keyring/backend.py | 26 ++++++++++++++++++++++++++ keyring/backends/SecretService.py | 22 ++-------------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/keyring/backend.py b/keyring/backend.py index 5dfe46a4..49b78671 100644 --- a/keyring/backend.py +++ b/keyring/backend.py @@ -218,3 +218,29 @@ def get_all_keyring(): viable_classes = KeyringBackend.get_viable_backends() rings = util.suppress_exceptions(viable_classes, exceptions=TypeError) return list(rings) + + +class SchemeSelectable: + """ + Allow a backend to select different "schemes" for the + username and service. + """ + + scheme = 'default' + schemes = dict( + default=dict(username='username', service='service'), + KeypassXC=dict(username='UserName', service='Title'), + ) + + def _query(self, service, username): + scheme = self.schemes[self.scheme] + return ( + { + scheme['username']: username, + scheme['service']: service, + } + if username + else { + scheme['service']: service, + } + ) diff --git a/keyring/backends/SecretService.py b/keyring/backends/SecretService.py index fa32d0e0..77d123dc 100644 --- a/keyring/backends/SecretService.py +++ b/keyring/backends/SecretService.py @@ -1,6 +1,7 @@ from contextlib import closing import logging +from .. import backend from ..util import properties from ..backend import KeyringBackend from ..credentials import SimpleCredential @@ -23,16 +24,10 @@ log = logging.getLogger(__name__) -class Keyring(KeyringBackend): +class Keyring(backend.SchemeSelectable, KeyringBackend): """Secret Service Keyring""" appid = 'Python keyring library' - scheme = 'default' - - schemes = dict( - default=dict(username='username', service='service'), - KeypassXC=dict(username='UserName', service='Title'), - ) @properties.ClassProperty @classmethod @@ -79,19 +74,6 @@ def unlock(self, item): if item.is_locked(): # User dismissed the prompt raise KeyringLocked('Failed to unlock the item!') - def _query(self, service, username): - scheme = self.schemes[self.scheme] - return ( - { - scheme['username']: username, - scheme['service']: service, - } - if username - else { - scheme['service']: service, - } - ) - def get_password(self, service, username): """Get password of the username for the service""" collection = self.get_preferred_collection() From d8ec65d3d45b7fcc2ea6e77dda238b58832bab1d Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 4 Aug 2022 19:45:04 -0400 Subject: [PATCH 06/10] Allow _query to include other keys --- keyring/backend.py | 7 ++++--- keyring/backends/SecretService.py | 5 +---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/keyring/backend.py b/keyring/backend.py index 49b78671..780ee698 100644 --- a/keyring/backend.py +++ b/keyring/backend.py @@ -232,9 +232,9 @@ class SchemeSelectable: KeypassXC=dict(username='UserName', service='Title'), ) - def _query(self, service, username): + def _query(self, service, username=None, **base): scheme = self.schemes[self.scheme] - return ( + return dict( { scheme['username']: username, scheme['service']: service, @@ -242,5 +242,6 @@ def _query(self, service, username): if username else { scheme['service']: service, - } + }, + **base, ) diff --git a/keyring/backends/SecretService.py b/keyring/backends/SecretService.py index 77d123dc..33e69a78 100644 --- a/keyring/backends/SecretService.py +++ b/keyring/backends/SecretService.py @@ -86,10 +86,7 @@ def get_password(self, service, username): def set_password(self, service, username, password): """Set password for the username of the service""" collection = self.get_preferred_collection() - attributes = dict( - self._query(service, username), - application=self.appid, - ) + attributes = self._query(service, username, application=self.appid) label = "Password for '{}' on '{}'".format(username, service) with closing(collection.connection): collection.create_item(label, attributes, password, replace=True) From e55d996d2a9c61699c8f3abf90d6c02ef032682b Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 4 Aug 2022 20:04:17 -0400 Subject: [PATCH 07/10] Add SelectableScheme to libsecret. --- keyring/backends/libsecret.py | 46 +++++++++++++++-------------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/keyring/backends/libsecret.py b/keyring/backends/libsecret.py index 51d7f630..39ad9a25 100644 --- a/keyring/backends/libsecret.py +++ b/keyring/backends/libsecret.py @@ -1,5 +1,6 @@ import logging +from .. import backend from ..util import properties from ..backend import KeyringBackend from ..credentials import SimpleCredential @@ -26,21 +27,26 @@ log = logging.getLogger(__name__) -class Keyring(KeyringBackend): +class Keyring(backend.SchemeSelectable, KeyringBackend): """libsecret Keyring""" appid = 'Python keyring library' - if available: - schema = Secret.Schema.new( + + @property + def schema(self): + return Secret.Schema.new( "org.freedesktop.Secret.Generic", Secret.SchemaFlags.NONE, - { - "application": Secret.SchemaAttributeType.STRING, - "service": Secret.SchemaAttributeType.STRING, - "username": Secret.SchemaAttributeType.STRING, - }, + self._query( + Secret.SchemaAttributeType.STRING, + Secret.SchemaAttributeType.STRING, + application=Secret.SchemaAttributeType.STRING, + ), ) - collection = Secret.COLLECTION_DEFAULT + + @property + def collection(self): + return Secret.COLLECTION_DEFAULT @properties.ClassProperty @classmethod @@ -53,11 +59,7 @@ def priority(cls): def get_password(self, service, username): """Get password of the username for the service""" - attributes = { - "application": self.appid, - "service": service, - "username": username, - } + attributes = self._query(service, username, application=self.appid) try: items = Secret.password_search_sync( self.schema, attributes, Secret.SearchFlags.UNLOCK, None @@ -78,11 +80,7 @@ def get_password(self, service, username): def set_password(self, service, username, password): """Set password for the username of the service""" - attributes = { - "application": self.appid, - "service": service, - "username": username, - } + attributes = self._query(service, username, application=self.appid) label = "Password for '{}' on '{}'".format(username, service) try: stored = Secret.password_store_sync( @@ -101,11 +99,7 @@ def set_password(self, service, username, password): def delete_password(self, service, username): """Delete the stored password (only the first one)""" - attributes = { - "application": self.appid, - "service": service, - "username": username, - } + attributes = self._query(service, username, application=self.appid) try: items = Secret.password_search_sync( self.schema, attributes, Secret.SearchFlags.UNLOCK, None @@ -136,9 +130,7 @@ def get_credential(self, service, username): 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 + query = self._query(service, username) try: items = Secret.password_search_sync( self.schema, query, Secret.SearchFlags.UNLOCK, None From ccd7d7de3d550a18822b953a4d8f8467f30e62bf Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 4 Aug 2022 20:48:22 -0400 Subject: [PATCH 08/10] Add tests for SchemeSelectable. --- keyring/backend.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/keyring/backend.py b/keyring/backend.py index 780ee698..6d3b66f3 100644 --- a/keyring/backend.py +++ b/keyring/backend.py @@ -224,6 +224,17 @@ class SchemeSelectable: """ Allow a backend to select different "schemes" for the username and service. + + >>> backend = SchemeSelectable() + >>> backend._query('contoso', 'alice') + {'username': 'alice', 'service': 'contoso'} + >>> backend._query('contoso') + {'service': 'contoso'} + >>> backend.scheme = 'KeypassXC' + >>> backend._query('contoso', 'alice') + {'UserName': 'alice', 'Title': 'contoso'} + >>> backend._query('contoso', 'alice', foo='bar') + {'UserName': 'alice', 'Title': 'contoso', 'foo': 'bar'} """ scheme = 'default' From 28b5a0ab6bfbefb7b2ecc6f44f67e9bfd119408f Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 4 Aug 2022 21:08:35 -0400 Subject: [PATCH 09/10] Update changelog --- CHANGES.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index cc3ad5d1..2583bd17 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,21 @@ +v23.8.0 +------- + +* #448: ``SecretService`` and ``libsecret`` backends now support a + new ``SelectableScheme``, allowing the keys for "username" and + "service" to be overridden for compatibility with other schemes + such as KeypassXC. + +* Introduced a new ``.with_properties`` method on backends to + produce a new keyring with different properties. Use for example + to get a keyring with a different ``keychain`` (macOS) or + ``scheme`` (SecretService/libsecret). e.g.:: + + keypass = keyring.get_keyring().with_properties(scheme='KeypassXC') + +* ``.with_keychain`` method on macOS is superseded by ``.with_properties`` + and so is now deprecated. + v23.7.0 ------- From d35fc44f697c2e3619e400ad7d423d184974708e Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 7 Aug 2022 12:02:23 -0400 Subject: [PATCH 10/10] Use connect spelling and capitalization on KeePassXC --- CHANGES.rst | 4 ++-- keyring/backend.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2583bd17..0e3cff47 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,14 +4,14 @@ v23.8.0 * #448: ``SecretService`` and ``libsecret`` backends now support a new ``SelectableScheme``, allowing the keys for "username" and "service" to be overridden for compatibility with other schemes - such as KeypassXC. + such as KeePassXC. * Introduced a new ``.with_properties`` method on backends to produce a new keyring with different properties. Use for example to get a keyring with a different ``keychain`` (macOS) or ``scheme`` (SecretService/libsecret). e.g.:: - keypass = keyring.get_keyring().with_properties(scheme='KeypassXC') + keypass = keyring.get_keyring().with_properties(scheme='KeePassXC') * ``.with_keychain`` method on macOS is superseded by ``.with_properties`` and so is now deprecated. diff --git a/keyring/backend.py b/keyring/backend.py index 6d3b66f3..05b5d5ab 100644 --- a/keyring/backend.py +++ b/keyring/backend.py @@ -230,7 +230,7 @@ class SchemeSelectable: {'username': 'alice', 'service': 'contoso'} >>> backend._query('contoso') {'service': 'contoso'} - >>> backend.scheme = 'KeypassXC' + >>> backend.scheme = 'KeePassXC' >>> backend._query('contoso', 'alice') {'UserName': 'alice', 'Title': 'contoso'} >>> backend._query('contoso', 'alice', foo='bar') @@ -240,7 +240,7 @@ class SchemeSelectable: scheme = 'default' schemes = dict( default=dict(username='username', service='service'), - KeypassXC=dict(username='UserName', service='Title'), + KeePassXC=dict(username='UserName', service='Title'), ) def _query(self, service, username=None, **base):