Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ X-Python3-Version: >= 3.9

Package: python3-proton-keyring-linux
Architecture: all
Depends: ${python3:Depends}, ${misc:Depends}, python3-proton-core, python3-keyring, python3-secretstorage, gnome-keyring
Depends: ${python3:Depends}, ${misc:Depends}, python3-proton-core, python3-keyring, python3-secretstorage
Suggests: gnome-keyring
Replaces: python3-proton-keyring-linux-secretservice
Breaks: python3-proton-keyring-linux-secretservice (<< 0.1.0)
Description: Python3 Proton linux keyring base implementation
163 changes: 154 additions & 9 deletions proton/keyring_linux/secretservice/secretservice_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,161 @@
along with ProtonVPN. If not, see <https://www.gnu.org/licenses/>.
"""
import json
import logging
import os
import threading
import weakref

import logging
import keyring
from keyring.credentials import SimpleCredential
from proton.keyring.exceptions import KeyringLocked, KeyringError
from proton.keyring_linux.core import KeyringBackendLinux

logger = logging.getLogger(__name__)


def _get_secret_service_backend():
"""Return Python Keyring's Secret Service backend with safer collection lookup."""
# pylint: disable=import-outside-toplevel
import secretstorage
from keyring.backends import SecretService

class ProviderAgnosticSecretServiceKeyring(SecretService.Keyring):
"""Secret Service backend with provider-agnostic collection handling.

A Secret Service client is identified by its unique D-Bus connection.
Keep one connection for the lifetime of this backend so providers can
remember authorization decisions across multiple keyring operations.
"""

def __init__(self):
super().__init__()
self._connection = None
self._connection_finalizer = None
self._operation_lock = threading.RLock()

def _get_connection(self):
if self._connection is None:
self._connection = secretstorage.dbus_init()
self._connection_finalizer = weakref.finalize(
self, self._connection.close
)
return self._connection

def close(self):
"""Close the persistent D-Bus connection, if it was opened."""
with self._operation_lock:
if (
self._connection_finalizer is not None
and self._connection_finalizer.alive
):
self._connection_finalizer()
self._connection = None
self._connection_finalizer = None

def get_preferred_collection(self):
connection = self._get_connection()
try:
if hasattr(self, "preferred_collection"):
collection = secretstorage.Collection(
connection, self.preferred_collection
)
else:
collection = self._get_default_or_only_collection(
secretstorage, connection
)
except secretstorage.exceptions.SecretStorageException as excp:
raise keyring.errors.InitError(
f"Failed to get a Secret Service collection: {excp}."
) from excp

if collection.is_locked():
collection.unlock()
if collection.is_locked():
raise keyring.errors.KeyringLocked(
"Failed to unlock the collection!"
)
return collection

def get_password(self, service, username):
"""Get a password without discarding the D-Bus client identity."""
with self._operation_lock:
collection = self.get_preferred_collection()
items = collection.search_items(self._query(service, username))
for item in items:
self.unlock(item)
return item.get_secret().decode("utf-8")
return None

def set_password(self, service, username, password):
"""Set a password using the persistent D-Bus connection."""
with self._operation_lock:
collection = self.get_preferred_collection()
attributes = self._query(
service, username, application=self.appid
)
label = f"Password for '{username}' on '{service}'"
collection.create_item(
label, attributes, password, replace=True
)

def delete_password(self, service, username):
"""Delete a password using the persistent D-Bus connection."""
with self._operation_lock:
collection = self.get_preferred_collection()
items = collection.search_items(self._query(service, username))
for item in items:
return item.delete()
raise keyring.errors.PasswordDeleteError("No such password!")

def get_credential(self, service, username):
"""Get a credential using the persistent D-Bus connection."""
with self._operation_lock:
scheme = self.schemes[self.scheme]
query = self._query(service, username)
collection = self.get_preferred_collection()
items = collection.search_items(query)
for item in items:
self.unlock(item)
item_username = item.get_attributes().get(
scheme["username"]
)
return SimpleCredential(
item_username, item.get_secret().decode("utf-8")
)
return None

@staticmethod
def _get_default_or_only_collection(secretstorage_module, connection):
try:
return secretstorage_module.get_collection_by_alias(
connection, "default"
)
except secretstorage_module.exceptions.ItemNotFoundException:
collections = list(
secretstorage_module.get_all_collections(connection)
)

if len(collections) == 1:
logger.warning(
"The default Secret Service collection alias is unavailable; "
"using the only advertised collection"
)
return collections[0]

if not collections:
return secretstorage_module.create_collection(
connection, "Default", "default"
)

raise keyring.errors.InitError(
"The default Secret Service collection alias is unavailable "
"and the provider advertises multiple collections"
)

return ProviderAgnosticSecretServiceKeyring()


# pylint: disable=too-few-public-methods
class KeyringBackendLinuxSecretService(KeyringBackendLinux):
"""Implements the Secret Service keyring backend."""
Expand All @@ -42,17 +187,17 @@ def _validate(cls):
if os.environ.get("SNAP") is not None:
return False
try:
# pylint: disable=import-outside-toplevel
from keyring.backends import SecretService
return cls._is_backend_working(SecretService.Keyring())
except ModuleNotFoundError:
logger.debug("Gnome-Keyring module not found")
# Python Keyring's priority check verifies that SecretStorage is
# installed and org.freedesktop.secrets is owned or activatable.
# Do not probe with get_password(): that requires a usable default
# collection and can create one as a side effect.
return _get_secret_service_backend().priority > 0
except (ModuleNotFoundError, RuntimeError):
logger.debug("Secret Service backend not available", exc_info=True)
return False

def __init__(self):
# pylint: disable=import-outside-toplevel
from keyring.backends import SecretService
self._backend = SecretService.Keyring()
self._backend = _get_secret_service_backend()
super().__init__(self._backend)

def _get_item(self, key):
Expand Down
3 changes: 1 addition & 2 deletions rpmbuild/SPECS/package.spec
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,14 @@ BuildRoot: %{_tmppath}/%{pep_625_name}-%{version}-%{release}-buildroot

BuildRequires: python3-devel
BuildRequires: python3-setuptools
BuildRequires: gnome-keyring
BuildRequires: python3-keyring
BuildRequires: python3-secretstorage
BuildRequires: python3-proton-core

Requires: gnome-keyring
Requires: python3-keyring
Requires: python3-secretstorage
Requires: python3-proton-core
Suggests: gnome-keyring

Conflicts: python3-proton-keyring-linux-secretservice < 0.1.0
Obsoletes: python3-proton-keyring-linux-secretservice
Expand Down
Loading