diff options
author | Philipp Hörist <forenjunkie@chello.at> | 2017-04-12 00:49:27 +0300 |
---|---|---|
committer | Philipp Hörist <forenjunkie@chello.at> | 2018-08-30 00:39:34 +0300 |
commit | 7776ae0a5907298539838c2524445858bc00a5b9 (patch) | |
tree | 607e7ef998289a5f950e2dfc7d2950d9c6c9e52c | |
parent | bd1b120b808458f79c450264ba831db63a65798b (diff) |
[openpgp] Inital commit
-rw-r--r-- | openpgp/__init__.py | 1 | ||||
-rw-r--r-- | openpgp/backend/__init__.py | 0 | ||||
-rw-r--r-- | openpgp/backend/gpgme.py | 196 | ||||
-rw-r--r-- | openpgp/backend/pygpg.py | 183 | ||||
-rw-r--r-- | openpgp/backend/sql.py | 102 | ||||
-rw-r--r-- | openpgp/gtk/__init__.py | 0 | ||||
-rw-r--r-- | openpgp/gtk/key.py | 263 | ||||
-rw-r--r-- | openpgp/gtk/style.css | 17 | ||||
-rw-r--r-- | openpgp/gtk/wizard.py | 253 | ||||
-rw-r--r-- | openpgp/manifest.ini | 8 | ||||
-rw-r--r-- | openpgp/modules/__init__.py | 0 | ||||
-rw-r--r-- | openpgp/modules/openpgp.py | 538 | ||||
-rw-r--r-- | openpgp/modules/pgp_keylist.py | 124 | ||||
-rw-r--r-- | openpgp/modules/util.py | 205 | ||||
-rw-r--r-- | openpgp/pgpplugin.py | 194 |
15 files changed, 2084 insertions, 0 deletions
diff --git a/openpgp/__init__.py b/openpgp/__init__.py new file mode 100644 index 0000000..4602461 --- /dev/null +++ b/openpgp/__init__.py @@ -0,0 +1 @@ +from .pgpplugin import OpenPGPPlugin diff --git a/openpgp/backend/__init__.py b/openpgp/backend/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/openpgp/backend/__init__.py diff --git a/openpgp/backend/gpgme.py b/openpgp/backend/gpgme.py new file mode 100644 index 0000000..238d256 --- /dev/null +++ b/openpgp/backend/gpgme.py @@ -0,0 +1,196 @@ +# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com> +# +# 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 <http://www.gnu.org/licenses/>. + +# XEP-0373: OpenPGP for XMPP + + +import io +from collections import namedtuple +import logging + +import gpg + +from gajim.common import app + +KeyringItem = namedtuple('KeyringItem', + 'type keyid userid fingerprint') + +log = logging.getLogger('gajim.plugin_system.openpgp.pgpme') + + +class PGPContext(): + def __init__(self, jid, gnuhome): + self.context = gpg.Context(home_dir=str(gnuhome)) + # self.create_new_key() + # self.get_key_by_name() + # self.get_key_by_fingerprint() + self.export_public_key() + + def create_new_key(self): + parms = """<GnupgKeyParms format="internal"> + Key-Type: RSA + Key-Length: 2048 + Subkey-Type: RSA + Subkey-Length: 2048 + Name-Real: Joe Tester + Name-Comment: with stupid passphrase + Name-Email: test@example.org + Passphrase: Crypt0R0cks + Expire-Date: 2020-12-31 + </GnupgKeyParms> + """ + + with self.context as c: + c.set_engine_info(gpg.constants.PROTOCOL_OpenPGP, None, app.gajimpaths['MY_DATA']) + c.set_progress_cb(gpg.callbacks.progress_stdout) + c.op_genkey(parms, None, None) + print("Generated key with fingerprint {0}.".format( + c.op_genkey_result().fpr)) + + def get_all_keys(self): + c = gpg.Context() + for key in c.keylist(): + user = key.uids[0] + print("Keys for %s (%s):" % (user.name, user.email)) + for subkey in key.subkeys: + features = [] + if subkey.can_authenticate: + features.append('auth') + if subkey.can_certify: + features.append('cert') + if subkey.can_encrypt: + features.append('encrypt') + if subkey.can_sign: + features.append('sign') + print(' %s %s' % (subkey.fpr, ','.join(features))) + + def get_key_by_name(self): + c = gpg.Context() + for key in c.keylist('john'): + print(key.subkeys[0].fpr) + + def get_key_by_fingerprint(self): + c = gpg.Context() + fingerprint = 'key fingerprint to search for' + try: + key = c.get_key(fingerprint) + print('%s (%s)' % (key.uids[0].name, key.uids[0].email)) + except gpg.errors.KeyNotFound: + print("No key for fingerprint '%s'." % fingerprint) + + def get_secret_key(self): + ''' + Key(can_authenticate=1, + can_certify=1, + can_encrypt=1, + can_sign=1, + chain_id=None, + disabled=0, + expired=0, + fpr='7ECE1F88BAFCA37F168E1556A4DBDD1BA55FE3CE', + invalid=0, + is_qualified=0, + issuer_name=None, + issuer_serial=None, + keylist_mode=1, + last_update=0, + origin=0, + owner_trust=5, + protocol=0, + revoked=0, + secret=1, + subkeys=[ + SubKey(can_authenticate=1, + can_certify=1, + can_encrypt=1, + can_sign=1, + card_number=None + curve=None, + disabled=0, + expired=0, + expires=0, + fpr='7ECE1F88BAFCA37F168E1556A4DBDD1BA55FE3CE', + invalid=0, + is_cardkey=0, + is_de_vs=1, + is_qualified=0, + keygrip='15BECB77301E4810ABB9CA6A9391158E575DABEC', + keyid='A4DBDD1BA55FE3CE', + length=2048, + pubkey_algo=1, + revoked=0, + secret=1, + timestamp=1525006759)], + uids=[ + UID(address=None, + comment='', + email='', + invalid=0, + last_update=0, + name='xmpp:philw@jabber.at', + origin=0, + revoked=0, + signatures=[], + tofu=[], + uid='xmpp:philw@jabber.at', + validity=5)]) + ''' + for key in self.context.keylist(secret=True): + break + return key.fpr, key.fpr[-16:] + + def get_keys(self, secret=False): + keys = [] + for key in self.context.keylist(): + for uid in key.uids: + if uid.uid.startswith('xmpp:'): + keys.append((key, uid.uid[5:])) + break + return keys + + def export_public_key(self): + # print(dir(self.context)) + result = self.context.key_export_minimal() + print(result) + + def encrypt_decrypt_files(self): + c = gpg.Context() + recipient = c.get_key("fingerprint of recipient's key") + + # Encrypt + with open('foo.txt', 'r') as input_file: + with open('foo.txt.gpg', 'wb') as output_file: + c.encrypt([recipient], 0, input_file, output_file) + + # Decrypt + with open('foo.txt.gpg', 'rb') as input_file: + with open('foo2.txt', 'w') as output_file: + c.decrypt(input_file, output_file) + + def encrypt(self): + c = gpg.Context() + recipient = c.get_key("fingerprint of recipient's key") + + plaintext_string = u'plain text data' + plaintext_bytes = io.BytesIO(plaintext_string.encode('utf8')) + encrypted_bytes = io.BytesIO() + c.encrypt([recipient], 0, plaintext_bytes, encrypted_bytes) + + def decrypt(self): + c = gpg.Context() + decrypted_bytes = io.BytesIO() + c.decrypt(encrypted_bytes, decrypted_bytes) + decrypted_string = decrypted_bytes.getvalue().decode('utf8') diff --git a/openpgp/backend/pygpg.py b/openpgp/backend/pygpg.py new file mode 100644 index 0000000..5b94e0e --- /dev/null +++ b/openpgp/backend/pygpg.py @@ -0,0 +1,183 @@ +# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com> +# +# 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 <http://www.gnu.org/licenses/>. + +# XEP-0373: OpenPGP for XMPP + +import os +import logging +from collections import namedtuple + +import gnupg + +from gajim.common import app + +from openpgp.modules.util import DecryptionFailed + +log = logging.getLogger('gajim.plugin_system.openpgp.pygnupg') +# gnupg.logger = log + +KeyringItem = namedtuple('KeyringItem', 'jid keyid fingerprint') + + +class PGPContext(gnupg.GPG): + def __init__(self, jid, gnupghome): + gnupg.GPG.__init__( + self, gpgbinary=app.get_gpg_binary(), gnupghome=str(gnupghome)) + + self._passphrase = 'gajimopenpgppassphrase' + self._jid = jid + self._own_fingerprint = None + + def _get_key_params(self, jid, passphrase): + ''' + Generate --gen-key input + ''' + + params = { + 'Key-Type': 'RSA', + 'Key-Length': 2048, + 'Name-Real': 'xmpp:%s' % jid, + 'Passphrase': passphrase, + } + + out = "Key-Type: %s\n" % params.pop('Key-Type') + for key, val in list(params.items()): + out += "%s: %s\n" % (key, val) + out += "%commit\n" + return out + + def generate_key(self): + super().gen_key(self._get_key_params(self._jid, self._passphrase)) + + def encrypt(self, payload, keys): + recipients = [key.fingerprint for key in keys] + log.info('encrypt to:') + for fingerprint in recipients: + log.info(fingerprint) + + result = super().encrypt(str(payload).encode('utf8'), + recipients, + sign=self._own_fingerprint, + always_trust=True, + passphrase=self._passphrase) + + if result.ok: + error = '' + else: + error = result.status + + return str(result), error + + def decrypt(self, payload): + result = super().decrypt(payload, + always_trust=True, + passphrase=self._passphrase) + if not result.ok: + raise DecryptionFailed(result.status) + + return result.data.decode('utf8') + + def get_key(self, fingerprint): + return super().list_keys(keys=[fingerprint]) + + def get_keys(self, secret=False): + result = super().list_keys(secret=secret) + keys = [] + for key in result: + item = self._make_keyring_item(key) + if item is None: + continue + keys.append(self._make_keyring_item(key)) + return keys + + @staticmethod + def _make_keyring_item(key): + userid = key['uids'][0] + if not userid.startswith('xmpp:'): + log.warning('Incorrect userid: %s found for key, ' + 'key will be ignored', userid) + return + jid = userid[5:] + return KeyringItem(jid, key['keyid'], key['fingerprint']) + + def import_key(self, data, jid): + log.info('Import key from %s', jid) + result = super().import_keys(data) + if not result: + log.error('Could not import key') + log.error(result.results[0]) + return + + if not self.validate_key(data, jid): + return None + key = self.get_key(result.results[0]['fingerprint']) + return self._make_keyring_item(key[0]) + + def validate_key(self, public_key, jid): + import tempfile + temppath = os.path.join(tempfile.gettempdir(), 'temp_pubkey') + with open(temppath, 'wb') as tempfile: + tempfile.write(public_key) + + result = self.scan_keys(temppath) + if result: + for uid in result.uids: + if uid.startswith('xmpp:'): + if uid[5:] == jid: + key_found = True + else: + log.warning('Found wrong userid in key: %s != %s', + uid[5:], jid) + log.debug(result) + os.remove(temppath) + return False + + if not key_found: + log.warning('No valid userid found in key') + log.debug(result) + os.remove(temppath) + return False + + log.info('Key validation succesful') + os.remove(temppath) + return True + + log.warning('Invalid key data: %s') + log.debug(result) + os.remove(temppath) + return False + + def get_own_key_details(self): + result = super().list_keys(secret=True) + if not result: + return None, None + + if len(result) > 1: + log.error('More than one secret key found') + return None, None + + self._own_fingerprint = result[0]['fingerprint'] + return self._own_fingerprint, int(result[0]['date']) + + def export_key(self, fingerprint): + key = super().export_keys( + fingerprint, secret=False, armor=False, minimal=False, + passphrase=self._passphrase) + return key + + def delete_key(self, fingerprint): + log.info('Delete Key: %s', fingerprint) + super().delete_keys(fingerprint, passphrase=self._passphrase) diff --git a/openpgp/backend/sql.py b/openpgp/backend/sql.py new file mode 100644 index 0000000..564b504 --- /dev/null +++ b/openpgp/backend/sql.py @@ -0,0 +1,102 @@ +# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com> +# +# 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 <http://www.gnu.org/licenses/>. + +# XEP-0373: OpenPGP for XMPP + +import sqlite3 +import logging +from collections import namedtuple + +log = logging.getLogger('gajim.plugin_system.openpgp.sql') + +TABLE_LAYOUT = ''' + CREATE TABLE contacts ( + jid TEXT, + fingerprint TEXT, + active BOOLEAN, + trust INTEGER, + timestamp INTEGER, + comment TEXT + ); + CREATE UNIQUE INDEX jid_fingerprint ON contacts (jid, fingerprint);''' + + +class Storage: + def __init__(self, folder_path): + self._con = sqlite3.connect(folder_path / 'contacts.db', + detect_types=sqlite3.PARSE_DECLTYPES) + self._con.row_factory = self._namedtuple_factory + self._create_database() + self._migrate_database() + self._con.execute("PRAGMA synchronous=FULL;") + self._con.commit() + + @staticmethod + def _namedtuple_factory(cursor, row): + fields = [col[0] for col in cursor.description] + Row = namedtuple("Row", fields) + named_row = Row(*row) + return named_row + + def _user_version(self): + return self._con.execute('PRAGMA user_version').fetchone()[0] + + def _create_database(self): + if not self._user_version(): + log.info('Create contacts.db') + self._execute_query(TABLE_LAYOUT) + + def _execute_query(self, query): + transaction = """ + BEGIN TRANSACTION; + %s + PRAGMA user_version=1; + END TRANSACTION; + """ % (query) + self._con.executescript(transaction) + + def _migrate_database(self): + pass + + def load_contacts(self): + sql = 'SELECT * from contacts' + rows = self._con.execute(sql).fetchall() + if rows is not None: + return rows + + def save_contact(self, db_values): + sql = '''REPLACE INTO + contacts(jid, fingerprint, active, trust, timestamp, comment) + VALUES(?, ?, ?, ?, ?, ?)''' + for values in db_values: + log.info('Store key: %s', values) + self._con.execute(sql, values) + self._con.commit() + + def set_trust(self, jid, fingerprint, trust): + sql = 'UPDATE contacts SET trust = ? WHERE jid = ? AND fingerprint = ?' + log.info('Set Trust: %s %s %s', trust, jid, fingerprint) + self._con.execute(sql, (trust, jid, fingerprint)) + self._con.commit() + + def delete_key(self, jid, fingerprint): + sql = 'DELETE from contacts WHERE jid = ? AND fingerprint = ?' + log.info('Delete Key: %s %s', jid, fingerprint) + self._con.execute(sql, (jid, fingerprint)) + self._con.commit() + + def cleanup(self): + self._con.close() diff --git a/openpgp/gtk/__init__.py b/openpgp/gtk/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/openpgp/gtk/__init__.py diff --git a/openpgp/gtk/key.py b/openpgp/gtk/key.py new file mode 100644 index 0000000..902e35f --- /dev/null +++ b/openpgp/gtk/key.py @@ -0,0 +1,263 @@ +# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com> +# +# 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 <http://www.gnu.org/licenses/>. + +# XEP-0373: OpenPGP for XMPP + +import logging +import time + +from gi.repository import Gtk + +from gajim.common import app +from gajim.common.const import DialogButton, ButtonAction + +from gajim.gtk import NewConfirmationDialog + +from openpgp.modules.util import Trust + +log = logging.getLogger('gajim.plugin_system.openpgp.keydialog') + +TRUST_DATA = { + Trust.NOT_TRUSTED: ('dialog-error-symbolic', + _('Not Trusted'), + 'error-color'), + Trust.UNKNOWN: ('security-low-symbolic', + _('Not Decided'), + 'warning-color'), + Trust.BLIND: ('security-medium-symbolic', + _('Blind Trust'), + 'openpgp-dark-success-color'), + Trust.VERIFIED: ('security-high-symbolic', + _('Verified'), + 'success-color') +} + + +class KeyDialog(Gtk.Dialog): + def __init__(self, account, jid, transient): + flags = Gtk.DialogFlags.DESTROY_WITH_PARENT + super().__init__(_('Public Keys for %s') % jid, None, flags) + + self.set_transient_for(transient) + self.set_resizable(True) + self.set_default_size(500, 300) + + self.get_style_context().add_class('openpgp-key-dialog') + + self.con = app.connections[account] + + self._listbox = Gtk.ListBox() + self._listbox.set_selection_mode(Gtk.SelectionMode.NONE) + + self._scrolled = Gtk.ScrolledWindow() + self._scrolled.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + self._scrolled.add(self._listbox) + + box = self.get_content_area() + box.pack_start(self._scrolled, True, True, 0) + + keys = self.con.get_module('OpenPGP').get_keys(jid, only_trusted=False) + for key in keys: + log.info('Load: %s', key.fingerprint) + self._listbox.add(KeyRow(key)) + self.show_all() + + +class KeyRow(Gtk.ListBoxRow): + def __init__(self, key): + Gtk.ListBoxRow.__init__(self) + self.set_activatable(False) + + self._dialog = self.get_toplevel() + self.key = key + + box = Gtk.Box() + box.set_spacing(12) + + self._trust_button = TrustButton(self) + box.add(self._trust_button) + + label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + fingerprint = Gtk.Label( + label=self._format_fingerprint(key.fingerprint)) + fingerprint.get_style_context().add_class('openpgp-mono') + if not key.active: + fingerprint.get_style_context().add_class('openpgp-inactive-color') + fingerprint.set_selectable(True) + fingerprint.set_halign(Gtk.Align.START) + fingerprint.set_valign(Gtk.Align.START) + fingerprint.set_hexpand(True) + label_box.add(fingerprint) + + date = Gtk.Label(label=self._format_timestamp(key.timestamp)) + date.set_halign(Gtk.Align.START) + date.get_style_context().add_class('openpgp-mono') + if not key.active: + date.get_style_context().add_class('openpgp-inactive-color') + label_box.add(date) + + box.add(label_box) + + self.add(box) + self.show_all() + + def delete_fingerprint(self, *args): + def _remove(): + self.get_parent().remove(self) + self.key.delete() + self.destroy() + + buttons = { + Gtk.ResponseType.CANCEL: DialogButton('Cancel'), + Gtk.ResponseType.OK: DialogButton('Delete', + _remove, + ButtonAction.DESTRUCTIVE), + } + + NewConfirmationDialog( + _('Delete Public Key'), + _('This will permanently delete this public key'), + buttons, + transient_for=self.get_toplevel()) + + def set_trust(self, trust): + icon_name, tooltip, css_class = TRUST_DATA[trust] + image = self._trust_button.get_child() + image.set_from_icon_name(icon_name, Gtk.IconSize.MENU) + image.get_style_context().add_class(css_class) + + @staticmethod + def _format_fingerprint(fingerprint): + fplen = len(fingerprint) + wordsize = fplen // 8 + buf = '' + for w in range(0, fplen, wordsize): + buf += '{0} '.format(fingerprint[w:w + wordsize]) + return buf.rstrip() + + @staticmethod + def _format_timestamp(timestamp): + return time.strftime('%Y-%m-%d %H:%M:%S', + time.localtime(timestamp)) + + +class TrustButton(Gtk.MenuButton): + def __init__(self, row): + Gtk.MenuButton.__init__(self) + self._row = row + self._css_class = '' + self.set_popover(TrustPopver(row)) + self.update() + + def update(self): + icon_name, tooltip, css_class = TRUST_DATA[self._row.key.trust] + image = self.get_child() + image.set_from_icon_name(icon_name, Gtk.IconSize.MENU) + # remove old color from icon + image.get_style_context().remove_class(self._css_class) + + if not self._row.key.active: + css_class = 'openpgp-inactive-color' + tooltip = '%s - %s' % (_('Inactive'), tooltip) + + image.get_style_context().add_class(css_class) + self._css_class = css_class + self.set_tooltip_text(tooltip) + + +class TrustPopver(Gtk.Popover): + def __init__(self, row): + Gtk.Popover.__init__(self) + self._row = row + self._listbox = Gtk.ListBox() + self._listbox.set_selection_mode(Gtk.SelectionMode.NONE) + if row.key.trust != Trust.VERIFIED: + self._listbox.add(VerifiedOption()) + if row.key.trust != Trust.NOT_TRUSTED: + self._listbox.add(NotTrustedOption()) + self._listbox.add(DeleteOption()) + self.add(self._listbox) + self._listbox.show_all() + self._listbox.connect('row-activated', self._activated) + self.get_style_context().add_class('openpgp-trust-popover') + + def _activated(self, listbox, row): + self.popdown() + if row.type_ is None: + self._row.delete_fingerprint() + else: + self._row.key.trust = row.type_ + self.get_relative_to().update() + self.update() + + def update(self): + self._listbox.foreach(lambda row: self._listbox.remove(row)) + if self._row.key.trust != Trust.VERIFIED: + self._listbox.add(VerifiedOption()) + if self._row.key.trust != Trust.NOT_TRUSTED: + self._listbox.add(NotTrustedOption()) + self._listbox.add(DeleteOption()) + + +class MenuOption(Gtk.ListBoxRow): + def __init__(self): + Gtk.ListBoxRow.__init__(self) + box = Gtk.Box() + box.set_spacing(6) + + image = Gtk.Image.new_from_icon_name(self.icon, + Gtk.IconSize.MENU) + label = Gtk.Label(label=self.label) + image.get_style_context().add_class(self.color) + + box.add(image) + box.add(label) + self.add(box) + self.show_all() + + +class VerifiedOption(MenuOption): + + type_ = Trust.VERIFIED + icon = 'security-high-symbolic' + label = _('Verified') + color = 'success-color' + + def __init__(self): + MenuOption.__init__(self) + + +class NotTrustedOption(MenuOption): + + type_ = Trust.NOT_TRUSTED + icon = 'dialog-error-symbolic' + label = _('Not Trusted') + color = 'error-color' + + def __init__(self): + MenuOption.__init__(self) + + +class DeleteOption(MenuOption): + + type_ = None + icon = 'user-trash-symbolic' + label = _('Delete') + color = '' + + def __init__(self): + MenuOption.__init__(self) diff --git a/openpgp/gtk/style.css b/openpgp/gtk/style.css new file mode 100644 index 0000000..ccdcd96 --- /dev/null +++ b/openpgp/gtk/style.css @@ -0,0 +1,17 @@ +.openpgp-dark-success-color { color: darker(@success_color); } +.openpgp-inactive-color { color: @unfocused_borders; } + +.openpgp-mono { font-size: 12px; font-family: monospace; } + +.openpgp-key-dialog > box { margin: 12px; } + +.openpgp-key-dialog scrolledwindow row { + border-bottom: 1px solid; + border-color: @unfocused_borders; + padding: 10px 20px 10px 10px; +} +.openpgp-key-dialog scrolledwindow row:last-child { border-bottom: 0px} + +.openpgp-key-dialog scrolledwindow { border: 1px solid; border-color:@unfocused_borders; } + +.openpgp-trust-popover row { padding: 10px 15px 10px 10px; } diff --git a/openpgp/gtk/wizard.py b/openpgp/gtk/wizard.py new file mode 100644 index 0000000..7213143 --- /dev/null +++ b/openpgp/gtk/wizard.py @@ -0,0 +1,253 @@ +# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com> +# +# 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 <http://www.gnu.org/licenses/>. + +# XEP-0373: OpenPGP for XMPP + +import logging +import threading +from enum import IntEnum + +from gi.repository import Gtk +from gi.repository import GLib + +from gajim.common import app + +log = logging.getLogger('gajim.plugin_system.openpgp.wizard') + + +class Page(IntEnum): + WELCOME = 0 + NEWKEY = 1 + SUCCESS = 2 + ERROR = 3 + + +class KeyWizard(Gtk.Assistant): + def __init__(self, plugin, account, chat_control): + Gtk.Assistant.__init__(self) + + self._con = app.connections[account] + self._plugin = plugin + self._account = account + self._data_form_widget = None + self._is_form = None + self._chat_control = chat_control + + self.set_application(app.app) + self.set_transient_for(chat_control.parent_win.window) + self.set_resizable(True) + self.set_position(Gtk.WindowPosition.CENTER) + + self.set_default_size(600, 400) + self.get_style_context().add_class('dialog-margin') + + self._add_page(WelcomePage()) + # self._add_page(BackupKeyPage()) + self._add_page(NewKeyPage(self, self._con)) + # self._add_page(SaveBackupCodePage()) + self._add_page(SuccessfulPage()) + self._add_page(ErrorPage()) + + self.connect('prepare', self._on_page_change) + self.connect('cancel', self._on_cancel) + self.connect('close', self._on_cancel) + + self._remove_sidebar() + self.show_all() + + def _add_page(self, page): + self.append_page(page) + self.set_page_type(page, page.type_) + self.set_page_title(page, page.title) + self.set_page_complete(page, page.complete) + + def _remove_sidebar(self): + main_box = self.get_children()[0] + sidebar = main_box.get_children()[0] + main_box.remove(sidebar) + + def _activate_encryption(self): + win = self._chat_control.parent_win.window + action = win.lookup_action( + 'set-encryption-%s' % self._chat_control.control_id) + action.activate(GLib.Variant("s", self._plugin.encryption_name)) + + def _on_page_change(self, assistant, page): + if self.get_current_page() == Page.NEWKEY: + if self._con.get_module('OpenPGP').secret_key_available: + self.set_current_page(Page.SUCCESS) + else: + page.generate() + elif self.get_current_page() == Page.SUCCESS: + self._activate_encryption() + + def _on_error(self, error_text): + log.info('Show Error page') + page = self.get_nth_page(Page.ERROR) + page.set_text(error_text) + self.set_current_page(Page.ERROR) + + def _on_cancel(self, widget): + self.destroy() + + +class WelcomePage(Gtk.Box): + + type_ = Gtk.AssistantPageType.INTRO + title = _('Welcome') + complete = True + + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.set_spacing(18) + title_label = Gtk.Label(label=_('Setup OpenPGP')) + text_label = Gtk.Label( + label=_('Gajim will now try to setup OpenPGP for you')) + self.add(title_label) + self.add(text_label) + + +class RequestPage(Gtk.Box): + + type_ = Gtk.AssistantPageType.INTRO + title = _('Request OpenPGP Key') + complete = False + + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.set_spacing(18) + spinner = Gtk.Spinner() + self.pack_start(spinner, True, True, 0) + spinner.start() + + +# class BackupKeyPage(Gtk.Box): + +# type_ = Gtk.AssistantPageType.INTRO +# title = _('Supply Backup Code') +# complete = True + +# def __init__(self): +# super().__init__(orientation=Gtk.Orientation.VERTICAL) +# self.set_spacing(18) +# title_label = Gtk.Label(label=_('Backup Code')) +# text_label = Gtk.Label( +# label=_('We found a backup Code, please supply your password')) +# self.add(title_label) +# self.add(text_label) +# entry = Gtk.Entry() +# self.add(entry) + + +class NewKeyPage(RequestPage): + + type_ = Gtk.AssistantPageType.PROGRESS + title = _('Generating new Key') + complete = False + + def __init__(self, assistant, con): + super().__init__() + self._assistant = assistant + self._con = con + + def generate(self): + log.info('Creating Key') + thread = threading.Thread(target=self.worker) + thread.start() + + def worker(self): + error = None + try: + self._con.get_module('OpenPGP').generate_key() + except Exception as e: + error = e + else: + self._con.get_module('OpenPGP').get_own_key_details() + self._con.get_module('OpenPGP').publish_key() + self._con.get_module('OpenPGP').query_key_list() + GLib.idle_add(self.finished, error) + + def finished(self, error): + if error is None: + self._assistant.set_current_page(Page.SUCCESS) + else: + log.error(error) + self._assistant.set_current_page(Page.ERROR) + + +# class SaveBackupCodePage(RequestPage): + +# type_ = Gtk.AssistantPageType.PROGRESS +# title = _('Save this code') +# complete = False + +# def __init__(self): +# super().__init__(orientation=Gtk.Orientation.VERTICAL) +# self.set_spacing(18) +# title_label = Gtk.Label(label=_('Backup Code')) +# text_label = Gtk.Label( +# label=_('This is your backup code, you need it if you reinstall Gajim')) +# self.add(title_label) +# self.add(text_label) + + +class SuccessfulPage(Gtk.Box): + + type_ = Gtk.AssistantPageType.SUMMARY + title = _('Setup successful') + complete = True + + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.set_spacing(12) + self.set_homogeneous(True) + + icon = Gtk.Image.new_from_icon_name('object-select-symbolic', + Gtk.IconSize.DIALOG) + icon.get_style_context().add_class('success-color') + icon.set_valign(Gtk.Align.END) + label = Gtk.Label(label=_('Setup successful')) + label.get_style_context().add_class('bold16') + label.set_valign(Gtk.Align.START) + + self.add(icon) + self.add(label) + + +class ErrorPage(Gtk.Box): + + type_ = Gtk.AssistantPageType.SUMMARY + title = _('Registration failed') + complete = True + + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.set_spacing(12) + self.set_homogeneous(True) + + icon = Gtk.Image.new_from_icon_name('dialog-error-symbolic', + Gtk.IconSize.DIALOG) + icon.get_style_context().add_class('error-color') + icon.set_valign(Gtk.Align.END) + self._label = Gtk.Label() + self._label.get_style_context().add_class('bold16') + self._label.set_valign(Gtk.Align.START) + + self.add(icon) + self.add(self._label) + + def set_text(self, text): + self._label.set_text(text) diff --git a/openpgp/manifest.ini b/openpgp/manifest.ini new file mode 100644 index 0000000..d50716b --- /dev/null +++ b/openpgp/manifest.ini @@ -0,0 +1,8 @@ +[info] +name: OpenPGP +short_name: openpgp +version: 0.90.0 +description: Experimental OpenPGP XEP-0373 Implementation +authors: Philipp Hörist <philipp@hoerist.com> +homepage: https://dev.gajim.org/gajim/gajim-plugins/wikis/pgp +min_gajim_version: 1.0.99 diff --git a/openpgp/modules/__init__.py b/openpgp/modules/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/openpgp/modules/__init__.py diff --git a/openpgp/modules/openpgp.py b/openpgp/modules/openpgp.py new file mode 100644 index 0000000..2101f64 --- /dev/null +++ b/openpgp/modules/openpgp.py @@ -0,0 +1,538 @@ +# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com> +# +# 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 <http://www.gnu.org/licenses/>. + +# XEP-0373: OpenPGP for XMPP + +import time +import logging +from pathlib import Path +from base64 import b64decode, b64encode + +from nbxmpp import Node, isResultNode + +from gajim.common import app +from gajim.common import configpaths +from gajim.common.connection_handlers_events import MessageNotSentEvent + +from openpgp.modules import util +from openpgp.modules.util import NS_OPENPGP_PUBLIC_KEYS +from openpgp.modules.util import NS_OPENPGP +from openpgp.modules.util import Key +from openpgp.modules.util import Trust +from openpgp.modules.util import VerifyFailed +from openpgp.modules.util import DecryptionFailed +from openpgp.backend.sql import Storage +from openpgp.backend.pygpg import PGPContext + + +log = logging.getLogger('gajim.plugin_system.openpgp') + + +ENCRYPTION_NAME = 'OpenPGP' + +# Module name +name = 'OpenPGP' +zeroconf = False + + +class KeyData: + ''' + Holds all data related to a certain key + ''' + def __init__(self, contact_data): + self._contact_data = contact_data + self.fingerprint = None + self.active = False + self._trust = Trust.UNKNOWN + self.timestamp = None + self.comment = None + self.has_pubkey = False + + @property + def trust(self): + return self._trust + + @trust.setter + def trust(self, value): + if value not in (Trust.NOT_TRUSTED, + Trust.UNKNOWN, + Trust.BLIND, + Trust.VERIFIED): + raise ValueError('Trust value not allowed: %s' % value) + self._trust = value + self._contact_data.set_trust(self.fingerprint, self._trust) + + @classmethod + def from_key(cls, contact_data, key, trust): + keydata = cls(contact_data) + keydata.fingerprint = key.fingerprint + keydata.timestamp = key.date + keydata.active = True + keydata._trust = trust + return keydata + + @classmethod + def from_row(cls, contact_data, row): + keydata = cls(contact_data) + keydata.fingerprint = row.fingerprint + keydata.timestamp = row.timestamp + keydata.comment = row.comment + keydata._trust = row.trust + keydata.active = row.active + return keydata + + def delete(self): + self._contact_data.delete_key(self.fingerprint) + + +class ContactData: + ''' + Holds all data related to a contact + ''' + def __init__(self, jid, storage, pgp): + self.jid = jid + self._key_store = {} + self._storage = storage + self._pgp = pgp + + @property + def userid(self): + if self._jid is None: + raise ValueError('JID not set') + return 'xmpp:%s' % self._jid + + @property + def default_trust(self): + for key in self._key_store: + if key.trust in (Trust.NOT_TRUSTED, Trust.BLIND): + return Trust.UNKNOWN + return Trust.BLIND + + def db_values(self): + for key in self._key_store.values(): + yield (self.jid, + key.fingerprint, + key.active, + key.trust, + key.timestamp, + key.comment) + + def add_from_key(self, key): + try: + keydata = self._key_store[key.fingerprint] + except KeyError: + keydata = KeyData.from_key(self, key, self.default_trust) + self._key_store[key.fingerprint] = keydata + log.info('Add from key: %s %s', self.jid, keydata.fingerprint) + return keydata + + def add_from_db(self, row): + try: + keydata = self._key_store[row.fingerprint] + except KeyError: + keydata = KeyData.from_row(self, row) + self._key_store[row.fingerprint] = keydata + log.info('Add from row: %s %s', self.jid, row.fingerprint) + return keydata + + def process_keylist(self, keylist): + log.info('Process keylist: %s %s', self.jid, keylist) + + if keylist is None: + for keydata in self._key_store.values(): + keydata.active = False + self._storage.save_contact(self.db_values()) + return [] + + missing_pub_keys = [] + fingerprints = set([key.fingerprint for key in keylist]) + if fingerprints == self._key_store.keys(): + log.info('No updates found') + for key in self._key_store.values(): + if not key.has_pubkey: + missing_pub_keys.append(key.fingerprint) + return missing_pub_keys + + for keydata in self._key_store.values(): + keydata.active = False + + for key in keylist: + try: + keydata = self._key_store[key.fingerprint] + keydata.active = True + if not keydata.has_pubkey: + missing_pub_keys.append(keydata.fingerprint) + except KeyError: + keydata = self.add_from_key(key) + missing_pub_keys.append(keydata.fingerprint) + + self._storage.save_contact(self.db_values()) + return missing_pub_keys + + def set_public_key(self, fingerprint): + try: + keydata = self._key_store[fingerprint] + except KeyError: + log.warning('Set public key on unknown fingerprint', + self.jid, fingerprint) + else: + keydata.has_pubkey = True + log.info('Set public key: %s %s', self.jid, fingerprint) + + def get_keys(self, only_trusted=True): + keys = list(self._key_store.values()) + if not only_trusted: + return keys + return [k for k in keys if k.active and k.trust in (Trust.VERIFIED, + Trust.BLIND)] + + def set_trust(self, fingerprint, trust): + self._storage.set_trust(self.jid, fingerprint, trust) + + def delete_key(self, fingerprint): + self._storage.delete_key(self.jid, fingerprint) + self._pgp.delete_key(fingerprint) + del self._key_store[fingerprint] + + +class PGPContacts: + ''' + Holds all contacts available for PGP encryption + ''' + def __init__(self, pgp, storage): + self._contacts = {} + self._storage = storage + self._pgp = pgp + self._load_from_storage() + self._load_from_keyring() + + def _load_from_keyring(self): + log.info('Load keys from keyring') + keyring = self._pgp.get_keys() + for key in keyring: + log.info('Found: %s %s', key.jid, key.fingerprint) + self.set_public_key(key.jid, key.fingerprint) + + def _load_from_storage(self): + log.info('Load contacts from storage') + rows = self._storage.load_contacts() + if rows is None: + return + + for row in rows: + log.info('Found: %s %s', row.jid, row.fingerprint) + try: + contact_data = self._contacts[row.jid] + except KeyError: + contact_data = ContactData(row.jid, self._storage, self._pgp) + contact_data.add_from_db(row) + self._contacts[row.jid] = contact_data + else: + contact_data.add_from_db(row) + + def process_keylist(self, jid, keylist): + try: + contact_data = self._contacts[jid] + except KeyError: + contact_data = ContactData(jid, self._storage, self._pgp) + missing_pub_keys = contact_data.process_keylist(keylist) + self._contacts[jid] = contact_data + else: + missing_pub_keys = contact_data.process_keylist(keylist) + + return missing_pub_keys + + def set_public_key(self, jid, fingerprint): + try: + contact_data = self._contacts[jid] + except KeyError: + log.warning('ContactData not found: %s %s', jid, fingerprint) + else: + contact_data.set_public_key(fingerprint) + + def get_keys(self, jid, only_trusted=True): + try: + contact_data = self._contacts[jid] + return contact_data.get_keys(only_trusted=only_trusted) + except KeyError: + return [] + + +class OpenPGP: + def __init__(self, con): + self._con = con + self._account = con.name + + self.handlers = [] + + self.own_jid = self.get_own_jid(stripped=True) + + path = Path(configpaths.get('MY_DATA')) / 'openpgp' / self.own_jid + if not path.exists(): + path.mkdir(parents=True) + + self._pgp = PGPContext(self.own_jid, path) + self._storage = Storage(path) + self._contacts = PGPContacts(self._pgp, self._storage) + self._fingerprint, self._date = self.get_own_key_details() + log.info('Own Fingerprint at start: %s', self._fingerprint) + + @property + def secret_key_available(self): + return self._fingerprint is not None + + def get_own_jid(self, stripped=False): + if stripped: + return self._con.get_own_jid().getStripped() + return self._con.get_own_jid() + + def get_own_key_details(self): + self._fingerprint, self._date = self._pgp.get_own_key_details() + return self._fingerprint, self._date + + def generate_key(self): + self._pgp.generate_key() + + def publish_key(self): + log.info('%s => Publish key', self._account) + key = self._pgp.export_key(self._fingerprint) + + date = time.strftime( + '%Y-%m-%dT%H:%M:%SZ', time.gmtime(self._date)) + pubkey_node = Node('pubkey', attrs={'xmlns': NS_OPENPGP, + 'date': date}) + data = pubkey_node.addChild('data') + data.addData(b64encode(key).decode('utf8')) + node = '%s:%s' % (NS_OPENPGP_PUBLIC_KEYS, self._fingerprint) + + self._con.get_module('PubSub').send_pb_publish( + self.own_jid, node, pubkey_node, + id_='current', cb=self._public_result) + + def _publish_key_list(self, keylist=None): + if keylist is None: + keylist = [Key(self._fingerprint, self._date)] + log.info('%s => Publish keys list', self._account) + self._con.get_module('PGPKeylist').send(keylist) + + def _public_result(self, con, stanza): + if not isResultNode(stanza): + log.error('%s => Publishing failed: %s', + self._account, stanza.getError()) + + def _query_public_key(self, jid, fingerprint): + log.info('%s => Fetch public key %s - %s', + self._account, fingerprint, jid) + node = '%s:%s' % (NS_OPENPGP_PUBLIC_KEYS, fingerprint) + self._con.get_module('PubSub').send_pb_retrieve( + jid, node, cb=self._public_key_received, fingerprint=fingerprint) + + def _public_key_received(self, con, stanza, fingerprint): + if not isResultNode(stanza): + log.error('%s => Public Key not found: %s', + self._account, stanza.getError()) + return + pubkey = util.unpack_public_key(stanza, fingerprint) + if pubkey is None: + log.warning('Invalid public key received:\n%s', stanza) + return + + jid = stanza.getFrom().getStripped() + result = self._pgp.import_key(pubkey, jid) + if result is not None: + self._contacts.set_public_key(jid, fingerprint) + + def query_key_list(self, jid=None): + if jid is None: + jid = self.own_jid + log.info('%s => Fetch keys list %s', self._account, jid) + self._con.get_module('PubSub').send_pb_retrieve( + jid, NS_OPENPGP_PUBLIC_KEYS, + cb=self._query_key_list_result) + + def _query_key_list_result(self, con, stanza): + from_jid = stanza.getFrom() + if from_jid is None: + from_jid = self.own_jid + else: + from_jid = from_jid.getStripped() + + if not isResultNode(stanza): + log.error('%s => Keys list query failed: %s', + self._account, stanza.getError()) + if from_jid == self.own_jid and self._fingerprint is not None: + self._publish_key_list() + return + + from_jid = stanza.getFrom() + if from_jid is None: + from_jid = self.own_jid + else: + from_jid = from_jid.getStripped() + + keylist = util.unpack_public_key_list(stanza, from_jid) + self.key_list_received(keylist, from_jid) + + def key_list_received(self, keylist, from_jid): + if keylist is None: + log.warning('Invalid keys list received') + if from_jid == self.own_jid and self._fingerprint is not None: + self._publish_key_list() + return + + if not keylist: + log.warning('%s => Empty keys list received from %s', + self._account, from_jid) + self._contacts.process_keylist(self.own_jid, keylist) + if from_jid == self.own_jid and self._fingerprint is not None: + self._publish_key_list() + return + + if from_jid == self.own_jid: + log.info('Received own keys list') + for key in keylist: + log.info(key.fingerprint) + for key in keylist: + # Check if own fingerprint is published + if key.fingerprint == self._fingerprint: + log.info('Own key found in keys list') + return + log.info('Own key not published') + if self._fingerprint is not None: + keylist.append(Key(self._fingerprint, None)) + self._publish_key_list(keylist) + return + + missing_pub_keys = self._contacts.process_keylist(from_jid, keylist) + + for key in keylist: + log.info(key.fingerprint) + + for fingerprint in missing_pub_keys: + self._query_public_key(from_jid, fingerprint) + + def decrypt_message(self, obj, callback): + if obj.encrypted: + # Another Plugin already decrypted the message + return + + if obj.name == 'message-received': + enc_tag = obj.stanza.getTag('openpgp', namespace=NS_OPENPGP) + jid = obj.jid + else: + enc_tag = obj.message.getTag('openpgp', namespace=NS_OPENPGP) + jid = obj.with_ + + if enc_tag is None: + return + + log.info('Received OpenPGP message from: %s', jid) + b64encode_payload = enc_tag.getData() + encrypted_payload = b64decode(b64encode_payload) + + try: + decrypted_payload = self._pgp.decrypt(encrypted_payload) + except DecryptionFailed as error: + log.warning(error) + return + + signcrypt = Node(node=decrypted_payload) + + signcrypt_jid = signcrypt.getTagAttr('to', 'jid') + if self.own_jid != signcrypt_jid: + log.warning('signcrypt "to" attr %s != %s', + self.own_jid, signcrypt_jid) + log.debug(signcrypt) + return + + payload = signcrypt.getTag('payload') + + body = None + if obj.name == 'message-received': + obj.stanza.delChild(enc_tag) + for node in payload.getChildren(): + if node.name == 'body': + body = node.getData() + obj.stanza.setTagData('body', body) + else: + obj.stanza.addChild(node=node) + else: + obj.msg_.delChild(enc_tag) + for node in payload.getChildren(): + if node.name == 'body': + body = node.getData() + obj.msg_.setTagData('body', node.getData()) + else: + obj.msg_.addChild(node=node) + + if body: + obj.msgtxt = body + + obj.encrypted = ENCRYPTION_NAME + callback(obj) + + def encrypt_message(self, obj, callback): + keys = self._contacts.get_keys(obj.jid) + if not keys: + # TODO: this should never happen in theory + log.error('Droping stanza to %s, because we have no key', obj.jid) + return + + keys += self._contacts.get_keys(self.own_jid) + keys += [Key(self._fingerprint, None)] + + payload = util.create_signcrypt_node(obj) + + encrypted_payload, error = self._pgp.encrypt(payload, keys) + if error: + log.error('Error: %s', error) + app.nec.push_incoming_event( + MessageNotSentEvent( + None, conn=self._con, jid=obj.jid, message=obj.message, + error=error, time_=time.time())) + return + + util.create_openpgp_message(obj, encrypted_payload) + + obj.encrypted = ENCRYPTION_NAME + self.print_msg_to_log(obj.msg_iq) + callback(obj) + + @staticmethod + def print_msg_to_log(stanza): + """ Prints a stanza in a fancy way to the log """ + log.debug('-'*15) + stanzastr = '\n' + stanza.__str__(fancy=True) + stanzastr = stanzastr[0:-1] + log.debug(stanzastr) + log.debug('-'*15) + + def get_keys(self, jid=None, only_trusted=True): + if jid is None: + jid = self.own_jid + return self._contacts.get_keys(jid, only_trusted=only_trusted) + + def clear_fingerprints(self): + self._publish_key_list() + + def cleanup(self): + self._storage.cleanup() + self._pgp = None + self._contacts = None + + +def get_instance(*args, **kwargs): + return OpenPGP(*args, **kwargs), 'OpenPGP' diff --git a/openpgp/modules/pgp_keylist.py b/openpgp/modules/pgp_keylist.py new file mode 100644 index 0000000..97f0283 --- /dev/null +++ b/openpgp/modules/pgp_keylist.py @@ -0,0 +1,124 @@ +# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com> +# +# 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 <http://www.gnu.org/licenses/>. + +# XEP-0373: OpenPGP for XMPP + +import logging +import time + +import nbxmpp + +from gajim.common import app +from gajim.common.exceptions import StanzaMalformed +from gajim.common.modules.pep import AbstractPEPModule, AbstractPEPData +from gajim.common.modules.date_and_time import parse_datetime + +from openpgp.modules import util +from openpgp.modules.util import Key + +log = logging.getLogger('gajim.plugin_system.openpgp.pep') + +# Module name +name = 'PGPKeylist' +zeroconf = False + + +class PGPKeylistData(AbstractPEPData): + + type_ = 'openpgp-keylist' + + def __init__(self, keylist): + self._pep_specific_data = keylist + self.data = keylist + + +class PGPKeylist(AbstractPEPModule): + ''' + <item> + <public-keys-list xmlns='urn:xmpp:openpgp:0'> + <pubkey-metadata + v4-fingerprint='1357B01865B2503C18453D208CAC2A9678548E35' + date='2018-03-01T15:26:12Z' + /> + <pubkey-metadata + v4-fingerprint='67819B343B2AB70DED9320872C6464AF2A8E4C02' + date='1953-05-16T12:00:00Z' + /> + </public-keys-list> + </item> + ''' + + name = 'openpgp-keylist' + namespace = util.NS_OPENPGP_PUBLIC_KEYS + pep_class = PGPKeylistData + store_publish = True + _log = log + + def __init__(self, con): + AbstractPEPModule.__init__(self, con, con.name) + + self.handlers = [] + + def _extract_info(self, item): + keylist_tag = item.getTag('public-keys-list', + namespace=util.NS_OPENPGP) + if keylist_tag is None: + raise StanzaMalformed('No public-keys-list node') + + metadata = keylist_tag.getTags('pubkey-metadata') + if not metadata: + raise StanzaMalformed('No metadata found') + + keylist = [] + for data in metadata: + attrs = data.getAttrs() + + if not attrs or 'v4-fingerprint' not in attrs: + raise StanzaMalformed('No fingerprint in metadata') + + date = attrs.get('date', None) + if date is None: + raise StanzaMalformed('No date in metadata') + else: + timestamp = parse_datetime(date, epoch=True) + if timestamp is None: + raise StanzaMalformed('Invalid date timestamp: %s', date) + + keylist.append(Key(attrs['v4-fingerprint'], timestamp)) + return keylist + + def _notification_received(self, jid, keylist): + con = app.connections[self._account] + con.get_module('OpenPGP').key_list_received(keylist.data, + jid.getStripped()) + + def _build_node(self, keylist): + keylist_node = nbxmpp.Node('public-keys-list', + {'xmlns': util.NS_OPENPGP}) + if keylist is None: + return keylist_node + for key in keylist: + attrs = {'v4-fingerprint': key.fingerprint} + if key.date is not None: + date = time.strftime( + '%Y-%m-%dT%H:%M:%SZ', time.gmtime(key.date)) + attrs['date'] = date + keylist_node.addChild('pubkey-metadata', attrs=attrs) + return keylist_node + + +def get_instance(*args, **kwargs): + return PGPKeylist(*args, **kwargs), 'PGPKeylist' diff --git a/openpgp/modules/util.py b/openpgp/modules/util.py new file mode 100644 index 0000000..b835190 --- /dev/null +++ b/openpgp/modules/util.py @@ -0,0 +1,205 @@ +# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com> +# +# 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 <http://www.gnu.org/licenses/>. + +# XEP-0373: OpenPGP for XMPP + +import logging +import random +import time +import string +from enum import IntEnum +from collections import namedtuple +from base64 import b64decode, b64encode + +import nbxmpp +from nbxmpp import Node + +NS_OPENPGP = 'urn:xmpp:openpgp:0' +NS_OPENPGP_PUBLIC_KEYS = 'urn:xmpp:openpgp:0:public-keys' +NS_NOTIFY = NS_OPENPGP_PUBLIC_KEYS + '+notify' + +NOT_ENCRYPTED_TAGS = [('no-store', nbxmpp.NS_MSG_HINTS), + ('store', nbxmpp.NS_MSG_HINTS), + ('no-copy', nbxmpp.NS_MSG_HINTS), + ('no-permanent-store', nbxmpp.NS_MSG_HINTS), + ('thread', None)] + +Key = namedtuple('Key', 'fingerprint date') + +log = logging.getLogger('gajim.plugin_system.openpgp.util') + + +class Trust(IntEnum): + NOT_TRUSTED = 0 + UNKNOWN = 1 + BLIND = 2 + VERIFIED = 3 + + +def unpack_public_key_list(stanza, from_jid): + fingerprints = [] + + parent = stanza.getTag('pubsub', namespace=nbxmpp.NS_PUBSUB) + if parent is None: + parent = stanza.getTag('event', namespace=nbxmpp.NS_PUBSUB_EVENT) + if parent is None: + log.warning('PGP keys list has no pubsub/event node') + return + + items = parent.getTag('items', attrs={'node': NS_OPENPGP_PUBLIC_KEYS}) + if items is None: + log.warning('PGP keys list has no items node') + return + + item = items.getTags('item') + if not item: + log.warning('PGP keys list has no item node') + return + + if len(item) > 1: + log.warning('PGP keys list has more than one item') + return + + key_list = item[0].getTag('public-keys-list', namespace=NS_OPENPGP) + if key_list is None: + log.warning('PGP keys list has no public-keys-list node') + return + + metadata = key_list.getTags('pubkey-metadata') + if not metadata: + return [] + + for node in metadata: + attrs = node.getAttrs() + if 'v4-fingerprint' not in attrs: + log.warning('No fingerprint in metadata node') + return + + date = attrs.get('date', None) + + fingerprints.append( + Key(attrs['v4-fingerprint'], date)) + + return fingerprints + + +def unpack_public_key(stanza, fingerprint): + pubsub = stanza.getTag('pubsub', namespace=nbxmpp.NS_PUBSUB) + if pubsub is None: + log.warning('PGP public key has no pubsub node') + return + node = '%s:%s' % (NS_OPENPGP_PUBLIC_KEYS, fingerprint) + items = pubsub.getTag('items', attrs={'node': node}) + if items is None: + log.warning('PGP public key has no items node') + return + + item = items.getTags('item') + if not item: + log.warning('PGP public key has no item node') + return + + if len(item) > 1: + log.warning('PGP public key has more than one item') + return + + pub_key = item[0].getTag('pubkey', namespace=NS_OPENPGP) + if pub_key is None: + log.warning('PGP public key has no pubkey node') + return + + data = pub_key.getTag('data') + if data is None: + log.warning('PGP public key has no data node') + return + + return b64decode(data.getData().encode('utf8')) + + +def create_signcrypt_node(obj): + ''' + <signcrypt xmlns='urn:xmpp:openpgp:0'> + <to jid='juliet@example.org'/> + <time stamp='2014-07-10T17:06:00+02:00'/> + <rpad> + f0rm1l4n4-mT8y33j!Y%fRSrcd^ZE4Q7VDt1L%WEgR!kv + </rpad> + <payload> + <body xmlns='jabber:client'> + This is a secret message. + </body> + </payload> + </signcrypt> + ''' + + encrypted_nodes = [] + child_nodes = obj.msg_iq.getChildren() + for node in child_nodes: + if (node.name, node.namespace) not in NOT_ENCRYPTED_TAGS: + if not node.namespace: + node.setNamespace(nbxmpp.NS_CLIENT) + encrypted_nodes.append(node) + obj.msg_iq.delChild(node) + + signcrypt = Node('signcrypt', attrs={'xmlns': NS_OPENPGP}) + signcrypt.addChild('to', attrs={'jid': obj.jid}) + + timestamp = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) + signcrypt.addChild('time', attrs={'stamp': timestamp}) + + signcrypt.addChild('rpad').addData(get_rpad()) + + payload = signcrypt.addChild('payload') + + for node in encrypted_nodes: + payload.addChild(node=node) + + return signcrypt + + +def get_rpad(): + rpad_range = random.randint(30, 50) + return ''.join( + random.choice(string.ascii_letters) for _ in range(rpad_range)) + + +def create_openpgp_message(obj, encrypted_payload): + b64encoded_payload = b64encode( + encrypted_payload.encode('utf-8')).decode('utf8') + + openpgp_node = nbxmpp.Node('openpgp', attrs={'xmlns': NS_OPENPGP}) + openpgp_node.addData(b64encoded_payload) + obj.msg_iq.addChild(node=openpgp_node) + + eme_node = nbxmpp.Node('encryption', + attrs={'xmlns': nbxmpp.NS_EME, + 'namespace': NS_OPENPGP}) + obj.msg_iq.addChild(node=eme_node) + + if obj.message: + obj.msg_iq.setBody(get_info_message()) + + +def get_info_message(): + return '[This message is *encrypted* with OpenPGP (See :XEP:`0373`]' + + +class VerifyFailed(Exception): + pass + + +class DecryptionFailed(Exception): + pass diff --git a/openpgp/pgpplugin.py b/openpgp/pgpplugin.py new file mode 100644 index 0000000..90884a0 --- /dev/null +++ b/openpgp/pgpplugin.py @@ -0,0 +1,194 @@ +# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com> +# +# 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 <http://www.gnu.org/licenses/>. + +# XEP-0373: OpenPGP for XMPP + +import logging +import os +from pathlib import Path + +from gi.repository import Gtk +from gi.repository import Gdk + +from gajim.plugins import GajimPlugin +from gajim.common import app +from gajim.common import ged +from gajim.common import configpaths +from gajim.common import helpers +from gajim.common.const import CSSPriority +from gajim.gtk.dialogs import ErrorDialog + +from openpgp.modules.util import NS_NOTIFY +from openpgp.modules import pgp_keylist +try: + from openpgp.modules import openpgp +except ImportError as e: + ERROR_MSG = str(e) +else: + ERROR_MSG = None + +log = logging.getLogger('gajim.plugin_system.openpgp') + + +#TODO: we cant encrypt "thread" right now, because its needed for Gajim to find ChatControls. + +class OpenPGPPlugin(GajimPlugin): + def init(self): + if ERROR_MSG: + self.activatable = False + self.available_text = ERROR_MSG + self.config_dialog = None + return + + self.events_handlers = { + 'signed-in': (ged.PRECORE, self.signed_in), + } + + self.modules = [pgp_keylist, + openpgp] + + self.encryption_name = 'OpenPGP' + self.config_dialog = None + self.gui_extension_points = { + 'encrypt' + self.encryption_name: (self._encrypt_message, None), + 'decrypt': (self._decrypt_message, 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), + 'update_caps': (self._update_caps, None), + } + + self.connections = {} + + self.plugin = self + self.announced = [] + self.own_key = None + self.pgp_instances = {} + self._create_paths() + self._load_css() + + def _load_css(self): + path = Path(__file__).parent / 'gtk' / 'style.css' + try: + with open(path, "r") as f: + css = f.read() + except Exception as exc: + log.error('Error loading css: %s', exc) + return + + try: + provider = Gtk.CssProvider() + provider.load_from_data(bytes(css.encode('utf-8'))) + Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), + provider, + CSSPriority.DEFAULT_THEME) + except Exception: + log.exception('Error loading application css') + + def _create_paths(self): + keyring_path = os.path.join(configpaths.get('MY_DATA'), 'openpgp') + if not os.path.exists(keyring_path): + os.makedirs(keyring_path) + + def signed_in(self, event): + account = event.conn.name + con = app.connections[account] + if con.get_module('OpenPGP').secret_key_available: + log.info('%s => Publish keylist and public key after sign in', + account) + con.get_module('OpenPGP').query_key_list() + con.get_module('OpenPGP').publish_key() + + def activate(self): + for account in app.connections: + if app.caps_hash[account] != '': + # Gajim has already a caps hash calculated, update it + helpers.update_optional_features(account) + + con = app.connections[account] + if app.account_is_connected(account): + if con.get_module('OpenPGP').secret_key_available: + log.info('%s => Publish keylist and public key ' + 'after plugin activation', account) + con.get_module('OpenPGP').query_key_list() + con.get_module('OpenPGP').publish_key() + + def deactivate(self): + pass + + @staticmethod + def _update_caps(account): + if NS_NOTIFY not in app.gajim_optional_features[account]: + app.gajim_optional_features[account].append(NS_NOTIFY) + + def activate_encryption(self, chat_control): + account = chat_control.account + jid = chat_control.contact.jid + con = app.connections[account] + if con.get_module('OpenPGP').secret_key_available: + keys = app.connections[account].get_module('OpenPGP').get_keys( + jid, only_trusted=False) + if not keys: + ErrorDialog( + _('No OpenPGP key'), + _('We didnt receive a OpenPGP key from this contact.')) + return + return True + else: + from openpgp.gtk.wizard import KeyWizard + KeyWizard(self, account, chat_control) + + def encryption_state(self, chat_control, state): + state['authenticated'] = True + state['visible'] = True + + def on_encryption_button_clicked(self, chat_control): + account = chat_control.account + jid = chat_control.contact.jid + transient = chat_control.parent_win.window + + from openpgp.gtk.key import KeyDialog + KeyDialog(account, jid, transient) + + def _before_sendmessage(self, chat_control): + account = chat_control.account + jid = chat_control.contact.jid + con = app.connections[account] + + if not con.get_module('OpenPGP').secret_key_available: + from openpgp.gtk.wizard import KeyWizard + KeyWizard(self, account, chat_control) + return + + keys = con.get_module('OpenPGP').get_keys(jid) + if not keys: + ErrorDialog( + _('Not Trusted'), + _('There was no trusted and active key found')) + chat_control.sendmessage = False + + def _encrypt_message(self, con, obj, callback): + if not con.get_module('OpenPGP').secret_key_available: + return + con.get_module('OpenPGP').encrypt_message(obj, callback) + + def _decrypt_message(self, con, obj, callback): + if not con.get_module('OpenPGP').secret_key_available: + return + con.get_module('OpenPGP').decrypt_message(obj, callback) |