From 1ec3bc945efde6dfad8d9dec183b74d9ab7758bb Mon Sep 17 00:00:00 2001 From: Patrick Ulbrich Date: Sat, 23 Nov 2019 19:29:06 +0100 Subject: [PATCH] Migrate to libsecret, closes #114 --- Mailnag/common/accounts.py | 51 ++++---- Mailnag/common/config.py | 3 +- Mailnag/common/credentialstore.py | 168 -------------------------- Mailnag/common/secretstore.py | 66 ++++++++++ Mailnag/configuration/configwindow.py | 3 +- Mailnag/daemon/mailnagdaemon.py | 5 +- README.md | 2 +- 7 files changed, 100 insertions(+), 198 deletions(-) delete mode 100644 Mailnag/common/credentialstore.py create mode 100644 Mailnag/common/secretstore.py diff --git a/Mailnag/common/accounts.py b/Mailnag/common/accounts.py index f141bf0..8d55015 100644 --- a/Mailnag/common/accounts.py +++ b/Mailnag/common/accounts.py @@ -1,4 +1,4 @@ -# Copyright 2011 - 2017 Patrick Ulbrich +# Copyright 2011 - 2019 Patrick Ulbrich # Copyright 2016 Thomas Haider # Copyright 2016, 2018 Timo Kankare # Copyright 2011 Ralf Hersel @@ -20,7 +20,10 @@ # import logging +import hashlib from Mailnag.backends import create_backend, get_mailbox_parameter_specs +from Mailnag.common.secretstore import SecretStore +from Mailnag.common.dist_cfg import PACKAGE_NAME account_defaults = { 'enabled' : '0', @@ -36,8 +39,6 @@ account_defaults = { 'folder' : '[]' } -CREDENTIAL_KEY = 'Mailnag password for %s://%s@%s' - # # Account class # @@ -177,10 +178,13 @@ class Account: # AccountManager class # class AccountManager: - def __init__(self, credentialstore = None): + def __init__(self): self._accounts = [] self._removed = [] - self._credentialstore = credentialstore + self._secretstore = SecretStore.get_default() + + if self._secretstore == None: + logging.warning("Failed to create secretstore - account passwords will be stored in plaintext config file.") def __len__(self): @@ -239,13 +243,13 @@ class AccountManager: option_spec = get_mailbox_parameter_specs(mailbox_type) options = self._get_cfg_options(cfg, section_name, option_spec) - # TODO: Getting password from credentials is mailbox specific. - # Every backend do not have or need password. + # TODO: Getting a password from the secretstore is mailbox specific. + # Not every backend requires a password. user = options.get('user') server = options.get('server') - if self._credentialstore != None and user and server: - protocol = 'imap' if imap else 'pop' - password = self._credentialstore.get(CREDENTIAL_KEY % (protocol, user, server)) + if self._secretstore != None and user and server: + password = self._secretstore.get(self._get_account_id(user, server, imap)) + if not password: password = '' options['password'] = password acc = Account(enabled=enabled, @@ -267,14 +271,12 @@ class AccountManager: i = i + 1 section_name = "account" + str(i) - # Delete secrets of removed accounts from the credential store + # Delete secrets of removed accounts from the secretstore # (it's important to do this before adding accounts, - # in case multiple accounts with the same credential key exist). - if self._credentialstore != None: + # in case multiple accounts with the same id exist). + if self._secretstore != None: for acc in self._removed: - protocol = 'imap' if acc.imap else 'pop' - # Note: CredentialStore implementations must check if the key acutally exists! - self._credentialstore.remove(CREDENTIAL_KEY % (protocol, acc.user, acc.server)) + self._secretstore.remove(self._get_account_id(acc.user, acc.server, acc.imap)) del self._removed[:] @@ -296,18 +298,23 @@ class AccountManager: config = acc.get_config() option_spec = get_mailbox_parameter_specs(acc.mailbox_type) - # TODO: Setting password to credentials is mailbox specific. - # Every backend do not have or need password. - if self._credentialstore != None: - protocol = 'imap' if acc.imap else 'pop' - self._credentialstore.set(CREDENTIAL_KEY % (protocol, acc.user, acc.server), acc.password) + # TODO: Storing a password is mailbox specific. + # Not every backend requires a password. + if self._secretstore != None: + self._secretstore.set(self._get_account_id(acc.user, acc.server, acc.imap), acc.password, + f'{PACKAGE_NAME.capitalize()} password for account {acc.user}@{acc.server}') config['password'] = '' - + self._set_cfg_options(cfg, section_name, config, option_spec) i = i + 1 + def _get_account_id(self, user, server, is_imap): + # TODO : Introduce account.uuid when rewriting account and backend code + return hashlib.md5((user + server + str(is_imap)).encode('utf-8')).hexdigest() + + def _get_account_cfg(self, cfg, section_name, option_name): if cfg.has_option(section_name, option_name): return cfg.get(section_name, option_name) diff --git a/Mailnag/common/config.py b/Mailnag/common/config.py index 6a1932a..fcbdf2d 100644 --- a/Mailnag/common/config.py +++ b/Mailnag/common/config.py @@ -1,4 +1,4 @@ -# Copyright 2011 - 2015 Patrick Ulbrich +# Copyright 2011 - 2019 Patrick Ulbrich # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -27,7 +27,6 @@ mailnag_defaults = { 'imap_idle_timeout' : '10', 'autostart' : '1', 'connectivity_test' : 'auto', - 'credentialstore' : 'auto', 'enabled_plugins' : 'dbusplugin, soundplugin, libnotifyplugin' } } diff --git a/Mailnag/common/credentialstore.py b/Mailnag/common/credentialstore.py deleted file mode 100644 index f2d8922..0000000 --- a/Mailnag/common/credentialstore.py +++ /dev/null @@ -1,168 +0,0 @@ -# Copyright 2015 - 2019 Patrick Ulbrich -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, -# MA 02110-1301, USA. -# - -import hashlib -from enum import Enum - - -class CredentialStoreType(Enum): - NONE = 'none' - GNOME = 'gnome' - # KDE = 'kde' - - -_credentialstoretype = CredentialStoreType.NONE - -try: - import gi - gi.require_version('GnomeKeyring', '1.0') - from gi.repository import GnomeKeyring - _credentialstoretype = CredentialStoreType.GNOME -except: pass - - -# -# CredentialStore base class -# -class CredentialStore: - _instance = None - - def set(self, key, secret): - pass - - - def get(self, key): - pass - - - def remove(self, key): - pass - - - @staticmethod - def get_default(): - if (CredentialStore._instance == None) and (_credentialstoretype != CredentialStoreType.NONE): - CredentialStore._instance = SupportedCredentialStores[_credentialstoretype]() - - return CredentialStore._instance - - - @staticmethod - def from_string(strn): - cs = None - if strn == 'auto': - cs = CredentialStore.get_default() - elif strn in SupportedCredentialStores: - cs = SupportedCredentialStores[strn]() - - return cs - - -# -# GNOME CredentialStore -# -class GnomeCredentialStore(CredentialStore): - def __init__(self): - (result, kr_name) = GnomeKeyring.get_default_keyring_sync() - self._defaultKeyring = kr_name - - if self._defaultKeyring == None: - self._defaultKeyring = 'login' - - result = GnomeKeyring.unlock_sync(self._defaultKeyring, None) - - if result != GnomeKeyring.Result.OK: - raise KeyringUnlockException('Failed to unlock default keyring') - - self._migrate_keyring() - - - def get(self, key): - attrs = self._get_attrs(key) - result, items = GnomeKeyring.find_items_sync(GnomeKeyring.ItemType.GENERIC_SECRET, attrs) - - if result == GnomeKeyring.Result.OK: - return items[0].secret - else: - return '' - - - def set(self, key, secret): - if secret == '': - return - - attrs = self._get_attrs(key) - - existing_secret = '' - result, items = GnomeKeyring.find_items_sync(GnomeKeyring.ItemType.GENERIC_SECRET, attrs) - - if result == GnomeKeyring.Result.OK: - existing_secret = items[0].secret - - if existing_secret != secret: - GnomeKeyring.item_create_sync(self._defaultKeyring, \ - GnomeKeyring.ItemType.GENERIC_SECRET, key, \ - attrs, secret, True) - - - def remove(self, key): - attrs = self._get_attrs(key) - result, items = GnomeKeyring.find_items_sync(GnomeKeyring.ItemType.GENERIC_SECRET, attrs) - - if result == GnomeKeyring.Result.OK: - GnomeKeyring.item_delete_sync(self._defaultKeyring, items[0].item_id) - - - def _get_attrs(self, key): - attrs = GnomeKeyring.Attribute.list_new() - keyid = hashlib.md5(key.encode('utf-8')).hexdigest() - GnomeKeyring.Attribute.list_append_string(attrs, 'source', 'Mailnag') - GnomeKeyring.Attribute.list_append_string(attrs, 'api-version', '1.1') - GnomeKeyring.Attribute.list_append_string(attrs, 'keyid', keyid) - - return attrs - - - # Migrates pre Mailnag 1.1 keyring items into the new format - def _migrate_keyring(self): - attrs = GnomeKeyring.Attribute.list_new() - GnomeKeyring.Attribute.list_append_string(attrs, 'application', 'Mailnag') - result, items = GnomeKeyring.find_items_sync(GnomeKeyring.ItemType.GENERIC_SECRET, attrs) - - if result == GnomeKeyring.Result.OK: - for i in items: - result, info = GnomeKeyring.item_get_info_sync(self._defaultKeyring, i.item_id) - self.set(info.get_display_name(), i.secret) - GnomeKeyring.item_delete_sync(self._defaultKeyring, i.item_id) - - -# -# Exception thrown if the GNOME keyring can't be unlocked -# -class KeyringUnlockException(Exception): - def __init__(self, message): - Exception.__init__(self, message) - - -# -# All supported credential stores -# -SupportedCredentialStores = { - CredentialStoreType.GNOME : GnomeCredentialStore - #CredentialStoreType.KDE : KDECredentialStore -} diff --git a/Mailnag/common/secretstore.py b/Mailnag/common/secretstore.py new file mode 100644 index 0000000..5510546 --- /dev/null +++ b/Mailnag/common/secretstore.py @@ -0,0 +1,66 @@ +# Copyright 2019 Patrick Ulbrich +# Copyright 2019 razer +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301, USA. +# + +from Mailnag.common.dist_cfg import PACKAGE_NAME + +try: + import gi + gi.require_version('Secret', '1') + from gi.repository import Secret + _libsecret_err = None +except ModuleNotFoundError as e: + _libsecret_err = e + + + +class SecretStore(): + _instance = None + + def __init__(self): + if _libsecret_err != None: + raise _libsecret_err + + self._schema = Secret.Schema.new( + f'com.github.pulb.{PACKAGE_NAME}', Secret.SchemaFlags.NONE, + {'id' : Secret.SchemaAttributeType.STRING}) + + + def get(self, secret_id): + return Secret.password_lookup_sync(self._schema, {'id': secret_id}, None) + + + def set(self, secret_id, secret, description): + Secret.password_store_sync( + self._schema, {'id': secret_id}, Secret.COLLECTION_DEFAULT, + description, secret, None) + + + def remove(self, secret_id): + return Secret.password_clear_sync(self._schema, {'id': secret_id}, None) + + + @staticmethod + def get_default(): + if _libsecret_err != None: + return None + + if SecretStore._instance == None: + SecretStore._instance = SecretStore() + + return SecretStore._instance diff --git a/Mailnag/configuration/configwindow.py b/Mailnag/configuration/configwindow.py index 7c79eb9..5e5ca86 100644 --- a/Mailnag/configuration/configwindow.py +++ b/Mailnag/configuration/configwindow.py @@ -30,7 +30,6 @@ from Mailnag.common.i18n import _ from Mailnag.common.utils import get_data_file from Mailnag.common.config import read_cfg, write_cfg from Mailnag.common.accounts import Account, AccountManager -from Mailnag.common.credentialstore import CredentialStore from Mailnag.common.plugins import Plugin from Mailnag.configuration.accountdialog import AccountDialog from Mailnag.configuration.plugindialog import PluginDialog @@ -67,7 +66,7 @@ class ConfigWindow: # # accounts page # - self._accountman = AccountManager(CredentialStore.from_string(self._cfg.get('core', 'credentialstore'))) + self._accountman = AccountManager() self._treeview_accounts = builder.get_object("treeview_accounts") self._liststore_accounts = builder.get_object("liststore_accounts") diff --git a/Mailnag/daemon/mailnagdaemon.py b/Mailnag/daemon/mailnagdaemon.py index 265ee51..608dcad 100644 --- a/Mailnag/daemon/mailnagdaemon.py +++ b/Mailnag/daemon/mailnagdaemon.py @@ -1,5 +1,5 @@ # Copyright 2016 Timo Kankare -# Copyright 2014, 2015 Patrick Ulbrich +# Copyright 2014 - 2019 Patrick Ulbrich # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -22,7 +22,6 @@ import logging import time from Mailnag.common.accounts import AccountManager -from Mailnag.common.credentialstore import CredentialStore from Mailnag.daemon.mailchecker import MailChecker from Mailnag.daemon.mails import Memorizer from Mailnag.daemon.idlers import IdlerRunner @@ -73,7 +72,7 @@ class MailnagDaemon: self._cfg = read_cfg() - accountman = AccountManager(CredentialStore.from_string(self._cfg.get('core', 'credentialstore'))) + accountman = AccountManager() accountman.load_from_cfg(self._cfg, enabled_only = True) self._accounts = accountman.to_list() self._hookreg = HookRegistry() diff --git a/README.md b/README.md index 16268b1..5845e0d 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ though make sure the requirements stated below are met. * python-dbus * pyxdg * gettext -* gir-gnomekeyring-1.0 (optional) +* gir1.2-secret-1 (optional) * networkmanager (optional)