diff --git a/CHANGES.rst b/CHANGES.rst index cc3ad5d1..0e3cff47 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 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='KeePassXC') + +* ``.with_keychain`` method on macOS is superseded by ``.with_properties`` + and so is now deprecated. + v23.7.0 ------- diff --git a/keyring/backend.py b/keyring/backend.py index be393a7e..05b5d5ab 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""" @@ -212,3 +218,41 @@ 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. + + >>> backend = SchemeSelectable() + >>> backend._query('contoso', 'alice') + {'username': 'alice', 'service': 'contoso'} + >>> backend._query('contoso') + {'service': 'contoso'} + >>> backend.scheme = 'KeePassXC' + >>> backend._query('contoso', 'alice') + {'UserName': 'alice', 'Title': 'contoso'} + >>> backend._query('contoso', 'alice', foo='bar') + {'UserName': 'alice', 'Title': 'contoso', 'foo': 'bar'} + """ + + scheme = 'default' + schemes = dict( + default=dict(username='username', service='service'), + KeePassXC=dict(username='UserName', service='Title'), + ) + + def _query(self, service, username=None, **base): + scheme = self.schemes[self.scheme] + return dict( + { + scheme['username']: username, + scheme['service']: service, + } + if username + else { + scheme['service']: service, + }, + **base, + ) diff --git a/keyring/backends/SecretService.py b/keyring/backends/SecretService.py index cf1a3d4d..33e69a78 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,7 +24,7 @@ log = logging.getLogger(__name__) -class Keyring(KeyringBackend): +class Keyring(backend.SchemeSelectable, KeyringBackend): """Secret Service Keyring""" appid = 'Python keyring library' @@ -77,7 +78,7 @@ 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 +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 = { - "application": self.appid, - "service": service, - "username": username, - } + 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) @@ -98,7 +95,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 +108,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')) 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 diff --git a/keyring/backends/macOS/__init__.py b/keyring/backends/macOS/__init__.py index e69b6ae9..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,6 +69,9 @@ def delete_password(self, service, username): ) def with_keychain(self, keychain): - alt = Keyring() - alt.keychain = keychain - return alt + warnings.warn( + "macOS.Keyring.with_keychain is deprecated. Use with_properties instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.with_properties(keychain=keychain) 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 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'