Migrate to libsecret, closes #114

This commit is contained in:
Patrick Ulbrich
2019-11-23 19:29:06 +01:00
parent 3a9bd9f34b
commit 1ec3bc945e
7 changed files with 100 additions and 198 deletions

View File

@@ -1,4 +1,4 @@
# Copyright 2011 - 2017 Patrick Ulbrich <zulu99@gmx.net>
# Copyright 2011 - 2019 Patrick Ulbrich <zulu99@gmx.net>
# Copyright 2016 Thomas Haider <t.haider@deprecate.de>
# Copyright 2016, 2018 Timo Kankare <timo.kankare@iki.fi>
# Copyright 2011 Ralf Hersel <ralf.hersel@gmx.net>
@@ -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)

View File

@@ -1,4 +1,4 @@
# Copyright 2011 - 2015 Patrick Ulbrich <zulu99@gmx.net>
# Copyright 2011 - 2019 Patrick Ulbrich <zulu99@gmx.net>
#
# 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'
}
}

View File

@@ -1,168 +0,0 @@
# Copyright 2015 - 2019 Patrick Ulbrich <zulu99@gmx.net>
#
# 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
}

View File

@@ -0,0 +1,66 @@
# Copyright 2019 Patrick Ulbrich <zulu99@gmx.net>
# Copyright 2019 razer <razerraz@free.fr>
#
# 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

View File

@@ -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")

View File

@@ -1,5 +1,5 @@
# Copyright 2016 Timo Kankare <timo.kankare@iki.fi>
# Copyright 2014, 2015 Patrick Ulbrich <zulu99@gmx.net>
# Copyright 2014 - 2019 Patrick Ulbrich <zulu99@gmx.net>
#
# 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()

View File

@@ -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)