Skip to content

Commit

Permalink
Merge pull request #586 from jaraco/feature/secretservice-scheme
Browse files Browse the repository at this point in the history
Allow scheme to be selectable in libsecret and SecretService backends
  • Loading branch information
jaraco committed Aug 7, 2022
2 parents ba4ce89 + d35fc44 commit 0ed196d
Show file tree
Hide file tree
Showing 7 changed files with 103 additions and 49 deletions.
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 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
-------

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 = '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,
)
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'

0 comments on commit 0ed196d

Please sign in to comment.