Skip to content

Commit 0ed196d

Browse files
authored
Merge pull request #586 from jaraco/feature/secretservice-scheme
Allow scheme to be selectable in libsecret and SecretService backends
2 parents ba4ce89 + d35fc44 commit 0ed196d

File tree

7 files changed

+103
-49
lines changed

7 files changed

+103
-49
lines changed

CHANGES.rst

+18
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
v23.8.0
2+
-------
3+
4+
* #448: ``SecretService`` and ``libsecret`` backends now support a
5+
new ``SelectableScheme``, allowing the keys for "username" and
6+
"service" to be overridden for compatibility with other schemes
7+
such as KeePassXC.
8+
9+
* Introduced a new ``.with_properties`` method on backends to
10+
produce a new keyring with different properties. Use for example
11+
to get a keyring with a different ``keychain`` (macOS) or
12+
``scheme`` (SecretService/libsecret). e.g.::
13+
14+
keypass = keyring.get_keyring().with_properties(scheme='KeePassXC')
15+
16+
* ``.with_keychain`` method on macOS is superseded by ``.with_properties``
17+
and so is now deprecated.
18+
119
v23.7.0
220
-------
321

keyring/backend.py

+44
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import abc
77
import logging
88
import operator
9+
import copy
910

1011
from typing import Optional
1112

@@ -151,6 +152,11 @@ def parse(item):
151152
for name, value in props:
152153
setattr(self, name, value)
153154

155+
def with_properties(self, **kwargs):
156+
alt = copy.copy(self)
157+
vars(alt).update(kwargs)
158+
return alt
159+
154160

155161
class Crypter:
156162
"""Base class providing encryption and decryption"""
@@ -212,3 +218,41 @@ def get_all_keyring():
212218
viable_classes = KeyringBackend.get_viable_backends()
213219
rings = util.suppress_exceptions(viable_classes, exceptions=TypeError)
214220
return list(rings)
221+
222+
223+
class SchemeSelectable:
224+
"""
225+
Allow a backend to select different "schemes" for the
226+
username and service.
227+
228+
>>> backend = SchemeSelectable()
229+
>>> backend._query('contoso', 'alice')
230+
{'username': 'alice', 'service': 'contoso'}
231+
>>> backend._query('contoso')
232+
{'service': 'contoso'}
233+
>>> backend.scheme = 'KeePassXC'
234+
>>> backend._query('contoso', 'alice')
235+
{'UserName': 'alice', 'Title': 'contoso'}
236+
>>> backend._query('contoso', 'alice', foo='bar')
237+
{'UserName': 'alice', 'Title': 'contoso', 'foo': 'bar'}
238+
"""
239+
240+
scheme = 'default'
241+
schemes = dict(
242+
default=dict(username='username', service='service'),
243+
KeePassXC=dict(username='UserName', service='Title'),
244+
)
245+
246+
def _query(self, service, username=None, **base):
247+
scheme = self.schemes[self.scheme]
248+
return dict(
249+
{
250+
scheme['username']: username,
251+
scheme['service']: service,
252+
}
253+
if username
254+
else {
255+
scheme['service']: service,
256+
},
257+
**base,
258+
)

keyring/backends/SecretService.py

+8-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from contextlib import closing
22
import logging
33

4+
from .. import backend
45
from ..util import properties
56
from ..backend import KeyringBackend
67
from ..credentials import SimpleCredential
@@ -23,7 +24,7 @@
2324
log = logging.getLogger(__name__)
2425

2526

26-
class Keyring(KeyringBackend):
27+
class Keyring(backend.SchemeSelectable, KeyringBackend):
2728
"""Secret Service Keyring"""
2829

2930
appid = 'Python keyring library'
@@ -77,19 +78,15 @@ def get_password(self, service, username):
7778
"""Get password of the username for the service"""
7879
collection = self.get_preferred_collection()
7980
with closing(collection.connection):
80-
items = collection.search_items({"username": username, "service": service})
81+
items = collection.search_items(self._query(service, username))
8182
for item in items:
8283
self.unlock(item)
8384
return item.get_secret().decode('utf-8')
8485

8586
def set_password(self, service, username, password):
8687
"""Set password for the username of the service"""
8788
collection = self.get_preferred_collection()
88-
attributes = {
89-
"application": self.appid,
90-
"service": service,
91-
"username": username,
92-
}
89+
attributes = self._query(service, username, application=self.appid)
9390
label = "Password for '{}' on '{}'".format(username, service)
9491
with closing(collection.connection):
9592
collection.create_item(label, attributes, password, replace=True)
@@ -98,7 +95,7 @@ def delete_password(self, service, username):
9895
"""Delete the stored password (only the first one)"""
9996
collection = self.get_preferred_collection()
10097
with closing(collection.connection):
101-
items = collection.search_items({"username": username, "service": service})
98+
items = collection.search_items(self._query(service, username))
10299
for item in items:
103100
return item.delete()
104101
raise PasswordDeleteError("No such password!")
@@ -111,16 +108,13 @@ def get_credential(self, service, username):
111108
and return a SimpleCredential containing the username and password
112109
Otherwise, it will return the first username and password combo that it finds.
113110
"""
114-
115-
query = {"service": service}
116-
if username:
117-
query["username"] = username
118-
111+
scheme = self.schemes[self.scheme]
112+
query = self._query(service, username)
119113
collection = self.get_preferred_collection()
120114

121115
with closing(collection.connection):
122116
items = collection.search_items(query)
123117
for item in items:
124118
self.unlock(item)
125-
username = item.get_attributes().get("username")
119+
username = item.get_attributes().get(scheme['username'])
126120
return SimpleCredential(username, item.get_secret().decode('utf-8'))

keyring/backends/libsecret.py

+19-27
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22

3+
from .. import backend
34
from ..util import properties
45
from ..backend import KeyringBackend
56
from ..credentials import SimpleCredential
@@ -26,21 +27,26 @@
2627
log = logging.getLogger(__name__)
2728

2829

29-
class Keyring(KeyringBackend):
30+
class Keyring(backend.SchemeSelectable, KeyringBackend):
3031
"""libsecret Keyring"""
3132

3233
appid = 'Python keyring library'
33-
if available:
34-
schema = Secret.Schema.new(
34+
35+
@property
36+
def schema(self):
37+
return Secret.Schema.new(
3538
"org.freedesktop.Secret.Generic",
3639
Secret.SchemaFlags.NONE,
37-
{
38-
"application": Secret.SchemaAttributeType.STRING,
39-
"service": Secret.SchemaAttributeType.STRING,
40-
"username": Secret.SchemaAttributeType.STRING,
41-
},
40+
self._query(
41+
Secret.SchemaAttributeType.STRING,
42+
Secret.SchemaAttributeType.STRING,
43+
application=Secret.SchemaAttributeType.STRING,
44+
),
4245
)
43-
collection = Secret.COLLECTION_DEFAULT
46+
47+
@property
48+
def collection(self):
49+
return Secret.COLLECTION_DEFAULT
4450

4551
@properties.ClassProperty
4652
@classmethod
@@ -53,11 +59,7 @@ def priority(cls):
5359

5460
def get_password(self, service, username):
5561
"""Get password of the username for the service"""
56-
attributes = {
57-
"application": self.appid,
58-
"service": service,
59-
"username": username,
60-
}
62+
attributes = self._query(service, username, application=self.appid)
6163
try:
6264
items = Secret.password_search_sync(
6365
self.schema, attributes, Secret.SearchFlags.UNLOCK, None
@@ -78,11 +80,7 @@ def get_password(self, service, username):
7880

7981
def set_password(self, service, username, password):
8082
"""Set password for the username of the service"""
81-
attributes = {
82-
"application": self.appid,
83-
"service": service,
84-
"username": username,
85-
}
83+
attributes = self._query(service, username, application=self.appid)
8684
label = "Password for '{}' on '{}'".format(username, service)
8785
try:
8886
stored = Secret.password_store_sync(
@@ -101,11 +99,7 @@ def set_password(self, service, username, password):
10199

102100
def delete_password(self, service, username):
103101
"""Delete the stored password (only the first one)"""
104-
attributes = {
105-
"application": self.appid,
106-
"service": service,
107-
"username": username,
108-
}
102+
attributes = self._query(service, username, application=self.appid)
109103
try:
110104
items = Secret.password_search_sync(
111105
self.schema, attributes, Secret.SearchFlags.UNLOCK, None
@@ -136,9 +130,7 @@ def get_credential(self, service, username):
136130
and return a SimpleCredential containing the username and password
137131
Otherwise, it will return the first username and password combo that it finds.
138132
"""
139-
query = {"service": service}
140-
if username:
141-
query["username"] = username
133+
query = self._query(service, username)
142134
try:
143135
items = Secret.password_search_sync(
144136
self.schema, query, Secret.SearchFlags.UNLOCK, None

keyring/backends/macOS/__init__.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import platform
22
import os
3+
import warnings
34

45
from ...backend import KeyringBackend
56
from ...errors import PasswordSetError
@@ -68,6 +69,9 @@ def delete_password(self, service, username):
6869
)
6970

7071
def with_keychain(self, keychain):
71-
alt = Keyring()
72-
alt.keychain = keychain
73-
return alt
72+
warnings.warn(
73+
"macOS.Keyring.with_keychain is deprecated. Use with_properties instead.",
74+
DeprecationWarning,
75+
stacklevel=2,
76+
)
77+
return self.with_properties(keychain=keychain)

keyring/testing/backend.py

+7
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,10 @@ def test_set_properties(self, monkeypatch):
163163
monkeypatch.setattr(os, 'environ', env)
164164
self.keyring.set_properties_from_env()
165165
assert self.keyring.foo_bar == 'fizz buzz'
166+
167+
def test_new_with_properties(self):
168+
alt = self.keyring.with_properties(foo='bar')
169+
assert alt is not self.keyring
170+
assert alt.foo == 'bar'
171+
with pytest.raises(AttributeError):
172+
self.keyring.foo

tests/backends/test_macOS.py

-5
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,3 @@
1212
class Test_macOSKeychain(BackendBasicTests):
1313
def init_keyring(self):
1414
return macOS.Keyring()
15-
16-
def test_alternate_keychain(self):
17-
alt = self.keyring.with_keychain('abcd')
18-
assert alt.keychain == 'abcd'
19-
assert self.keyring.keychain != 'abcd'

0 commit comments

Comments
 (0)