From 466a4e91f789e6cabd28dccdd385ccd7b52b5cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20H=C3=B6rist?= Date: Sun, 14 Apr 2019 22:08:07 +0200 Subject: [PATCH] [pgp] Move all Gajim PGP code into plugin --- pgp/__init__.py | 2 +- pgp/backend/__init__.py | 0 pgp/backend/python_gnupg.py | 153 ++++++++++++++++ pgp/backend/store.py | 105 +++++++++++ pgp/exceptions.py | 24 +++ pgp/gtk/__init__.py | 0 pgp/gtk/choose_key.ui | 69 ++++++++ pgp/gtk/config.py | 104 +++++++++++ pgp/gtk/config.ui | 39 ++++ pgp/gtk/key.py | 154 ++++++++++++++++ pgp/modules/__init__.py | 0 pgp/modules/pgp_legacy.py | 298 +++++++++++++++++++++++++++++++ pgp/modules/util.py | 49 +++++ pgp/pgpplugin.py | 345 ------------------------------------ pgp/plugin.py | 182 +++++++++++++++++++ 15 files changed, 1178 insertions(+), 346 deletions(-) create mode 100644 pgp/backend/__init__.py create mode 100644 pgp/backend/python_gnupg.py create mode 100644 pgp/backend/store.py create mode 100644 pgp/exceptions.py create mode 100644 pgp/gtk/__init__.py create mode 100644 pgp/gtk/choose_key.ui create mode 100644 pgp/gtk/config.py create mode 100644 pgp/gtk/config.ui create mode 100644 pgp/gtk/key.py create mode 100644 pgp/modules/__init__.py create mode 100644 pgp/modules/pgp_legacy.py create mode 100644 pgp/modules/util.py delete mode 100644 pgp/pgpplugin.py create mode 100644 pgp/plugin.py diff --git a/pgp/__init__.py b/pgp/__init__.py index bd90642..3c9248b 100644 --- a/pgp/__init__.py +++ b/pgp/__init__.py @@ -1 +1 @@ -from .pgpplugin import OldPGPPlugin +from .plugin import PGPPlugin diff --git a/pgp/backend/__init__.py b/pgp/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pgp/backend/python_gnupg.py b/pgp/backend/python_gnupg.py new file mode 100644 index 0000000..6bd90c2 --- /dev/null +++ b/pgp/backend/python_gnupg.py @@ -0,0 +1,153 @@ +# Copyright (C) 2019 Philipp Hörist +# Copyright (C) 2003-2014 Yann Leboulanger +# Copyright (C) 2005 Alex Mauer +# Copyright (C) 2005-2006 Nikos Kouremenos +# Copyright (C) 2007 Stephan Erb +# Copyright (C) 2008 Jean-Marie Traissard +# Jonathan Schleifer +# +# This file is part of PGP Gajim Plugin. +# +# PGP Gajim Plugin 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; version 3 only. +# +# PGP Gajim Plugin 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 PGP Gajim Plugin. If not, see . + +import os +import logging +from functools import lru_cache + +import gnupg + +from gajim.common.helpers import Singleton + +from pgp.exceptions import SignError + + +logger = logging.getLogger('gajim.p.pgplegacy') +if logger.getEffectiveLevel() == logging.DEBUG: + logger = logging.getLogger('gnupg') + logger.addHandler(logging.StreamHandler()) + logger.setLevel(logging.DEBUG) + + +class PGP(gnupg.GPG, metaclass=Singleton): + def __init__(self, binary, encoding=None): + super().__init__(gpgbinary=binary, + use_agent=True) + + if encoding is not None: + self.encoding = encoding + self.decode_errors = 'replace' + + def encrypt(self, payload, recipients, always_trust=False): + if not always_trust: + # check that we'll be able to encrypt + result = self.get_key(recipients[0]) + for key in result: + if key['trust'] not in ('f', 'u'): + return '', 'NOT_TRUSTED ' + key['keyid'][-8:] + + result = super().encrypt( + payload.encode('utf8'), + recipients, + always_trust=always_trust) + + if result.ok: + error = '' + else: + error = result.status + + return self._strip_header_footer(str(result)), error + + def decrypt(self, payload): + data = self._add_header_footer(payload, 'MESSAGE') + result = super().decrypt(data.encode('utf8')) + + return result.data.decode('utf8') + + @lru_cache(maxsize=8) + def sign(self, payload, key_id): + if payload is None: + payload = '' + result = super().sign(payload.encode('utf8'), + keyid=key_id, + detach=True) + + if result.fingerprint: + return self._strip_header_footer(str(result)) + raise SignError(result.status) + + def verify(self, payload, signed): + # Hash algorithm is not transfered in the signed + # presence stanza so try all algorithms. + # Text name for hash algorithms from RFC 4880 - section 9.4 + + if payload is None: + payload = '' + + hash_algorithms = ['SHA512', 'SHA384', 'SHA256', + 'SHA224', 'SHA1', 'RIPEMD160'] + for algo in hash_algorithms: + data = os.linesep.join( + ['-----BEGIN PGP SIGNED MESSAGE-----', + 'Hash: ' + algo, + '', + payload, + self._add_header_footer(signed, 'SIGNATURE')] + ) + result = super().verify(data.encode('utf8')) + if result.valid: + return result.key_id + + def get_key(self, key_id): + return super().list_keys(keys=[key_id]) + + def get_keys(self, secret=False): + keys = {} + result = super().list_keys(secret=secret) + + for key in result: + # Take first not empty uid + keys[key['keyid'][8:]] = [uid for uid in key['uids'] if uid][0] + return keys + + @staticmethod + def _strip_header_footer(data): + """ + Remove header and footer from data + """ + if not data: + return '' + lines = data.splitlines() + while lines[0] != '': + lines.remove(lines[0]) + while lines[0] == '': + lines.remove(lines[0]) + i = 0 + for line in lines: + if line: + if line[0] == '-': + break + i = i+1 + line = '\n'.join(lines[0:i]) + return line + + @staticmethod + def _add_header_footer(data, type_): + """ + Add header and footer from data + """ + out = "-----BEGIN PGP %s-----" % type_ + os.linesep + out = out + "Version: PGP" + os.linesep + out = out + os.linesep + out = out + data + os.linesep + out = out + "-----END PGP %s-----" % type_ + os.linesep + return out diff --git a/pgp/backend/store.py b/pgp/backend/store.py new file mode 100644 index 0000000..bb094c0 --- /dev/null +++ b/pgp/backend/store.py @@ -0,0 +1,105 @@ +# Copyright (C) 2019 Philipp Hörist +# +# This file is part of the PGP Gajim Plugin. +# +# PGP Gajim Plugin 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; version 3 only. +# +# PGP Gajim Plugin 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 PGP Gajim Plugin. If not, see . + +import json +from pathlib import Path + +from gajim.common import app +from gajim.common import configpaths +from gajim.common.helpers import delay_execution + + +class KeyStore: + def __init__(self, account, own_jid, log): + self._log = log + self._account = account + self._store = { + 'own_key_data': None, + 'contact_key_data': {}, + } + + own_bare_jid = own_jid.getBare() + path = Path(configpaths.get('PLUGINS_DATA')) / 'pgplegacy' / own_bare_jid + if not path.exists(): + path.mkdir(parents=True) + + self._store_path = path / 'store' + if self._store_path.exists(): + with self._store_path.open('r') as file: + try: + self._store = json.load(file) + except Exception: + log.exception('Could not load config') + + if not self._store['contact_key_data']: + self._migrate() + + def _migrate(self): + keys = {} + attached_keys = app.config.get_per( + 'accounts', self._account, 'attached_gpg_keys').split() + if attached_keys is None: + return + + for i in range(len(attached_keys) // 2): + keys[attached_keys[2 * i]] = attached_keys[2 * i + 1] + + for jid, key_id in keys.items(): + self.set_contact_key_data(jid, (key_id, '')) + + own_key_id = app.config.get_per('accounts', self._account, 'keyid') + own_key_user = app.config.get_per('accounts', self._account, 'keyname') + if own_key_id: + self.set_own_key_data((own_key_id, own_key_user)) + self._log.info('Migration successful') + + @delay_execution(500) + def _save_store(self): + with self._store_path.open('w') as file: + json.dump(self._store, file) + + def _get_dict_key(self, jid): + return '%s-%s' % (self._account, jid) + + def set_own_key_data(self, key_data): + if key_data is None: + self._store['own_key_data'] = None + else: + self._store['own_key_data'] = { + 'key_id': key_data[0], + 'key_user': key_data[1] + } + self._save_store() + + def get_own_key_data(self): + return self._store['own_key_data'] + + def get_contact_key_data(self, jid): + key_ids = self._store['contact_key_data'] + dict_key = self._get_dict_key(jid) + return key_ids.get(dict_key) + + def set_contact_key_data(self, jid, key_data): + key_ids = self._store['contact_key_data'] + dict_key = self._get_dict_key(jid) + if key_data is None: + self._store['contact_key_data'][dict_key] = None + else: + key_ids[dict_key] = { + 'key_id': key_data[0], + 'key_user': key_data[1] + } + self._save_store() diff --git a/pgp/exceptions.py b/pgp/exceptions.py new file mode 100644 index 0000000..a8fb64c --- /dev/null +++ b/pgp/exceptions.py @@ -0,0 +1,24 @@ +# Copyright (C) 2019 Philipp Hörist +# +# This file is part of the PGP Gajim Plugin. +# +# PGP Gajim Plugin 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; version 3 only. +# +# PGP Gajim Plugin 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 PGP Gajim Plugin. If not, see . + +class SignError(Exception): + pass + +class KeyMismatch(Exception): + pass + +class NoKeyIdFound(Exception): + pass diff --git a/pgp/gtk/__init__.py b/pgp/gtk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pgp/gtk/choose_key.ui b/pgp/gtk/choose_key.ui new file mode 100644 index 0000000..37decf8 --- /dev/null +++ b/pgp/gtk/choose_key.ui @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + True + False + 6 + vertical + 6 + + + True + True + True + in + + + True + True + liststore + 1 + + + + + + + Key ID + descending + + + + 0 + + + + + + + Contact Name + 1 + + + + 1 + + + + + + + + + True + True + 1 + + + + diff --git a/pgp/gtk/config.py b/pgp/gtk/config.py new file mode 100644 index 0000000..cd0ef74 --- /dev/null +++ b/pgp/gtk/config.py @@ -0,0 +1,104 @@ +# Copyright (C) 2019 Philipp Hörist +# +# This file is part of the PGP Gajim Plugin. +# +# PGP Gajim Plugin 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; version 3 only. +# +# PGP Gajim Plugin 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 PGP Gajim Plugin. If not, see . + +from pathlib import Path + +from gi.repository import Gtk +from gi.repository import Gdk + +from gajim.common import app + +from gajim.plugins.helpers import get_builder +from gajim.plugins.plugins_i18n import _ + +from pgp.gtk.key import ChooseGPGKeyDialog + + +class PGPConfigDialog(Gtk.ApplicationWindow): + def __init__(self, plugin, parent): + Gtk.ApplicationWindow.__init__(self) + self.set_application(app.app) + self.set_show_menubar(False) + self.set_title(_('PGP Configuration')) + self.set_transient_for(parent) + self.set_resizable(True) + self.set_type_hint(Gdk.WindowTypeHint.DIALOG) + self.set_destroy_with_parent(True) + + ui_path = Path(__file__).parent + self._ui = get_builder(ui_path.resolve() / 'config.ui') + + self.add(self._ui.config_box) + + self._ui.connect_signals(self) + + self._plugin = plugin + + for account in app.connections.keys(): + page = Page(plugin, account) + self._ui.stack.add_titled(page, + account, + app.get_account_label(account)) + + self.show_all() + + +class Page(Gtk.Box): + def __init__(self, plugin, account): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) + + self._con = app.connections[account] + self._plugin = plugin + self._label = Gtk.Label() + self._button = Gtk.Button(label=_('Assign Key')) + self._button.connect('clicked', self._on_assign) + + self._load_key() + self.add(self._label) + self.add(self._button) + self.show_all() + + def _on_assign(self, _button): + backend = self._con.get_module('PGPLegacy').pgp_backend + secret_keys = backend.get_keys(secret=True) + dialog = ChooseGPGKeyDialog(secret_keys, self.get_toplevel()) + dialog.connect('response', self._on_response) + + def _load_key(self): + key_data = self._con.get_module('PGPLegacy').get_own_key_data() + if key_data is None: + self._set_key(None) + else: + self._set_key((key_data['key_id'], key_data['key_user'])) + + def _on_response(self, dialog, response): + if response != Gtk.ResponseType.OK: + return + + if dialog.selected_key is None: + self._con.get_module('PGPLegacy').set_own_key_data(None) + self._set_key(None) + else: + self._con.get_module('PGPLegacy').set_own_key_data( + dialog.selected_key) + self._set_key(dialog.selected_key) + + def _set_key(self, key_data): + if key_data is None: + self._label.set_text(_('No key assigned')) + else: + key_id, key_user = key_data + self._label.set_text('%s %s' % (key_id, key_user)) diff --git a/pgp/gtk/config.ui b/pgp/gtk/config.ui new file mode 100644 index 0000000..c09c849 --- /dev/null +++ b/pgp/gtk/config.ui @@ -0,0 +1,39 @@ + + + + + + 500 + 400 + True + False + 12 + + + True + False + stack + + + False + True + 0 + + + + + True + False + crossfade + + + + + + False + True + 1 + + + + diff --git a/pgp/gtk/key.py b/pgp/gtk/key.py new file mode 100644 index 0000000..10e360c --- /dev/null +++ b/pgp/gtk/key.py @@ -0,0 +1,154 @@ +# Copyright (C) 2019 Philipp Hörist +# +# This file is part of the PGP Gajim Plugin. +# +# PGP Gajim Plugin 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; version 3 only. +# +# PGP Gajim Plugin 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 PGP Gajim Plugin. If not, see . + +from pathlib import Path + +from gi.repository import Gtk + +from gajim.common import app +from gajim.plugins.plugins_i18n import _ +from gajim.plugins.helpers import get_builder + + +class KeyDialog(Gtk.Dialog): + def __init__(self, plugin, account, jid, transient): + super().__init__(title=_('Assign key for %s') % jid, + destroy_with_parent=True) + + self.set_transient_for(transient) + self.set_resizable(True) + self.set_default_size(500, 300) + + self._plugin = plugin + self._jid = jid + self._con = app.connections[account] + + self._label = Gtk.Label() + + self._assign_button = Gtk.Button(label='assign') + self._assign_button.connect('clicked', self._choose_key) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.add(self._label) + box.add(self._assign_button) + + area = self.get_content_area() + area.pack_start(box, True, True, 0) + + self._load_key() + self.show_all() + + def _choose_key(self, *args): + backend = self._con.get_module('PGPLegacy').pgp_backend + dialog = ChooseGPGKeyDialog(backend.get_keys(), self) + dialog.connect('response', self._on_response) + + def _load_key(self): + key_data = self._con.get_module('PGPLegacy').get_contact_key_data( + self._jid) + if key_data is None: + self._set_key(None) + else: + self._set_key(key_data.values()) + + def _on_response(self, dialog, response): + if response != Gtk.ResponseType.OK: + return + + if dialog.selected_key is None: + self._con.get_module('PGPLegacy').set_contact_key_data( + self._jid, None) + self._set_key(None) + else: + self._con.get_module('PGPLegacy').set_contact_key_data( + self._jid, dialog.selected_key) + self._set_key(dialog.selected_key) + + def _set_key(self, key_data): + if key_data is None: + self._label.set_text(_('No key assigned')) + else: + key_id, key_user = key_data + self._label.set_text('%s %s' % (key_id, key_user)) + + +class ChooseGPGKeyDialog(Gtk.Dialog): + def __init__(self, secret_keys, transient_for): + Gtk.Dialog.__init__(self, + title=_('Assign PGP Key'), + transient_for=transient_for) + + secret_keys[_('None')] = _('None') + + self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) + self.set_resizable(True) + self.set_default_size(500, 300) + + self.add_button(_('OK'), Gtk.ResponseType.OK) + self.add_button(_('Cancel'), Gtk.ResponseType.CANCEL) + + self._selected_key = None + + ui_path = Path(__file__).parent + self._ui = get_builder(ui_path.resolve() / 'choose_key.ui') + + self._ui.keys_treeview = self._ui.keys_treeview + + model = self._ui.keys_treeview.get_model() + model.set_sort_func(1, self._sort) + + model = self._ui.keys_treeview.get_model() + for key_id in secret_keys.keys(): + model.append((key_id, secret_keys[key_id])) + + self.get_content_area().add(self._ui.box) + + self._ui.connect_signals(self) + + self.connect_after('response', self._on_response) + + self.show_all() + + @property + def selected_key(self): + return self._selected_key + + @staticmethod + def _sort(model, iter1, iter2, _data): + value1 = model[iter1][1] + value2 = model[iter2][1] + if value1 == _('None'): + return -1 + if value2 == _('None'): + return 1 + if value1 < value2: + return -1 + return 1 + + def _on_response(self, _dialog, _response): + self.destroy() + + def _on_row_changed(self, treeview): + selection = treeview.get_selection() + model, iter_ = selection.get_selected() + if iter_ is None: + self._selected_key = None + else: + key_id, key_user = model[iter_][0], model[iter_][1] + if key_id == _('None'): + self._selected_key = None + else: + self._selected_key = key_id, key_user diff --git a/pgp/modules/__init__.py b/pgp/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pgp/modules/pgp_legacy.py b/pgp/modules/pgp_legacy.py new file mode 100644 index 0000000..37e1ed6 --- /dev/null +++ b/pgp/modules/pgp_legacy.py @@ -0,0 +1,298 @@ +# Copyright (C) 2019 Philipp Hörist +# +# This file is part of the PGP Gajim Plugin. +# +# PGP Gajim Plugin 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; version 3 only. +# +# PGP Gajim Plugin 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 PGP Gajim Plugin. If not, see . + +import os +import time +import threading + +import nbxmpp +from nbxmpp.structs import StanzaHandler +from gi.repository import GLib + +from gajim.common import app +from gajim.common.nec import NetworkEvent +from gajim.common.const import EncryptionData +from gajim.common.modules.base import BaseModule + +from gajim.plugins.plugins_i18n import _ + +from pgp.backend.python_gnupg import PGP +from pgp.modules.util import prepare_stanza +from pgp.backend.store import KeyStore +from pgp.exceptions import SignError +from pgp.exceptions import KeyMismatch +from pgp.exceptions import NoKeyIdFound + + +# Module name +name = 'PGPLegacy' +zeroconf = True + +ALLOWED_TAGS = [('request', nbxmpp.NS_RECEIPTS), + ('active', nbxmpp.NS_CHATSTATES), + ('gone', nbxmpp.NS_CHATSTATES), + ('inactive', nbxmpp.NS_CHATSTATES), + ('paused', nbxmpp.NS_CHATSTATES), + ('composing', nbxmpp.NS_CHATSTATES), + ('no-store', nbxmpp.NS_MSG_HINTS), + ('store', nbxmpp.NS_MSG_HINTS), + ('no-copy', nbxmpp.NS_MSG_HINTS), + ('no-permanent-store', nbxmpp.NS_MSG_HINTS), + ('replace', nbxmpp.NS_CORRECT), + ('origin-id', nbxmpp.NS_SID), + ] + + +class PGPLegacy(BaseModule): + def __init__(self, con): + BaseModule.__init__(self, con, plugin=True) + + self.handlers = [ + StanzaHandler(name='message', + callback=self._message_received, + ns=nbxmpp.NS_ENCRYPTED, + priority=9), + StanzaHandler(name='presence', + callback=self._on_presence_received, + ns=nbxmpp.NS_SIGNED, + priority=48), + ] + + self.own_jid = self._con.get_own_jid() + + self._store = KeyStore(self._account, self.own_jid, self._log) + self._pgp = PGP() + self._always_trust = [] + self._presence_key_id_store = {} + + @property + def pgp_backend(self): + return self._pgp + + def set_own_key_data(self, *args, **kwargs): + return self._store.set_own_key_data(*args, **kwargs) + + def get_own_key_data(self, *args, **kwargs): + return self._store.get_own_key_data(*args, **kwargs) + + def set_contact_key_data(self, *args, **kwargs): + return self._store.set_contact_key_data(*args, **kwargs) + + def get_contact_key_data(self, *args, **kwargs): + return self._store.get_contact_key_data(*args, **kwargs) + + def has_valid_key_assigned(self, jid): + key_data = self.get_contact_key_data(jid) + if key_data is None: + return False + key_id = key_data['key_id'] + announced_key_id = self._presence_key_id_store.get(jid) + if announced_key_id is None: + return True + if announced_key_id == key_id: + return True + raise KeyMismatch(announced_key_id) + + def _on_presence_received(self, _con, _stanza, properties): + if properties.signed is None: + return + jid = properties.jid.getBare() + + key_id = self._pgp.verify(properties.status, properties.signed) + self._log.info('Presence from %s was signed with key-id: %s', + jid, key_id) + if key_id is None: + return + + self._presence_key_id_store[jid] = key_id[8:] + + key_data = self.get_contact_key_data(jid) + if key_data is not None: + return + + key = self._pgp.get_key(key_id) + if not key: + self._log.info('Key-id %s not found in keyring, cant assign to %s', + key_id, jid) + return + + self._log.info('Assign key-id: %s to %s', key_id, jid) + self.set_contact_key_data(jid, (key_id[8:], key[0]['uids'][0])) + + def _message_received(self, _con, stanza, properties): + if not properties.is_pgp_legacy or properties.from_muc: + return + + from_jid = properties.jid.getBare() + self._log.info('Message received from: %s', from_jid) + + payload = self._pgp.decrypt(properties.pgp_legacy) + prepare_stanza(stanza, payload) + + properties.encrypted = EncryptionData({'name': 'PGP'}) + + def encrypt_message(self, con, event, callback): + if not event.message: + callback(event) + return + + to_jid = app.get_jid_without_resource(event.jid) + try: + key_id, own_key_id = self._get_key_ids(to_jid) + except NoKeyIdFound as error: + self._log.warning(error) + return + + always_trust = key_id in self._always_trust + self._encrypt(con, event, [key_id, own_key_id], callback, always_trust) + + def _encrypt(self, con, event, keys, callback, always_trust): + result = self._pgp.encrypt(event.message, keys, always_trust) + encrypted_payload, error = result + if error: + self._handle_encrypt_error(con, error, event, keys, callback) + return + + self._cleanup_stanza(event) + self._create_pgp_legacy_message(event.msg_iq, encrypted_payload) + + event.xhtml = None + event.encrypted = 'PGP' + event.additional_data['encrypted'] = {'name': 'PGP'} + + callback(event) + + def _handle_encrypt_error(self, con, error, event, keys, callback): + if error.startswith('NOT_TRUSTED'): + def on_yes(checked): + if checked: + self._always_trust.append(keys[0]) + self._encrypt(con, event, keys, callback, True) + + def on_no(): + self._raise_message_not_sent(con, event, error) + + app.nec.push_incoming_event( + NetworkEvent('pgp-not-trusted', on_yes=on_yes, on_no=on_no)) + + else: + self._raise_message_not_sent(con, event, error) + + @staticmethod + def _raise_message_not_sent(con, event, error): + session = event.session if hasattr(event, 'session') else None + app.nec.push_incoming_event( + NetworkEvent('message-not-sent', + conn=con, + jid=event.jid, + message=event.message, + error=_('Encryption error: %s') % error, + time_=time.time(), + session=session)) + + def _create_pgp_legacy_message(self, stanza, payload): + stanza.setBody(self._get_info_message()) + stanza.setTag('x', namespace=nbxmpp.NS_ENCRYPTED).setData(payload) + eme_node = nbxmpp.Node('encryption', + attrs={'xmlns': nbxmpp.NS_EME, + 'namespace': nbxmpp.NS_ENCRYPTED}) + stanza.addChild(node=eme_node) + + def sign_presence(self, presence, status): + key_data = self.get_own_key_data() + if key_data is None: + self._log.warning('No own key id found, cant sign presence') + return + + try: + result = self._pgp.sign(status, key_data['key_id']) + except SignError as error: + self._log.warning('Sign Error: %s', error) + return + # self._log.debug(self._pgp.sign.cache_info()) + self._log.info('Presence signed') + presence.setTag(nbxmpp.NS_SIGNED + ' x').setData(result) + + @staticmethod + def _get_info_message(): + msg = '[This message is *encrypted* (See :XEP:`27`]' + lang = os.getenv('LANG') + if lang is not None and not lang.startswith('en'): + # we're not english: one in locale and one en + msg = _('[This message is *encrypted* (See :XEP:`27`]') + \ + ' (' + msg + ')' + return msg + + def _get_key_ids(self, jid): + key_data = self.get_contact_key_data(jid) + if key_data is None: + raise NoKeyIdFound('No key id found for %s' % jid) + key_id = key_data['key_id'] + + own_key_data = self.get_own_key_data() + if own_key_data is None: + raise NoKeyIdFound('Own key id not found') + own_key_id = own_key_data['key_id'] + return key_id, own_key_id + + @staticmethod + def _cleanup_stanza(obj): + ''' We make sure only allowed tags are in the stanza ''' + stanza = nbxmpp.Message( + to=obj.msg_iq.getTo(), + typ=obj.msg_iq.getType()) + stanza.setID(obj.stanza_id) + stanza.setThread(obj.msg_iq.getThread()) + for tag, ns in ALLOWED_TAGS: + node = obj.msg_iq.getTag(tag, namespace=ns) + if node: + stanza.addChild(node=node) + obj.msg_iq = stanza + + def encrypt_file(self, file, callback): + thread = threading.Thread(target=self._encrypt_file_thread, + args=(file, callback)) + thread.daemon = True + thread.start() + + def _encrypt_file_thread(self, file, callback): + try: + key_id, own_key_id = self._get_key_ids(file.contact.jid) + except NoKeyIdFound as error: + self._log.warning(error) + return + + encrypted = self._pgp.encrypt_file(file.get_data(), + [key_id, own_key_id]) + if not encrypted: + GLib.idle_add(self._on_file_encryption_error, encrypted.status) + return + + file.encrypted = True + file.size = len(encrypted.data) + file.path += '.pgp' + file.data = encrypted.data + if file.event.isSet(): + return + GLib.idle_add(callback, file) + + @staticmethod + def _on_file_encryption_error(error): + app.nec.push_incoming_event( + NetworkEvent('pgp-file-encryption-error', error=error)) + +def get_instance(*args, **kwargs): + return PGPLegacy(*args, **kwargs), 'PGPLegacy' diff --git a/pgp/modules/util.py b/pgp/modules/util.py new file mode 100644 index 0000000..aff48c6 --- /dev/null +++ b/pgp/modules/util.py @@ -0,0 +1,49 @@ +# Copyright (C) 2019 Philipp Hörist +# +# This file is part of the PGP Gajim Plugin. +# +# PGP Gajim Plugin 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; version 3 only. +# +# PGP Gajim Plugin 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 PGP Gajim Plugin. If not, see . + +import os +import subprocess + +import nbxmpp + + +def prepare_stanza(stanza, plaintext): + delete_nodes(stanza, 'encrypted', nbxmpp.NS_ENCRYPTED) + delete_nodes(stanza, 'body') + stanza.setBody(plaintext) + + +def delete_nodes(stanza, name, namespace=None): + nodes = stanza.getTags(name, namespace=namespace) + for node in nodes: + stanza.delChild(node) + + +def find_gpg(): + def _search(binary): + if os.name == 'nt': + gpg_cmd = binary + ' -h >nul 2>&1' + else: + gpg_cmd = binary + ' -h >/dev/null 2>&1' + if subprocess.call(gpg_cmd, shell=True): + return False + return True + + if _search('gpg2'): + return 'gpg2' + + if _search('gpg'): + return 'gpg' diff --git a/pgp/pgpplugin.py b/pgp/pgpplugin.py deleted file mode 100644 index 5fb2ae7..0000000 --- a/pgp/pgpplugin.py +++ /dev/null @@ -1,345 +0,0 @@ -# -*- coding: utf-8 -*- - -''' -Copyright 2017 Philipp Hörist - -This file is part of Gajim. - -Gajim 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; version 3 only. - -Gajim 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 Gajim. If not, see . -''' - -import os -import logging -import time -import threading -import queue - -import nbxmpp -from gi.repository import GLib - -from gajim.common import app -from gajim.common.nec import NetworkEvent -from gajim.plugins import GajimPlugin -from gajim.plugins.plugins_i18n import _ - -from gajim.gtk.dialogs import ErrorDialog -from gajim.gtk.dialogs import InformationDialog -from gajim.gtk.dialogs import YesNoDialog - -log = logging.getLogger('gajim.p.oldpgp') - -ERROR_MSG = '' -if not app.is_installed('GPG'): - if os.name == 'nt': - ERROR_MSG = _('Please install GnuPG / Gpg4win') - else: - ERROR_MSG = _('Please install python-gnupg and PGP') - -ALLOWED_TAGS = [('request', nbxmpp.NS_RECEIPTS), - ('active', nbxmpp.NS_CHATSTATES), - ('gone', nbxmpp.NS_CHATSTATES), - ('inactive', nbxmpp.NS_CHATSTATES), - ('paused', nbxmpp.NS_CHATSTATES), - ('composing', nbxmpp.NS_CHATSTATES), - ('no-store', nbxmpp.NS_MSG_HINTS), - ('store', nbxmpp.NS_MSG_HINTS), - ('no-copy', nbxmpp.NS_MSG_HINTS), - ('no-permanent-store', nbxmpp.NS_MSG_HINTS), - ('replace', nbxmpp.NS_CORRECT), - ('origin-id', nbxmpp.NS_SID), - ] - - -class OldPGPPlugin(GajimPlugin): - - def init(self): - self.description = _('PGP encryption as per XEP-0027') - if ERROR_MSG: - self.activatable = False - self.available_text = ERROR_MSG - return - self.config_dialog = None - self.encryption_name = 'PGP' - self.allow_zeroconf = True - self.gui_extension_points = { - 'encrypt' + self.encryption_name: (self._encrypt_message, None), - 'decrypt': (self._message_received, None), - 'send_message' + self.encryption_name: ( - self._before_sendmessage, None), - 'encryption_dialog' + self.encryption_name: ( - self.on_encryption_button_clicked, None), - 'encryption_state' + self.encryption_name: ( - self.encryption_state, None)} - - self.decrypt_queue = queue.Queue() - self.thread = None - - def get_gpg(self, account): - return app.connections[account].gpg - - def activate(self): - pass - - def deactivate(self): - pass - - @staticmethod - def activate_encryption(chat_control): - return True - - @staticmethod - def encryption_state(chat_control, state): - key_id = chat_control.contact.keyID - account = chat_control.account - authenticated, _ = check_state(key_id, account) - state['visible'] = True - state['authenticated'] = authenticated - - @staticmethod - def on_encryption_button_clicked(chat_control): - account = chat_control.account - key_id = chat_control.contact.keyID - transient = chat_control.parent_win.window - authenticated, info = check_state(key_id, account) - InformationDialog(authenticated, info, transient) - - @staticmethod - def _before_sendmessage(chat_control): - account = chat_control.account - if not chat_control.contact.keyID: - ErrorDialog( - _('No OpenPGP key assigned'), - _('No OpenPGP key is assigned to this contact. So you cannot ' - 'encrypt messages with OpenPGP.')) - chat_control.sendmessage = False - elif not app.config.get_per('accounts', account, 'keyid'): - ErrorDialog( - _('No OpenPGP key assigned'), - _('No OpenPGP key is assigned to your account. So you cannot ' - 'encrypt messages with OpenPGP.')) - chat_control.sendmessage = False - - @staticmethod - def _get_info_message(): - msg = '[This message is *encrypted* (See :XEP:`27`]' - lang = os.getenv('LANG') - if lang is not None and not lang.startswith('en'): - # we're not english: one in locale and one en - msg = _('[This message is *encrypted* (See :XEP:`27`]') + \ - ' (' + msg + ')' - return msg - - def _message_received(self, conn, obj, callback): - if obj.encrypted: - # Another Plugin already decrypted the message - return - account = conn.name - if obj.name == 'message-received': - enc_tag = obj.stanza.getTag('x', namespace=nbxmpp.NS_ENCRYPTED) - elif obj.name == 'mam-message-received': - # Compatibility for Gajim 1.0.3 - if hasattr(obj, 'message'): - message = obj.message - else: - message = obj.msg_ - enc_tag = message.getTag('x', namespace=nbxmpp.NS_ENCRYPTED) - else: - return - if enc_tag: - encmsg = enc_tag.getData() - key_id = app.config.get_per('accounts', account, 'keyid') - if key_id: - obj.encrypted = self.encryption_name - self.add_additional_data(obj.additional_data) - self.decrypt_queue.put([encmsg, key_id, obj, conn, callback]) - if not self.thread: - self.thread = threading.Thread(target=self.worker) - self.thread.start() - return - - def worker(self): - while True: - try: - item = self.decrypt_queue.get(block=False) - encmsg, key_id, obj, conn, callback = item - account = conn.name - decmsg = self.get_gpg(account).decrypt(encmsg, key_id) - decmsg = conn.connection.Dispatcher. \ - replace_non_character(decmsg) - # \x00 chars are not allowed in C (so in GTK) - msg = decmsg.replace('\x00', '') - obj.msgtxt = msg - GLib.idle_add(callback, obj) - except queue.Empty: - self.thread = None - break - - def _encrypt_message(self, conn, obj, callback): - account = conn.name - if not obj.message: - # We only encrypt the actual message - self._finished_encrypt(obj, callback=callback) - return - - if obj.keyID == 'UNKNOWN': - error = _('Neither the remote presence is signed, nor a key was ' - 'assigned.') - elif obj.keyID.endswith('MISMATCH'): - error = _('The contact\'s key (%s) does not match the key assigned ' - 'in Gajim.' % obj.keyID[:8]) - else: - my_key_id = app.config.get_per('accounts', account, 'keyid') - key_list = [obj.keyID, my_key_id] - - def _on_encrypted(output): - msgenc, error = output - if error.startswith('NOT_TRUSTED'): - def on_yes(checked): - if checked: - obj.conn.gpg.always_trust.append(obj.keyID) - app.thread_interface( - self.get_gpg(account).encrypt, - [obj.message, key_list, True], - _on_encrypted, []) - - def on_no(): - self._finished_encrypt( - obj, msgenc=msgenc, error=error, conn=conn) - - YesNoDialog( - _('Untrusted OpenPGP key'), - _('The OpenPGP key used to encrypt this chat is not ' - 'trusted. Do you really want to encrypt this ' - 'message?'), - checktext=_('_Do not ask me again'), - on_response_yes=on_yes, - on_response_no=on_no) - else: - self._finished_encrypt( - obj, msgenc=msgenc, error=error, conn=conn, - callback=callback) - app.thread_interface( - self.get_gpg(account).encrypt, - [obj.message, key_list, False], - _on_encrypted, []) - return - self._finished_encrypt(conn, obj, error=error) - - def _finished_encrypt(self, obj, msgenc=None, error=None, - conn=None, callback=None): - if error: - log.error('python-gnupg error: %s', error) - app.nec.push_incoming_event( - NetworkEvent('message-not-sent', - conn=conn, - jid=obj.jid, - message=obj.message, - error=error, - time_=time.time(), - session=obj.session)) - return - - self.cleanup_stanza(obj) - - if msgenc: - obj.msg_iq.setBody(self._get_info_message()) - obj.msg_iq.setTag( - 'x', namespace=nbxmpp.NS_ENCRYPTED).setData(msgenc) - eme_node = nbxmpp.Node('encryption', - attrs={'xmlns': nbxmpp.NS_EME, - 'namespace': nbxmpp.NS_ENCRYPTED}) - obj.msg_iq.addChild(node=eme_node) - - # Set xhtml to None so it doesn't get logged - obj.xhtml = None - obj.encrypted = self.encryption_name - self.add_additional_data(obj.additional_data) - print_msg_to_log(obj.msg_iq) - - callback(obj) - - def encrypt_file(self, file, account, callback): - thread = threading.Thread(target=self._encrypt_file_thread, - args=(file, account, callback)) - thread.daemon = True - thread.start() - - def _encrypt_file_thread(self, file, account, callback): - my_key_id = app.config.get_per('accounts', account, 'keyid') - key_list = [file.contact.keyID, my_key_id] - - encrypted = self.get_gpg(account).encrypt_file(file.get_data(), key_list) - if not encrypted: - GLib.idle_add(self._on_file_encryption_error, file, encrypted.status) - return - - file.encrypted = True - file.size = len(encrypted.data) - file.path += '.pgp' - file.data = encrypted.data - if file.event.isSet(): - return - GLib.idle_add(callback, file) - - @staticmethod - def _on_file_encryption_error(file, error): - ErrorDialog(_('Error'), error) - - @staticmethod - def cleanup_stanza(obj): - ''' We make sure only allowed tags are in the stanza ''' - stanza = nbxmpp.Message( - to=obj.msg_iq.getTo(), - typ=obj.msg_iq.getType()) - stanza.setID(obj.stanza_id) - stanza.setThread(obj.msg_iq.getThread()) - for tag, ns in ALLOWED_TAGS: - node = obj.msg_iq.getTag(tag, namespace=ns) - if node: - stanza.addChild(node=node) - obj.msg_iq = stanza - - def add_additional_data(self, data): - data['encrypted'] = {'name': self.encryption_name} - - -def print_msg_to_log(stanza): - """ Prints a stanza in a fancy way to the log """ - stanzastr = '\n' + stanza.__str__(fancy=True) + '\n' - stanzastr = stanzastr[0:-1] - log.debug('\n' + '-'*15 + stanzastr + '-'*15) - - -def check_state(key_id, account): - error = None - if key_id.endswith('MISMATCH'): - verification_status = _('''Contact's identity NOT verified''') - info = _('The contact\'s key (%s) does not match the key ' - 'assigned in Gajim.') % key_id[:8] - elif not key_id: - # No key assigned nor a key is used by remote contact - verification_status = _('No OpenPGP key assigned') - info = _('No OpenPGP key is assigned to this contact. So you cannot' - ' encrypt messages.') - else: - error = app.connections[account].gpg.encrypt('test', [key_id])[1] - if error: - verification_status = _('''Contact's identity NOT verified''') - info = _('OpenPGP key is assigned to this contact, but you ' - 'do not trust their key, so message cannot be ' - 'encrypted. Use your OpenPGP client to trust their key.') - else: - verification_status = _('''Contact's identity verified''') - info = _('OpenPGP Key is assigned to this contact, and you ' - 'trust their key, so messages will be encrypted.') - return (verification_status, info) diff --git a/pgp/plugin.py b/pgp/plugin.py new file mode 100644 index 0000000..e78d09d --- /dev/null +++ b/pgp/plugin.py @@ -0,0 +1,182 @@ +# Copyright (C) 2019 Philipp Hörist +# +# This file is part of the PGP Gajim Plugin. +# +# PGP Gajim Plugin 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; version 3 only. +# +# PGP Gajim Plugin 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 PGP Gajim Plugin. If not, see . + +import os +import logging +from functools import partial +from distutils.version import LooseVersion as V + +import nbxmpp + +from gajim.common import app +from gajim.common import ged +from gajim.plugins import GajimPlugin +from gajim.plugins.plugins_i18n import _ + +from gajim.gtk.dialogs import ErrorDialog +from gajim.gtk.dialogs import YesNoDialog + +from pgp.gtk.key import KeyDialog +from pgp.gtk.config import PGPConfigDialog +from pgp.exceptions import KeyMismatch +from pgp.modules.util import find_gpg + +ENCRYPTION_NAME = 'PGP' + +log = logging.getLogger('gajim.p.pgplegacy') + +ERROR = False +try: + import gnupg +except ImportError: + ERROR = True +else: + # We need https://pypi.python.org/pypi/python-gnupg + # but https://pypi.python.org/pypi/gnupg shares the same package name. + # It cannot be used as a drop-in replacement. + # We test with a version check if python-gnupg is installed as it is + # on a much lower version number than gnupg + # Also we need at least python-gnupg 0.3.8 + v_gnupg = gnupg.__version__ + if V(v_gnupg) < V('0.3.8') or V(v_gnupg) > V('1.0.0'): + log.error('We need python-gnupg >= 0.3.8') + ERROR = True + +ERROR_MSG = None +BINARY = find_gpg() +log.info('Found GPG executable: %s', BINARY) + +if BINARY is None or ERROR: + if os.name == 'nt': + ERROR_MSG = _('Please install GnuPG / Gpg4win') + else: + ERROR_MSG = _('Please install python-gnupg and gnupg') +else: + from pgp.modules import pgp_legacy + from pgp.backend.python_gnupg import PGP + + +class PGPPlugin(GajimPlugin): + + def init(self): + # pylint: disable=attribute-defined-outside-init + self.description = _('PGP encryption as per XEP-0027') + if ERROR_MSG: + self.activatable = False + self.available_text = ERROR_MSG + return + + self.config_dialog = partial(PGPConfigDialog, self) + self.encryption_name = ENCRYPTION_NAME + self.allow_zeroconf = True + self.gui_extension_points = { + 'encrypt' + ENCRYPTION_NAME: (self._encrypt_message, None), + 'send_message' + ENCRYPTION_NAME: ( + self._before_sendmessage, None), + 'encryption_dialog' + ENCRYPTION_NAME: ( + self._on_encryption_dialog, None), + 'encryption_state' + ENCRYPTION_NAME: ( + self._encryption_state, None), + 'send-presence': (self._on_send_presence, None), + } + + self.modules = [pgp_legacy] + + self.events_handlers = { + 'pgp-not-trusted': (ged.PRECORE, self._on_not_trusted), + 'pgp-file-encryption-error': (ged.PRECORE, + self._on_file_encryption_error), + } + + self._pgp = PGP(BINARY) + + @staticmethod + def get_pgp_module(account): + return app.connections[account].get_module('PGPLegacy') + + def activate(self): + pass + + def deactivate(self): + pass + + @staticmethod + def activate_encryption(_chat_control): + return True + + @staticmethod + def _encryption_state(_chat_control, state): + state['visible'] = True + state['authenticated'] = True + + def _on_encryption_dialog(self, chat_control): + account = chat_control.account + jid = chat_control.contact.jid + transient = chat_control.parent_win.window + KeyDialog(self, account, jid, transient) + + def _on_send_presence(self, account, presence): + status = presence.getStatus() + self.get_pgp_module(account).sign_presence(presence, status) + + @staticmethod + def _on_not_trusted(event): + YesNoDialog( + _('Untrusted PGP key'), + _('The PGP key used to encrypt this chat is not ' + 'trusted. Do you really want to encrypt this ' + 'message?'), + checktext=_('_Do not ask me again'), + on_response_yes=event.on_yes, + on_response_no=event.on_no) + + @staticmethod + def _before_sendmessage(chat_control): + account = chat_control.account + jid = chat_control.contact.jid + + con = app.connections[account] + try: + valid = con.get_module('PGPLegacy').has_valid_key_assigned(jid) + except KeyMismatch as announced_key_id: + ErrorDialog( + _('PGP Key mismatch'), + _('The contact\'s key (%s) does not match the key ' + 'assigned in Gajim.') % announced_key_id) + chat_control.sendmessage = False + return + + if not valid: + ErrorDialog( + _('No OpenPGP key assigned'), + _('No OpenPGP key is assigned to this contact.')) + chat_control.sendmessage = False + elif con.get_module('PGPLegacy').get_own_key_data() is None: + ErrorDialog( + _('No OpenPGP key assigned'), + _('No OpenPGP key is assigned to your account.')) + chat_control.sendmessage = False + + def _encrypt_message(self, conn, event, callback): + account = conn.name + self.get_pgp_module(account).encrypt_message(conn, event, callback) + + def encrypt_file(self, file, account, callback): + self.get_pgp_module(account).encrypt_file(file, callback) + + @staticmethod + def _on_file_encryption_error(event): + ErrorDialog(_('Error'), event.error)