Welcome to mirror list, hosted at ThFree Co, Russian Federation.

dev.gajim.org/gajim/gajim.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Brötzmann <mailtrash@posteo.de>2023-04-09 21:13:39 +0300
committerPhilipp Hörist <philipp@hoerist.com>2023-04-09 21:13:39 +0300
commit7a585f20d48f0bb03544ac66394a49fe937e3625 (patch)
treea503d2159add9803e42b87316d6ee7b44fa17c1a
parent24548c14e87e5044eebaf0092ab27ef19cfb8659 (diff)
feat: Integrate OMEMO plugin
-rw-r--r--.gitlab-ci.yml1
-rw-r--r--README.md2
-rw-r--r--debian/control2
-rw-r--r--flatpak/org.gajim.Gajim.Devel.yaml27
-rw-r--r--flatpak/org.gajim.Gajim.yaml28
-rw-r--r--gajim/common/client.py18
-rw-r--r--gajim/common/const.py15
-rw-r--r--gajim/common/events.py9
-rw-r--r--gajim/common/modules/httpupload.py5
-rw-r--r--gajim/common/modules/omemo.py640
-rw-r--r--gajim/common/modules/util.py18
-rw-r--r--gajim/common/omemo/__init__.py0
-rw-r--r--gajim/common/omemo/aes.py105
-rw-r--r--gajim/common/omemo/state.py534
-rw-r--r--gajim/common/omemo/util.py62
-rw-r--r--gajim/common/setting_values.py4
-rw-r--r--gajim/common/storage/omemo.py801
-rw-r--r--gajim/data/gui/contact_info.ui41
-rw-r--r--gajim/data/gui/groupchat_details.ui41
-rw-r--r--gajim/data/gui/omemo_trust_manager.ui425
-rw-r--r--gajim/data/icons/hicolor/scalable/categories/qr-code-scan-symbolic.svg75
-rw-r--r--gajim/data/style/gajim.css21
-rw-r--r--gajim/gtk/accounts.py41
-rw-r--r--gajim/gtk/builder.pyi26
-rw-r--r--gajim/gtk/chat_stack.py10
-rw-r--r--gajim/gtk/contact_info.py9
-rw-r--r--gajim/gtk/control.py8
-rw-r--r--gajim/gtk/conversation/rows/encryption_info.py85
-rw-r--r--gajim/gtk/conversation/view.py5
-rw-r--r--gajim/gtk/groupchat_details.py6
-rw-r--r--gajim/gtk/message_actions_box.py42
-rw-r--r--gajim/gtk/omemo_trust_manager.py545
-rw-r--r--gajim/plugins/manifest.py1
-rw-r--r--pyproject.toml3
-rw-r--r--typings/qrcode/__init__.py14
-rw-r--r--typings/qrcode/constants.py4
-rwxr-xr-xwin/_base.sh7
-rwxr-xr-xwin/dev_env.sh5
38 files changed, 3653 insertions, 32 deletions
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 <philipp AT hoerist.com>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+# 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
--- /dev/null
+++ b/gajim/common/omemo/__init__.py
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 <http://www.gnu.org/licenses/>.
+
+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 <philipp AT hoerist.com>
+# Copyright (C) 2015 Bahtiar `kalkin-` Gadimov <bahtiar@gadimov.de>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+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 <bahtiar@gadimov.de>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+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 <philipp AT hoerist.com>
+# Copyright (C) 2015 Tarek Galal <tare2.galal@gmail.com>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+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 @@
<?xml version="1.0" encoding="UTF-8"?>
-<!-- Generated with glade 3.38.2 -->
+<!-- Generated with glade 3.40.0 -->
<interface>
<requires lib="gtk+" version="3.24"/>
<!-- n-columns=2 n-rows=6 -->
@@ -727,6 +727,39 @@
</packing>
</child>
<child>
+ <object class="GtkScrolledWindow" id="encryption_scrolled">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="hscrollbar-policy">never</property>
+ <child>
+ <object class="GtkViewport">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <child>
+ <object class="GtkBox" id="encryption_box">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="margin-start">36</property>
+ <property name="margin-end">36</property>
+ <property name="margin-top">36</property>
+ <property name="margin-bottom">36</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="name">encryption-omemo</property>
+ <property name="title" translatable="yes">Encryption (OMEMO)</property>
+ <property name="icon-name">channel-secure-symbolic</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
<object class="GtkBox" id="groups_page_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
@@ -874,7 +907,7 @@
<property name="name">groups</property>
<property name="title" translatable="yes">Groups</property>
<property name="icon-name">view-pin-symbolic</property>
- <property name="position">2</property>
+ <property name="position">3</property>
</packing>
</child>
<child>
@@ -935,7 +968,7 @@
<property name="name">notes</property>
<property name="title" translatable="yes">Notes</property>
<property name="icon-name">x-office-address-book-symbolic</property>
- <property name="position">3</property>
+ <property name="position">4</property>
</packing>
</child>
<child>
@@ -1005,7 +1038,7 @@
<property name="name">devices</property>
<property name="title" translatable="yes">Devices</property>
<property name="icon-name">computer-symbolic</property>
- <property name="position">4</property>
+ <property name="position">5</property>
</packing>
</child>
<style>
diff --git a/gajim/data/gui/groupchat_details.ui b/gajim/data/gui/groupchat_details.ui
index a259f61b1..b2a0e6256 100644
--- a/gajim/data/gui/groupchat_details.ui
+++ b/gajim/data/gui/groupchat_details.ui
@@ -130,6 +130,39 @@
</packing>
</child>
<child>
+ <object class="GtkScrolledWindow" id="encryption_scrolled">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="hscrollbar-policy">never</property>
+ <child>
+ <object class="GtkViewport">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <child>
+ <object class="GtkBox" id="encryption_box">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="margin-start">36</property>
+ <property name="margin-end">36</property>
+ <property name="margin-top">36</property>
+ <property name="margin-bottom">36</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="name">encryption-omemo</property>
+ <property name="title" translatable="yes">Encryption (OMEMO)</property>
+ <property name="icon-name">channel-secure-symbolic</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
<object class="GtkBox" id="manage_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
@@ -151,7 +184,7 @@
<property name="name">manage</property>
<property name="title" translatable="yes">Manage</property>
<property name="icon-name">document-edit-symbolic</property>
- <property name="position">2</property>
+ <property name="position">3</property>
</packing>
</child>
<child>
@@ -170,7 +203,7 @@
<property name="name">affiliations</property>
<property name="title" translatable="yes">Affiliations</property>
<property name="icon-name">system-users-symbolic</property>
- <property name="position">3</property>
+ <property name="position">4</property>
</packing>
</child>
<child>
@@ -189,7 +222,7 @@
<property name="name">outcasts</property>
<property name="title" translatable="yes">Outcasts</property>
<property name="icon-name">system-users-symbolic</property>
- <property name="position">4</property>
+ <property name="position">5</property>
</packing>
</child>
<child>
@@ -205,7 +238,7 @@
<property name="name">config</property>
<property name="title" translatable="yes">Configuration</property>
<property name="icon-name">document-properties-symbolic</property>
- <property name="position">5</property>
+ <property name="position">6</property>
</packing>
</child>
<style>
diff --git a/gajim/data/gui/omemo_trust_manager.ui b/gajim/data/gui/omemo_trust_manager.ui
new file mode 100644
index 000000000..86be1f42f
--- /dev/null
+++ b/gajim/data/gui/omemo_trust_manager.ui
@@ -0,0 +1,425 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.40.0 -->
+<interface>
+ <requires lib="gtk+" version="3.24"/>
+ <object class="GtkPopover" id="search_popover">
+ <property name="can-focus">False</property>
+ <child>
+ <object class="GtkSearchEntry" id="search">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="caps-lock-warning">False</property>
+ <property name="primary-icon-name">edit-find-symbolic</property>
+ <property name="primary-icon-activatable">False</property>
+ <property name="primary-icon-sensitive">False</property>
+ <signal name="search-changed" handler="_on_search_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ <object class="GtkPopover" id="qr_code_popover">
+ <property name="can-focus">False</property>
+ <property name="relative-to">qr_menu_button</property>
+ <property name="constrain-to">none</property>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">12</property>
+ <child>
+ <object class="GtkLabel">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="label" translatable="yes">This Device</property>
+ <style>
+ <class name="bold16"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="comparing_instructions">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="margin-bottom">13</property>
+ <property name="wrap">True</property>
+ <property name="max-width-chars">42</property>
+ <property name="xalign">0</property>
+ <style>
+ <class name="dim-label"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="our_fingerprint_2">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="selectable">True</property>
+ <style>
+ <class name="monospace"/>
+ <class name="small-label"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkImage" id="qr_code_image">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="halign">center</property>
+ <property name="icon_size">6</property>
+ <style>
+ <class name="border"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="qr_dependency_missing">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="label" translatable="yes">For verification via QR-Code you have to install &lt;tt&gt;python-qrcode&lt;/tt&gt;.</property>
+ <property name="use-markup">True</property>
+ <property name="wrap">True</property>
+ <property name="max-width-chars">42</property>
+ <property name="xalign">0</property>
+ <style>
+ <class name="dim-label"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ <style>
+ <class name="padding-18"/>
+ </style>
+ </object>
+ </child>
+ </object>
+ <object class="GtkStack" id="stack">
+ <property name="width-request">400</property>
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="hexpand">True</property>
+ <property name="vexpand">True</property>
+ <property name="transition-type">crossfade</property>
+ <property name="interpolate-size">True</property>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">12</property>
+ <child>
+ <object class="GtkLabel">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="label" translatable="yes">This Device</property>
+ <property name="xalign">0</property>
+ <style>
+ <class name="bold"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkListBox">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="selection-mode">none</property>
+ <child>
+ <object class="GtkListBoxRow">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="selectable">False</property>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="spacing">12</property>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">3</property>
+ <child>
+ <object class="GtkLabel">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="label" translatable="yes">Fingerprint for this Device</property>
+ <property name="xalign">0</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="our_fingerprint_1">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="selectable">True</property>
+ <property name="xalign">0</property>
+ <style>
+ <class name="monospace"/>
+ <class name="small-label"/>
+ <class name="dim-label"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkMenuButton" id="qr_menu_button">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="receives-default">True</property>
+ <property name="tooltip-text" translatable="yes">Scan QR Code</property>
+ <property name="halign">end</property>
+ <property name="valign">center</property>
+ <property name="direction">up</property>
+ <property name="popover">qr_code_popover</property>
+ <child>
+ <object class="GtkImage">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="icon-name">qr-code-scan-symbolic</property>
+ </object>
+ </child>
+ <style>
+ <class name="image-button"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="pack-type">end</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="manage_trust_button">
+ <property name="can-focus">True</property>
+ <property name="receives-default">True</property>
+ <property name="no-show-all">True</property>
+ <property name="tooltip-text" translatable="yes">Manage your Devices…</property>
+ <property name="valign">center</property>
+ <signal name="clicked" handler="_on_manage_trust_clicked" swapped="no"/>
+ <child>
+ <object class="GtkImage">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="icon-name">preferences-system-symbolic</property>
+ </object>
+ </child>
+ <style>
+ <class name="image-button"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="pack-type">end</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <style>
+ <class name="settings-box"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="list_heading">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="margin-top">12</property>
+ <property name="wrap">True</property>
+ <property name="max-width-chars">42</property>
+ <property name="xalign">0</property>
+ <style>
+ <class name="bold"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="list_heading_box">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="spacing">12</property>
+ <child>
+ <object class="GtkSwitch" id="show_inactive_switch">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="valign">center</property>
+ <signal name="notify::active" handler="_on_show_inactive" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="pack-type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="label" translatable="yes">Show Inactive Devices</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="pack-type">end</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkMenuButton" id="search_button">
+ <property name="can-focus">True</property>
+ <property name="receives-default">True</property>
+ <property name="no-show-all">True</property>
+ <property name="tooltip-text" translatable="yes">Search…</property>
+ <property name="halign">start</property>
+ <property name="valign">center</property>
+ <property name="popover">search_popover</property>
+ <child>
+ <object class="GtkImage">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="icon-name">edit-find-symbolic</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkListBox" id="list">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="selection-mode">none</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="clear_devices_button">
+ <property name="label" translatable="yes">Clear Devices…</property>
+ <property name="can-focus">True</property>
+ <property name="receives-default">True</property>
+ <property name="no-show-all">True</property>
+ <property name="halign">start</property>
+ <signal name="clicked" handler="_on_clear_devices_clicked" swapped="no"/>
+ <style>
+ <class name="destructive-action"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">5</property>
+ </packing>
+ </child>
+ <style>
+ <class name="omemo-trust-manager"/>
+ </style>
+ </object>
+ <packing>
+ <property name="name">manage-keys</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="halign">center</property>
+ <property name="valign">center</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkLabel">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="label" translatable="yes">Account is not connected.</property>
+ <property name="wrap">True</property>
+ <property name="max-width-chars">42</property>
+ <style>
+ <class name="dim-label"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="name">no-connection</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+</interface>
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 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ width="296"
+ height="296"
+ viewBox="0 0 296 296"
+ version="1.1"
+ id="svg879"
+ sodipodi:docname="qr-code-scan.svg"
+ xml:space="preserve"
+ inkscape:version="1.2.1 (9c6d41e410, 2022-07-14, custom)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
+ id="namedview881"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:showpageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:deskcolor="#d1d1d1"
+ showgrid="false"
+ inkscape:zoom="1.1820013"
+ inkscape:cx="-30.033808"
+ inkscape:cy="205.58353"
+ inkscape:window-width="1920"
+ inkscape:window-height="998"
+ inkscape:window-x="0"
+ inkscape:window-y="0"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg879" /><defs
+ id="defs5"><rect
+ id="p"
+ width="8"
+ height="8" /></defs><g
+ id="g3488"
+ transform="matrix(1.1697246,0,0,1.1697246,-27.551074,-25.261865)"><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="M 251.48889,66.946387 V 46.712014 H 231.25452 211.02015 V 36.475802 26.23959 h 21.16314 c 22.41235,0 24.39388,0.156166 28.55025,2.250069 3.06508,1.544132 6.94528,5.251442 8.51507,8.135659 2.59204,4.762428 2.71271,6.062675 2.71271,29.230841 V 87.18076 h -10.23621 -10.23622 z"
+ id="path3464" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="m 28.196638,66.017616 c 0,-22.412343 0.156166,-24.393876 2.250069,-28.550247 1.544132,-3.065083 5.251442,-6.945286 8.135659,-8.515074 4.762428,-2.592038 6.062675,-2.712705 29.230841,-2.712705 H 89.137808 V 36.475802 46.712014 H 68.903435 48.669062 V 66.946387 87.18076 H 38.43285 28.196638 Z"
+ id="path3462" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="M 170.5514,97.175398 V 66.701287 l 30.35156,0.12255 30.35156,0.12255 0.12255,30.351559 0.12255,30.351564 H 201.02551 170.5514 Z m 40.46875,0.241574 V 87.18076 h -10.23621 -10.23622 v 9.918809 c 0,5.455351 0.14283,10.061641 0.31741,10.236211 0.17457,0.17457 4.78086,0.3174 10.23621,0.3174 h 9.91881 z"
+ id="path3460" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="M 140.08082,107.41513 V 87.18076 h 9.99816 9.99816 v 20.23437 20.23438 h -9.99816 -9.99816 z"
+ id="path3458" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="m 68.780885,97.297946 0.12255,-30.351559 30.351559,-0.12255 30.351556,-0.12255 V 97.175398 127.64951 H 99.132446 68.658335 Z m 40.719795,0 0.12849,-10.117186 H 99.38349 89.137808 v 10.245682 10.245678 l 10.117186,-0.12849 10.117186,-0.1285 z"
+ id="path3456" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="m 68.665384,148.12193 v -9.99816 h 10.236212 10.236212 v 9.99816 9.99816 H 78.901596 68.665384 Z"
+ id="path3454" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="m 139.97126,168.47533 -0.12849,-10.11719 -10.11719,-0.12849 -30.662081,-0.28013 v -9.98869 -9.98869 l 40.779271,0.15163 h 20.23437 v 20.23437 20.23437 h -9.98869 -9.98869 z"
+ id="path3452"
+ sodipodi:nodetypes="ccccccccccccc" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="m 211.02015,193.82781 v -15.2353 h -10.23621 -10.23622 v 15.2353 15.23529 h 10.23622 10.23621 z"
+ id="path3474" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="m 140.08082,209.0631 v -20.47242 h 15.23529 15.23529 v -4.99908 -4.99909 h 9.99816 9.99816 V 168.3563 158.12009 h -9.99816 -9.99816 v -9.99816 -9.99816 h 9.99816 9.99816 v 9.99816 9.99816 h 10.23622 10.23621 v -9.99816 -9.99816 h 10.23621 10.23621 v 9.99816 9.99816 h -10.23621 -10.23621 v 10.23621 10.23621 h 10.23621 10.23621 v 15.2353 15.23529 h -10.23621 -10.23621 v 10.24568 10.24568 l -10.11719,-0.12849 -10.11718,-0.1285 -0.1285,-10.11718 -0.1285,-10.11719 h -15.22582 -15.22582 v 10.23621 10.23621 h -9.99816 -9.99816 z"
+ id="path3450" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="m 68.982786,229.21812 c -0.174571,-0.17457 -0.317402,-13.88633 -0.317402,-30.47058 v -30.15319 h 30.470585 30.470581 v 30.47059 30.47058 H 99.453372 c -16.584254,0 -30.296015,-0.14283 -30.470586,-0.3174 z m 40.517894,-30.27221 -0.1285,-10.11718 -10.117186,-0.1285 -10.117186,-0.12849 v 10.24568 10.24568 h 10.245682 10.24568 z"
+ id="path3448" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="m 211.02015,259.76806 v -10.23621 h 20.23437 20.23437 V 229.29747 209.0631 h 10.23622 10.23621 v 21.3246 c 0,23.16817 -0.12067,24.46841 -2.71271,29.23084 -1.56979,2.88422 -5.44999,6.59153 -8.51507,8.13566 -4.15637,2.0939 -6.1379,2.25007 -28.55025,2.25007 h -21.16314 z"
+ id="path3446" /><path
+ style="fill:#000000;stroke-width:0.476103"
+ d="m 43.551277,269.26862 c -1.374571,-0.37014 -3.39419,-1.12956 -4.488044,-1.6876 -2.760243,-1.40817 -6.658458,-5.21488 -8.15389,-7.96248 -2.592038,-4.76243 -2.712705,-6.06267 -2.712705,-29.23084 V 209.0631 H 38.43285 48.669062 v 20.23437 20.23438 h 20.234373 20.234373 v 10.23621 10.23621 l -21.543656,-0.0313 c -16.989841,-0.0247 -22.071931,-0.17358 -24.042875,-0.70431 z"
+ id="path3346" /></g></svg>
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 = '<a href="%s">%s</a>' % (
+ '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 <http://www.gnu.org/licenses/>.
+
+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 <philipp AT hoerist.com>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+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"] [" "])