From 7a585f20d48f0bb03544ac66394a49fe937e3625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Br=C3=B6tzmann?= Date: Sun, 9 Apr 2023 18:13:39 +0000 Subject: feat: Integrate OMEMO plugin --- .gitlab-ci.yml | 1 + README.md | 2 + debian/control | 2 + flatpak/org.gajim.Gajim.Devel.yaml | 27 + flatpak/org.gajim.Gajim.yaml | 28 + gajim/common/client.py | 18 + gajim/common/const.py | 15 +- gajim/common/events.py | 9 + gajim/common/modules/httpupload.py | 5 + gajim/common/modules/omemo.py | 640 ++++++++++++++++ gajim/common/modules/util.py | 18 + gajim/common/omemo/__init__.py | 0 gajim/common/omemo/aes.py | 105 +++ gajim/common/omemo/state.py | 534 ++++++++++++++ gajim/common/omemo/util.py | 62 ++ gajim/common/setting_values.py | 4 +- gajim/common/storage/omemo.py | 801 +++++++++++++++++++++ gajim/data/gui/contact_info.ui | 41 +- gajim/data/gui/groupchat_details.ui | 41 +- gajim/data/gui/omemo_trust_manager.ui | 425 +++++++++++ .../scalable/categories/qr-code-scan-symbolic.svg | 75 ++ gajim/data/style/gajim.css | 21 + gajim/gtk/accounts.py | 41 +- gajim/gtk/builder.pyi | 26 + gajim/gtk/chat_stack.py | 10 +- gajim/gtk/contact_info.py | 9 +- gajim/gtk/control.py | 8 + gajim/gtk/conversation/rows/encryption_info.py | 85 +++ gajim/gtk/conversation/view.py | 5 + gajim/gtk/groupchat_details.py | 6 + gajim/gtk/message_actions_box.py | 42 +- gajim/gtk/omemo_trust_manager.py | 545 ++++++++++++++ gajim/plugins/manifest.py | 1 + pyproject.toml | 3 + typings/qrcode/__init__.py | 14 + typings/qrcode/constants.py | 4 + win/_base.sh | 7 +- win/dev_env.sh | 5 +- 38 files changed, 3653 insertions(+), 32 deletions(-) create mode 100644 gajim/common/modules/omemo.py create mode 100644 gajim/common/omemo/__init__.py create mode 100644 gajim/common/omemo/aes.py create mode 100644 gajim/common/omemo/state.py create mode 100644 gajim/common/omemo/util.py create mode 100644 gajim/common/storage/omemo.py create mode 100644 gajim/data/gui/omemo_trust_manager.ui create mode 100644 gajim/data/icons/hicolor/scalable/categories/qr-code-scan-symbolic.svg create mode 100644 gajim/gtk/conversation/rows/encryption_info.py create mode 100644 gajim/gtk/omemo_trust_manager.py create mode 100644 typings/qrcode/__init__.py create mode 100644 typings/qrcode/constants.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 57edd0846..5babbc930 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,6 +28,7 @@ test-pyright: - "**/*.py" script: - pip3 install git+https://dev.gajim.org/gajim/python-nbxmpp.git + - pip3 install git+https://dev.gajim.org/gajim/omemo-dr.git - pip3 install --config-settings=config=Gtk3,Gdk3,GtkSource4 git+https://github.com/pygobject/pygobject-stubs.git - npm install pyright@1.1.292 - node_modules/.bin/pyright --version diff --git a/README.md b/README.md index d36dfe30f..6a9afbeeb 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ - [GtkSourceView](https://gitlab.gnome.org/GNOME/gtksourceview) - [Pango](https://gitlab.gnome.org/GNOME/pango) (>=1.50.0) - [sqlite](https://www.sqlite.org/) (>=3.33.0) +- [omemo-dr](https://dev.gajim.org/gajim/omemo-dr) (>=1.0.0) +- [qrcode](https://pypi.org/project/qrcode/) (>=7.3.1) ### Optional Runtime Requirements diff --git a/debian/control b/debian/control index 232d58ea9..d27066e1f 100644 --- a/debian/control +++ b/debian/control @@ -13,6 +13,7 @@ Build-Depends: python3-gi, python3-gi-cairo, python3-nbxmpp-nightly (>=20230405), + python3-omemo-dr (>=20230407), python3-setuptools, python3-packaging, python3-cryptography (>=3.4.8), @@ -35,6 +36,7 @@ Depends: python3-gi (>= 3.42.0), python3-gi-cairo (>= 1.14.0~), python3-nbxmpp-nightly (>=20230405), + python3-omemo-dr (>=20230407), gir1.2-pango-1.0 (>= 1.50.0), gir1.2-gtk-3.0 (>= 3.24.30), gir1.2-gtksource-4, diff --git a/flatpak/org.gajim.Gajim.Devel.yaml b/flatpak/org.gajim.Gajim.Devel.yaml index c61387628..79a25ae7b 100644 --- a/flatpak/org.gajim.Gajim.Devel.yaml +++ b/flatpak/org.gajim.Gajim.Devel.yaml @@ -328,6 +328,33 @@ modules: - type: git url: https://dev.gajim.org/gajim/python-nbxmpp.git + # OMEMO dependencies + - name: python3-protobuf + buildsystem: simple + build-commands: + - pip3 install --no-deps protobuf-4.21.1-py3-none-any.whl + sources: + - type: file + url: https://files.pythonhosted.org/packages/py3/p/protobuf/protobuf-4.21.1-py3-none-any.whl + sha256: 79cd8d0a269b714f6b32641f86928c718e8d234466919b3f552bfb069dbb159b + + - name: python3-omemo-dr + buildsystem: simple + build-commands: + - pip3 install --no-build-isolation . + sources: + - type: git + url: https://dev.gajim.org/gajim/omemo-dr.git + + - name: python3-qrcode + buildsystem: simple + build-commands: + - pip3 install . + sources: + - type: archive + url: https://files.pythonhosted.org/packages/source/q/qrcode/qrcode-7.3.1.tar.gz + sha256: 375a6ff240ca9bd41adc070428b5dfc1dcfbb0f2507f1ac848f6cded38956578 + - name: gajim buildsystem: simple build-commands: diff --git a/flatpak/org.gajim.Gajim.yaml b/flatpak/org.gajim.Gajim.yaml index 34eed8be1..cd16dba38 100644 --- a/flatpak/org.gajim.Gajim.yaml +++ b/flatpak/org.gajim.Gajim.yaml @@ -322,6 +322,34 @@ modules: url: https://files.pythonhosted.org/packages/py3/n/nbxmpp/nbxmpp-4.2.2-py3-none-any.whl sha256: 807d8bbe19dcc77e23cd2b0420581fecd1168ad5cb88b201a15ec7d0b1f8aff3 + # OMEMO dependencies + - name: python3-protobuf + buildsystem: simple + build-commands: + - pip3 install --no-deps protobuf-4.21.1-py3-none-any.whl + sources: + - type: file + url: https://files.pythonhosted.org/packages/py3/p/protobuf/protobuf-4.21.1-py3-none-any.whl + sha256: 79cd8d0a269b714f6b32641f86928c718e8d234466919b3f552bfb069dbb159b + + - name: python3-omemo-dr + buildsystem: simple + build-commands: + - pip3 install --no-build-isolation . + sources: + - type: archive + url: https://files.pythonhosted.org/packages/source/o/omemo-dr/omemo-dr-0.99.0.tar.gz + sha256: 79f9b166b350b3baced2bb0a998a3f8a3cf8756b5b5714d08c5cee5c90e62a8e + + - name: python3-qrcode + buildsystem: simple + build-commands: + - pip3 install . + sources: + - type: archive + url: https://files.pythonhosted.org/packages/source/q/qrcode/qrcode-7.3.1.tar.gz + sha256: 375a6ff240ca9bd41adc070428b5dfc1dcfbb0f2507f1ac848f6cded38956578 + - name: gajim buildsystem: simple build-commands: diff --git a/gajim/common/client.py b/gajim/common/client.py index 0d96b95d7..3a1cb2932 100644 --- a/gajim/common/client.py +++ b/gajim/common/client.py @@ -18,6 +18,7 @@ from typing import Any from typing import Optional import logging +import time import nbxmpp from gi.repository import Gio @@ -37,6 +38,7 @@ from gajim.common.const import ClientState from gajim.common.const import SimpleClientState from gajim.common.events import AccountConnected from gajim.common.events import AccountDisconnected +from gajim.common.events import MessageNotSent from gajim.common.events import MessageSent from gajim.common.events import Notification from gajim.common.events import PasswordRequired @@ -470,6 +472,22 @@ class Client(Observable): self._send_message(message) return + if method == 'OMEMO': + try: + self.get_module('OMEMO').encrypt_message(message) + except Exception: + log.exception('Error') + app.ged.raise_event( + MessageNotSent(client=self._client, + jid=message.jid, + message=message.message, + error=_('Encryption error'), + time=time.time())) + return + + self._send_message(message) + return + # TODO: Make extension point return encrypted message extension = 'encrypt' if message.is_groupchat: diff --git a/gajim/common/const.py b/gajim/common/const.py index f04bc92c6..eb6fc2bdc 100644 --- a/gajim/common/const.py +++ b/gajim/common/const.py @@ -42,6 +42,18 @@ class EncryptionData(NamedTuple): additional_data: Any = None +class EncryptionInfoMsg(Enum): + BAD_OMEMO_CONFIG = _('This chat’s configuration is unsuitable for ' + 'encryption with OMEMO. To use OMEMO in this chat, ' + 'it should be non-anonymous and members-only.') + NO_FINGERPRINTS = _('To send an encrypted message, you have to decide ' + 'whether to trust the device of your contact.') + QUERY_DEVICES = _('No devices found to encypt this message to. ' + 'Querying for devices now…') + UNDECIDED_FINGERPRINTS = _('There are devices for which you have not made ' + 'a trust decision yet.') + + class Entity(NamedTuple): jid: JID node: str @@ -912,7 +924,8 @@ COMMON_FEATURES = [ Namespace.JINGLE_BYTESTREAM, Namespace.JINGLE_IBB, Namespace.AVATAR_METADATA + '+notify', - Namespace.MESSAGE_MODERATE + Namespace.MESSAGE_MODERATE, + Namespace.OMEMO_TEMP_DL + '+notify' ] diff --git a/gajim/common/events.py b/gajim/common/events.py index 22e5948c3..2f76f604a 100644 --- a/gajim/common/events.py +++ b/gajim/common/events.py @@ -37,6 +37,7 @@ from nbxmpp.structs import ModerationData from nbxmpp.structs import RosterItem from nbxmpp.structs import TuneData +from gajim.common.const import EncryptionInfoMsg from gajim.common.const import JingleState from gajim.common.const import KindConstant from gajim.common.file_props import FileProp @@ -780,3 +781,11 @@ class MUCUserStatusShowChanged(ApplicationEvent): nick: str status: str show_value: str + + +@dataclass +class EncryptionInfo(ApplicationEvent): + name: str = field(init=False, default='encryption-check') + account: str + jid: JID + message: EncryptionInfoMsg diff --git a/gajim/common/modules/httpupload.py b/gajim/common/modules/httpupload.py index ded5c9a83..25597417a 100644 --- a/gajim/common/modules/httpupload.py +++ b/gajim/common/modules/httpupload.py @@ -212,6 +212,11 @@ class HTTPUpload(BaseModule): def _start_transfer(self, transfer: HTTPFileTransfer) -> None: if transfer.encryption is not None and not transfer.is_encrypted: transfer.set_encrypting() + if transfer.encryption == 'OMEMO': + self._client.get_module('OMEMO').encrypt_file( + transfer, self._start_transfer) + return + plugin = app.plugin_manager.encryption_plugins[transfer.encryption] if hasattr(plugin, 'encrypt_file'): plugin.encrypt_file(transfer, diff --git a/gajim/common/modules/omemo.py b/gajim/common/modules/omemo.py new file mode 100644 index 000000000..f41072aa6 --- /dev/null +++ b/gajim/common/modules/omemo.py @@ -0,0 +1,640 @@ +# Copyright (C) 2019 Philipp Hörist +# +# 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 . + +# XEP-0384: OMEMO Encryption + +from __future__ import annotations + +from typing import Any +from typing import Callable +from typing import Optional + +import binascii +import threading + +from gi.repository import GLib +from nbxmpp.const import Affiliation +from nbxmpp.const import PresenceType +from nbxmpp.errors import StanzaError +from nbxmpp.modules.omemo import create_omemo_message +from nbxmpp.modules.omemo import get_key_transport_message +from nbxmpp.modules.util import is_error +from nbxmpp.namespaces import Namespace +from nbxmpp.protocol import JID +from nbxmpp.protocol import Message +from nbxmpp.protocol import NodeProcessed +from nbxmpp.protocol import Presence +from nbxmpp.structs import MessageProperties +from nbxmpp.structs import OMEMOMessage +from nbxmpp.structs import PresenceProperties +from nbxmpp.structs import StanzaHandler +from nbxmpp.task import Task + +from gajim.common import app +from gajim.common import ged +from gajim.common import types +from gajim.common.const import EncryptionData +from gajim.common.const import EncryptionInfoMsg +from gajim.common.const import Trust as GajimTrust +from gajim.common.events import EncryptionInfo +from gajim.common.events import MucAdded +from gajim.common.events import MucDiscoUpdate +from gajim.common.events import SignedIn +from gajim.common.i18n import _ +from gajim.common.modules.base import BaseModule +from gajim.common.modules.contacts import GroupchatContact +from gajim.common.modules.httpupload import HTTPFileTransfer +from gajim.common.modules.util import as_task +from gajim.common.modules.util import event_node +from gajim.common.modules.util import prepare_stanza +from gajim.common.omemo.aes import aes_encrypt_file +from gajim.common.omemo.state import DecryptionFailed +from gajim.common.omemo.state import DuplicateMessage +from gajim.common.omemo.state import KeyExchangeMessage +from gajim.common.omemo.state import MessageNotForDevice +from gajim.common.omemo.state import OmemoState +from gajim.common.omemo.state import SelfMessage +from gajim.common.omemo.util import Trust +from gajim.common.structs import OutgoingMessage + +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), + ('origin-id', Namespace.SID), +] + + +class OMEMO(BaseModule): + + _nbxmpp_extends = 'OMEMO' + _nbxmpp_methods = [ + 'set_devicelist', + 'request_devicelist', + 'set_bundle', + 'request_bundle', + ] + + def __init__(self, client: types.Client) -> None: + BaseModule.__init__(self, client) + + self.handlers = [ + StanzaHandler(name='message', + callback=self._message_received, + ns=Namespace.OMEMO_TEMP, + priority=9), + StanzaHandler(name='presence', + callback=self._on_muc_user_presence, + ns=Namespace.MUC_USER, + priority=48), + ] + + self._register_pubsub_handler(self._devicelist_notification_received) + self.register_events([ + ('signed-in', ged.CORE, self._on_signed_in), + ('muc-disco-update', ged.GUI1, self._on_muc_disco_update), + ('muc-added', ged.GUI1, self._on_muc_added) + ]) + + self.allow_groupchat = True + + self._own_jid = self._client.get_own_jid().bare + self._backend = OmemoState(self._account, self._own_jid, self) + + self._omemo_groupchats: set[str] = set() + self._muc_temp_store: dict[bytes, str] = {} + self._query_for_bundles: list[str] = [] + self._device_bundle_querys: list[int] = [] + self._query_for_devicelists: list[str] = [] + + def _on_signed_in(self, _event: SignedIn) -> None: + self._log.info('Announce Support after Sign In') + self._query_for_bundles = [] + self.set_bundle() + self.request_devicelist() + + def _on_muc_disco_update(self, event: MucDiscoUpdate) -> None: + self._check_if_omemo_capable(str(event.jid)) + + def _on_muc_added(self, event: MucAdded) -> None: + client = app.get_client(event.account) + contact = client.get_module('Contacts').get_contact(event.jid) + if not isinstance(contact, GroupchatContact): + self._log.warning('%s is not a groupchat contact', contact) + return + + # Event is triggert on every join, avoid multiple connects + contact.disconnect_all_from_obj(self) + contact.connect('room-joined', self._on_room_joined) + + def _on_room_joined(self, + contact: GroupchatContact, + _signal_name: str + ) -> None: + + jid = str(contact.jid) + self._check_if_omemo_capable(jid) + if self.is_omemo_groupchat(jid): + self.get_affiliation_list(jid) + + @property + def backend(self) -> OmemoState: + return self._backend + + def check_send_preconditions(self, contact: types.ChatContactT) -> bool: + jid = str(contact.jid) + if contact.is_groupchat: + if not self.is_omemo_groupchat(jid): + app.ged.raise_event(EncryptionInfo( + account=contact.account, + jid=contact.jid, + message=EncryptionInfoMsg.BAD_OMEMO_CONFIG)) + return False + + missing = True + for member_jid in self.backend.get_muc_members(jid): + if not self.are_keys_missing(member_jid): + missing = False + if missing: + self._log.info('%s => No Trusted Fingerprints for %s', + contact.account, jid) + app.ged.raise_event(EncryptionInfo( + account=contact.account, + jid=contact.jid, + message=EncryptionInfoMsg.NO_FINGERPRINTS)) + return False + else: + # check if we have devices for the contact + if not self.backend.get_devices(jid, without_self=True): + self.request_devicelist(jid) + app.ged.raise_event(EncryptionInfo( + account=contact.account, + jid=contact.jid, + message=EncryptionInfoMsg.QUERY_DEVICES)) + return False + + # check if bundles are missing for some devices + if self.backend.storage.hasUndecidedFingerprints(jid): + self._log.info('%s => Undecided Fingerprints for %s', + contact.account, jid) + app.ged.raise_event(EncryptionInfo( + account=contact.account, + jid=contact.jid, + message=EncryptionInfoMsg.UNDECIDED_FINGERPRINTS)) + return False + + if self._new_fingerprints_available(contact): + return False + + self._log.debug('%s => Sending Message to %s', + contact.account, jid) + + return True + + def _new_fingerprints_available(self, contact: types.ChatContactT) -> bool: + fingerprints: list[int] = [] + if contact.is_groupchat: + for member_jid in self.backend.get_muc_members(str(contact.jid), + without_self=False): + fingerprints = self.backend.storage.getNewFingerprints( + member_jid) + if fingerprints: + break + + else: + fingerprints = self.backend.storage.getNewFingerprints( + str(contact.jid)) + + if not fingerprints: + return False + + app.ged.raise_event(EncryptionInfo( + account=contact.account, + jid=contact.jid, + message=EncryptionInfoMsg.UNDECIDED_FINGERPRINTS)) + + return True + + def is_omemo_groupchat(self, room_jid: str) -> bool: + return room_jid in self._omemo_groupchats + + def encrypt_message(self, event: OutgoingMessage) -> bool: + if not event.message: + return False + + omemo_message = self.backend.encrypt(str(event.jid), event.message) + if omemo_message is None: + raise Exception('Encryption error') + + create_omemo_message(event.stanza, omemo_message, + node_whitelist=ALLOWED_TAGS) + + if event.is_groupchat: + self._muc_temp_store[omemo_message.payload] = event.message + else: + event.xhtml = None + event.additional_data['encrypted'] = { + 'name': 'OMEMO', + 'trust': GajimTrust[Trust.VERIFIED.name]} + + self._debug_print_stanza(event.stanza) + return True + + def encrypt_file(self, + transfer: HTTPFileTransfer, + callback: Callable[..., Any] + ) -> None: + + thread = threading.Thread(target=self._encrypt_file_thread, + args=(transfer, callback)) + thread.daemon = True + thread.start() + + @staticmethod + def _encrypt_file_thread(transfer: HTTPFileTransfer, + callback: Callable[..., Any], + *args: Any, + **kwargs: Any + ) -> None: + + result = aes_encrypt_file(transfer.get_data()) + transfer.size = len(result.payload) + fragment = binascii.hexlify(result.iv + result.key).decode() + transfer.set_uri_transform_func( + lambda uri: 'aesgcm%s#%s' % (uri[5:], fragment)) + transfer.set_encrypted_data(result.payload) + GLib.idle_add(callback, transfer) + + def _send_key_transport_message(self, + typ: str, + jid: str, + devices: list[int] + ) -> None: + + omemo_message = self.backend.encrypt_key_transport(jid, devices) + if omemo_message is None: + self._log.warning('Key transport message to %s (%s) failed', + jid, devices) + return + + transport_message = get_key_transport_message(typ, jid, omemo_message) + self._log.info('Send key transport message %s (%s)', jid, devices) + self._client.send_stanza(transport_message) + + def _message_received(self, + _client: types.xmppClient, + stanza: Message, + properties: MessageProperties + ) -> None: + + if not properties.is_omemo: + return + + if properties.is_carbon_message and properties.carbon.is_sent: + from_jid = self._own_jid + + elif properties.is_mam_message: + from_jid = self._process_mam_message(properties) + + elif properties.from_muc: + from_jid = self._process_muc_message(properties) + + else: + from_jid = properties.jid.bare + + if from_jid is None: + return + + self._log.info('Message received from: %s', from_jid) + + assert isinstance(properties.omemo, OMEMOMessage) + try: + plaintext, fingerprint, trust = self.backend.decrypt_message( + properties.omemo, from_jid) + except (KeyExchangeMessage, DuplicateMessage): + raise NodeProcessed + + except SelfMessage: + if not properties.from_muc: + raise NodeProcessed + + if properties.omemo.payload not in self._muc_temp_store: + self._log.warning("Can't decrypt own GroupChat Message") + return + + plaintext = self._muc_temp_store[properties.omemo.payload] + fingerprint = self.backend.own_fingerprint + trust = Trust.VERIFIED + del self._muc_temp_store[properties.omemo.payload] + + except DecryptionFailed: + return + + except MessageNotForDevice: + if properties.omemo.payload is None: + # Key Transport message for another device + return + + plaintext = _('This message was encrypted with OMEMO, ' + 'but not for your device.') + # Neither trust nor fingerprint can be verified if we didn't + # successfully decrypt the message + trust = Trust.UNTRUSTED + fingerprint = None + + prepare_stanza(stanza, plaintext) + self._debug_print_stanza(stanza) + properties.encrypted = EncryptionData({ + 'name': 'OMEMO', + 'fingerprint': fingerprint, + 'trust': GajimTrust[trust.name]}) + + def _process_muc_message(self, + properties: MessageProperties + ) -> Optional[str]: + + resource = properties.jid.resource + if properties.muc_ofrom is not None: + # History Message from MUC + return properties.muc_ofrom.bare + + contact = self._client.get_module('Contacts').get_contact( + properties.jid) + if contact.real_jid is not None: + return contact.real_jid.bare + + assert isinstance(properties.omemo, OMEMOMessage) + self._log.info('Groupchat: Last resort trying to find SID in DB') + from_jid = self.backend.storage.getJidFromDevice(properties.omemo.sid) + if not from_jid: + self._log.error( + "Can't decrypt GroupChat Message from %s", resource) + return None + return from_jid + + def _process_mam_message(self, + properties: MessageProperties + ) -> Optional[str]: + + self._log.info('Message received, archive: %s', properties.mam.archive) + if properties.from_muc: + self._log.info('MUC MAM Message received') + if properties.muc_user is None or properties.muc_user.jid is None: + self._log.warning('Received MAM Message which can ' + 'not be mapped to a real jid') + return None + return properties.muc_user.jid.bare + return properties.from_.bare + + def _on_muc_user_presence(self, + _client: types.xmppClient, + _stanza: Presence, + properties: PresenceProperties + ) -> None: + + if properties.type == PresenceType.ERROR: + return + + if properties.is_muc_destroyed: + return + + room = properties.jid.bare + + if properties.muc_user is None or properties.muc_user.jid is None: + # No real jid found + return + + jid = properties.muc_user.jid.bare + if properties.muc_user.affiliation in (Affiliation.OUTCAST, + Affiliation.NONE): + self.backend.remove_muc_member(room, jid) + else: + self.backend.add_muc_member(room, jid) + + if self.is_omemo_groupchat(room): + if not self.is_contact_in_roster(jid): + # Query Devicelists from JIDs not in our Roster + self._log.info('%s not in Roster, query devicelist...', jid) + self.request_devicelist(jid) + + def get_affiliation_list(self, room_jid: str) -> None: + for affiliation in ('owner', 'admin', 'member'): + self._nbxmpp('MUC').get_affiliation( + room_jid, + affiliation, + callback=self._on_affiliations_received, + user_data=room_jid) + + def _on_affiliations_received(self, task: Task) -> None: + room_jid = task.get_user_data() + try: + result = task.finish() + except StanzaError as error: + self._log.info('Affiliation request failed: %s', error) + return + + for user_jid in result.users: + jid = str(user_jid) + self.backend.add_muc_member(room_jid, jid) + + if not self.is_contact_in_roster(jid): + # Query Devicelists from JIDs not in our Roster + self._log.info('%s not in Roster, query devicelist...', jid) + self.request_devicelist(jid) + + def is_contact_in_roster(self, jid: str) -> bool: + if jid == self._own_jid: + return True + + roster_item = self._client.get_module('Roster').get_item(jid) + if roster_item is None: + return False + + contact = self._client.get_module('Contacts').get_contact(jid) + return contact.subscription == 'both' + + def _check_if_omemo_capable(self, jid: str) -> None: + disco_info = app.storage.cache.get_last_disco_info(jid) + if disco_info.muc_is_members_only and disco_info.muc_is_nonanonymous: + self._log.info('OMEMO room discovered: %s', jid) + self._omemo_groupchats.add(jid) + else: + self._log.info('OMEMO room removed due to config change: %s', jid) + self._omemo_groupchats.discard(jid) + + def _check_for_missing_sessions(self, jid: str) -> None: + devices_without_session = self.backend.devices_without_sessions(jid) + for device_id in devices_without_session: + if device_id in self._device_bundle_querys: + continue + self._device_bundle_querys.append(device_id) + self.request_bundle(jid, device_id) + + def are_keys_missing(self, contact_jid: str) -> bool: + ''' Checks if devicekeys are missing and queries the + bundles + + Parameters + ---------- + contact_jid : str + bare jid of the contact + + Returns + ------- + bool + Returns True if there are no trusted Fingerprints + ''' + + # Fetch Bundles of own other Devices + if self._own_jid not in self._query_for_bundles: + + devices_without_session = self.backend.devices_without_sessions( + self._own_jid) + + self._query_for_bundles.append(self._own_jid) + + if devices_without_session: + for device_id in devices_without_session: + self.request_bundle(self._own_jid, device_id) + + # Fetch Bundles of contacts devices + if contact_jid not in self._query_for_bundles: + + devices_without_session = self.backend.devices_without_sessions( + contact_jid) + + self._query_for_bundles.append(contact_jid) + + if devices_without_session: + for device_id in devices_without_session: + self.request_bundle(contact_jid, device_id) + + if self.backend.has_trusted_keys(contact_jid): + return False + return True + + def set_bundle(self) -> None: + self._nbxmpp('OMEMO').set_bundle(self.backend.bundle, + self.backend.own_device) + + @as_task + def request_bundle(self, jid: str, device_id: int): + _task = yield # noqa: F841 + + self._log.info('Fetch device bundle %s %s', device_id, jid) + + bundle = yield self._nbxmpp('OMEMO').request_bundle(jid, device_id) + + if is_error(bundle) or bundle is None: + self._log.info('Bundle request failed: %s %s: %s', + jid, device_id, bundle) + return + + self.backend.build_session(jid, device_id, bundle) + self._log.info('Session created for: %s', jid) + # TODO: In MUC we should send a groupchat message + self._send_key_transport_message('chat', jid, [device_id]) + + # Trigger dialog to trust new Fingerprints if + # the Chat Window is Open + app.ged.raise_event(EncryptionInfo( + account=self._account, + jid=JID.from_string(jid), + message=EncryptionInfoMsg.UNDECIDED_FINGERPRINTS)) + + def set_devicelist(self, devicelist: Optional[list[int]] = None) -> None: + devicelist_: set[int] = {self.backend.own_device} + if devicelist is not None: + devicelist_.update(devicelist) + self._log.info('Publishing own devicelist: %s', devicelist_) + self._nbxmpp('OMEMO').set_devicelist(devicelist_) + + def clear_devicelist(self) -> None: + self.backend.update_devicelist( + self._own_jid, [self.backend.own_device]) + self.set_devicelist() + + @as_task + def request_devicelist(self, jid: Optional[str] = None): + _task = yield # noqa: F841 + + if jid is None: + jid = self._own_jid + + if jid in self._query_for_devicelists: + return + + self._query_for_devicelists.append(jid) + + devicelist = yield self._nbxmpp('OMEMO').request_devicelist(jid=jid) + if is_error(devicelist) or devicelist is None: + self._log.info('Devicelist request failed: %s %s', jid, devicelist) + devicelist = [] + + self._process_devicelist_update(jid, devicelist) + + @event_node(Namespace.OMEMO_TEMP_DL) + def _devicelist_notification_received(self, + _client: types.xmppClient, + _stanza: Message, + properties: MessageProperties + ) -> None: + + if properties.pubsub_event.retracted: + return + + devicelist = properties.pubsub_event.data or [] + + self._process_devicelist_update(str(properties.jid), devicelist) + + def _process_devicelist_update(self, + jid: str, + devicelist: list[int] + ) -> None: + + own_devices = jid is None or self._client.get_own_jid().bare_match(jid) + if own_devices: + jid = self._own_jid + + self._log.info('Received device list for %s: %s', jid, devicelist) + # Pass a copy, we need the full list for potential set_devicelist() + self.backend.update_devicelist(jid, list(devicelist)) + + if jid in self._query_for_bundles: + self._query_for_bundles.remove(jid) + + if own_devices: + if not self.backend.is_own_device_published: + # Our own device_id is not in the list, it could be + # overwritten by some other client + self.set_devicelist(devicelist) + + self._check_for_missing_sessions(jid) + + def _debug_print_stanza(self, stanza: Any) -> None: + stanzastr = '\n' + stanza.__str__(fancy=True) + stanzastr = stanzastr[0:-1] + self._log.debug(stanzastr) diff --git a/gajim/common/modules/util.py b/gajim/common/modules/util.py index 4cb030d1e..3686d0739 100644 --- a/gajim/common/modules/util.py +++ b/gajim/common/modules/util.py @@ -17,6 +17,7 @@ from __future__ import annotations from typing import Any +from typing import Optional from typing import Union import logging @@ -26,6 +27,7 @@ from logging import LoggerAdapter import nbxmpp from nbxmpp.const import MessageType +from nbxmpp.namespaces import Namespace from nbxmpp.protocol import JID from nbxmpp.protocol import Message from nbxmpp.structs import EMEData @@ -184,3 +186,19 @@ def check_if_message_correction(properties: MessageProperties, app.ged.raise_event(event) return True + + +def prepare_stanza(stanza: Message, plaintext: str) -> None: + delete_nodes(stanza, 'encrypted', Namespace.OMEMO_TEMP) + delete_nodes(stanza, 'body') + stanza.setBody(plaintext) + + +def delete_nodes(stanza: Message, + name: str, + namespace: Optional[str] = None + ) -> None: + + nodes = stanza.getTags(name, namespace=namespace) + for node in nodes: + stanza.delChild(node) diff --git a/gajim/common/omemo/__init__.py b/gajim/common/omemo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/gajim/common/omemo/aes.py b/gajim/common/omemo/aes.py new file mode 100644 index 000000000..b4910bf6e --- /dev/null +++ b/gajim/common/omemo/aes.py @@ -0,0 +1,105 @@ +# 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 . + +from __future__ import annotations + +from typing import NamedTuple +from typing import Union + +import logging +import os + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import algorithms +from cryptography.hazmat.primitives.ciphers import Cipher +from cryptography.hazmat.primitives.ciphers.modes import GCM + +log = logging.getLogger('gajim.c.omemo.aes') + +IV_SIZE = 12 + + +class EncryptionResult(NamedTuple): + payload: bytes + key: bytes + iv: bytes + + +def _decrypt(key: bytes, iv: bytes, tag: bytes, data: bytes) -> bytes: + decryptor = Cipher( + algorithms.AES(key), + GCM(iv, tag=tag), + backend=default_backend()).decryptor() + return decryptor.update(data) + decryptor.finalize() + + +def aes_decrypt(_key: bytes, iv: bytes, payload: bytes) -> str: + if len(_key) >= 32: + # XEP-0384 + log.debug('XEP Compliant Key/Tag') + data = payload + key = _key[:16] + tag = _key[16:] + else: + # Legacy + log.debug('Legacy Key/Tag') + data = payload[:-16] + key = _key + tag = payload[-16:] + + return _decrypt(key, iv, tag, data).decode() + + +def aes_decrypt_file(key: bytes, iv: bytes, payload: bytes) -> bytes: + data = payload[:-16] + tag = payload[-16:] + return _decrypt(key, iv, tag, data) + + +def _encrypt(data: Union[str, bytes], + key_size: int, + iv_size: int = IV_SIZE + ) -> tuple[bytes, bytes, bytes, bytes]: + + if isinstance(data, str): + data = data.encode() + key = os.urandom(key_size) + iv = os.urandom(iv_size) + encryptor = Cipher( + algorithms.AES(key), + GCM(iv), + backend=default_backend()).encryptor() + + payload = encryptor.update(data) + encryptor.finalize() + return key, iv, encryptor.tag, payload + + +def aes_encrypt(plaintext: str) -> EncryptionResult: + key, iv, tag, payload = _encrypt(plaintext, 16) + key += tag + return EncryptionResult(payload=payload, key=key, iv=iv) + + +def aes_encrypt_file(data: bytes) -> EncryptionResult: + key, iv, tag, payload, = _encrypt(data, 32) + payload += tag + return EncryptionResult(payload=payload, key=key, iv=iv) + + +def get_new_key() -> bytes: + return os.urandom(16) + + +def get_new_iv() -> bytes: + return os.urandom(IV_SIZE) diff --git a/gajim/common/omemo/state.py b/gajim/common/omemo/state.py new file mode 100644 index 000000000..776af9671 --- /dev/null +++ b/gajim/common/omemo/state.py @@ -0,0 +1,534 @@ +# Copyright (C) 2019 Philipp Hörist +# Copyright (C) 2015 Bahtiar `kalkin-` Gadimov +# +# 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 typing import Optional + +import time +from collections import defaultdict +from pathlib import Path + +from nbxmpp.structs import OMEMOBundle +from nbxmpp.structs import OMEMOMessage +from omemo_dr.ecc.djbec import CurvePublicKey +from omemo_dr.exceptions import DuplicateMessageException +from omemo_dr.identitykey import IdentityKey +from omemo_dr.identitykeypair import IdentityKeyPair +from omemo_dr.protocol.prekeywhispermessage import PreKeyWhisperMessage +from omemo_dr.protocol.whispermessage import WhisperMessage +from omemo_dr.sessionbuilder import SessionBuilder +from omemo_dr.sessioncipher import SessionCipher +from omemo_dr.state.prekeybundle import PreKeyBundle +from omemo_dr.util.keyhelper import KeyHelper + +from gajim.common import app +from gajim.common import configpaths +from gajim.common import types +from gajim.common.omemo.aes import aes_decrypt +from gajim.common.omemo.aes import aes_encrypt +from gajim.common.omemo.aes import get_new_iv +from gajim.common.omemo.aes import get_new_key +from gajim.common.omemo.util import DEFAULT_PREKEY_AMOUNT +from gajim.common.omemo.util import get_fingerprint +from gajim.common.omemo.util import MIN_PREKEY_AMOUNT +from gajim.common.omemo.util import SPK_ARCHIVE_TIME +from gajim.common.omemo.util import SPK_CYCLE_TIME +from gajim.common.omemo.util import Trust +from gajim.common.omemo.util import UNACKNOWLEDGED_COUNT +from gajim.common.storage.omemo import OMEMOStorage + + +class OmemoState: + def __init__(self, + account: str, + own_jid: str, + xmpp_con: types.xmppClient + ) -> None: + + self._account = account + self._own_jid = own_jid + self._log = xmpp_con._log + self._session_ciphers: dict[ + str, dict[int, SessionCipher]] = defaultdict(dict) + + data_dir = Path(configpaths.get('MY_DATA')) + db_path = data_dir / f'omemo_{self._own_jid}.db' + self._storage = OMEMOStorage(account, db_path, self._log) + + self.xmpp_con = xmpp_con + + self._log.info('%s PreKeys available', + self._storage.getPreKeyCount()) + + self._device_store: dict[str, set[int]] = defaultdict(set) + self._muc_member_store: dict[str, set[str]] = defaultdict(set) + + reg_id = self._storage.getLocalRegistrationId() + if reg_id is None: + raise ValueError('No own device found') + + self._own_device = reg_id + self.add_device(self._own_jid, self._own_device) + self._log.info('Our device id: %s', self._own_device) + + for jid, device in self._storage.getActiveDeviceTuples(): + self._log.info('Load device from storage: %s - %s', jid, device) + self.add_device(jid, device) + + def build_session(self, + jid: str, + device_id: int, + bundle: OMEMOBundle + ) -> None: + + session = SessionBuilder(self._storage, self._storage, self._storage, + self._storage, jid, device_id) + + registration_id = self._storage.getLocalRegistrationId() + + prekey = bundle.pick_prekey() + otpk = CurvePublicKey(prekey['key'][1:]) + + spk = CurvePublicKey(bundle.spk['key'][1:]) + ik = IdentityKey(CurvePublicKey(bundle.ik[1:])) + + prekey_bundle = PreKeyBundle(registration_id, + device_id, + prekey['id'], + otpk, + bundle.spk['id'], + spk, + bundle.spk_signature, + ik) + + session.processPreKeyBundle(prekey_bundle) + self._get_session_cipher(jid, device_id) + + @property + def storage(self) -> OMEMOStorage: + return self._storage + + @property + def own_fingerprint(self) -> str: + return get_fingerprint(self._storage.getIdentityKeyPair()) + + @property + def bundle(self) -> OMEMOBundle: + self._check_pre_key_count() + + bundle: dict[str, Any] = {'otpks': []} + for k in self._storage.loadPendingPreKeys(): + key = k.getKeyPair().getPublicKey().serialize() + bundle['otpks'].append({'key': key, 'id': k.getId()}) + + ik_pair = self._storage.getIdentityKeyPair() + bundle['ik'] = ik_pair.getPublicKey().serialize() + + self._cycle_signed_pre_key(ik_pair) + + spk = self._storage.loadSignedPreKey( + self._storage.getCurrentSignedPreKeyId()) + bundle['spk_signature'] = spk.getSignature() + bundle['spk'] = {'key': spk.getKeyPair().getPublicKey().serialize(), + 'id': spk.getId()} + + return OMEMOBundle(**bundle) + + def decrypt_message(self, + omemo_message: OMEMOMessage, + jid: str + ) -> tuple[str, str, Trust]: + + if omemo_message.sid == self.own_device: + self._log.info('Received previously sent message by us') + raise SelfMessage + + try: + encrypted_key, prekey = omemo_message.keys[self.own_device] + except KeyError: + self._log.info('Received message not for our device') + raise MessageNotForDevice + + try: + if prekey: + key, fingerprint, trust = self._process_pre_key_message( + jid, omemo_message.sid, encrypted_key) + else: + key, fingerprint, trust = self._process_message( + jid, omemo_message.sid, encrypted_key) + + except DuplicateMessageException: + self._log.info('Received duplicated message') + raise DuplicateMessage + + except Exception as error: + self._log.warning(error) + raise DecryptionFailed + + if omemo_message.payload is None: + self._log.debug('Decrypted Key Exchange Message') + raise KeyExchangeMessage + + try: + result = aes_decrypt(key, omemo_message.iv, omemo_message.payload) + except Exception as error: + self._log.warning(error) + raise DecryptionFailed + + self._log.debug('Decrypted Message => %s', result) + return result, fingerprint, trust + + def _get_whisper_message(self, + jid: str, + device: int, + key: bytes + ) -> tuple[bytes, bool]: + + cipher = self._get_session_cipher(jid, device) + cipher_key = cipher.encrypt(key) + prekey = isinstance(cipher_key, PreKeyWhisperMessage) + return cipher_key.serialize(), prekey + + def encrypt(self, jid: str, plaintext: str) -> Optional[OMEMOMessage]: + try: + devices_for_encryption = self.get_devices_for_encryption(jid) + except NoDevicesFound: + self._log.warning('No devices for encryption found for: %s', jid) + return + + result = aes_encrypt(plaintext) + whisper_messages: dict[ + str, dict[int, tuple[bytes, bool]]] = defaultdict(dict) + + for jid_, device in devices_for_encryption: + count = self._storage.getUnacknowledgedCount(jid_, device) + if count >= UNACKNOWLEDGED_COUNT: + self._log.warning('Set device inactive %s because of %s ' + 'unacknowledged messages', device, count) + self.remove_device(jid_, device) + + try: + whisper_messages[jid_][device] = self._get_whisper_message( + jid_, device, result.key) + except Exception: + self._log.exception('Failed to encrypt') + continue + + recipients = set(whisper_messages.keys()) + if jid != self._own_jid: + recipients -= {self._own_jid} + if not recipients: + self._log.error('Encrypted keys empty') + return + + encrypted_keys: dict[int, tuple[bytes, bool]] = {} + for jid_ in whisper_messages: + encrypted_keys.update(whisper_messages[jid_]) + + self._log.debug('Finished encrypting message') + return OMEMOMessage(sid=self.own_device, + keys=encrypted_keys, + iv=result.iv, + payload=result.payload) + + def encrypt_key_transport(self, + jid: str, + devices: list[int] + ) -> Optional[OMEMOMessage]: + + whisper_messages: dict[ + str, dict[int, tuple[bytes, bool]]] = defaultdict(dict) + for device in devices: + try: + whisper_messages[jid][device] = self._get_whisper_message( + jid, device, get_new_key()) + except Exception: + self._log.exception('Failed to encrypt') + continue + + if not whisper_messages[jid]: + self._log.error('Encrypted keys empty') + return + + self._log.debug('Finished Key Transport message') + return OMEMOMessage(sid=self.own_device, + keys=whisper_messages[jid], + iv=get_new_iv(), + payload=None) + + def has_trusted_keys(self, jid: str) -> bool: + inactive = self._storage.getInactiveSessionsKeys(jid) + trusted = self._storage.getTrustedFingerprints(jid) + return bool(set(trusted) - set(inactive)) + + def devices_without_sessions(self, jid: str) -> list[int]: + known_devices = self.get_devices(jid, without_self=True) + missing_devices = [dev + for dev in known_devices + if not self._storage.containsSession(jid, dev)] + if missing_devices: + self._log.info('Missing device sessions for %s: %s', + jid, missing_devices) + return missing_devices + + def _get_session_cipher(self, jid: str, device_id: int) -> SessionCipher: + try: + return self._session_ciphers[jid][device_id] + except KeyError: + cipher = SessionCipher(self._storage, self._storage, self._storage, + self._storage, jid, device_id) + self._session_ciphers[jid][device_id] = cipher + return cipher + + def _process_pre_key_message(self, + jid: str, + device: int, + key: bytes + ) -> tuple[bytes, str, Trust]: + + self._log.info('Process pre key message from %s', jid) + pre_key_message = PreKeyWhisperMessage.from_bytes(key) + if not pre_key_message.getPreKeyId(): + raise Exception('Received Pre Key Message ' + 'without PreKey => %s' % jid) + + session_cipher = self._get_session_cipher(jid, device) + key = session_cipher.decryptPkmsg(pre_key_message) + + identity_key = pre_key_message.getIdentityKey() + trust = self._get_trust_from_identity_key(jid, identity_key) + fingerprint = get_fingerprint(identity_key) + + self._storage.setIdentityLastSeen(jid, identity_key) + + self.xmpp_con.set_bundle() + self.add_device(jid, device) + return key, fingerprint, trust + + def _process_message(self, + jid: str, + device: int, + key: bytes + ) -> tuple[bytes, str, Trust]: + + self._log.info('Process message from %s', jid) + message = WhisperMessage.from_bytes(key) + + session_cipher = self._get_session_cipher(jid, device) + key = session_cipher.decryptMsg(message) + + identity_key = self._get_identity_key_from_device(jid, device) + trust = self._get_trust_from_identity_key(jid, identity_key) + fingerprint = get_fingerprint(identity_key) + + self._storage.setIdentityLastSeen(jid, identity_key) + + self.add_device(jid, device) + + return key, fingerprint, trust + + @staticmethod + def _get_identity_key_from_pk_message(key): + pre_key_message = PreKeyWhisperMessage.from_bytes(key) + return pre_key_message.getIdentityKey() + + def _get_identity_key_from_device(self, + jid: str, + device: int + ) -> Optional[IdentityKey]: + + session_record = self._storage.loadSession(jid, device) + return session_record.getSessionState().getRemoteIdentityKey() + + def _get_trust_from_identity_key(self, + jid: str, + identity_key: IdentityKey + ) -> Trust: + + trust = self._storage.getTrustForIdentity(jid, identity_key) + return Trust(trust) if trust is not None else Trust.UNDECIDED + + def _check_pre_key_count(self) -> None: + # Check if enough PreKeys are available + pre_key_count = self._storage.getPreKeyCount() + if pre_key_count < MIN_PREKEY_AMOUNT: + missing_count = DEFAULT_PREKEY_AMOUNT - pre_key_count + self._storage.generateNewPreKeys(missing_count) + self._log.info('%s PreKeys created', missing_count) + + def _cycle_signed_pre_key(self, ik_pair: IdentityKeyPair) -> None: + # Publish every SPK_CYCLE_TIME a new SignedPreKey + # Delete all existing SignedPreKeys that are older + # then SPK_ARCHIVE_TIME + + # Check if SignedPreKey exist and create if not + if not self._storage.getCurrentSignedPreKeyId(): + spk = KeyHelper.generateSignedPreKey( + ik_pair, self._storage.getNextSignedPreKeyId()) + self._storage.storeSignedPreKey(spk.getId(), spk) + self._log.debug('New SignedPreKey created, because none existed') + + # if SPK_CYCLE_TIME is reached, generate a new SignedPreKey + now = int(time.time()) + timestamp = self._storage.getSignedPreKeyTimestamp( + self._storage.getCurrentSignedPreKeyId()) + + if int(timestamp) < now - SPK_CYCLE_TIME: + spk = KeyHelper.generateSignedPreKey( + ik_pair, self._storage.getNextSignedPreKeyId()) + self._storage.storeSignedPreKey(spk.getId(), spk) + self._log.debug('Cycled SignedPreKey') + + # Delete all SignedPreKeys that are older than SPK_ARCHIVE_TIME + timestamp = now - SPK_ARCHIVE_TIME + self._storage.removeOldSignedPreKeys(timestamp) + + def update_devicelist(self, jid: str, devicelist: list[int]) -> None: + for device in list(devicelist): + if device == self.own_device: + continue + count = self._storage.getUnacknowledgedCount(jid, device) + if count > UNACKNOWLEDGED_COUNT: + self._log.warning('Ignore device because of %s unacknowledged' + ' messages: %s %s', count, jid, device) + devicelist.remove(device) + + self._device_store[jid] = set(devicelist) + self._log.info('Saved devices for %s', jid) + self._storage.setActiveState(jid, devicelist) + + def add_muc_member(self, room_jid: str, jid: str) -> None: + self._log.info('Saved MUC member %s %s', room_jid, jid) + self._muc_member_store[room_jid].add(jid) + + def remove_muc_member(self, room_jid: str, jid: str) -> None: + self._log.info('Removed MUC member %s %s', room_jid, jid) + self._muc_member_store[room_jid].discard(jid) + + def get_muc_members(self, + room_jid: str, + without_self: bool = True + ) -> set[str]: + + members = set(self._muc_member_store[room_jid]) + if without_self: + members.discard(self._own_jid) + return members + + def add_device(self, jid: str, device: int) -> None: + self._device_store[jid].add(device) + + def remove_device(self, jid: str, device: int) -> None: + self._device_store[jid].discard(device) + self._storage.setInactive(jid, device) + + def get_devices(self, jid: str, without_self: bool = False) -> set[int]: + devices = set(self._device_store[jid]) + if without_self: + devices.discard(self.own_device) + return devices + + def get_devices_for_encryption(self, jid: str) -> set[tuple[str, int]]: + devices_for_encryption: list[tuple[str, int]] = [] + + client = app.get_client(self._account) + contact = client.get_module('Contacts').get_contact(jid) + if contact.is_groupchat: + devices_for_encryption = self._get_devices_for_muc_encryption(jid) + else: + devices_for_encryption = self._get_devices_for_encryption(jid) + + if not devices_for_encryption: + raise NoDevicesFound + + devices_for_encryption += self._get_own_devices_for_encryption() + return set(devices_for_encryption) + + def _get_devices_for_muc_encryption(self, + jid: str + ) -> list[tuple[str, int]]: + + devices_for_encryption: list[tuple[str, int]] = [] + for jid_ in self._muc_member_store[jid]: + devices_for_encryption += self._get_devices_for_encryption(jid_) + return devices_for_encryption + + def _get_own_devices_for_encryption(self) -> list[tuple[str, int]]: + devices_for_encryption: list[tuple[str, int]] = [] + own_devices = self.get_devices(self._own_jid, without_self=True) + for device in own_devices: + if self._storage.isTrusted(self._own_jid, device): + devices_for_encryption.append((self._own_jid, device)) + return devices_for_encryption + + def _get_devices_for_encryption(self, jid: str) -> list[tuple[str, int]]: + devices_for_encryption: list[tuple[str, int]] = [] + devices = self.get_devices(jid) + + for device in devices: + if self._storage.isTrusted(jid, device): + devices_for_encryption.append((jid, device)) + return devices_for_encryption + + @property + def own_device(self) -> int: + return self._own_device + + @property + def devices_for_publish(self) -> set[int]: + devices = self.get_devices(self._own_jid) + if self.own_device not in devices: + devices.add(self.own_device) + return devices + + @property + def is_own_device_published(self) -> bool: + return self.own_device in self.get_devices(self._own_jid) + + +class NoDevicesFound(Exception): + pass + + +class NoValidSessions(Exception): + pass + + +class SelfMessage(Exception): + pass + + +class MessageNotForDevice(Exception): + pass + + +class DecryptionFailed(Exception): + pass + + +class KeyExchangeMessage(Exception): + pass + + +class InvalidMessage(Exception): + pass + + +class DuplicateMessage(Exception): + pass diff --git a/gajim/common/omemo/util.py b/gajim/common/omemo/util.py new file mode 100644 index 000000000..13c4fdc80 --- /dev/null +++ b/gajim/common/omemo/util.py @@ -0,0 +1,62 @@ +# Copyright (C) 2015 Bahtiar `kalkin-` Gadimov +# +# 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 + +import binascii +import textwrap +from enum import IntEnum + +from omemo_dr.identitykey import IdentityKey +from omemo_dr.identitykeypair import IdentityKeyPair + +DEFAULT_PREKEY_AMOUNT = 100 +MIN_PREKEY_AMOUNT = 80 +SPK_ARCHIVE_TIME = 86400 * 15 # 15 Days +SPK_CYCLE_TIME = 86400 # 24 Hours +UNACKNOWLEDGED_COUNT = 2000 + + +class Trust(IntEnum): + UNTRUSTED = 0 + VERIFIED = 1 + UNDECIDED = 2 + BLIND = 3 + + +def get_fingerprint(identity_key: IdentityKeyPair, + formatted: bool = False + ) -> str: + + public_key = identity_key.getPublicKey().serialize() + fingerprint = binascii.hexlify(public_key).decode()[2:] + if not formatted: + return fingerprint + fplen = len(fingerprint) + wordsize = fplen // 8 + buf = '' + for word in range(0, fplen, wordsize): + buf += '{0} '.format(fingerprint[word:word + wordsize]) + buf = textwrap.fill(buf, width=36) + return buf.rstrip().upper() + + +class IdentityKeyExtended(IdentityKey): + def __hash__(self) -> int: + return hash(self.publicKey.serialize()) + + def get_fingerprint(self, formatted: bool = False) -> str: + return get_fingerprint(self, formatted=formatted) diff --git a/gajim/common/setting_values.py b/gajim/common/setting_values.py index 000aa6e11..73d2be21b 100644 --- a/gajim/common/setting_values.py +++ b/gajim/common/setting_values.py @@ -314,7 +314,8 @@ BoolAccountSettings = Literal[ 'test_ft_proxies_on_startup', 'use_custom_host', 'use_ft_proxies', - 'use_plain_connection' + 'use_plain_connection', + 'omemo_blind_trust' ] @@ -476,6 +477,7 @@ ACCOUNT_SETTINGS = { 'zeroconf_first_name': '', 'zeroconf_jabber_id': '', 'zeroconf_last_name': '', + 'omemo_blind_trust': True, }, 'contact': { diff --git a/gajim/common/storage/omemo.py b/gajim/common/storage/omemo.py new file mode 100644 index 000000000..086c458e6 --- /dev/null +++ b/gajim/common/storage/omemo.py @@ -0,0 +1,801 @@ +# Copyright (C) 2019 Philipp Hörist +# Copyright (C) 2015 Tarek Galal +# +# 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 typing import NamedTuple +from typing import Optional + +import sqlite3 +import time +from collections import namedtuple +from pathlib import Path + +from omemo_dr.ecc.djbec import CurvePublicKey +from omemo_dr.ecc.djbec import DjbECPrivateKey +from omemo_dr.exceptions import InvalidKeyIdException +from omemo_dr.identitykey import IdentityKey +from omemo_dr.identitykeypair import IdentityKeyPair +from omemo_dr.state.axolotlstore import AxolotlStore +from omemo_dr.state.prekeyrecord import PreKeyRecord +from omemo_dr.state.sessionrecord import SessionRecord +from omemo_dr.state.signedprekeyrecord import SignedPreKeyRecord +from omemo_dr.util.keyhelper import KeyHelper +from omemo_dr.util.medium import Medium + +from gajim.common import app +from gajim.common.modules.util import LogAdapter +from gajim.common.omemo.util import DEFAULT_PREKEY_AMOUNT +from gajim.common.omemo.util import IdentityKeyExtended +from gajim.common.omemo.util import Trust + + +def _convert_identity_key(key: bytes) -> Optional[IdentityKeyExtended]: + if not key: + return + return IdentityKeyExtended(CurvePublicKey(key[1:])) + + +def _convert_record(record: bytes) -> SessionRecord: + return SessionRecord(serialized=record) + + +sqlite3.register_converter('pk', _convert_identity_key) +sqlite3.register_converter('session_record', _convert_record) + + +class OMEMOStorage(AxolotlStore): + def __init__(self, account: str, db_path: Path, log: LogAdapter) -> None: + self._log = log + self._account = account + self._con = sqlite3.connect(db_path, + detect_types=sqlite3.PARSE_COLNAMES) + self._con.row_factory = self._namedtuple_factory + self.createDb() + self.migrateDb() + + self._con.execute('PRAGMA secure_delete=1') + self._con.execute('PRAGMA synchronous=NORMAL;') + mode = self._con.execute('PRAGMA journal_mode;').fetchone()[0] + + # WAL is a persistent DB mode, don't override it if user has set it + if mode != 'wal': + self._con.execute('PRAGMA journal_mode=MEMORY;') + self._con.commit() + + if not self.getLocalRegistrationId(): + self._log.info('Generating OMEMO keys') + self._generate_axolotl_keys() + + def _is_blind_trust_enabled(self) -> bool: + return app.settings.get_account_setting(self._account, + 'omemo_blind_trust') + + @staticmethod + def _namedtuple_factory(cursor: sqlite3.Cursor, + row: tuple[Any, ...] + ) -> NamedTuple: + + fields: list[str] = [] + for col in cursor.description: + if col[0] == '_id': + fields.append('id') + elif 'strftime' in col[0]: + fields.append('formated_time') + elif 'MAX' in col[0] or 'COUNT' in col[0]: + col_name = col[0].replace('(', '_') + col_name = col_name.replace(')', '') + fields.append(col_name.lower()) + else: + fields.append(col[0]) + return namedtuple('Row', fields)(*row) # pyright: ignore + + def _generate_axolotl_keys(self) -> None: + identity_key_pair = KeyHelper.generateIdentityKeyPair() + registration_id = KeyHelper.getRandomSequence(2147483647) + pre_keys = KeyHelper.generatePreKeys( + KeyHelper.getRandomSequence(4294967296), + DEFAULT_PREKEY_AMOUNT) + self.storeLocalData(registration_id, identity_key_pair) + + signed_pre_key = KeyHelper.generateSignedPreKey( + identity_key_pair, KeyHelper.getRandomSequence(65536)) + + self.storeSignedPreKey(signed_pre_key.getId(), signed_pre_key) + + for pre_key in pre_keys: + self.storePreKey(pre_key.getId(), pre_key) + + def user_version(self) -> int: + return self._con.execute('PRAGMA user_version').fetchone()[0] + + def createDb(self) -> None: + if self.user_version() == 0: + + create_tables = ''' + CREATE TABLE IF NOT EXISTS secret ( + device_id INTEGER, public_key BLOB, private_key BLOB); + + CREATE TABLE IF NOT EXISTS identities ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, recipient_id TEXT, + registration_id INTEGER, public_key BLOB, + timestamp INTEGER, trust INTEGER, + shown INTEGER DEFAULT 0); + + CREATE UNIQUE INDEX IF NOT EXISTS + public_key_index ON identities (public_key, recipient_id); + + CREATE TABLE IF NOT EXISTS prekeys( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + prekey_id INTEGER UNIQUE, sent_to_server BOOLEAN, + record BLOB); + + CREATE TABLE IF NOT EXISTS signed_prekeys ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + prekey_id INTEGER UNIQUE, + timestamp NUMERIC DEFAULT CURRENT_TIMESTAMP, record BLOB); + + CREATE TABLE IF NOT EXISTS sessions ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_id TEXT, device_id INTEGER, + record BLOB, timestamp INTEGER, active INTEGER DEFAULT 1, + UNIQUE(recipient_id, device_id)); + + ''' + + create_db_sql = ''' + BEGIN TRANSACTION; + %s + PRAGMA user_version=12; + END TRANSACTION; + ''' % (create_tables) + self._con.executescript(create_db_sql) + + def migrateDb(self) -> None: + ''' Migrates the DB + ''' + + # Find all double entries and delete them + if self.user_version() < 2: + delete_dupes = ''' DELETE FROM identities WHERE _id not in ( + SELECT MIN(_id) + FROM identities + GROUP BY + recipient_id, public_key + ); + ''' + + self._con.executescript( + ''' BEGIN TRANSACTION; + %s + PRAGMA user_version=2; + END TRANSACTION; + ''' % (delete_dupes)) + + if self.user_version() < 3: + # Create a UNIQUE INDEX so every public key/recipient_id tuple + # can only be once in the db + add_index = ''' CREATE UNIQUE INDEX IF NOT EXISTS + public_key_index + ON identities (public_key, recipient_id); + ''' + + self._con.executescript( + ''' BEGIN TRANSACTION; + %s + PRAGMA user_version=3; + END TRANSACTION; + ''' % (add_index)) + + if self.user_version() < 4: + # Adds column 'active' to the sessions table + add_active = ''' ALTER TABLE sessions + ADD COLUMN active INTEGER DEFAULT 1; + ''' + + self._con.executescript( + ''' BEGIN TRANSACTION; + %s + PRAGMA user_version=4; + END TRANSACTION; + ''' % (add_active)) + + if self.user_version() < 5: + # Adds DEFAULT Timestamp + add_timestamp = ''' + DROP TABLE signed_prekeys; + CREATE TABLE IF NOT EXISTS signed_prekeys ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + prekey_id INTEGER UNIQUE, + timestamp NUMERIC DEFAULT CURRENT_TIMESTAMP, record BLOB); + ALTER TABLE identities ADD COLUMN shown INTEGER DEFAULT 0; + UPDATE identities SET shown = 1; + ''' + + self._con.executescript( + ''' BEGIN TRANSACTION; + %s + PRAGMA user_version=5; + END TRANSACTION; + ''' % (add_timestamp)) + + if self.user_version() < 6: + # Move secret data into own table + # We add +1 to registration id because we did that in other code in + # earlier versions. On this migration we correct this mistake now. + move = ''' + CREATE TABLE IF NOT EXISTS secret ( + device_id INTEGER, public_key BLOB, private_key BLOB); + INSERT INTO secret (device_id, public_key, private_key) + SELECT registration_id + 1, public_key, private_key + FROM identities + WHERE recipient_id = -1; + ''' + + self._con.executescript( + ''' BEGIN TRANSACTION; + %s + PRAGMA user_version=6; + END TRANSACTION; + ''' % move) + + if self.user_version() < 7: + # Convert old device ids to integer + convert = ''' + UPDATE secret SET device_id = device_id % 2147483646; + ''' + + self._con.executescript( + ''' BEGIN TRANSACTION; + %s + PRAGMA user_version=7; + END TRANSACTION; + ''' % convert) + + if self.user_version() < 8: + # Sanitize invalid BLOBs from the python2 days + query_keys = '''SELECT recipient_id, + registration_id, + CAST(public_key as BLOB) as public_key, + CAST(private_key as BLOB) as private_key, + timestamp, trust, shown + FROM identities''' + rows = self._con.execute(query_keys).fetchall() + + delete = 'DELETE FROM identities' + self._con.execute(delete) + + insert = '''INSERT INTO identities ( + recipient_id, registration_id, public_key, private_key, + timestamp, trust, shown) + VALUES (?, ?, ?, ?, ?, ?, ?)''' + for row in rows: + try: + self._con.execute(insert, row) + except Exception as error: + self._log.warning(error) + self._con.execute('PRAGMA user_version=8') + self._con.commit() + + if self.user_version() < 9: + # Sanitize invalid BLOBs from the python2 days + query_keys = '''SELECT device_id, + CAST(public_key as BLOB) as public_key, + CAST(private_key as BLOB) as private_key + FROM secret''' + rows = self._con.execute(query_keys).fetchall() + + delete = 'DELETE FROM secret' + self._con.execute(delete) + + insert = '''INSERT INTO secret (device_id, public_key, private_key) + VALUES (?, ?, ?)''' + for row in rows: + try: + self._con.execute(insert, row) + except Exception as error: + self._log.warning(error) + self._con.execute('PRAGMA user_version=9') + self._con.commit() + + if self.user_version() < 10: + # Sanitize invalid BLOBs from the python2 days + query_keys = '''SELECT _id, + recipient_id, + device_id, + CAST(record as BLOB) as record, + timestamp, + active + FROM sessions''' + rows = self._con.execute(query_keys).fetchall() + + delete = 'DELETE FROM sessions' + self._con.execute(delete) + + insert = '''INSERT INTO sessions (_id, recipient_id, device_id, + record, timestamp, active) + VALUES (?, ?, ?, ?, ?, ?)''' + for row in rows: + try: + self._con.execute(insert, row) + except Exception as error: + self._log.warning(error) + self._con.execute('PRAGMA user_version=10') + self._con.commit() + + if self.user_version() < 11: + # Sanitize invalid BLOBs from the python2 days + query_keys = '''SELECT _id, + prekey_id, + sent_to_server, + CAST(record as BLOB) as record + FROM prekeys''' + rows = self._con.execute(query_keys).fetchall() + + delete = 'DELETE FROM prekeys' + self._con.execute(delete) + + insert = '''INSERT INTO prekeys ( + _id, prekey_id, sent_to_server, record) + VALUES (?, ?, ?, ?)''' + for row in rows: + try: + self._con.execute(insert, row) + except Exception as error: + self._log.warning(error) + self._con.execute('PRAGMA user_version=11') + self._con.commit() + + if self.user_version() < 12: + # Sanitize invalid BLOBs from the python2 days + query_keys = '''SELECT _id, + prekey_id, + timestamp, + CAST(record as BLOB) as record + FROM signed_prekeys''' + rows = self._con.execute(query_keys).fetchall() + + delete = 'DELETE FROM signed_prekeys' + self._con.execute(delete) + + insert = '''INSERT INTO signed_prekeys ( + _id, prekey_id, timestamp, record) + VALUES (?, ?, ?, ?)''' + for row in rows: + try: + self._con.execute(insert, row) + except Exception as error: + self._log.warning(error) + self._con.execute('PRAGMA user_version=12') + self._con.commit() + + def loadSignedPreKey(self, signedPreKeyId: int) -> SignedPreKeyRecord: + query = 'SELECT record FROM signed_prekeys WHERE prekey_id = ?' + result = self._con.execute(query, (signedPreKeyId, )).fetchone() + if result is None: + raise InvalidKeyIdException('No such signedprekeyrecord! %s ' % + signedPreKeyId) + return SignedPreKeyRecord.from_bytes(result.record) + + def loadSignedPreKeys(self) -> list[SignedPreKeyRecord]: + query = 'SELECT record FROM signed_prekeys' + results = self._con.execute(query).fetchall() + return [SignedPreKeyRecord.from_bytes(row.record) for row in results] + + def storeSignedPreKey(self, + signedPreKeyId: int, + signedPreKeyRecord: SignedPreKeyRecord + ) -> None: + + query = 'INSERT INTO signed_prekeys (prekey_id, record) VALUES(?,?)' + self._con.execute(query, (signedPreKeyId, + signedPreKeyRecord.serialize())) + self._con.commit() + + def containsSignedPreKey(self, signedPreKeyId: int) -> bool: + query = 'SELECT record FROM signed_prekeys WHERE prekey_id = ?' + result = self._con.execute(query, (signedPreKeyId,)).fetchone() + return result is not None + + def removeSignedPreKey(self, signedPreKeyId: int) -> None: + query = 'DELETE FROM signed_prekeys WHERE prekey_id = ?' + self._con.execute(query, (signedPreKeyId,)) + self._con.commit() + + def getNextSignedPreKeyId(self) -> int: + result = self.getCurrentSignedPreKeyId() + if result is None: + return 1 # StartId if no SignedPreKeys exist + return (result % (Medium.MAX_VALUE - 1)) + 1 + + def getCurrentSignedPreKeyId(self) -> Optional[int]: + query = 'SELECT MAX(prekey_id) FROM signed_prekeys' + result = self._con.execute(query).fetchone() + return result.max_prekey_id if result is not None else None + + def getSignedPreKeyTimestamp(self, signedPreKeyId: int) -> int: + query = '''SELECT strftime('%s', timestamp) FROM + signed_prekeys WHERE prekey_id = ?''' + + result = self._con.execute(query, (signedPreKeyId,)).fetchone() + if result is None: + raise InvalidKeyIdException('No such signedprekeyrecord! %s' % + signedPreKeyId) + + return result.formated_time + + def removeOldSignedPreKeys(self, timestamp: int) -> None: + query = '''DELETE FROM signed_prekeys + WHERE timestamp < datetime(?, "unixepoch")''' + self._con.execute(query, (timestamp,)) + self._con.commit() + + def loadSession(self, recipientId: str, deviceId: int) -> SessionRecord: + query = '''SELECT record as "record [session_record]" + FROM sessions WHERE recipient_id = ? AND device_id = ?''' + result = self._con.execute(query, (recipientId, deviceId)).fetchone() + return result.record if result is not None else SessionRecord() + + def getJidFromDevice(self, device_id: int) -> Optional[str]: + query = '''SELECT recipient_id + FROM sessions WHERE device_id = ?''' + result = self._con.execute(query, (device_id, )).fetchone() + return result.recipient_id if result is not None else None + + def getActiveDeviceTuples(self): + query = '''SELECT recipient_id, device_id + FROM sessions WHERE active = 1''' + return self._con.execute(query).fetchall() + + def storeSession(self, + recipientId: str, + deviceId: int, + sessionRecord: SessionRecord + ) -> None: + + query = '''INSERT INTO sessions(recipient_id, device_id, record) + VALUES(?,?,?)''' + try: + self._con.execute(query, (recipientId, + deviceId, + sessionRecord.serialize())) + except sqlite3.IntegrityError: + query = '''UPDATE sessions SET record = ? + WHERE recipient_id = ? AND device_id = ?''' + self._con.execute(query, (sessionRecord.serialize(), + recipientId, + deviceId)) + + self._con.commit() + + def containsSession(self, recipientId: str, deviceId: int) -> bool: + query = '''SELECT record FROM sessions + WHERE recipient_id = ? AND device_id = ?''' + result = self._con.execute(query, (recipientId, deviceId)).fetchone() + return result is not None + + def deleteSession(self, recipientId: str, deviceId: int) -> None: + self._log.info('Delete session for %s %s', recipientId, deviceId) + query = 'DELETE FROM sessions WHERE recipient_id = ? AND device_id = ?' + self._con.execute(query, (recipientId, deviceId)) + self._con.commit() + + def deleteAllSessions(self, recipientId: str) -> None: + query = 'DELETE FROM sessions WHERE recipient_id = ?' + self._con.execute(query, (recipientId,)) + self._con.commit() + + def getSessionsFromJid(self, recipientId: str): + query = '''SELECT recipient_id, + device_id, + record as "record [session_record]", + active + FROM sessions WHERE recipient_id = ?''' + return self._con.execute(query, (recipientId,)).fetchall() + + def getSessionsFromJids(self, recipientIds: list[str]): + query = ''' + SELECT recipient_id, + device_id, + record as "record [session_record]", + active + FROM sessions + WHERE recipient_id IN ({})'''.format( + ', '.join(['?'] * len(recipientIds))) + return self._con.execute(query, recipientIds).fetchall() + + def setActiveState(self, jid: str, devicelist: list[int]) -> None: + query = ''' + UPDATE sessions SET active = 1 + WHERE recipient_id = ? AND device_id IN ({})'''.format( + ', '.join(['?'] * len(devicelist))) + self._con.execute(query, (jid,) + tuple(devicelist)) + + query = ''' + UPDATE sessions SET active = 0 + WHERE recipient_id = ? AND device_id NOT IN ({})'''.format( + ', '.join(['?'] * len(devicelist))) + self._con.execute(query, (jid,) + tuple(devicelist)) + self._con.commit() + + def setInactive(self, jid: str, device_id: int) -> None: + query = '''UPDATE sessions SET active = 0 + WHERE recipient_id = ? AND device_id = ?''' + self._con.execute(query, (jid, device_id)) + self._con.commit() + + def getInactiveSessionsKeys(self, + recipientId: str + ) -> list[IdentityKeyExtended]: + + query = '''SELECT record as "record [session_record]" FROM sessions + WHERE active = 0 AND recipient_id = ?''' + results = self._con.execute(query, (recipientId,)).fetchall() + + keys: list[IdentityKeyExtended] = [] + for result in results: + key = result.record.getSessionState().getRemoteIdentityKey() + keys.append(IdentityKeyExtended(key.getPublicKey())) + return keys + + def loadPreKey(self, preKeyId: int) -> PreKeyRecord: + query = '''SELECT record FROM prekeys WHERE prekey_id = ?''' + + result = self._con.execute(query, (preKeyId,)).fetchone() + if result is None: + raise Exception('No such prekeyRecord!') + return PreKeyRecord.from_bytes(result.record) + + def loadPendingPreKeys(self) -> list[PreKeyRecord]: + query = '''SELECT record FROM prekeys''' + result = self._con.execute(query).fetchall() + return [PreKeyRecord.from_bytes(row.record) for row in result] + + def storePreKey(self, preKeyId: int, preKeyRecord: PreKeyRecord) -> None: + query = 'INSERT INTO prekeys (prekey_id, record) VALUES(?,?)' + self._con.execute(query, (preKeyId, preKeyRecord.serialize())) + self._con.commit() + + def containsPreKey(self, preKeyId: int) -> bool: + query = 'SELECT record FROM prekeys WHERE prekey_id = ?' + result = self._con.execute(query, (preKeyId,)).fetchone() + return result is not None + + def removePreKey(self, preKeyId: int) -> None: + query = 'DELETE FROM prekeys WHERE prekey_id = ?' + self._con.execute(query, (preKeyId,)) + self._con.commit() + + def getCurrentPreKeyId(self) -> int: + query = 'SELECT MAX(prekey_id) FROM prekeys' + return self._con.execute(query).fetchone().max_prekey_id + + def getPreKeyCount(self) -> int: + query = 'SELECT COUNT(prekey_id) FROM prekeys' + return self._con.execute(query).fetchone().count_prekey_id + + def generateNewPreKeys(self, count: int) -> None: + prekey_id = self.getCurrentPreKeyId() or 0 + pre_keys = KeyHelper.generatePreKeys(prekey_id + 1, count) + for pre_key in pre_keys: + self.storePreKey(pre_key.getId(), pre_key) + + def getIdentityKeyPair(self) -> IdentityKeyPair: + query = '''SELECT public_key as "public_key [pk]", private_key + FROM secret LIMIT 1''' + result = self._con.execute(query).fetchone() + + return IdentityKeyPair.new(result.public_key, + DjbECPrivateKey(result.private_key)) + + def getLocalRegistrationId(self) -> Optional[int]: # pyright: ignore + query = 'SELECT device_id FROM secret LIMIT 1' + result = self._con.execute(query).fetchone() + return result.device_id if result is not None else None + + def storeLocalData(self, + device_id: int, + identityKeyPair: IdentityKeyPair + ) -> None: + + query = 'SELECT * FROM secret' + result = self._con.execute(query).fetchone() + if result is not None: + self._log.error('Trying to save secret key into ' + 'non-empty secret table') + return + + query = '''INSERT INTO secret(device_id, public_key, private_key) + VALUES(?, ?, ?)''' + + public_key = identityKeyPair.getPublicKey().getPublicKey().serialize() + private_key = identityKeyPair.getPrivateKey().serialize() + self._con.execute(query, (device_id, public_key, private_key)) + self._con.commit() + + def saveIdentity(self, recipientId: str, identityKey: IdentityKey) -> None: + query = '''INSERT INTO identities + (recipient_id, public_key, trust, shown) + VALUES(?, ?, ?, ?)''' + if not self.containsIdentity(recipientId, identityKey): + trust = self.getDefaultTrust(recipientId) + self._con.execute(query, (recipientId, + identityKey.getPublicKey().serialize(), + trust, + 1 if trust == Trust.BLIND else 0)) + self._con.commit() + + def containsIdentity(self, + recipientId: str, + identityKey: IdentityKey + ) -> bool: + + query = '''SELECT * FROM identities WHERE recipient_id = ? + AND public_key = ?''' + + public_key = identityKey.getPublicKey().serialize() + result = self._con.execute(query, (recipientId, + public_key)).fetchone() + + return result is not None + + def deleteIdentity(self, + recipientId: str, + identityKey: IdentityKey + ) -> None: + + query = '''DELETE FROM identities + WHERE recipient_id = ? AND public_key = ?''' + public_key = identityKey.getPublicKey().serialize() + self._con.execute(query, (recipientId, public_key)) + self._con.commit() + + def isTrustedIdentity(self, + recipientId: str, + identityKey: IdentityKey + ) -> bool: + + return True + + def getTrustForIdentity(self, + recipientId: str, + identityKey: IdentityKey + ) -> Optional[Trust]: + + query = '''SELECT trust FROM identities WHERE recipient_id = ? + AND public_key = ?''' + public_key = identityKey.getPublicKey().serialize() + result = self._con.execute(query, (recipientId, public_key)).fetchone() + return result.trust if result is not None else None + + def getFingerprints(self, jid: str): + query = '''SELECT recipient_id, + public_key as "public_key [pk]", + trust, + timestamp + FROM identities + WHERE recipient_id = ? ORDER BY trust ASC''' + return self._con.execute(query, (jid,)).fetchall() + + def getMucFingerprints(self, jids: list[str]): + query = ''' + SELECT recipient_id, + public_key as "public_key [pk]", + trust, + timestamp + FROM identities + WHERE recipient_id IN ({}) ORDER BY trust ASC + '''.format(', '.join(['?'] * len(jids))) + + return self._con.execute(query, jids).fetchall() + + def hasUndecidedFingerprints(self, jid: str) -> bool: + query = '''SELECT public_key as "public_key [pk]" FROM identities + WHERE recipient_id = ? AND trust = ?''' + result = self._con.execute(query, (jid, Trust.UNDECIDED)).fetchall() + undecided = [row.public_key for row in result] + + inactive = self.getInactiveSessionsKeys(jid) + undecided = set(undecided) - set(inactive) + return bool(undecided) + + def getDefaultTrust(self, jid: str) -> Trust: + if not self._is_blind_trust_enabled(): + return Trust.UNDECIDED + + query = '''SELECT * FROM identities + WHERE recipient_id = ? AND trust IN (0, 1)''' + result = self._con.execute(query, (jid,)).fetchone() + if result is None: + return Trust.BLIND + return Trust.UNDECIDED + + def getTrustedFingerprints(self, jid: str) -> list[IdentityKeyExtended]: + query = '''SELECT public_key as "public_key [pk]" FROM identities + WHERE recipient_id = ? AND trust IN(1, 3)''' + result = self._con.execute(query, (jid,)).fetchall() + return [row.public_key for row in result] + + def getNewFingerprints(self, jid: str) -> list[int]: + query = '''SELECT _id FROM identities WHERE shown = 0 + AND recipient_id = ?''' + + result = self._con.execute(query, (jid,)).fetchall() + return [row.id for row in result] + + def setShownFingerprints(self, fingerprints: list[int]) -> None: + query = 'UPDATE identities SET shown = 1 WHERE _id IN ({})'.format( + ', '.join(['?'] * len(fingerprints))) + self._con.execute(query, fingerprints) + self._con.commit() + + def setTrust(self, + recipient_id: str, + identityKey: IdentityKey, + trust: Trust + ) -> None: + + query = '''UPDATE identities SET trust = ? WHERE public_key = ? + AND recipient_id = ?''' + public_key = identityKey.getPublicKey().serialize() + self._con.execute(query, (trust, public_key, recipient_id)) + self._con.commit() + + def isTrusted(self, recipient_id: str, device_id: int) -> bool: + record = self.loadSession(recipient_id, device_id) + if record.isFresh(): + return False + identity_key = record.getSessionState().getRemoteIdentityKey() + return self.getTrustForIdentity( + recipient_id, identity_key) in (Trust.VERIFIED, Trust.BLIND) + + def getIdentityLastSeen(self, + recipient_id: str, + identity_key: IdentityKey + ) -> Optional[int]: + + serialized = identity_key.getPublicKey().serialize() + query = '''SELECT timestamp FROM identities + WHERE recipient_id = ? AND public_key = ?''' + result = self._con.execute(query, (recipient_id, + serialized)).fetchone() + return result.timestamp if result is not None else None + + def setIdentityLastSeen(self, + recipient_id: str, + identity_key: IdentityKey + ) -> None: + + timestamp = int(time.time()) + serialized = identity_key.getPublicKey().serialize() + self._log.info('Set last seen for %s %s', recipient_id, timestamp) + query = '''UPDATE identities SET timestamp = ? + WHERE recipient_id = ? AND public_key = ?''' + self._con.execute(query, (timestamp, recipient_id, serialized)) + self._con.commit() + + def getUnacknowledgedCount(self, recipient_id: str, device_id: int) -> int: + record = self.loadSession(recipient_id, device_id) + if record.isFresh(): + return 0 + state = record.getSessionState() + return state.getSenderChainKey().getIndex() + + def getSubDeviceSessions(self, recipientId: str) -> list[int]: + # Not used + return [] diff --git a/gajim/data/gui/contact_info.ui b/gajim/data/gui/contact_info.ui index 477e8183b..fbd7a3a51 100644 --- a/gajim/data/gui/contact_info.ui +++ b/gajim/data/gui/contact_info.ui @@ -1,5 +1,5 @@ - + @@ -726,6 +726,39 @@ 1 + + + True + True + never + + + True + False + + + True + False + 36 + 36 + 36 + 36 + vertical + + + + + + + + + + encryption-omemo + Encryption (OMEMO) + channel-secure-symbolic + 2 + + True @@ -874,7 +907,7 @@ groups Groups view-pin-symbolic - 2 + 3 @@ -935,7 +968,7 @@ notes Notes x-office-address-book-symbolic - 3 + 4 @@ -1005,7 +1038,7 @@ devices Devices computer-symbolic - 4 + 5 + + + False + True + 0 + + + + + True + False + 13 + True + 42 + 0 + + + + False + True + 1 + + + + + True + True + True + + + + False + True + 2 + + + + + True + False + center + 6 + + + + False + True + 3 + + + + + True + False + For verification via QR-Code you have to install <tt>python-qrcode</tt>. + True + True + 42 + 0 + + + + False + True + 4 + + + + + + + + 400 + True + False + True + True + crossfade + True + + + True + False + vertical + 12 + + + True + False + This Device + 0 + + + + False + True + 0 + + + + + True + False + none + + + True + True + False + + + True + False + 12 + + + True + False + vertical + 3 + + + True + False + Fingerprint for this Device + 0 + + + False + True + 0 + + + + + True + True + True + 0 + + + + False + True + 1 + + + + + False + True + 0 + + + + + True + True + True + Scan QR Code + end + center + up + qr_code_popover + + + True + False + qr-code-scan-symbolic + + + + + + False + True + end + 1 + + + + + True + True + True + Manage your Devices… + center + + + + True + False + preferences-system-symbolic + + + + + + False + True + end + 2 + + + + + + + + + + False + True + 1 + + + + + True + False + 12 + True + 42 + 0 + + + + False + True + 2 + + + + + True + False + 12 + + + True + True + center + + + + False + True + end + 0 + + + + + True + False + Show Inactive Devices + + + False + True + end + 1 + + + + + True + True + True + Search… + start + center + search_popover + + + True + False + edit-find-symbolic + + + + + False + True + 2 + + + + + False + True + 3 + + + + + True + False + none + + + True + True + 4 + + + + + Clear Devices… + True + True + True + start + + + + + False + True + 5 + + + + + + manage-keys + + + + + True + False + center + center + vertical + + + True + False + Account is not connected. + True + 42 + + + + False + True + 0 + + + + + no-connection + 1 + + + + diff --git a/gajim/data/icons/hicolor/scalable/categories/qr-code-scan-symbolic.svg b/gajim/data/icons/hicolor/scalable/categories/qr-code-scan-symbolic.svg new file mode 100644 index 000000000..adc7dc0ed --- /dev/null +++ b/gajim/data/icons/hicolor/scalable/categories/qr-code-scan-symbolic.svg @@ -0,0 +1,75 @@ + + diff --git a/gajim/data/style/gajim.css b/gajim/data/style/gajim.css index 0c0e4caca..ccabee8f7 100644 --- a/gajim/data/style/gajim.css +++ b/gajim/data/style/gajim.css @@ -956,6 +956,27 @@ popover.combo scrollbar.vertical { border-bottom: 0px; } +/* OMEMO */ +.fingerprint { + color: @insensitive_fg_color; + font-family: monospace; + font-size: 90%; +} +.omemo-inactive-color { color: @insensitive_fg_color; } + +.omemo-trust-manager list row { + border-bottom: 1px solid; + border-color: @unfocused_borders; + padding: 10px 20px 10px 10px; +} +.omemo-trust-manager list { + border: 1px solid; + border-color:@unfocused_borders; +} +.omemo-trust-manager list > row { outline: none; } + +.omemo-trust-popover row { padding: 10px 15px 10px 10px; } + /* StartChatDialog */ .start-chat-treeview { padding: 5px; } .start-chat-treeview:focus {outline: none; } diff --git a/gajim/gtk/accounts.py b/gajim/gtk/accounts.py index d49cafb37..c03b13e2d 100644 --- a/gajim/gtk/accounts.py +++ b/gajim/gtk/accounts.py @@ -42,6 +42,7 @@ from gajim.gtk.const import SettingType from gajim.gtk.dialogs import ConfirmationDialog from gajim.gtk.dialogs import DialogButton from gajim.gtk.menus import build_accounts_menu +from gajim.gtk.omemo_trust_manager import OMEMOTrustManager from gajim.gtk.settings import PopoverSetting from gajim.gtk.settings import SettingsBox from gajim.gtk.settings import SettingsDialog @@ -327,6 +328,7 @@ class AccountSubMenu(Gtk.ListBox): self.add(BackMenuItem()) self.add(PageMenuItem('general', _('General'))) self.add(PageMenuItem('privacy', _('Privacy'))) + self.add(PageMenuItem('encryption-omemo', _('Encryption (OMEMO)'))) self.add(PageMenuItem('connection', _('Connection'))) self.add(PageMenuItem('advanced', _('Advanced'))) self.add(RemoveMenuItem()) @@ -429,6 +431,8 @@ class PageMenuItem(MenuItem): icon = 'avatar-default-symbolic' elif name == 'privacy': icon = 'preferences-system-privacy-symbolic' + elif name == 'encryption-omemo': + icon = 'channel-secure-symbolic' elif name == 'connection': icon = 'preferences-system-network-symbolic' elif name == 'advanced': @@ -454,8 +458,9 @@ class Account: self._settings = settings self._settings.add_page(GeneralPage(account)) - self._settings.add_page(ConnectionPage(account)) self._settings.add_page(PrivacyPage(account)) + self._settings.add_page(EncryptionOMEMOPage(account)) + self._settings.add_page(ConnectionPage(account)) self._settings.add_page(AdvancedPage(account)) self._account_row = AccountRow(account) @@ -639,6 +644,7 @@ class GenericSettingPage(Gtk.Box): Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL, spacing=12) self.set_valign(Gtk.Align.START) self.set_vexpand(True) + self.get_style_context().add_class('settings-page') self.account = account self.listbox = SettingsBox(account) @@ -915,6 +921,39 @@ class PrivacyPage(GenericSettingPage): 'send_marker', None, context='private') +class EncryptionOMEMOPage(GenericSettingPage): + + name = 'encryption-omemo' + + def __init__(self, account: str) -> None: + settings = [ + Setting(SettingKind.SWITCH, + _('Blind Trust'), + SettingType.ACCOUNT_CONFIG, + 'omemo_blind_trust', + desc=_('Blindly trust new devices until you verify them')) + ] + GenericSettingPage.__init__(self, account, settings) + + heading = Gtk.Label(label=_('Trust Management')) + heading.get_style_context().add_class('bold') + heading.set_xalign(0) + self.add(heading) + + btbv_label = Gtk.Label() + btbv_label.set_xalign(0) + markup = '%s' % ( + 'https://dev.gajim.org/gajim/gajim/-/wikis/help/OMEMO', + _('Read more about blind trust')) + btbv_label.set_markup(markup) + self.add(btbv_label) + + omemo_trust_manager = OMEMOTrustManager(account) + omemo_trust_manager.set_margin_top(18) + self.pack_end(omemo_trust_manager, True, True, 0) + self.reorder_child(omemo_trust_manager, 0) + + class ConnectionPage(GenericSettingPage): name = 'connection' diff --git a/gajim/gtk/builder.pyi b/gajim/gtk/builder.pyi index 0bbc8cb8a..05f05eeed 100644 --- a/gajim/gtk/builder.pyi +++ b/gajim/gtk/builder.pyi @@ -280,6 +280,8 @@ class ContactInfoBuilder(Builder): to_subscription_button: Gtk.Button contact_settings_box: Gtk.Box remove_history_button: Gtk.Button + encryption_scrolled: Gtk.ScrolledWindow + encryption_box: Gtk.Box groups_page_box: Gtk.Box groups_treeview: Gtk.TreeView tree_selection: Gtk.TreeSelection @@ -414,6 +416,8 @@ class GroupchatDetailsBuilder(Builder): main_stack: Gtk.Stack info_box: Gtk.Box settings_box: Gtk.Box + encryption_scrolled: Gtk.ScrolledWindow + encryption_box: Gtk.Box manage_box: Gtk.Box affiliation_box: Gtk.Box outcasts_box: Gtk.Box @@ -591,6 +595,26 @@ class MessageActionsBoxBuilder(Builder): input_scrolled: Gtk.ScrolledWindow +class OmemoTrustManagerBuilder(Builder): + search_popover: Gtk.Popover + search: Gtk.SearchEntry + qr_code_popover: Gtk.Popover + comparing_instructions: Gtk.Label + our_fingerprint_2: Gtk.Label + qr_code_image: Gtk.Image + qr_dependency_missing: Gtk.Label + stack: Gtk.Stack + our_fingerprint_1: Gtk.Label + qr_menu_button: Gtk.MenuButton + manage_trust_button: Gtk.Button + list_heading: Gtk.Label + list_heading_box: Gtk.Box + show_inactive_switch: Gtk.Switch + search_button: Gtk.MenuButton + list: Gtk.ListBox + clear_devices_button: Gtk.Button + + class PasswordDialogBuilder(Builder): pass_box: Gtk.Box header: Gtk.Label @@ -1028,6 +1052,8 @@ def get_builder(file_name: Literal['message_actions_box.ui'], widgets: list[str] @overload def get_builder(file_name: Literal['password_dialog.ui'], widgets: list[str] = ...) -> PasswordDialogBuilder: ... # noqa @overload +def get_builder(file_name: Literal['omemo_trust_manager.ui'], widgets: list[str] = ...) -> OmemoTrustManagerBuilder: ... #noqa +@overload def get_builder(file_name: Literal['pep_config.ui'], widgets: list[str] = ...) -> PepConfigBuilder: ... # noqa @overload def get_builder(file_name: Literal['plugins.ui'], widgets: list[str] = ...) -> PluginsBuilder: ... # noqa diff --git a/gajim/gtk/chat_stack.py b/gajim/gtk/chat_stack.py index 9ffa32a3d..51a226e60 100644 --- a/gajim/gtk/chat_stack.py +++ b/gajim/gtk/chat_stack.py @@ -742,8 +742,14 @@ class ChatStack(Gtk.Stack, EventHelper): contact = self._current_contact assert contact is not None + client = app.get_client(contact.account) + encryption = contact.settings.get('encryption') - if encryption: + if encryption == 'OMEMO': + if not client.get_module('OMEMO').check_send_preconditions(contact): + return + + elif encryption: if encryption not in app.plugin_manager.encryption_plugins: ErrorDialog(_('Encryption error'), _('Missing necessary encryption plugin')) @@ -756,8 +762,6 @@ class ChatStack(Gtk.Stack, EventHelper): if not self._chat_control.sendmessage: return - client = app.get_client(contact.account) - message = remove_invalid_xml_chars(message) if message in ('', '\n'): return diff --git a/gajim/gtk/contact_info.py b/gajim/gtk/contact_info.py index 2d87fa6bf..632946fb1 100644 --- a/gajim/gtk/contact_info.py +++ b/gajim/gtk/contact_info.py @@ -49,6 +49,7 @@ from gajim.gtk.builder import get_builder from gajim.gtk.contact_settings import ContactSettings from gajim.gtk.dialogs import ConfirmationDialog from gajim.gtk.dialogs import DialogButton +from gajim.gtk.omemo_trust_manager import OMEMOTrustManager from gajim.gtk.sidebar_switcher import SideBarSwitcher from gajim.gtk.structs import RemoveHistoryActionParams from gajim.gtk.util import connect_destroy @@ -108,6 +109,7 @@ class ContactInfo(Gtk.ApplicationWindow, EventHelper): if isinstance(self.contact, BareContact): self._fill_settings_page(self.contact) + self._fill_encryption_page(self.contact) self._fill_device_info(self.contact) self._fill_groups_page(self.contact) self._fill_note_page(self.contact) @@ -119,7 +121,6 @@ class ContactInfo(Gtk.ApplicationWindow, EventHelper): self.connect('key-press-event', self._on_key_press) self.connect('destroy', self._on_destroy) - # pylint: disable=line-too-long connect_destroy(self._ui.tree_selection, 'changed', self._on_group_selection_changed) connect_destroy(self._ui.toggle_renderer, @@ -133,7 +134,6 @@ class ContactInfo(Gtk.ApplicationWindow, EventHelper): ('unsubscribed-presence-received', ged.GUI1, self._on_unsubscribed_presence_received), ]) - # pylint: enable=line-too-long self.add(self._ui.main_grid) self.show_all() @@ -184,6 +184,11 @@ class ContactInfo(Gtk.ApplicationWindow, EventHelper): self._switcher.set_row_visible('information', True) + def _fill_encryption_page(self, contact: ContactT) -> None: + self._ui.encryption_box.add( + OMEMOTrustManager(self.contact.account, self.contact)) + self._switcher.set_row_visible('encryption-omemo', True) + def _fill_note_page(self, contact: BareContact) -> None: if not contact.is_in_roster: return diff --git a/gajim/gtk/control.py b/gajim/gtk/control.py index 8190dc0e3..a59760e9c 100644 --- a/gajim/gtk/control.py +++ b/gajim/gtk/control.py @@ -232,6 +232,7 @@ class ChatControl(EventHelper): ('file-request-sent', ged.GUI2, self._on_file_request_event), ('http-upload-started', ged.GUI2, self._on_http_upload_started), ('http-upload-error', ged.GUI2, self._on_http_upload_error), + ('encryption-check', ged.GUI2, self._on_encryption_info), ]) def _is_event_processable(self, event: Any) -> bool: @@ -445,6 +446,13 @@ class ChatControl(EventHelper): def _on_http_upload_error(self, event: events.HTTPUploadError) -> None: self.add_info_message(event.error_msg) + def _on_encryption_info(self, event: events.EncryptionInfo) -> None: + if not self._is_event_processable(event): + return + + if self._allow_add_message(): + self._scrolled_view.add_encryption_info(event) + @property def is_chat(self) -> bool: return isinstance(self.contact, BareContact) diff --git a/gajim/gtk/conversation/rows/encryption_info.py b/gajim/gtk/conversation/rows/encryption_info.py new file mode 100644 index 000000000..deedf2e77 --- /dev/null +++ b/gajim/gtk/conversation/rows/encryption_info.py @@ -0,0 +1,85 @@ +# This file is part of Gajim. +# +# Gajim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation; version 3 only. +# +# Gajim is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Gajim. If not, see . + +import time +from datetime import datetime + +from gi.repository import Gtk + +from gajim.common.const import AvatarSize +from gajim.common.const import EncryptionInfoMsg +from gajim.common.events import EncryptionInfo +from gajim.common.i18n import _ +from gajim.common.modules.contacts import BareContact + +from ...util import open_window +from .base import BaseRow +from .widgets import DateTimeLabel +from .widgets import SimpleLabel + + +class EncryptionInfoRow(BaseRow): + def __init__(self, event: EncryptionInfo) -> None: + BaseRow.__init__(self, event.account) + + self.type = 'encryption_info' + timestamp = time.time() + self.timestamp = datetime.fromtimestamp(timestamp) + self._event = event + + avatar_placeholder = Gtk.Box() + avatar_placeholder.set_size_request(AvatarSize.ROSTER, -1) + + icon = Gtk.Image.new_from_icon_name('channel-secure-symbolic', + Gtk.IconSize.LARGE_TOOLBAR) + icon.get_style_context().add_class('dim-label') + avatar_placeholder.add(icon) + self.grid.attach(avatar_placeholder, 0, 0, 1, 1) + + timestamp_widget = DateTimeLabel(self.timestamp) + timestamp_widget.set_valign(Gtk.Align.START) + timestamp_widget.set_margin_start(0) + self.grid.attach(timestamp_widget, 1, 0, 1, 1) + + self._label = SimpleLabel() + self._label.set_text(event.message.value) + self.grid.attach(self._label, 1, 1, 1, 1) + + if event.message in (EncryptionInfoMsg.NO_FINGERPRINTS, + EncryptionInfoMsg.UNDECIDED_FINGERPRINTS): + button = Gtk.Button(label=_('Manage Trust')) + button.set_halign(Gtk.Align.START) + button.connect('clicked', self._on_manage_trust_clicked) + self.grid.attach(button, 1, 2, 1, 1) + + self.show_all() + + def _on_manage_trust_clicked(self, _button: Gtk.Button) -> None: + contact = self._client.get_module('Contacts').get_contact( + self._event.jid) + if contact.is_groupchat: + open_window('GroupchatDetails', + contact=contact, + page='encryption-omemo') + return + + if isinstance(contact, BareContact) and contact.is_self: + window = open_window('AccountsWindow') + window.select_account(contact.account, page='encryption-omemo') + return + + open_window('ContactInfo', + account=contact.account, + contact=contact, + page='encryption-omemo') diff --git a/gajim/gtk/conversation/view.py b/gajim/gtk/conversation/view.py index 4bd6db2b1..561bcfcd9 100644 --- a/gajim/gtk/conversation/view.py +++ b/gajim/gtk/conversation/view.py @@ -52,6 +52,7 @@ from gajim.gtk.conversation.rows.base import BaseRow from gajim.gtk.conversation.rows.call import CallRow from gajim.gtk.conversation.rows.command_output import CommandOutputRow from gajim.gtk.conversation.rows.date import DateRow +from gajim.gtk.conversation.rows.encryption_info import EncryptionInfoRow from gajim.gtk.conversation.rows.file_transfer import FileTransferRow from gajim.gtk.conversation.rows.file_transfer_jingle import \ FileTransferJingleRow @@ -476,6 +477,10 @@ class ConversationView(Gtk.ScrolledWindow): db_message=db_message) self._insert_message(jingle_transfer_row) + def add_encryption_info(self, event: events.EncryptionInfo) -> None: + assert self._contact is not None + self._insert_message(EncryptionInfoRow(event)) + def add_call_message(self, event: Optional[events.JingleRequestReceived] = None, db_message: Optional[ConversationRow] = None diff --git a/gajim/gtk/groupchat_details.py b/gajim/gtk/groupchat_details.py index 15129968c..4efed4b45 100644 --- a/gajim/gtk/groupchat_details.py +++ b/gajim/gtk/groupchat_details.py @@ -34,6 +34,7 @@ from gajim.gtk.groupchat_info import GroupChatInfoScrolled from gajim.gtk.groupchat_manage import GroupchatManage from gajim.gtk.groupchat_outcasts import GroupchatOutcasts from gajim.gtk.groupchat_settings import GroupChatSettings +from gajim.gtk.omemo_trust_manager import OMEMOTrustManager from gajim.gtk.sidebar_switcher import SideBarSwitcher from gajim.gtk.structs import RemoveHistoryActionParams @@ -71,6 +72,7 @@ class GroupchatDetails(Gtk.ApplicationWindow): self._add_groupchat_info() self._add_groupchat_settings() + self._add_groupchat_encryption() self._add_groupchat_manage() self._add_affiliations() self._add_outcasts() @@ -173,6 +175,10 @@ class GroupchatDetails(Gtk.ApplicationWindow): self._ui.settings_box.add(scrolled_window) + def _add_groupchat_encryption(self) -> None: + self._ui.encryption_box.add( + OMEMOTrustManager(self._contact.account, self._contact)) + def _add_affiliations(self) -> None: affiliations = GroupchatAffiliation(self._client, self._contact) self._ui.affiliation_box.add(affiliations) diff --git a/gajim/gtk/message_actions_box.py b/gajim/gtk/message_actions_box.py index 8b7faa8be..74d272a2d 100644 --- a/gajim/gtk/message_actions_box.py +++ b/gajim/gtk/message_actions_box.py @@ -36,6 +36,7 @@ from gajim.common.commands import CommandFailed from gajim.common.const import Direction from gajim.common.const import SimpleClientState from gajim.common.i18n import _ +from gajim.common.modules.contacts import BareContact from gajim.common.modules.contacts import GroupchatContact from gajim.common.modules.contacts import GroupchatParticipant from gajim.common.types import ChatContactT @@ -46,6 +47,7 @@ from gajim.gtk.menus import get_encryption_menu from gajim.gtk.menus import get_format_menu from gajim.gtk.message_input import MessageInputTextView from gajim.gtk.security_label_selector import SecurityLabelSelector +from gajim.gtk.util import open_window log = logging.getLogger('gajim.gtk.messageactionsbox') @@ -278,7 +280,7 @@ class MessageActionsBox(Gtk.Grid): if current_state == new_state: return - if new_state: + if new_state and new_state != 'OMEMO': plugin = app.plugin_manager.encryption_plugins.get(new_state) if plugin is None: # TODO: Add GUI error here @@ -322,17 +324,21 @@ class MessageActionsBox(Gtk.Grid): def _update_encryption_details_button(self) -> None: contact = self.get_current_contact() - state = contact.settings.get('encryption') + encryption = contact.settings.get('encryption') - encryption_state = {'visible': bool(state), - 'enc_type': state, + encryption_state = {'visible': bool(encryption), + 'enc_type': encryption, 'authenticated': False} - if state: - app.plugin_manager.extension_point( - f'encryption_state{state}', - app.window.get_control(), - encryption_state) + if encryption: + if encryption == 'OMEMO': + encryption_state['authenticated'] = True + else: + # Only fire extension_point for plugins (i.e. not OMEMO) + app.plugin_manager.extension_point( + f'encryption_state{encryption}', + app.window.get_control(), + encryption_state) visible, enc_type, authenticated = encryption_state.values() assert isinstance(visible, bool) @@ -359,6 +365,24 @@ class MessageActionsBox(Gtk.Grid): def _on_encryption_details_clicked(self, _button: Gtk.Button) -> None: contact = self.get_current_contact() encryption = contact.settings.get('encryption') + if encryption == 'OMEMO': + if contact.is_groupchat: + open_window('GroupchatDetails', + contact=contact, + page='encryption-omemo') + return + + if isinstance(contact, BareContact) and contact.is_self: + window = open_window('AccountsWindow') + window.select_account(contact.account, page='encryption-omemo') + return + + open_window('ContactInfo', + account=contact.account, + contact=contact, + page='encryption-omemo') + return + app.plugin_manager.extension_point( f'encryption_dialog{encryption}', app.window.get_control()) diff --git a/gajim/gtk/omemo_trust_manager.py b/gajim/gtk/omemo_trust_manager.py new file mode 100644 index 000000000..c2b704397 --- /dev/null +++ b/gajim/gtk/omemo_trust_manager.py @@ -0,0 +1,545 @@ +# Copyright (C) 2019 Philipp Hörist +# +# This file is part of Gajim. +# +# 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 typing import cast +from typing import Optional +from typing import Union + +import locale +import logging +import os +import tempfile +import time + +from gi.repository import GdkPixbuf +from gi.repository import Gtk +from nbxmpp.protocol import JID +from omemo_dr.identitykeypair import IdentityKeyPair + +from gajim.common import app +from gajim.common import ged +from gajim.common import types +from gajim.common.events import AccountConnected +from gajim.common.events import AccountDisconnected +from gajim.common.ged import EventHelper +from gajim.common.i18n import _ +from gajim.common.modules.contacts import BareContact +from gajim.common.omemo.util import get_fingerprint +from gajim.common.omemo.util import IdentityKeyExtended +from gajim.common.omemo.util import Trust + +from .builder import get_builder +from .dialogs import ConfirmationDialog +from .dialogs import DialogButton +from .util import open_window + +log = logging.getLogger('gajim.gui.omemo_trust_dialog') + + +TRUST_DATA = { + Trust.UNTRUSTED: ('dialog-error-symbolic', + _('Untrusted'), + 'error-color'), + Trust.UNDECIDED: ('security-low-symbolic', + _('Not Decided'), + 'warning-color'), + Trust.VERIFIED: ('security-high-symbolic', + _('Verified'), + 'encrypted-color'), + Trust.BLIND: ('security-medium-symbolic', + _('Blind Trust'), + 'encrypted-color') +} + + +class OMEMOTrustManager(Gtk.Box, EventHelper): + def __init__(self, + account: str, + contact: Optional[types.ChatContactT] = None + ) -> None: + + Gtk.Box.__init__(self) + EventHelper.__init__(self) + + self._account = account + self._contact = contact + + self._ui = get_builder('omemo_trust_manager.ui') + self.add(self._ui.stack) + + self._ui.list.set_filter_func(self._filter_func, None) + self._ui.list.set_sort_func(self._sort_func, None) + + self._ui.connect_signals(self) + + self.connect('destroy', self._on_destroy) + self.show_all() + + self.register_events([ + ('account-connected', ged.GUI2, self._on_account_state), + ('account-disconnected', ged.GUI2, self._on_account_state) + ]) + + if not app.account_is_connected(account): + self._ui.stack.set_visible_child_name('no-connection') + return + + self._update() + + def _update(self) -> None: + client = app.get_client(self._account) + if self._contact is None: + self._contact = client.get_module('Contacts').get_contact( + client.get_own_jid().bare) + + if isinstance(self._contact, BareContact) and self._contact.is_self: + header_text = _('Other devices connected with your account') + popover_qr_text = _('Compare this code with the one shown on your ' + 'contact’s screen to ensure the safety of ' + 'your end-to-end encrypted chat.') + else: + header_text = _('Devices connected with %s') % self._contact.name + popover_qr_text = _('Compare this code with the one shown on your ' + 'contact’s screen to ensure the safety of ' + 'your end-to-end encrypted chat ' + 'with %s.') % self._contact.name + + self._ui.list_heading.set_text(header_text) + self._ui.comparing_instructions.set_text(popover_qr_text) + + self._omemo = client.get_module('OMEMO') + + self._identity_key = cast( + IdentityKeyPair, self._omemo.backend.storage.getIdentityKeyPair()) + our_fpr_formatted = get_fingerprint(self._identity_key, formatted=True) + self._ui.our_fingerprint_1.set_text(our_fpr_formatted) + self._ui.our_fingerprint_2.set_text(our_fpr_formatted) + + self.update() + self._load_qrcode() + + def update(self) -> None: + assert self._contact is not None + self._ui.list.foreach(self._ui.list.remove) + + if isinstance(self._contact, BareContact) and self._contact.is_self: + self._ui.clear_devices_button.show() + self._ui.list_heading_box.set_halign(Gtk.Align.START) + else: + self._ui.manage_trust_button.show() + if self._contact.is_groupchat: + self._ui.search_button.show() + else: + self._ui.list_heading_box.set_halign(Gtk.Align.START) + + self._load_fingerprints(self._contact) + + def _on_destroy(self, *args: Any) -> None: + self.unregister_events() + self._ui.list.set_filter_func(None) + self._ui.search.disconnect_by_func( # pyright: ignore + self._on_search_changed) + app.check_finalize(self) + + def _on_account_state(self, + event: Union[AccountConnected, AccountDisconnected] + ) -> None: + + if not app.account_is_connected(self._account): + return + + if isinstance(event, AccountConnected): + self._update() + self._ui.stack.set_visible_child_name('manage-keys') + else: + self._ui.stack.set_visible_child_name('no-connection') + + def _filter_func(self, row: KeyRow, _user_data: Any) -> bool: + search_text = self._ui.search.get_text() + if search_text and search_text.lower() not in str(row.jid): + return False + if self._ui.show_inactive_switch.get_active(): + return True + return row.active + + @staticmethod + def _sort_func(row1: KeyRow, row2: KeyRow, _user_data: Any) -> int: + result = locale.strcoll(str(row1.jid), str(row2.jid)) + if result != 0: + return result + + if row1.active != row2.active: + return -1 if row1.active else 1 + + if row1.trust != row2.trust: + return -1 if row1.trust > row2.trust else 1 + return 0 + + def _on_search_changed(self, _entry: Gtk.SearchEntry) -> None: + self._ui.list.invalidate_filter() + + def _load_fingerprints(self, contact: types.ChatContactT) -> None: + if contact.is_groupchat: + members = list(self._omemo.backend.get_muc_members( + str(contact.jid))) + sessions = self._omemo.backend.storage.getSessionsFromJids(members) + results = self._omemo.backend.storage.getMucFingerprints(members) + else: + sessions = self._omemo.backend.storage.getSessionsFromJid( + str(contact.jid)) + results = self._omemo.backend.storage.getFingerprints( + str(contact.jid)) + + rows: dict[IdentityKeyExtended, KeyRow] = {} + for result in results: + rows[result.public_key] = KeyRow(contact, + result.recipient_id, + result.public_key, + result.trust, + result.timestamp) + + for item in sessions: + if item.record.isFresh(): + return + identity_key = item.record.getSessionState().getRemoteIdentityKey() + identity_key = IdentityKeyExtended(identity_key.getPublicKey()) + try: + key_row = rows[identity_key] + except KeyError: + log.warning('Could not find session identitykey %s', + item.device_id) + self._omemo.backend.storage.deleteSession(item.recipient_id, + item.device_id) + continue + + key_row.active = item.active + key_row.device_id = item.device_id + + for row in rows.values(): + self._ui.list.add(row) + + @staticmethod + def _get_qrcode(jid: str, sid: int, identity_key: IdentityKeyPair) -> str: + fingerprint = get_fingerprint(identity_key) + path = os.path.join(tempfile.gettempdir(), + 'omemo_{}.png'.format(jid)) + + ver_string = 'xmpp:{}?omemo-sid-{}={}'.format(jid, sid, fingerprint) + log.debug('Verification String: %s', ver_string) + + import qrcode + qr = qrcode.QRCode(version=None, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=6, + border=4) + qr.add_data(ver_string) + qr.make(fit=True) + + img = qr.make_image(fill_color='black', back_color='white') + img.save(path) + return path + + def _load_qrcode(self) -> None: + try: + client = app.get_client(self._account) + path = self._get_qrcode(client.get_own_jid().bare, + self._omemo.backend.own_device, + self._identity_key) + except ImportError: + log.exception('Failed to generate QR code') + self._ui.qr_code_image.hide() + self._ui.qr_dependency_missing.show() + else: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(path) + self._ui.qr_code_image.set_from_pixbuf(pixbuf) + self._ui.qr_code_image.show() + self._ui.qr_dependency_missing.hide() + + def _on_show_inactive(self, switch: Gtk.Switch, _param: Any) -> None: + self._ui.list.invalidate_filter() + + def _on_clear_devices_clicked(self, _button: Gtk.Button) -> None: + def _clear(): + self._omemo.clear_devicelist() + + ConfirmationDialog( + _('Clear Devices'), + _('Clear Devices Now?'), + _('This will clear the devices store for your account.'), + [DialogButton.make('Cancel'), + DialogButton.make('Accept', + text=_('_Clear Devices'), + callback=_clear)], + transient_for=cast(Gtk.Window, self.get_toplevel())).show() + + def _on_manage_trust_clicked(self, _button: Gtk.Button) -> None: + assert self._contact is not None + window = open_window('AccountsWindow') + window.select_account(self._contact.account, 'encryption-omemo') + + +class KeyRow(Gtk.ListBoxRow): + def __init__(self, + contact: types.ChatContactT, + jid: JID, + identity_key: IdentityKeyExtended, + trust: Trust, + last_seen: Optional[float] + ) -> None: + + Gtk.ListBoxRow.__init__(self) + self.set_activatable(False) + + self._active = False + self._device_id: Optional[int] = None + self._identity_key = identity_key + self.trust = trust + self.jid = jid + + client = app.get_client(contact.account) + self._omemo = client.get_module('OMEMO') + + grid = Gtk.Grid() + grid.set_column_spacing(12) + + self._trust_button = TrustButton(self) + grid.attach(self._trust_button, 1, 1, 1, 3) + + if contact.is_groupchat: + jid_label = Gtk.Label(label=str(jid)) + jid_label.set_selectable(False) + jid_label.set_halign(Gtk.Align.START) + jid_label.set_valign(Gtk.Align.START) + jid_label.set_hexpand(True) + grid.attach(jid_label, 2, 1, 1, 1) + + self.fingerprint = Gtk.Label( + label=self._identity_key.get_fingerprint(formatted=True)) + self.fingerprint.get_style_context().add_class('monospace') + self.fingerprint.get_style_context().add_class('small-label') + self.fingerprint.get_style_context().add_class('dim-label') + self.fingerprint.set_selectable(True) + self.fingerprint.set_halign(Gtk.Align.START) + self.fingerprint.set_valign(Gtk.Align.START) + self.fingerprint.set_hexpand(True) + grid.attach(self.fingerprint, 2, 2, 1, 1) + + if last_seen is not None: + last_seen_str = time.strftime('%d-%m-%Y %H:%M:%S', + time.localtime(last_seen)) + else: + last_seen_str = _('Never') + last_seen_label = Gtk.Label(label=_('Last seen: %s') % last_seen_str) + last_seen_label.set_halign(Gtk.Align.START) + last_seen_label.set_valign(Gtk.Align.START) + last_seen_label.set_hexpand(True) + last_seen_label.get_style_context().add_class('small-label') + last_seen_label.get_style_context().add_class('dim-label') + grid.attach(last_seen_label, 2, 3, 1, 1) + + self.add(grid) + + self.connect('destroy', self._on_destroy) + self.show_all() + + def _on_destroy(self, *args: Any) -> None: + app.check_finalize(self) + + def delete_fingerprint(self, *args: Any) -> None: + listbox = cast(Gtk.ListBox, self.get_parent()) + window = cast(Gtk.Window, listbox.get_window()) + + def _remove(): + self._omemo.backend.remove_device(str(self.jid), self.device_id) + self._omemo.backend.storage.deleteSession( + str(self.jid), self.device_id) + self._omemo.backend.storage.deleteIdentity( + str(self.jid), self._identity_key) + + listbox.remove(self) + self.destroy() + + ConfirmationDialog( + _('Delete'), + _('Delete Fingerprint'), + _('Doing so will permanently delete this Fingerprint'), + [DialogButton.make('Cancel'), + DialogButton.make('Remove', + text=_('Delete'), + callback=_remove)], + transient_for=window).show() + + def set_trust(self) -> None: + icon_name, tooltip, css_class = TRUST_DATA[self.trust] + image = cast(Gtk.Image, self._trust_button.get_child()) + image.set_from_icon_name(icon_name, Gtk.IconSize.MENU) + image.get_style_context().add_class(css_class) + image.set_tooltip_text(tooltip) + + self._omemo.backend.storage.setTrust( + str(self.jid), self._identity_key, self.trust) + + @property + def active(self) -> bool: + return self._active + + @active.setter + def active(self, active: bool) -> None: + context = self.fingerprint.get_style_context() + self._active = bool(active) + if self._active: + context.remove_class('omemo-inactive-color') + else: + context.add_class('omemo-inactive-color') + self._trust_button.update() + + @property + def device_id(self) -> Optional[int]: + return self._device_id + + @device_id.setter + def device_id(self, device_id: int) -> None: + self._device_id = device_id + + +class TrustButton(Gtk.MenuButton): + def __init__(self, row: KeyRow) -> None: + Gtk.MenuButton.__init__(self) + self._row = row + self._css_class = '' + self._trust_popover = TrustPopver(row) + self.set_popover(self._trust_popover) + self.set_valign(Gtk.Align.CENTER) + self.update() + self.connect('destroy', self._on_destroy) + + def _on_destroy(self, *args: Any) -> None: + self._trust_popover.destroy() + app.check_finalize(self) + + def update(self) -> None: + icon_name, tooltip, css_class = TRUST_DATA[self._row.trust] + image = cast(Gtk.Image, self.get_child()) + image.set_from_icon_name(icon_name, Gtk.IconSize.MENU) + image.get_style_context().remove_class(self._css_class) + + if not self._row.active: + css_class = 'omemo-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: KeyRow) -> None: + Gtk.Popover.__init__(self) + self._row = row + self._listbox = Gtk.ListBox() + self._listbox.set_selection_mode(Gtk.SelectionMode.NONE) + self.update() + self.add(self._listbox) + self._listbox.show_all() + self._listbox.connect('row-activated', self._activated) + self.get_style_context().add_class('omemo-trust-popover') + + def _activated(self, _listbox: Gtk.ListBox, row: MenuOption) -> None: + self.popdown() + if row.type_ is None: + self._row.delete_fingerprint() + else: + self._row.trust = row.type_ + self._row.set_trust() + trust_button = cast(TrustButton, self.get_relative_to()) + trust_button.update() + self.update() + + def update(self) -> None: + self._listbox.foreach(self._listbox.remove) + if self._row.trust != Trust.VERIFIED: + self._listbox.add(VerifiedOption()) + if self._row.trust != Trust.BLIND: + self._listbox.add(BlindOption()) + if self._row.trust != Trust.UNTRUSTED: + self._listbox.add(NotTrustedOption()) + self._listbox.add(DeleteOption()) + + +class MenuOption(Gtk.ListBoxRow): + def __init__(self, + icon: str, + label_text: str, + color: str, + type_: Optional[Trust] = None + ) -> None: + + Gtk.ListBoxRow.__init__(self) + + self.type_ = type_ + self.icon = icon + self.label = label_text + self.color = color + + box = Gtk.Box() + box.set_spacing(6) + + image = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU) + label = Gtk.Label(label=label_text) + image.get_style_context().add_class(color) + + box.add(image) + box.add(label) + self.add(box) + self.show_all() + + +class BlindOption(MenuOption): + def __init__(self) -> None: + MenuOption.__init__(self, + 'security-medium-symbolic', + _('Blind Trust'), + 'encrypted-color', + Trust.BLIND) + + +class VerifiedOption(MenuOption): + def __init__(self) -> None: + MenuOption.__init__(self, + 'security-high-symbolic', + _('Verified'), + 'encrypted-color', + Trust.VERIFIED) + + +class NotTrustedOption(MenuOption): + def __init__(self) -> None: + MenuOption.__init__(self, + 'dialog-error-symbolic', + _('Untrusted'), + 'error-color', + Trust.UNTRUSTED) + + +class DeleteOption(MenuOption): + def __init__(self) -> None: + MenuOption.__init__(self, + 'user-trash-symbolic', + _('Delete'), + '') diff --git a/gajim/plugins/manifest.py b/gajim/plugins/manifest.py index d4ba66d03..b6846ab82 100644 --- a/gajim/plugins/manifest.py +++ b/gajim/plugins/manifest.py @@ -32,6 +32,7 @@ from .plugins_i18n import _ as p_ BLOCKED_PLUGINS = [ 'appindicator_integration', + 'omemo', 'plugin_installer', 'syntax_highlight', 'url_image_preview' diff --git a/pyproject.toml b/pyproject.toml index bf04a23a0..553b08ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ "cryptography>=3.4.8", "pycairo>=1.16.0", "PyGObject>=3.42.0", + "qrcode>=7.3.1", + "omemo-dr>=0.99.0,<2.0.0", ] dynamic = ["version"] @@ -260,6 +262,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" target-version = "py310" [tool.ruff.per-file-ignores] +"gajim/common/storage/omemo.py" = ["N803"] "test/*" = ["E402"] "test/common/test_styling.py" = ["RUF001", "E501"] "test/common/test_regex.py" = ["RUF001"] diff --git a/typings/qrcode/__init__.py b/typings/qrcode/__init__.py new file mode 100644 index 000000000..e4289335a --- /dev/null +++ b/typings/qrcode/__init__.py @@ -0,0 +1,14 @@ + +from __future__ import annotations + +from typing import Any +from typing import Optional + +from . import constants as constants + + +class QRCode: + def __init__(self, version: Optional[int], error_correction: int, box_size: int, border: int, image_factory: Optional[Any] = None, mask_pattern: Optional[Any] = None) -> None: ... + def add_data(self, data: str) -> None: ... + def make(self, fit: bool) -> None: ... + def make_image(self, image_factory: Optional[Any] = None, fill_color: str, back_color: str) -> Any: ... diff --git a/typings/qrcode/constants.py b/typings/qrcode/constants.py new file mode 100644 index 000000000..3f3582636 --- /dev/null +++ b/typings/qrcode/constants.py @@ -0,0 +1,4 @@ + +from __future__ import annotations + +ERROR_CORRECT_L: int ... diff --git a/win/_base.sh b/win/_base.sh index eb793de4e..b586913a6 100755 --- a/win/_base.sh +++ b/win/_base.sh @@ -81,6 +81,7 @@ function install_deps { mingw-w64-"${ARCH}"-python-keyring \ mingw-w64-"${ARCH}"-python-packaging \ mingw-w64-"${ARCH}"-python-pillow \ + mingw-w64-"${ARCH}"-python-protobuf \ mingw-w64-"${ARCH}"-python-pygments \ mingw-w64-"${ARCH}"-python-setuptools \ mingw-w64-"${ARCH}"-python-setuptools-scm \ @@ -104,8 +105,8 @@ function install_deps { PIP_REQUIREMENTS="\ git+https://dev.gajim.org/gajim/python-nbxmpp.git +git+https://dev.gajim.org/gajim/omemo-dr.git python-gnupg -python-axolotl qrcode css_parser sentry-sdk @@ -145,10 +146,6 @@ function install_gajim { build_python "${MISC}"/create-launcher.py \ "${QL_VERSION}" "${MINGW_ROOT}"/bin - # Install omemo plugin - curl -o "${BUILD_ROOT}"/omemo.zip https://ftp.gajim.org/plugins/master/omemo/omemo_2.9.0.zip - 7z x -o"${PACKAGE_DIR}"/gajim/data/plugins/omemo "${BUILD_ROOT}"/omemo.zip - # Install language dicts curl -o "${BUILD_ROOT}"/speller_dicts.zip https://gajim.org/downloads/snap/win/build/speller_dicts.zip 7z x -o"${MINGW_ROOT}"/share "${BUILD_ROOT}"/speller_dicts.zip diff --git a/win/dev_env.sh b/win/dev_env.sh index 2eb77d430..801624b48 100755 --- a/win/dev_env.sh +++ b/win/dev_env.sh @@ -44,10 +44,11 @@ function main { PIP_REQUIREMENTS="\ git+https://dev.gajim.org/gajim/python-nbxmpp.git -python-axolotl +git+https://dev.gajim.org/gajim/omemo-dr.git python-gnupg -css_parser qrcode +css_parser +sentry-sdk " pip3 install precis-i18n pip3 install $(echo "$PIP_REQUIREMENTS" | tr ["\\n"] [" "]) -- cgit v1.2.3