257 lines
7.4 KiB
Python
257 lines
7.4 KiB
Python
|
"""
|
||
|
Keyring implementation support
|
||
|
"""
|
||
|
|
||
|
import os
|
||
|
import abc
|
||
|
import logging
|
||
|
import operator
|
||
|
import copy
|
||
|
|
||
|
from typing import Optional
|
||
|
|
||
|
from .py310compat import metadata
|
||
|
from . import credentials, errors, util
|
||
|
from ._compat import properties
|
||
|
|
||
|
log = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
by_priority = operator.attrgetter('priority')
|
||
|
_limit = None
|
||
|
|
||
|
|
||
|
class KeyringBackendMeta(abc.ABCMeta):
|
||
|
"""
|
||
|
A metaclass that's both an ABCMeta and a type that keeps a registry of
|
||
|
all (non-abstract) types.
|
||
|
"""
|
||
|
|
||
|
def __init__(cls, name, bases, dict):
|
||
|
super().__init__(name, bases, dict)
|
||
|
if not hasattr(cls, '_classes'):
|
||
|
cls._classes = set()
|
||
|
classes = cls._classes
|
||
|
if not cls.__abstractmethods__:
|
||
|
classes.add(cls)
|
||
|
|
||
|
|
||
|
class KeyringBackend(metaclass=KeyringBackendMeta):
|
||
|
"""The abstract base class of the keyring, every backend must implement
|
||
|
this interface.
|
||
|
"""
|
||
|
|
||
|
def __init__(self):
|
||
|
self.set_properties_from_env()
|
||
|
|
||
|
# @abc.abstractproperty
|
||
|
def priority(cls):
|
||
|
"""
|
||
|
Each backend class must supply a priority, a number (float or integer)
|
||
|
indicating the priority of the backend relative to all other backends.
|
||
|
The priority need not be static -- it may (and should) vary based
|
||
|
attributes of the environment in which is runs (platform, available
|
||
|
packages, etc.).
|
||
|
|
||
|
A higher number indicates a higher priority. The priority should raise
|
||
|
a RuntimeError with a message indicating the underlying cause if the
|
||
|
backend is not suitable for the current environment.
|
||
|
|
||
|
As a rule of thumb, a priority between zero but less than one is
|
||
|
suitable, but a priority of one or greater is recommended.
|
||
|
"""
|
||
|
|
||
|
@properties.classproperty
|
||
|
def viable(cls):
|
||
|
with errors.ExceptionRaisedContext() as exc:
|
||
|
cls.priority
|
||
|
return not exc
|
||
|
|
||
|
@classmethod
|
||
|
def get_viable_backends(cls):
|
||
|
"""
|
||
|
Return all subclasses deemed viable.
|
||
|
"""
|
||
|
return filter(operator.attrgetter('viable'), cls._classes)
|
||
|
|
||
|
@properties.classproperty
|
||
|
def name(cls):
|
||
|
"""
|
||
|
The keyring name, suitable for display.
|
||
|
|
||
|
The name is derived from module and class name.
|
||
|
"""
|
||
|
parent, sep, mod_name = cls.__module__.rpartition('.')
|
||
|
mod_name = mod_name.replace('_', ' ')
|
||
|
return ' '.join([mod_name, cls.__name__])
|
||
|
|
||
|
def __str__(self):
|
||
|
keyring_class = type(self)
|
||
|
return "{}.{} (priority: {:g})".format(
|
||
|
keyring_class.__module__, keyring_class.__name__, keyring_class.priority
|
||
|
)
|
||
|
|
||
|
@abc.abstractmethod
|
||
|
def get_password(self, service: str, username: str) -> Optional[str]:
|
||
|
"""Get password of the username for the service"""
|
||
|
return None
|
||
|
|
||
|
@abc.abstractmethod
|
||
|
def set_password(self, service: str, username: str, password: str) -> None:
|
||
|
"""Set password for the username of the service.
|
||
|
|
||
|
If the backend cannot store passwords, raise
|
||
|
PasswordSetError.
|
||
|
"""
|
||
|
raise errors.PasswordSetError("reason")
|
||
|
|
||
|
# for backward-compatibility, don't require a backend to implement
|
||
|
# delete_password
|
||
|
# @abc.abstractmethod
|
||
|
def delete_password(self, service: str, username: str) -> None:
|
||
|
"""Delete the password for the username of the service.
|
||
|
|
||
|
If the backend cannot delete passwords, raise
|
||
|
PasswordDeleteError.
|
||
|
"""
|
||
|
raise errors.PasswordDeleteError("reason")
|
||
|
|
||
|
# for backward-compatibility, don't require a backend to implement
|
||
|
# get_credential
|
||
|
# @abc.abstractmethod
|
||
|
def get_credential(
|
||
|
self,
|
||
|
service: str,
|
||
|
username: Optional[str],
|
||
|
) -> Optional[credentials.Credential]:
|
||
|
"""Gets the username and password for the service.
|
||
|
Returns a Credential instance.
|
||
|
|
||
|
The *username* argument is optional and may be omitted by
|
||
|
the caller or ignored by the backend. Callers must use the
|
||
|
returned username.
|
||
|
"""
|
||
|
# The default implementation requires a username here.
|
||
|
if username is not None:
|
||
|
password = self.get_password(service, username)
|
||
|
if password is not None:
|
||
|
return credentials.SimpleCredential(username, password)
|
||
|
return None
|
||
|
|
||
|
def set_properties_from_env(self):
|
||
|
"""For all KEYRING_PROPERTY_* env var, set that property."""
|
||
|
|
||
|
def parse(item):
|
||
|
key, value = item
|
||
|
pre, sep, name = key.partition('KEYRING_PROPERTY_')
|
||
|
return sep and (name.lower(), value)
|
||
|
|
||
|
props = filter(None, map(parse, os.environ.items()))
|
||
|
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"""
|
||
|
|
||
|
@abc.abstractmethod
|
||
|
def encrypt(self, value):
|
||
|
"""Encrypt the value."""
|
||
|
pass
|
||
|
|
||
|
@abc.abstractmethod
|
||
|
def decrypt(self, value):
|
||
|
"""Decrypt the value."""
|
||
|
pass
|
||
|
|
||
|
|
||
|
class NullCrypter(Crypter):
|
||
|
"""A crypter that does nothing"""
|
||
|
|
||
|
def encrypt(self, value):
|
||
|
return value
|
||
|
|
||
|
def decrypt(self, value):
|
||
|
return value
|
||
|
|
||
|
|
||
|
def _load_plugins():
|
||
|
"""
|
||
|
Locate all setuptools entry points by the name 'keyring backends'
|
||
|
and initialize them.
|
||
|
Any third-party library may register an entry point by adding the
|
||
|
following to their setup.cfg::
|
||
|
|
||
|
[options.entry_points]
|
||
|
keyring.backends =
|
||
|
plugin_name = mylib.mymodule:initialize_func
|
||
|
|
||
|
`plugin_name` can be anything, and is only used to display the name
|
||
|
of the plugin at initialization time.
|
||
|
|
||
|
`initialize_func` is optional, but will be invoked if callable.
|
||
|
"""
|
||
|
for ep in metadata.entry_points(group='keyring.backends'):
|
||
|
try:
|
||
|
log.debug('Loading %s', ep.name)
|
||
|
init_func = ep.load()
|
||
|
if callable(init_func):
|
||
|
init_func()
|
||
|
except Exception:
|
||
|
log.exception(f"Error initializing plugin {ep}.")
|
||
|
|
||
|
|
||
|
@util.once
|
||
|
def get_all_keyring():
|
||
|
"""
|
||
|
Return a list of all implemented keyrings that can be constructed without
|
||
|
parameters.
|
||
|
"""
|
||
|
_load_plugins()
|
||
|
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 is not None
|
||
|
else {
|
||
|
scheme['service']: service,
|
||
|
},
|
||
|
**base,
|
||
|
)
|