pgp/0000775000175500017550000000000014760553104011405 5ustar debacledebaclepgp/__init__.py0000664000175500017550000000007614760553104013521 0ustar debacledebaclefrom .plugin import PGPPlugin # pyright: ignore # noqa: F401 pgp/backend/0000775000175500017550000000000014760553104012774 5ustar debacledebaclepgp/backend/__init__.py0000664000175500017550000000000014760553104015073 0ustar debacledebaclepgp/backend/python_gnupg.py0000664000175500017550000001242314760553104016071 0ustar debacledebacle# 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 . from typing import Any import logging import os from functools import lru_cache import gnupg from gajim.common.util.classes 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(metaclass=Singleton): def __init__(self) -> None: self._pgp = gnupg.GPG(use_agent=True) self._pgp.decode_errors = "replace" def encrypt( self, data: str, recipients: list[str], always_trust: bool = False ) -> tuple[str, str]: 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 = self._pgp.encrypt( data.encode("utf8"), recipients, always_trust=always_trust ) if result.ok: error = "" else: error = result.status return self._strip_header_footer(str(result)), error def encrypt_file(self, file: Any, recipients: list[str]) -> gnupg.Crypt: return self._pgp.encrypt_file(file, recipients) def decrypt(self, payload: str) -> str: data = self._add_header_footer(payload, "MESSAGE") result = self._pgp.decrypt(data.encode("utf8")) return str(result) @lru_cache(maxsize=8) # noqa: B019 def sign(self, payload: str | None, key_id: str) -> str: if payload is None: payload = "" result = self._pgp.sign(payload.encode("utf8"), keyid=key_id, detach=True) if result: return self._strip_header_footer(str(result)) raise SignError(result.status) def verify(self, payload: str | None, signed: str) -> str | None: # Hash algorithm is not transferred 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 = self._pgp.verify(data.encode("utf8")) if result: return result.fingerprint def get_key(self, key_id: str) -> gnupg.ListKeys: return self._pgp.list_keys(keys=[key_id]) def get_keys(self, secret: bool = False) -> dict[str, str]: keys: dict[str, str] = {} result = self._pgp.list_keys(secret=secret) for key in result: # Take first not empty uid keys[key["fingerprint"]] = next(uid for uid in key["uids"] if uid) return keys def list_keys( self, secret: bool = False, keys: list[str] | None = None, sigs: bool = False ) -> list[str]: res = self._pgp.list_keys(secret, keys, sigs) return res.fingerprints @staticmethod def _strip_header_footer(data: str) -> str: """ 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: str, type_: str) -> str: """ 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 pgp/backend/store.py0000664000175500017550000001541414760553104014507 0ustar debacledebacle# 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 __future__ import annotations from typing import Any import json import logging from collections.abc import Callable from pathlib import Path from nbxmpp import JID from gajim.common import app from gajim.common import configpaths CURRENT_STORE_VERSION = 3 class KeyResolveError(Exception): pass class KeyStore: def __init__( self, account: str, own_jid: JID, log: logging.LoggerAdapter[Any], list_keys_func: Callable[..., list[str]], ) -> None: self._list_keys_func = list_keys_func self._log = log self._account = account own_bare_jid = own_jid.bare 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(): # having store v2 or higher with self._store_path.open("r") as file: try: self._store = json.load(file) except Exception: log.exception("Could not load config") self._store = self._empty_store() ver = self._store.get("_version", 2) if ver > CURRENT_STORE_VERSION: raise Exception("Unknown store version! Please upgrade pgp plugin.") elif ver == 2: self._migrate_v2_store() self._save_store() elif ver != CURRENT_STORE_VERSION: # garbled version self._store = self._empty_store() log.warning("Bad pgp key store version. Initializing new.") else: # having store v1 or fresh install self._store = self._empty_store() self._migrate_v1_store() self._migrate_v2_store() self._save_store() @staticmethod def _empty_store() -> dict[str, Any]: return { "_version": CURRENT_STORE_VERSION, "own_key_data": None, "contact_key_data": {}, } def _migrate_v1_store(self) -> None: keys: dict[str, str] = {} attached_keys = app.settings.get_account_setting( self._account, "attached_gpg_keys" ) if not attached_keys: return attached_keys = attached_keys.split() 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_nosync(jid, (key_id, "")) own_key_id = app.settings.get_account_setting(self._account, "keyid") own_key_user = app.settings.get_account_setting(self._account, "keyname") if own_key_id: self._set_own_key_data_nosync((own_key_id, own_key_user)) attached_keys = app.settings.set_account_setting( self._account, "attached_gpg_keys", "" ) self._log.info("Migration from store v1 was successful") def _migrate_v2_store(self) -> None: own_key_data = self.get_own_key_data() if own_key_data is not None: own_key_id, own_key_user = ( own_key_data["key_id"], own_key_data["key_user"], ) try: own_key_fp = self._resolve_short_id(own_key_id, has_secret=True) self._set_own_key_data_nosync((own_key_fp, own_key_user)) except KeyResolveError: self._set_own_key_data_nosync(None) prune_list: list[str] = [] for dict_key, key_data in self._store["contact_key_data"].items(): try: key_data["key_id"] = self._resolve_short_id(key_data["key_id"]) except KeyResolveError: prune_list.append(dict_key) for dict_key in prune_list: del self._store["contact_key_data"][dict_key] self._store["_version"] = CURRENT_STORE_VERSION self._log.info("Migration from store v2 was successful") def _save_store(self) -> None: with self._store_path.open("w") as file: json.dump(self._store, file) def _get_dict_key(self, jid: str) -> str: return "%s-%s" % (self._account, jid) def _resolve_short_id(self, short_id: str, has_secret: bool = False) -> str: fingerprints = self._list_keys_func(secret=has_secret, keys=[short_id]) if len(fingerprints) == 1: return fingerprints[0] elif len(fingerprints) > 1: self._log.critical( "Key collision during migration. Key ID is %s. Removing binding...", repr(short_id), ) else: self._log.warning( "Key %s was not found during migration. Removing binding...", repr(short_id), ) raise KeyResolveError def set_own_key_data(self, key_data: tuple[str, str] | None) -> None: self._set_own_key_data_nosync(key_data) self._save_store() def _set_own_key_data_nosync(self, key_data: tuple[str, str] | None) -> None: 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], } def get_own_key_data(self) -> dict[str, str] | None: return self._store["own_key_data"] def get_contact_key_data(self, jid: str) -> dict[str, str] | None: 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: str, key_data: tuple[str, str] | None) -> None: self._set_contact_key_data_nosync(jid, key_data) self._save_store() def _set_contact_key_data_nosync( self, jid: str, key_data: tuple[str, str] | None ) -> None: key_ids = self._store["contact_key_data"] dict_key = self._get_dict_key(jid) if key_data is None: key_ids[dict_key] = None else: key_ids[dict_key] = {"key_id": key_data[0], "key_user": key_data[1]} pgp/exceptions.py0000664000175500017550000000146314760553104014144 0ustar debacledebacle# 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 pgp/gtk/0000775000175500017550000000000014760553104012172 5ustar debacledebaclepgp/gtk/__init__.py0000664000175500017550000000000014760553104014271 0ustar debacledebaclepgp/gtk/choose_key.ui0000664000175500017550000000471014760553104014663 0ustar debacledebacle vertical 6 1 1 1 1 liststore 1 Key ID descending 0 Contact Name 1 1 horizontal 6 Cancel OK pgp/gtk/config.py0000664000175500017550000001015014760553104014006 0ustar debacledebacle# 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 __future__ import annotations from typing import cast from typing import TYPE_CHECKING from pathlib import Path from gi.repository import GLib from gi.repository import Gtk from gajim.common import app from gajim.gtk.util.classes import SignalManager from gajim.gtk.widgets import GajimAppWindow from gajim.plugins.helpers import get_builder from gajim.plugins.plugins_i18n import _ from ..modules.pgp_legacy import PGPLegacy from .key import ChooseGPGKeyDialog if TYPE_CHECKING: from ..plugin import PGPPlugin class ConfigBuilder(Gtk.Builder): config_box: Gtk.Box sidebar: Gtk.StackSidebar stack: Gtk.Stack class PGPConfigDialog(GajimAppWindow): def __init__(self, plugin: PGPPlugin, transient: Gtk.Window) -> None: GajimAppWindow.__init__( self, name="PGPConfigDialog", title=_("PGP Configuration"), default_width=600, default_height=500, transient_for=transient, modal=True, ) ui_path = Path(__file__).parent self._ui = cast( ConfigBuilder, get_builder(str(ui_path.resolve() / "config.ui")) ) self.set_child(self._ui.config_box) self._plugin = plugin for account in app.settings.get_active_accounts(): module = cast( PGPLegacy, app.get_client(account).get_module("PGPLegacy"), # pyright: ignore ) page = Page(module) self._ui.stack.add_titled(page, account, app.get_account_label(account)) self.show() def _cleanup(self) -> None: del self._plugin class Page(Gtk.Box, SignalManager): def __init__(self, module: PGPLegacy) -> None: SignalManager.__init__(self) Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) self._module = module self._label = Gtk.Label() self._button = Gtk.Button(label=_("Assign Key")) self._button.add_css_class("suggested-action") self._button.set_halign(Gtk.Align.CENTER) self._button.set_margin_top(18) self._connect(self._button, "clicked", self._on_assign) self._load_key() self.append(self._label) self.append(self._button) def _on_assign(self, _button: Gtk.Button) -> None: secret_keys = self._module.pgp_backend.get_keys(secret=True) ChooseGPGKeyDialog( secret_keys, cast(Gtk.Window, self.get_root()), self._on_response ) def _load_key(self) -> None: key_data = self._module.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, key: tuple[str, str] | None) -> None: if key is None: self._module.set_own_key_data(None) self._set_key(None) else: self._module.set_own_key_data(key) self._set_key(key) def _set_key(self, key_data: tuple[str, str] | None) -> None: if key_data is None: self._label.set_text(_("No key assigned")) else: key_id, key_user = key_data self._label.set_markup( "%s %s" % (key_id, GLib.markup_escape_text(key_user)) ) def do_unroot(self) -> None: Gtk.Box.do_unroot(self) self._disconnect_all() del self._module app.check_finalize(self) pgp/gtk/config.ui0000664000175500017550000000127314760553104014001 0ustar debacledebacle 12 stack 400 350 1 crossfade pgp/gtk/key.py0000664000175500017550000001374114760553104013342 0ustar debacledebacle# 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 __future__ import annotations from typing import Any from typing import cast from typing import TYPE_CHECKING from collections.abc import Callable from pathlib import Path from gi.repository import GLib from gi.repository import Gtk from nbxmpp import JID from gajim.common import app from gajim.gtk.widgets import GajimAppWindow from gajim.plugins.helpers import get_builder from gajim.plugins.plugins_i18n import _ from ..modules.pgp_legacy import PGPLegacy if TYPE_CHECKING: from ..plugin import PGPPlugin class ChooseKeyBuilder(Gtk.Builder): liststore: Gtk.ListStore box: Gtk.Box keys_treeview: Gtk.TreeView cancel_button: Gtk.Button ok_button: Gtk.Button class KeyDialog(GajimAppWindow): def __init__( self, plugin: PGPPlugin, account: str, jid: JID, transient: Gtk.Window ) -> None: GajimAppWindow.__init__( self, name="PGPKeyDialog", title=_("Assign key for %s") % jid, default_width=450, transient_for=transient, modal=True, ) self.window.set_resizable(True) self._plugin = plugin self._jid = str(jid) self._module = cast( PGPLegacy, app.get_client(account).get_module("PGPLegacy"), # pyright: ignore ) self._label = Gtk.Label() self._assign_button = Gtk.Button(label=_("Assign Key")) self._assign_button.get_style_context().add_class("suggested-action") self._assign_button.set_halign(Gtk.Align.CENTER) self._assign_button.set_margin_top(18) self._connect(self._assign_button, "clicked", self._choose_key) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box.append(self._label) box.append(self._assign_button) self.set_child(box) self._load_key() self.show() def _cleanup(self) -> None: del self._plugin del self._module def _choose_key(self, _button: Gtk.Button) -> None: ChooseGPGKeyDialog( self._module.pgp_backend.get_keys(), self.window, self._on_response ) def _load_key(self) -> None: key_data = self._module.get_contact_key_data(self._jid) if key_data is None: self._set_key(None) else: key_id, key_user = key_data.values() self._set_key((key_id, key_user)) def _on_response(self, key: tuple[str, str] | None) -> None: if key is None: self._module.set_contact_key_data(self._jid, None) self._set_key(None) else: self._module.set_contact_key_data(self._jid, key) self._set_key(key) def _set_key(self, key_data: tuple[str, str] | None) -> None: if key_data is None: self._label.set_text(_("No key assigned")) else: key_id, key_user = key_data self._label.set_markup( "%s %s" % (key_id, GLib.markup_escape_text(key_user)) ) class ChooseGPGKeyDialog(GajimAppWindow): def __init__( self, secret_keys: dict[str, str], transient: Gtk.Window, callback: Callable[[tuple[str, str] | None], None], ) -> None: GajimAppWindow.__init__( self, name="PGPChooseKeyDialog", title=_("Assign PGP Key"), default_width=450, default_height=400, transient_for=transient, modal=True, ) secret_keys[_("None")] = _("None") self.window.set_resizable(True) self._callback = callback self._selected_key = None ui_path = Path(__file__).parent self._ui = cast( ChooseKeyBuilder, get_builder(str(ui_path.resolve() / "choose_key.ui")) ) self._connect(self._ui.cancel_button, "clicked", self._on_cancel) self._connect(self._ui.ok_button, "clicked", self._on_ok) self._connect(self._ui.keys_treeview, "cursor-changed", self._on_row_changed) model = cast(Gtk.ListStore, self._ui.keys_treeview.get_model()) model.set_sort_func(1, self._sort) for key_id, key_label in secret_keys.items(): model.append((key_id, key_label)) self.set_child(self._ui.box) self.show() def _cleanup(self) -> None: del self._callback @staticmethod def _sort( model: Gtk.TreeModel, iter1: Gtk.TreeIter, iter2: Gtk.TreeIter, _data: Any ) -> int: 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_cancel(self, _button: Gtk.Button) -> None: self.close() def _on_ok(self, _button: Gtk.Button) -> None: self._callback(self._selected_key) self.close() def _on_row_changed(self, treeview: Gtk.TreeView) -> None: 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 pgp/modules/0000775000175500017550000000000014760553104013055 5ustar debacledebaclepgp/modules/__init__.py0000664000175500017550000000000014760553104015154 0ustar debacledebaclepgp/modules/events.py0000664000175500017550000000222214760553104014731 0ustar debacledebacle# This file is part of OMEMO Gajim Plugin. # # OMEMO 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. # # OMEMO 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 OMEMO Gajim Plugin. If not, see . from __future__ import annotations from typing import Any from collections.abc import Callable from dataclasses import dataclass from dataclasses import field from gajim.common.events import ApplicationEvent @dataclass class PGPNotTrusted(ApplicationEvent): name: str = field(init=False, default="pgp-not-trusted") on_yes: Callable[..., Any] on_no: Callable[..., Any] @dataclass class PGPFileEncryptionError(ApplicationEvent): name: str = field(init=False, default="pgp-file-encryption-error") error: str pgp/modules/pgp_legacy.py0000664000175500017550000003065714760553104015554 0ustar debacledebacle# 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 typing import Any import os import threading import time from collections.abc import Callable import nbxmpp from gi.repository import GLib from nbxmpp.client import Client as nbxmppClient from nbxmpp.namespaces import Namespace from nbxmpp.protocol import Message from nbxmpp.protocol import Presence from nbxmpp.structs import EncryptionData from nbxmpp.structs import MessageProperties from nbxmpp.structs import PresenceProperties from nbxmpp.structs import StanzaHandler from gajim.common import app from gajim.common.client import Client from gajim.common.const import Trust from gajim.common.events import MessageNotSent from gajim.common.modules.base import BaseModule from gajim.common.modules.httpupload import HTTPFileTransfer from gajim.common.structs import OutgoingMessage from gajim.plugins.plugins_i18n import _ from pgp.backend.python_gnupg import PGP from pgp.backend.store import KeyStore from pgp.exceptions import KeyMismatch from pgp.exceptions import NoKeyIdFound from pgp.exceptions import SignError from pgp.modules.events import PGPFileEncryptionError from pgp.modules.events import PGPNotTrusted from pgp.modules.util import prepare_stanza # Module name name = "PGPLegacy" zeroconf = True ENCRYPTION_NAME = "PGP" ALLOWED_TAGS = [ ("request", Namespace.RECEIPTS), ("active", Namespace.CHATSTATES), ("gone", Namespace.CHATSTATES), ("inactive", Namespace.CHATSTATES), ("paused", Namespace.CHATSTATES), ("composing", Namespace.CHATSTATES), ("markable", Namespace.CHATMARKERS), ("no-store", Namespace.HINTS), ("store", Namespace.HINTS), ("no-copy", Namespace.HINTS), ("no-permanent-store", Namespace.HINTS), ("replace", Namespace.CORRECT), ("thread", None), ("reply", Namespace.REPLY), ("fallback", Namespace.FALLBACK), ("origin-id", Namespace.SID), ("reactions", Namespace.REACTIONS), ] class PGPLegacy(BaseModule): def __init__(self, client: Client) -> None: BaseModule.__init__(self, client, plugin=True) self.handlers = [ StanzaHandler( name="message", callback=self._message_received, ns=Namespace.ENCRYPTED, priority=9, ), StanzaHandler( name="presence", callback=self._on_presence_received, ns=Namespace.SIGNED, priority=48, ), ] self.own_jid = self._client.get_own_jid() self._pgp = PGP() self._store = KeyStore( self._account, self.own_jid, self._log, self._pgp.list_keys ) self._always_trust: list[str] = [] self._presence_fingerprint_store: dict[str, str] = {} @property def pgp_backend(self) -> PGP: return self._pgp def set_own_key_data(self, keydata: tuple[str, str] | None) -> None: return self._store.set_own_key_data(keydata) def get_own_key_data(self) -> dict[str, str] | None: return self._store.get_own_key_data() def set_contact_key_data(self, jid: str, key_data: tuple[str, str] | None) -> None: return self._store.set_contact_key_data(jid, key_data) def get_contact_key_data(self, jid: str) -> dict[str, str] | None: return self._store.get_contact_key_data(jid) def has_valid_key_assigned(self, jid: str) -> bool: key_data = self.get_contact_key_data(jid) if key_data is None: return False key_id = key_data["key_id"] announced_fingerprint = self._presence_fingerprint_store.get(jid) if announced_fingerprint is None: return True if announced_fingerprint == key_id: return True raise KeyMismatch(announced_fingerprint) def _on_presence_received( self, _client: nbxmppClient, _stanza: Presence, properties: PresenceProperties ): if properties.signed is None: return assert properties.jid is not None jid = properties.jid.bare fingerprint = self._pgp.verify(properties.status, properties.signed) if fingerprint is None: self._log.info( "Presence from %s was signed but no corresponding key was found", jid ) return self._presence_fingerprint_store[jid] = fingerprint self._log.info( "Presence from %s was verified successfully, fingerprint: %s", jid, fingerprint, ) key_data = self.get_contact_key_data(jid) if key_data is None: self._log.info("No key assigned for contact: %s", jid) return if key_data["key_id"] != fingerprint: self._log.warning( "Fingerprint mismatch, " "Presence was signed with fingerprint: %s, " "Assigned key fingerprint: %s", fingerprint, key_data["key_id"], ) return def _message_received( self, _client: nbxmppClient, stanza: Message, properties: MessageProperties ) -> None: if not properties.is_pgp_legacy or properties.from_muc: return remote_jid = properties.remote_jid self._log.info("Message received from: %s", remote_jid) assert properties.pgp_legacy is not None payload = self._pgp.decrypt(properties.pgp_legacy) prepare_stanza(stanza, payload) properties.encrypted = EncryptionData( protocol=ENCRYPTION_NAME, key="Unknown", trust=Trust.UNDECIDED ) def encrypt_message( self, client: Client, message: OutgoingMessage, callback: Callable[[OutgoingMessage], None], ) -> None: if not message.get_text(): callback(message) return to_jid = str(message.contact.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(client, message, [key_id, own_key_id], callback, always_trust) def _encrypt( self, client: Client, message: OutgoingMessage, recipients: list[str], callback: Callable[[OutgoingMessage], None], always_trust: bool, ) -> None: text = message.get_text() assert text is not None result = self._pgp.encrypt(text, recipients, always_trust) encrypted_payload, error = result if error: self._handle_encrypt_error(client, error, message, recipients, callback) return self._cleanup_stanza(message) self._create_pgp_legacy_message(message.get_stanza(), encrypted_payload) message.set_encryption( EncryptionData( protocol=ENCRYPTION_NAME, key="Unknown", trust=Trust.VERIFIED, ) ) callback(message) def _handle_encrypt_error( self, client: Client, error: str, message: OutgoingMessage, recipients: list[str], callback: Callable[[OutgoingMessage], None], ) -> None: if error.startswith("NOT_TRUSTED"): def on_yes(checked: bool) -> None: if checked: self._always_trust.append(recipients[0]) self._encrypt(client, message, recipients, callback, True) def on_no() -> None: self._raise_message_not_sent(client, message, error) app.ged.raise_event(PGPNotTrusted(on_yes=on_yes, on_no=on_no)) else: self._raise_message_not_sent(client, message, error) @staticmethod def _raise_message_not_sent( client: Client, message: OutgoingMessage, error: str ) -> None: text = message.get_text() assert text is not None app.ged.raise_event( MessageNotSent( client=client, jid=str(message.contact.jid), message=text, error=_("Encryption error: %s") % error, time=time.time(), ) ) def _create_pgp_legacy_message(self, stanza: Message, payload: str) -> None: stanza.setBody(self._get_info_message()) stanza.setTag("x", namespace=Namespace.ENCRYPTED).setData(payload) eme_node = nbxmpp.Node( "encryption", attrs={"xmlns": Namespace.EME, "namespace": Namespace.ENCRYPTED}, ) stanza.addChild(node=eme_node) def sign_presence(self, presence: nbxmpp.Presence, status: str) -> None: key_data = self.get_own_key_data() if key_data is None: self._log.warning("No own key id found, can’t 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(Namespace.SIGNED + " x").setData(result) @staticmethod def _get_info_message() -> str: 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: str) -> tuple[str, str]: 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(message: OutgoingMessage) -> None: """We make sure only allowed tags are in the stanza""" original_stanza = message.get_stanza() stanza = nbxmpp.Message( to=original_stanza.getTo(), typ=original_stanza.getType() ) stanza.setID(original_stanza.getID()) stanza.setThread(original_stanza.getThread()) for tag, ns in ALLOWED_TAGS: node = original_stanza.getTag(tag, namespace=ns) if node: stanza.addChild(node=node) message.set_stanza(stanza) def encrypt_file( self, transfer: HTTPFileTransfer, callback: Callable[[HTTPFileTransfer], None] ) -> None: thread = threading.Thread( target=self._encrypt_file_thread, args=(transfer, callback) ) thread.daemon = True thread.start() def _encrypt_file_thread( self, transfer: HTTPFileTransfer, callback: Callable[[HTTPFileTransfer], None] ) -> None: try: key_id, own_key_id = self._get_key_ids(str(transfer.contact.jid)) except NoKeyIdFound as error: self._log.warning(error) return stream = open(transfer.path, "rb") encrypted = self._pgp.encrypt_file(stream, [key_id, own_key_id]) stream.close() if not encrypted: GLib.idle_add(self._on_file_encryption_error, encrypted.status) return transfer.size = len(encrypted.data) transfer.set_uri_transform_func(lambda uri: "%s.pgp" % uri) transfer.set_encrypted_data(encrypted.data) GLib.idle_add(callback, transfer) @staticmethod def _on_file_encryption_error(error: str) -> None: app.ged.raise_event(PGPFileEncryptionError(error=error)) def get_instance(*args: Any, **kwargs: Any) -> tuple[PGPLegacy, str]: return PGPLegacy(*args, **kwargs), "PGPLegacy" pgp/modules/util.py0000664000175500017550000000305214760553104014404 0ustar debacledebacle# 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 from nbxmpp import Message from nbxmpp.namespaces import Namespace def prepare_stanza(stanza: Message, plaintext: str) -> None: delete_nodes(stanza, "encrypted", Namespace.ENCRYPTED) delete_nodes(stanza, "body") stanza.setBody(plaintext) def delete_nodes(stanza: Message, name: str, namespace: str | None = None) -> None: nodes = stanza.getTags(name, namespace=namespace) for node in nodes: stanza.delChild(node) def find_gpg(): def _search(binary: str) -> bool: 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): # noqa: S602, SIM103 return False return True if _search("gpg2"): return "gpg2" if _search("gpg"): return "gpg" pgp/org.gajim.Gajim.Plugin.pgp.metainfo.xml0000664000175500017550000000074614760553104020664 0ustar debacledebacle org.gajim.Gajim.Plugin.pgp org.gajim.Gajim PGP Plugin XMPP Extension Protocol (XEP) for secure multi-client end-to-end encryption https://gajim.org/ CC-BY-SA-3.0 GPL-3.0-only gajim-devel_AT_gajim.org pgp/pgp.png0000664000175500017550000004300614760553104012704 0ustar debacledebaclePNG  IHDRa pHYs+BiTXtXML:com.adobe.xmp Adobe Photoshop CC 2015 (Windows) 2018-04-11T09:37:37+02:00 2018-04-11T09:54:14+02:00 2018-04-11T09:54:14+02:00 image/png 3 adobe:docid:photoshop:356b697c-3d5c-11e8-a5a8-9b3fdeeac916 xmp.iid:dcfea758-14e6-c64b-95c0-f37bfbc9e1fe adobe:docid:photoshop:7b04ee80-3d5d-11e8-a5a8-9b3fdeeac916 xmp.did:4e8c8c3c-f3af-294d-ad50-65ad2ed1ece1 created xmp.iid:4e8c8c3c-f3af-294d-ad50-65ad2ed1ece1 2018-04-11T09:37:37+02:00 Adobe Photoshop CC 2015 (Windows) converted from image/png to application/vnd.adobe.photoshop saved xmp.iid:7a8631e4-a0c7-a049-87d2-838e2b35ccdd 2018-04-11T09:54:07+02:00 Adobe Photoshop CC 2015 (Windows) / saved xmp.iid:801e915c-81cb-1740-9cf0-2d91b5216cdd 2018-04-11T09:54:14+02:00 Adobe Photoshop CC 2015 (Windows) / converted from application/vnd.adobe.photoshop to image/png derived converted from application/vnd.adobe.photoshop to image/png saved xmp.iid:dcfea758-14e6-c64b-95c0-f37bfbc9e1fe 2018-04-11T09:54:14+02:00 Adobe Photoshop CC 2015 (Windows) / xmp.iid:801e915c-81cb-1740-9cf0-2d91b5216cdd xmp.did:4e8c8c3c-f3af-294d-ad50-65ad2ed1ece1 xmp.did:4e8c8c3c-f3af-294d-ad50-65ad2ed1ece1 1 960000/10000 960000/10000 2 65535 16 16 ekPq`qi rfᝥ ZkR Umt${zߛ3WyI͜u`sCvEiċHZfN)u'/8ιfvvnAR}ʹeIENDB`pgp/plugin-manifest.json0000664000175500017550000000067714760553104015414 0ustar debacledebacle{ "authors": [ "Philipp Hörist " ], "description": "PGP encryption as per XEP-0027.", "homepage": "https://dev.gajim.org/gajim/gajim-plugins/wikis/pgpplugin", "config_dialog": true, "name": "PGP", "platforms": [ "others", "linux", "darwin", "win32" ], "requirements": [ "gajim>=2.0.0" ], "short_name": "pgp", "version": "1.7.0" }pgp/plugin.py0000664000175500017550000001561214760553104013262 0ustar debacledebacle# 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 __future__ import annotations from typing import Any from typing import TYPE_CHECKING import logging import os from collections.abc import Callable from functools import partial import nbxmpp from packaging.version import Version as V from gajim.common import app from gajim.common import ged from gajim.common.client import Client from gajim.common.modules.httpupload import HTTPFileTransfer from gajim.common.structs import OutgoingMessage from gajim.gtk.control import ChatControl from gajim.gtk.dialogs import ConfirmationCheckDialog from gajim.gtk.dialogs import DialogButton from gajim.gtk.dialogs import SimpleDialog from gajim.plugins import GajimPlugin from gajim.plugins.plugins_i18n import _ from pgp.exceptions import KeyMismatch from pgp.gtk.config import PGPConfigDialog from pgp.gtk.key import KeyDialog from pgp.modules.events import PGPFileEncryptionError from pgp.modules.events import PGPNotTrusted from pgp.modules.util import find_gpg if TYPE_CHECKING: from pgp.modules.pgp_legacy import PGPLegacy 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") class PGPPlugin(GajimPlugin): def init(self): self.description = _("PGP encryption as per XEP-0027") if error_msg: self.activatable = False self.config_dialog = None 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), } from pgp.modules import pgp_legacy 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), } @staticmethod def get_pgp_module(account: str) -> PGPLegacy: return app.get_client(account).get_module("PGPLegacy") # pyright: ignore def activate(self) -> None: pass def deactivate(self) -> None: pass def activate_encryption(self, chat_control: ChatControl) -> bool: return True @staticmethod def _encryption_state(_chat_control: ChatControl, state: dict[str, Any]) -> None: state["visible"] = True state["authenticated"] = True def _on_encryption_dialog(self, chat_control: ChatControl): account = chat_control.account jid = chat_control.contact.jid transient = app.window KeyDialog(self, account, jid, transient) def _on_send_presence(self, account: str, presence: nbxmpp.Presence) -> None: status = presence.getStatus() self.get_pgp_module(account).sign_presence(presence, status) @staticmethod def _on_not_trusted(event: PGPNotTrusted) -> None: ConfirmationCheckDialog( _("Untrusted PGP key"), _( "The PGP key used to encrypt this chat is not " "trusted. Do you really want to encrypt this " "message?" ), _("_Do not ask me again"), [ DialogButton.make("Cancel", text=_("_No"), callback=event.on_no), DialogButton.make( "OK", text=_("_Encrypt Anyway"), callback=event.on_yes ), ], ).show() def _before_sendmessage(self, chat_control: ChatControl) -> None: account = chat_control.account jid = str(chat_control.contact.jid) pgp = self.get_pgp_module(account) try: valid = pgp.has_valid_key_assigned(jid) except KeyMismatch as announced_key_id: SimpleDialog( _("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: SimpleDialog( _("No OpenPGP key assigned"), _("No OpenPGP key is assigned to this contact."), ) chat_control.sendmessage = False elif pgp.get_own_key_data() is None: SimpleDialog( _("No OpenPGP key assigned"), _("No OpenPGP key is assigned to your account."), ) chat_control.sendmessage = False def _encrypt_message( self, client: Client, event: OutgoingMessage, callback: Callable[[OutgoingMessage], None], ): self.get_pgp_module(client.name).encrypt_message(client, event, callback) def encrypt_file( self, transfer: HTTPFileTransfer, account: str, callback: Callable[[HTTPFileTransfer], None], ): self.get_pgp_module(account).encrypt_file(transfer, callback) @staticmethod def _on_file_encryption_error(event: PGPFileEncryptionError) -> None: SimpleDialog(_("Error"), event.error)