Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow scheme to be selectable in libsecret and SecretService backends #586

Merged
merged 10 commits into from Aug 7, 2022
18 changes: 18 additions & 0 deletions 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
-------

Expand Down
44 changes: 44 additions & 0 deletions keyring/backend.py
Expand Up @@ -6,6 +6,7 @@
import abc
import logging
import operator
import copy

from typing import Optional

Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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 = 'KeypassXC'
>>> 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'),
KeypassXC=dict(username='UserName', service='Title'),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be KeePassXC (with e instead of y):

https://keepassxc.org/

Please update it in all other places too.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d35fc44.

)

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,
)
22 changes: 8 additions & 14 deletions 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
Expand All @@ -23,7 +24,7 @@
log = logging.getLogger(__name__)


class Keyring(KeyringBackend):
class Keyring(backend.SchemeSelectable, KeyringBackend):
"""Secret Service Keyring"""

appid = 'Python keyring library'
Expand Down Expand Up @@ -77,19 +78,15 @@ 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')

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)
Expand All @@ -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!")
Expand All @@ -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'))
46 changes: 19 additions & 27 deletions 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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions keyring/backends/macOS/__init__.py
@@ -1,5 +1,6 @@
import platform
import os
import warnings

from ...backend import KeyringBackend
from ...errors import PasswordSetError
Expand Down Expand Up @@ -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)
7 changes: 7 additions & 0 deletions keyring/testing/backend.py
Expand Up @@ -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
5 changes: 0 additions & 5 deletions tests/backends/test_macOS.py
Expand Up @@ -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'