diff --git a/omemo/__init__.py b/omemo/__init__.py index 0220169fc663726a215a95df3c5d73f24a615dc1..47757fca5ab12115d6243757adf51ea80f343827 100644 --- a/omemo/__init__.py +++ b/omemo/__init__.py @@ -1,883 +1 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2015 Bahtiar `kalkin-` Gadimov -# Copyright 2015 Daniel Gultsch -# -# This file is part of Gajim-OMEMO plugin. -# -# The Gajim-OMEMO 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, either version 3 of the License, or (at your option) any -# later version. -# -# Gajim-OMEMO 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 -# the Gajim-OMEMO plugin. If not, see . -# - -import logging -import os -import sqlite3 - -from common import caps_cache, gajim, ged -from common.pep import SUPPORTED_PERSONAL_USER_EVENTS -from plugins import GajimPlugin -from plugins.helpers import log_calls -from nbxmpp.simplexml import Node -from nbxmpp import NS_CORRECT - -from . import ui -from .ui import Ui -from .xmpp import ( - NS_NOTIFY, NS_OMEMO, NS_EME, BundleInformationAnnouncement, - BundleInformationQuery, DeviceListAnnouncement, DevicelistQuery, - DevicelistPEP, OmemoMessage, successful, unpack_device_bundle, - unpack_device_list_update, unpack_encrypted) - -# from common import demandimport -# demandimport.enable() -# demandimport.ignore += ['_imp'] - - -IQ_CALLBACK = {} - -AXOLOTL_MISSING = 'You are missing Python-Axolotl or use an outdated version' -PROTOBUF_MISSING = 'OMEMO cant import Google Protobuf, you can find help in ' \ - 'the GitHub Wiki' -GAJIM_VERSION = 'OMEMO only works with the latest Gajim version, get the ' \ - 'latest version from gajim.org' -ERROR_MSG = '' - -NS_HINTS = 'urn:xmpp:hints' -NS_PGP = 'urn:xmpp:openpgp:0' -DB_DIR = gajim.gajimpaths.data_root - -log = logging.getLogger('gajim.plugin_system.omemo') - - -try: - from .omemo.state import OmemoState -except Exception as e: - log.error(e) - ERROR_MSG = 'Error: {}'.format(e) - -try: - import google.protobuf -except Exception as e: - log.error(e) - ERROR_MSG = PROTOBUF_MISSING - -try: - SETUPTOOLS_MISSING = False - from pkg_resources import parse_version -except Exception as e: - SETUPTOOLS_MISSING = True - ERROR_MSG = 'You are missing the Setuptools package.' - -if not SETUPTOOLS_MISSING: - try: - import axolotl - if parse_version(axolotl.__version__) < parse_version('0.1.35'): - ERROR_MSG = AXOLOTL_MISSING - except Exception as e: - log.error(e) - ERROR_MSG = AXOLOTL_MISSING - -# pylint: disable=no-init -# pylint: disable=attribute-defined-outside-init - - -class OmemoPlugin(GajimPlugin): - - omemo_states = {} - ui_list = {} - - @log_calls('OmemoPlugin') - def init(self): - """ Init """ - if ERROR_MSG: - self.activatable = False - self.available_text = ERROR_MSG - return - self.events_handlers = { - 'mam-message-received': (ged.PRECORE, self.mam_message_received), - 'message-received': (ged.PRECORE, self.message_received), - 'pep-received': (ged.PRECORE, self.handle_device_list_update), - 'raw-iq-received': (ged.PRECORE, self.handle_iq_received), - 'signed-in': (ged.PRECORE, self.signed_in), - 'stanza-message-outgoing': - (ged.PRECORE, self.handle_outgoing_stanza), - 'message-outgoing': - (ged.PRECORE, self.handle_outgoing_event), - } - self.config_dialog = ui.OMEMOConfigDialog(self) - self.gui_extension_points = {'chat_control': (self.connect_ui, - self.disconnect_ui)} - SUPPORTED_PERSONAL_USER_EVENTS.append(DevicelistPEP) - self.plugin = self - self.announced = [] - self.query_for_bundles = [] - - @log_calls('OmemoPlugin') - def get_omemo_state(self, account): - """ Returns the the OmemoState for the specified account. - Creates the OmemoState if it does not exist yet. - - Parameters - ---------- - account : str - the account name - - Returns - ------- - OmemoState - """ - if account not in self.omemo_states: - self.deactivate_gajim_e2e(account) - db_path = os.path.join(DB_DIR, 'omemo_' + account + '.db') - conn = sqlite3.connect(db_path, check_same_thread=False) - - my_jid = gajim.get_jid_from_account(account) - - self.omemo_states[account] = OmemoState(my_jid, conn, account, - self.plugin) - - return self.omemo_states[account] - - @staticmethod - def deactivate_gajim_e2e(account): - """ Deativates E2E encryption in Gajim """ - gajim.config.set_per('accounts', account, - 'autonegotiate_esessions', False) - gajim.config.set_per('accounts', account, - 'enable_esessions', False) - log.info(str(account) + " => Gajim E2E encryption disabled") - - @log_calls('OmemoPlugin') - def signed_in(self, event): - """ Method called on SignIn - - Parameters - ---------- - event : SignedInEvent - """ - account = event.conn.name - log.debug(account + - ' => Announce Support after Sign In') - self.query_for_bundles = [] - self.announced = [] - self.announced.append(account) - self.publish_bundle(account) - self.query_own_devicelist(account) - - @log_calls('OmemoPlugin') - def activate(self): - """ Method called when the Plugin is activated in the PluginManager - """ - self.query_for_bundles = [] - if NS_NOTIFY not in gajim.gajim_common_features: - gajim.gajim_common_features.append(NS_NOTIFY) - self._compute_caps_hash() - # Publish bundle information - for account in gajim.connections: - if account not in self.announced: - if gajim.account_is_connected(account): - log.debug(account + - ' => Announce Support after Plugin Activation') - self.announced.append(account) - self.publish_bundle(account) - self.query_own_devicelist(account) - - @log_calls('OmemoPlugin') - def deactivate(self): - """ Method called when the Plugin is deactivated in the PluginManager - - Removes OMEMO from the Entity Capabilities list - """ - if NS_NOTIFY in gajim.gajim_common_features: - gajim.gajim_common_features.remove(NS_NOTIFY) - self._compute_caps_hash() - - @staticmethod - def _compute_caps_hash(): - """ Computes the hash for Entity Capabilities and publishes it """ - for acc in gajim.connections: - gajim.caps_hash[acc] = caps_cache.compute_caps_hash( - [gajim.gajim_identity], - gajim.gajim_common_features + - gajim.gajim_optional_features[acc]) - # re-send presence with new hash - connected = gajim.connections[acc].connected - if connected > 1 and gajim.SHOW_LIST[connected] != 'invisible': - gajim.connections[acc].change_status( - gajim.SHOW_LIST[connected], gajim.connections[acc].status) - - @log_calls('OmemoPlugin') - def mam_message_received(self, msg): - """ Handles an incoming MAM message - - Payload is decrypted and the plaintext is written into the - event object. Afterwards the event is passed on further to Gajim. - - Parameters - ---------- - msg : MamMessageReceivedEvent - - Returns - ------- - Return means that the Event is passed on to Gajim - """ - if msg.msg_.getTag('openpgp', namespace=NS_PGP): - return - - omemo_encrypted_tag = msg.msg_.getTag('encrypted', namespace=NS_OMEMO) - if omemo_encrypted_tag: - account = msg.conn.name - log.debug(account + ' => OMEMO MAM msg received') - - state = self.get_omemo_state(account) - - from_jid = str(msg.msg_.getAttr('from')) - from_jid = gajim.get_jid_without_resource(from_jid) - - msg_dict = unpack_encrypted(omemo_encrypted_tag) - - msg_dict['sender_jid'] = from_jid - - plaintext = state.decrypt_msg(msg_dict) - - if not plaintext: - return - - self.print_msg_to_log(msg.msg_) - - msg.msgtxt = plaintext - - contact_jid = msg.with_ - - if account in self.ui_list and \ - contact_jid in self.ui_list[account]: - self.ui_list[account][contact_jid].activate_omemo() - return False - - elif msg.msg_.getTag('body'): - account = msg.conn.name - - jid = msg.with_ - state = self.get_omemo_state(account) - omemo_enabled = state.encryption.is_active(jid) - - if omemo_enabled: - msg.msgtxt = '**Unencrypted** ' + msg.msgtxt - - @log_calls('OmemoPlugin') - def message_received(self, msg): - """ Handles an incoming message - - Payload is decrypted and the plaintext is written into the - event object. Afterwards the event is passed on further to Gajim. - - Parameters - ---------- - msg : MessageReceivedEvent - - Returns - ------- - Return means that the Event is passed on to Gajim - """ - - if msg.stanza.getTag('openpgp', namespace=NS_PGP): - return - - if msg.stanza.getTag('encrypted', namespace=NS_OMEMO) and \ - msg.mtype == 'chat': - account = msg.conn.name - log.debug(account + ' => OMEMO msg received') - - state = self.get_omemo_state(account) - if msg.forwarded and msg.sent: - from_jid = str(msg.stanza.getTo()) # why gajim? why? - log.debug('message was forwarded doing magic') - else: - from_jid = str(msg.stanza.getFrom()) - self.print_msg_to_log(msg.stanza) - msg_dict = unpack_encrypted(msg.stanza.getTag - ('encrypted', namespace=NS_OMEMO)) - msg_dict['sender_jid'] = gajim.get_jid_without_resource(from_jid) - plaintext = state.decrypt_msg(msg_dict) - - if not plaintext: - return - - msg.msgtxt = plaintext - # Gajim bug: there must be a body or the message - # gets dropped from history - msg.stanza.setBody(plaintext) - - contact_jid = gajim.get_jid_without_resource(from_jid) - if account in self.ui_list and \ - contact_jid in self.ui_list[account]: - self.ui_list[account][contact_jid].activate_omemo() - return False - - elif msg.stanza.getTag('body') and msg.mtype == 'chat': - account = msg.conn.name - - from_jid = str(msg.stanza.getFrom()) - jid = gajim.get_jid_without_resource(from_jid) - state = self.get_omemo_state(account) - omemo_enabled = state.encryption.is_active(jid) - - if omemo_enabled: - msg.msgtxt = '**Unencrypted** ' + msg.msgtxt - # msg.stanza.setBody(msg.msgtxt) - - try: - gui = self.ui_list[account].get(jid, None) - if gui and gui.encryption_active(): - gui.plain_warning() - except KeyError: - log.debug('No Ui present for ' + jid + - ', Ui Warning not shown') - - @log_calls('OmemoPlugin') - def handle_outgoing_event(self, event): - """ Handles a message outgoing event - - In this event we have no stanza. XHTML is set to None - so that it doesnt make its way into the stanza - - Parameters - ---------- - event : MessageOutgoingEvent - - Returns - ------- - Return if encryption is not activated - """ - account = event.account - state = self.get_omemo_state(account) - - if not state.encryption.is_active(event.jid): - return False - - event.xhtml = None - - @log_calls('OmemoPlugin') - def handle_outgoing_stanza(self, event): - """ Manipulates the outgoing stanza - - The body is getting encrypted - - Parameters - ---------- - event : StanzaMessageOutgoingEvent - - Returns - ------- - Return if encryption is not activated or any other - exception or error occurs - """ - try: - if not event.msg_iq.getTag('body'): - return - - account = event.conn.name - state = self.get_omemo_state(account) - full_jid = str(event.msg_iq.getAttr('to')) - to_jid = gajim.get_jid_without_resource(full_jid) - if not state.encryption.is_active(to_jid): - return - - # Delete previous Message out of Correction Message Stanza - if event.msg_iq.getTag('replace', namespace=NS_CORRECT): - event.msg_iq.delChild('encrypted', attrs={'xmlns': NS_OMEMO}) - - plaintext = event.msg_iq.getBody().encode('utf-8') - - msg_dict = state.create_msg( - gajim.get_jid_from_account(account), to_jid, plaintext) - - if not msg_dict: - return True - - encrypted_node = OmemoMessage(msg_dict) - - # Check if non-OMEMO resource is online - contacts = gajim.contacts.get_contacts(account, to_jid) - non_omemo_resource_online = False - for contact in contacts: - if contact.show == 'offline': - continue - if not contact.supports(NS_NOTIFY): - log.debug(contact.get_full_jid() + - ' => Contact doesnt support OMEMO, ' - 'adding Info Message to Body') - support_msg = 'You received a message encrypted with ' \ - 'OMEMO but your client doesnt support OMEMO.' - event.msg_iq.setBody(support_msg) - non_omemo_resource_online = True - if not non_omemo_resource_online: - event.msg_iq.delChild('body') - - event.msg_iq.addChild(node=encrypted_node) - - # XEP-xxxx: Explicit Message Encryption - if not event.msg_iq.getTag('encrypted', attrs={'xmlns': NS_EME}): - eme_node = Node('encrypted', attrs={'xmlns': NS_EME, - 'name': 'OMEMO', - 'namespace': NS_OMEMO}) - event.msg_iq.addChild(node=eme_node) - - # Store Hint for MAM - store = Node('store', attrs={'xmlns': NS_HINTS}) - event.msg_iq.addChild(node=store) - self.print_msg_to_log(event.msg_iq) - except Exception as e: - log.debug(e) - return True - - @log_calls('OmemoPlugin') - def handle_device_list_update(self, event): - """ Check if the passed event is a device list update and store the new - device ids. - - Parameters - ---------- - event : PEPReceivedEvent - - Returns - ------- - bool - True if the given event was a valid device list update event - - - See also - -------- - 4.2 Discovering peer support - http://conversations.im/xeps/multi-end.html#usecases-discovering - """ - if event.pep_type != 'headline': - return False - - devices_list = list(set(unpack_device_list_update(event.stanza, - event.conn.name))) - if len(devices_list) == 0: - return False - account = event.conn.name - contact_jid = gajim.get_jid_without_resource(event.fjid) - state = self.get_omemo_state(account) - my_jid = gajim.get_jid_from_account(account) - - if contact_jid == my_jid: - log.info(account + ' => Received own device list:' + str( - devices_list)) - state.set_own_devices(devices_list) - state.store.sessionStore.setActiveState(devices_list, my_jid) - - # remove contact from list, so on send button pressed - # we query for bundle and build a session - if contact_jid in self.query_for_bundles: - self.query_for_bundles.remove(contact_jid) - - if not state.own_device_id_published(): - # Our own device_id is not in the list, it could be - # overwritten by some other client - self.publish_own_devices_list(account) - else: - log.info(account + ' => Received device list for ' + - contact_jid + ':' + str(devices_list)) - state.set_devices(contact_jid, devices_list) - state.store.sessionStore.setActiveState(devices_list, contact_jid) - - # remove contact from list, so on send button pressed - # we query for bundle and build a session - if contact_jid in self.query_for_bundles: - self.query_for_bundles.remove(contact_jid) - - # Enable Encryption on receiving first Device List - if not state.encryption.exist(contact_jid): - if account in self.ui_list and \ - contact_jid in self.ui_list[account]: - log.debug(account + - ' => Switch encryption ON automatically ...') - self.ui_list[account][contact_jid].activate_omemo() - else: - log.debug(account + - ' => Switch encryption ON automatically ...') - self.omemo_enable_for(contact_jid, account) - - if account in self.ui_list and \ - contact_jid not in self.ui_list[account]: - - chat_control = gajim.interface.msg_win_mgr.get_control( - contact_jid, account) - - if chat_control: - self.connect_ui(chat_control) - - return True - - @log_calls('OmemoPlugin') - def publish_own_devices_list(self, account): - """ Check if the passed event is a device list update and store the new - device ids. - - Parameters - ---------- - account : str - the account name - """ - state = self.get_omemo_state(account) - devices_list = state.own_devices - devices_list.append(state.own_device_id) - devices_list = list(set(devices_list)) - state.set_own_devices(devices_list) - - log.debug(account + ' => Publishing own Devices: ' + str( - devices_list)) - iq = DeviceListAnnouncement(devices_list) - gajim.connections[account].connection.send(iq) - id_ = str(iq.getAttr('id')) - IQ_CALLBACK[id_] = lambda event: log.debug(event) - - @log_calls('OmemoPlugin') - def connect_ui(self, chat_control): - """ Method called from Gajim when a Chat Window is opened - - Parameters - ---------- - chat_control : ChatControl - Gajim ChatControl object - """ - account = chat_control.contact.account.name - contact_jid = chat_control.contact.jid - if account not in self.ui_list: - self.ui_list[account] = {} - state = self.get_omemo_state(account) - my_jid = gajim.get_jid_from_account(account) - omemo_enabled = state.encryption.is_active(contact_jid) - if omemo_enabled: - log.debug(account + " => Adding OMEMO ui for " + contact_jid) - self.ui_list[account][contact_jid] = Ui(self, chat_control, - omemo_enabled, state) - self.ui_list[account][contact_jid].new_fingerprints_available() - return - if contact_jid in state.device_ids or contact_jid == my_jid: - log.debug(account + " => Adding OMEMO ui for " + contact_jid) - self.ui_list[account][contact_jid] = Ui(self, chat_control, - omemo_enabled, state) - self.ui_list[account][contact_jid].new_fingerprints_available() - else: - log.warning(account + " => No devices for " + contact_jid) - - @log_calls('OmemoPlugin') - def disconnect_ui(self, chat_control): - """ Calls the removeUi method to remove all relatad UI objects. - - Parameters - ---------- - chat_control : ChatControl - Gajim ChatControl object - """ - contact_jid = chat_control.contact.jid - account = chat_control.contact.account.name - self.ui_list[account][contact_jid].removeUi() - - def are_keys_missing(self, account, contact_jid): - """ Checks if devicekeys are missing and querys the - bundles - - Parameters - ---------- - account : str - the account name - contact_jid : str - bare jid of the contact - - Returns - ------- - bool - Returns True if there are no trusted Fingerprints - """ - state = self.get_omemo_state(account) - my_jid = gajim.get_jid_from_account(account) - - # Fetch Bundles of own other Devices - if my_jid not in self.query_for_bundles: - - devices_without_session = state \ - .devices_without_sessions(my_jid) - - self.query_for_bundles.append(my_jid) - - if devices_without_session: - for device_id in devices_without_session: - self.fetch_device_bundle_information(account, my_jid, - device_id) - - # Fetch Bundles of contacts devices - if contact_jid not in self.query_for_bundles: - - devices_without_session = state \ - .devices_without_sessions(contact_jid) - - self.query_for_bundles.append(contact_jid) - - if devices_without_session: - for device_id in devices_without_session: - self.fetch_device_bundle_information(account, contact_jid, - device_id) - - if state.getTrustedFingerprints(contact_jid): - return False - else: - return True - - @staticmethod - def handle_iq_received(event): - """ Method called when an IQ is received - - Parameters - ---------- - event : RawIqReceived - """ - id_ = str(event.stanza.getAttr("id")) - if id_ in IQ_CALLBACK: - try: - IQ_CALLBACK[id_](event.stanza) - except: - raise - finally: - del IQ_CALLBACK[id_] - - @log_calls('OmemoPlugin') - def fetch_device_bundle_information(self, account, jid, device_id): - """ Fetch bundle information for specified jid, key, and create axolotl - session on success. - - Parameters - ---------- - account : str - The account name - jid : str - The jid to query for bundle information - device_id : int - The device_id for which we are missing an axolotl session - """ - log.info(account + ' => Fetch bundle device ' + str(device_id) + - '#' + jid) - iq = BundleInformationQuery(jid, device_id) - iq_id = str(iq.getAttr('id')) - IQ_CALLBACK[iq_id] = \ - lambda stanza: self.session_from_prekey_bundle(account, - stanza, jid, - device_id) - gajim.connections[account].connection.send(iq) - - @log_calls('OmemoPlugin') - def session_from_prekey_bundle(self, account, stanza, - recipient_id, device_id): - """ Starts a session from a PreKey bundle. - - This method tries to build an axolotl session when a PreKey bundle - is fetched. - - If a session can not be build it will fail silently but log the a - warning. - - See also - -------- - - 4.4 Building a session: - http://conversations.im/xeps/multi-end.html#usecases-building - - Parameters: - ----------- - account : str - The account name - stanza - The stanza object received from callback - recipient_id : str - The recipient jid - device_id : int - The device_id for which the bundle was queried - - """ - state = self.get_omemo_state(account) - bundle_dict = unpack_device_bundle(stanza, device_id) - if not bundle_dict: - log.warning('Failed to build Session with ' + recipient_id) - return - - if state.build_session(recipient_id, device_id, bundle_dict): - log.info(account + ' => session created for: ' + recipient_id) - # Trigger dialog to trust new Fingerprints if - # the Chat Window is Open - if account in self.ui_list and \ - recipient_id in self.ui_list[account]: - self.ui_list[account][recipient_id]. \ - new_fingerprints_available() - - @log_calls('OmemoPlugin') - def query_own_devicelist(self, account): - """ Query own devicelist from the server. - - Parameters - ---------- - account : str - the account name - """ - my_jid = gajim.get_jid_from_account(account) - iq = DevicelistQuery(my_jid) - gajim.connections[account].connection.send(iq) - log.info(account + ' => Querry own devicelist ...') - id_ = str(iq.getAttr("id")) - IQ_CALLBACK[id_] = lambda stanza: \ - self.handle_devicelist_result(account, stanza) - - @log_calls('OmemoPlugin') - def publish_bundle(self, account): - """ Publish our bundle information to the PEP node. - - Parameters - ---------- - account : str - the account name - - See also - -------- - 4.3 Announcing bundle information: - http://conversations.im/xeps/multi-end.html#usecases-announcing - """ - state = self.get_omemo_state(account) - iq = BundleInformationAnnouncement(state.bundle, state.own_device_id) - gajim.connections[account].connection.send(iq) - id_ = str(iq.getAttr("id")) - log.info(account + " => Publishing bundle ...") - IQ_CALLBACK[id_] = lambda stanza: \ - self.handle_publish_result(account, stanza) - - @staticmethod - def handle_publish_result(account, stanza): - """ Log if publishing our bundle was successful - - Parameters - ---------- - account : str - the account name - stanza - The stanza object received from callback - """ - if successful(stanza): - log.info(account + ' => Publishing bundle was successful') - else: - log.error(account + ' => Publishing bundle was NOT successful') - - @log_calls('OmemoPlugin') - def handle_devicelist_result(self, account, stanza): - """ If query was successful add own device to the list. - - Parameters - ---------- - account : str - the account name - stanza - The stanza object received from callback - """ - - my_jid = gajim.get_jid_from_account(account) - state = self.get_omemo_state(account) - - if successful(stanza): - log.info(account + ' => Devicelistquery was successful') - devices_list = list(set(unpack_device_list_update(stanza, account))) - if len(devices_list) == 0: - return False - contact_jid = stanza.getAttr('from') - if contact_jid == my_jid: - state.set_own_devices(devices_list) - state.store.sessionStore.setActiveState(devices_list, my_jid) - - # remove contact from list, so on send button pressed - # we query for bundle and build a session - if contact_jid in self.query_for_bundles: - self.query_for_bundles.remove(contact_jid) - - if not state.own_device_id_published(): - # Our own device_id is not in the list, it could be - # overwritten by some other client - self.publish_own_devices_list(account) - else: - log.error(account + ' => Devicelistquery was NOT successful') - self.publish_own_devices_list(account) - - @log_calls('OmemoPlugin') - def clear_device_list(self, account): - """ Clears the local devicelist of our own devices and publishes - a new one including only the current ID of this device - - Parameters - ---------- - account : str - the account name - """ - connection = gajim.connections[account].connection - if not connection: - return - state = self.get_omemo_state(account) - devices_list = [state.own_device_id] - state.set_own_devices(devices_list) - - log.info(account + ' => Clearing devices_list ' + str(devices_list)) - iq = DeviceListAnnouncement(devices_list) - connection.send(iq) - id_ = str(iq.getAttr('id')) - IQ_CALLBACK[id_] = lambda event: log.info(event) - - @staticmethod - def print_msg_to_log(stanza): - """ Prints a stanza in a fancy way to the log """ - log.debug('-'*15) - stanzastr = '\n' + stanza.__str__(fancy=True) - stanzastr = stanzastr[0:-1] - log.debug(stanzastr) - log.debug('-'*15) - - @log_calls('OmemoPlugin') - def omemo_enable_for(self, jid, account): - """ Used by the UI to enable OMEMO for a specified contact. - - To activate OMEMO check first if a Ui Object exists for the - Contact. If it exists use Ui.activate_omemo(). Only if there - is no Ui Object for the contact this method is to be used. - - Parameters - ---------- - jid : str - bare jid - account : str - the account name - """ - state = self.get_omemo_state(account) - state.encryption.activate(jid) - - @log_calls('OmemoPlugin') - def omemo_disable_for(self, jid, account): - """ Used by the UI to disable OMEMO for a specified contact. - - WARNING - OMEMO should only be disabled through - User interaction with the UI. - - Parameters - ---------- - jid : str - bare jid - account : str - the account name - """ - state = self.get_omemo_state(account) - state.encryption.deactivate(jid) +from .omemoplugin import OmemoPlugin diff --git a/omemo/omemoplugin.py b/omemo/omemoplugin.py new file mode 100644 index 0000000000000000000000000000000000000000..49fc0125dff06d2d9862e6d738e5893f056f33c0 --- /dev/null +++ b/omemo/omemoplugin.py @@ -0,0 +1,1135 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2015 Bahtiar `kalkin-` Gadimov +# Copyright 2015 Daniel Gultsch +# +# This file is part of Gajim-OMEMO plugin. +# +# The Gajim-OMEMO 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, either version 3 of the License, or (at your option) any +# later version. +# +# Gajim-OMEMO 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 +# the Gajim-OMEMO plugin. If not, see . +# + +import logging +import os +import sqlite3 +import shutil +import message_control + +from common import caps_cache, gajim, ged, configpaths +from common.pep import SUPPORTED_PERSONAL_USER_EVENTS +from plugins import GajimPlugin +from plugins.helpers import log_calls +from nbxmpp.simplexml import Node +from nbxmpp import NS_CORRECT, NS_ADDRESS + +from .xmpp import ( + NS_NOTIFY, NS_OMEMO, NS_EME, BundleInformationAnnouncement, + BundleInformationQuery, DeviceListAnnouncement, DevicelistQuery, + DevicelistPEP, OmemoMessage, successful, unpack_device_bundle, + unpack_device_list_update, unpack_encrypted) + + +IQ_CALLBACK = {} + +AXOLOTL_MISSING = 'You are missing Python-Axolotl or use an outdated version' +PROTOBUF_MISSING = 'OMEMO cant import Google Protobuf, you can find help in ' \ + 'the GitHub Wiki' +GAJIM_VERSION = 'OMEMO only works with the latest Gajim version, get the ' \ + 'latest version from gajim.org' +ERROR_MSG = '' + +NS_HINTS = 'urn:xmpp:hints' +NS_PGP = 'urn:xmpp:openpgp:0' +DB_DIR_OLD = gajim.gajimpaths.data_root +DB_DIR_NEW = configpaths.gajimpaths['MY_DATA'] + +log = logging.getLogger('gajim.plugin_system.omemo') + +try: + import google.protobuf +except Exception as e: + log.error(e) + ERROR_MSG = PROTOBUF_MISSING + +try: + import axolotl +except Exception as e: + log.error(e) + ERROR_MSG = AXOLOTL_MISSING + +if not ERROR_MSG: + try: + from .omemo.state import OmemoState + from .ui import Ui, OMEMOConfigDialog + except Exception as e: + log.error(e) + ERROR_MSG = 'Error: ' + str(e) + +# pylint: disable=no-init +# pylint: disable=attribute-defined-outside-init + + +class OmemoPlugin(GajimPlugin): + + omemo_states = {} + ui_list = {} + groupchat = {} + temp_groupchat = {} + + @log_calls('OmemoPlugin') + def init(self): + """ Init """ + if ERROR_MSG: + self.activatable = False + self.available_text = ERROR_MSG + self.config_dialog = None + return + self.events_handlers = { + 'mam-message-received': (ged.PRECORE, self.mam_message_received), + 'message-received': (ged.PRECORE, self.message_received), + 'pep-received': (ged.PRECORE, self.handle_device_list_update), + 'raw-iq-received': (ged.PRECORE, self.handle_iq_received), + 'signed-in': (ged.PRECORE, self.signed_in), + 'stanza-message-outgoing': + (ged.PRECORE, self.handle_outgoing_stanza), + 'message-outgoing': + (ged.PRECORE, self.handle_outgoing_event), + 'gc-stanza-message-outgoing': + (ged.PRECORE, self.handle_outgoing_gc_stanza), + 'gc-presence-received': (ged.PRECORE, self.gc_presence_received), + 'gc-config-changed-received': + (ged.PRECORE, self.gc_config_changed_received), + 'muc-admin-received': (ged.PRECORE, self.room_memberlist_received), + } + + self.config_dialog = OMEMOConfigDialog(self) + self.gui_extension_points = {'chat_control': (self.connect_ui, + self.disconnect_ui), + 'groupchat_control': (self.connect_ui, + self.disconnect_ui)} + SUPPORTED_PERSONAL_USER_EVENTS.append(DevicelistPEP) + self.plugin = self + self.announced = [] + self.query_for_bundles = [] + self.disabled_accounts = [] + self.gc_message = {} + + self.config_default_values = {'DISABLED_ACCOUNTS': ([], ''), } + + for account in self.plugin.config['DISABLED_ACCOUNTS']: + self.disabled_accounts.append(account) + + def migrate_dbpath(self, account, my_jid): + old_dbpath = os.path.join(DB_DIR_OLD, 'omemo_' + account + '.db') + new_dbpath = os.path.join(DB_DIR_NEW, 'omemo_' + my_jid + '.db') + + if os.path.exists(old_dbpath): + log.debug('Migrating DBName and Path ..') + try: + shutil.move(old_dbpath, new_dbpath) + return new_dbpath + except Exception: + log.exception('Migration Error:') + return old_dbpath + + return new_dbpath + + @log_calls('OmemoPlugin') + def get_omemo_state(self, account): + """ Returns the the OmemoState for the specified account. + Creates the OmemoState if it does not exist yet. + + Parameters + ---------- + account : str + the account name + + Returns + ------- + OmemoState + """ + if account in self.disabled_accounts: + return + if account not in self.omemo_states: + self.deactivate_gajim_e2e(account) + my_jid = gajim.get_jid_from_account(account) + db_path = self.migrate_dbpath(account, my_jid) + + conn = sqlite3.connect(db_path, check_same_thread=False) + self.omemo_states[account] = OmemoState(my_jid, conn, account, + self.plugin) + + return self.omemo_states[account] + + @staticmethod + def deactivate_gajim_e2e(account): + """ Deativates E2E encryption in Gajim """ + gajim.config.set_per('accounts', account, + 'autonegotiate_esessions', False) + gajim.config.set_per('accounts', account, + 'enable_esessions', False) + log.info(str(account) + " => Gajim E2E encryption disabled") + + @log_calls('OmemoPlugin') + def signed_in(self, event): + """ Method called on SignIn + + Parameters + ---------- + event : SignedInEvent + """ + account = event.conn.name + if account in self.disabled_accounts: + return + log.debug(account + + ' => Announce Support after Sign In') + self.query_for_bundles = [] + self.announced = [] + self.announced.append(account) + self.publish_bundle(account) + self.query_own_devicelist(account) + + @log_calls('OmemoPlugin') + def activate(self): + """ Method called when the Plugin is activated in the PluginManager + """ + self.query_for_bundles = [] + # Publish bundle information and Entity Caps + for account in gajim.connections: + if account in self.disabled_accounts: + log.debug(account + + ' => Account is disabled') + continue + if NS_NOTIFY not in gajim.gajim_optional_features[account]: + gajim.gajim_optional_features[account].append(NS_NOTIFY) + self._compute_caps_hash(account) + if account not in self.announced: + if gajim.account_is_connected(account): + log.debug(account + + ' => Announce Support after Plugin Activation') + self.announced.append(account) + self.publish_bundle(account) + self.query_own_devicelist(account) + + @log_calls('OmemoPlugin') + def deactivate(self): + """ Method called when the Plugin is deactivated in the PluginManager + + Removes OMEMO from the Entity Capabilities list + """ + for account in gajim.connections: + if account in self.disabled_accounts: + continue + if NS_NOTIFY in gajim.gajim_optional_features[account]: + gajim.gajim_optional_features[account].remove(NS_NOTIFY) + self._compute_caps_hash(account) + + @staticmethod + def _compute_caps_hash(account): + """ Computes the hash for Entity Capabilities and publishes it """ + gajim.caps_hash[account] = caps_cache.compute_caps_hash( + [gajim.gajim_identity], + gajim.gajim_common_features + + gajim.gajim_optional_features[account]) + # re-send presence with new hash + connected = gajim.connections[account].connected + if connected > 1 and gajim.SHOW_LIST[connected] != 'invisible': + gajim.connections[account].change_status( + gajim.SHOW_LIST[connected], gajim.connections[account].status) + + @log_calls('OmemoPlugin') + def mam_message_received(self, msg): + """ Handles an incoming MAM message + + Payload is decrypted and the plaintext is written into the + event object. Afterwards the event is passed on further to Gajim. + + Parameters + ---------- + msg : MamMessageReceivedEvent + + Returns + ------- + Return means that the Event is passed on to Gajim + """ + account = msg.conn.name + if account in self.disabled_accounts: + return + + if msg.msg_.getTag('openpgp', namespace=NS_PGP): + return + + omemo_encrypted_tag = msg.msg_.getTag('encrypted', namespace=NS_OMEMO) + if omemo_encrypted_tag: + log.debug(account + ' => OMEMO MAM msg received') + + state = self.get_omemo_state(account) + + from_jid = str(msg.msg_.getAttr('from')) + from_jid = gajim.get_jid_without_resource(from_jid) + + msg_dict = unpack_encrypted(omemo_encrypted_tag) + + msg_dict['sender_jid'] = from_jid + + plaintext = state.decrypt_msg(msg_dict) + + if not plaintext: + return + + self.print_msg_to_log(msg.msg_) + + msg.msgtxt = plaintext + + contact_jid = msg.with_ + + if account in self.ui_list and \ + contact_jid in self.ui_list[account]: + self.ui_list[account][contact_jid].activate_omemo() + return False + + elif msg.msg_.getTag('body'): + account = msg.conn.name + + jid = msg.with_ + state = self.get_omemo_state(account) + omemo_enabled = state.encryption.is_active(jid) + + if omemo_enabled: + msg.msgtxt = '**Unencrypted** ' + msg.msgtxt + + @log_calls('OmemoPlugin') + def message_received(self, msg): + """ Handles an incoming message + + Payload is decrypted and the plaintext is written into the + event object. Afterwards the event is passed on further to Gajim. + + Parameters + ---------- + msg : MessageReceivedEvent + + Returns + ------- + Return means that the Event is passed on to Gajim + """ + account = msg.conn.name + if account in self.disabled_accounts: + return + + if msg.stanza.getTag('openpgp', namespace=NS_PGP): + return + + if msg.stanza.getTag('encrypted', namespace=NS_OMEMO): + log.debug(account + ' => OMEMO msg received') + + state = self.get_omemo_state(account) + if msg.forwarded and msg.sent: + from_jid = str(msg.stanza.getTo()) # why gajim? why? + log.debug('message was forwarded doing magic') + else: + from_jid = str(msg.stanza.getFrom()) + + self.print_msg_to_log(msg.stanza) + msg_dict = unpack_encrypted(msg.stanza.getTag + ('encrypted', namespace=NS_OMEMO)) + + if msg.mtype == 'groupchat': + address_tag = msg.stanza.getTag('addresses', + namespace=NS_ADDRESS) + if address_tag: # History Message from MUC + from_jid = address_tag.getTag( + 'address', attrs={'type': 'ofrom'}).getAttr('jid') + else: + try: + from_jid = self.groupchat[msg.jid][msg.resource] + except KeyError: + log.debug('Groupchat: Last resort trying to ' + 'find SID in DB') + from_jid = state.store. \ + getJidFromDevice(msg_dict['sid']) + if not from_jid: + log.error(account + + ' => Cant decrypt GroupChat Message ' + 'from ' + msg.resource) + return True + self.groupchat[msg.jid][msg.resource] = from_jid + + log.debug('GroupChat Message from: %s', from_jid) + + plaintext = '' + if msg_dict['sid'] == state.own_device_id: + if msg_dict['payload'] in self.gc_message: + plaintext = self.gc_message[msg_dict['payload']] + del self.gc_message[msg_dict['payload']] + else: + log.error(account + ' => Cant decrypt own GroupChat ' + 'Message') + else: + msg_dict['sender_jid'] = gajim. \ + get_jid_without_resource(from_jid) + plaintext = state.decrypt_msg(msg_dict) + + if not plaintext: + return True + + msg.msgtxt = plaintext + # Gajim bug: there must be a body or the message + # gets dropped from history + msg.stanza.setBody(plaintext) + + if msg.mtype != 'groupchat': + contact_jid = gajim.get_jid_without_resource(from_jid) + if account in self.ui_list and \ + contact_jid in self.ui_list[account]: + self.ui_list[account][contact_jid].activate_omemo() + return False + + elif msg.stanza.getTag('body'): + account = msg.conn.name + + from_jid = str(msg.stanza.getFrom()) + jid = gajim.get_jid_without_resource(from_jid) + state = self.get_omemo_state(account) + omemo_enabled = state.encryption.is_active(jid) + + if omemo_enabled: + msg.msgtxt = '**Unencrypted** ' + msg.msgtxt + # msg.stanza.setBody(msg.msgtxt) + + try: + gui = self.ui_list[account].get(jid, None) + if gui and gui.encryption_active(): + gui.plain_warning() + except KeyError: + log.debug('No Ui present for ' + jid + + ', Ui Warning not shown') + + def room_memberlist_received(self, event): + account = event.conn.name + if account in self.disabled_accounts: + return + log.debug('Room %s Memberlist received: %s', + event.fjid, event.users_dict) + room = event.fjid + + def jid_known(jid): + for nick in self.groupchat[room]: + if self.groupchat[room][nick] == jid: + return True + return False + + for jid in event.users_dict: + if not jid_known(jid): + # Add JID with JID because we have no Nick yet + self.groupchat[room][jid] = jid + log.debug('JID Added: ' + jid) + + @log_calls('OmemoPlugin') + def gc_presence_received(self, event): + account = event.conn.name + if account in self.disabled_accounts: + return + if not hasattr(event, 'real_jid') or not event.real_jid: + return + + room = event.room_jid + jid = gajim.get_jid_without_resource(event.real_jid) + nick = event.nick + + if '303' in event.status_code: # Nick Changed + if room in self.groupchat: + if nick in self.groupchat[room]: + del self.groupchat[room][nick] + self.groupchat[room][event.new_nick] = jid + log.debug('Nick Change: old: %s, new: %s, jid: %s ', + nick, event.new_nick, jid) + log.debug('Members after Change: %s', self.groupchat[room]) + else: + if nick in self.temp_groupchat[room]: + del self.temp_groupchat[room][nick] + self.temp_groupchat[room][event.new_nick] = jid + + return + + if room not in self.groupchat: + + if room not in self.temp_groupchat: + self.temp_groupchat[room] = {} + + if nick not in self.temp_groupchat[room]: + self.temp_groupchat[room][nick] = jid + + else: + # Check if we received JID over Memberlist + if jid in self.groupchat[room]: + del self.groupchat[room][jid] + + # Add JID with Nick + if nick not in self.groupchat[room]: + self.groupchat[room][nick] = jid + log.debug('JID Added: ' + jid) + + if '100' in event.status_code: # non-anonymous Room (Full JID) + + if room not in self.groupchat: + self.groupchat[room] = self.temp_groupchat[room] + + log.debug('OMEMO capable Room found: %s', room) + + gajim.connections[account].get_affiliation_list(room, 'owner') + gajim.connections[account].get_affiliation_list(room, 'admin') + gajim.connections[account].get_affiliation_list(room, 'member') + + self.ui_list[account][room].sensitive(True) + + @log_calls('OmemoPlugin') + def gc_config_changed_received(self, event): + account = event.conn.name + if account in self.disabled_accounts: + return + log.debug('CONFIG CHANGE') + log.debug(event.room_jid) + log.debug(event.status_code) + + def handle_outgoing_gc_stanza(self, event): + """ Manipulates the outgoing groupchat stanza + + The body is getting encrypted + + Parameters + ---------- + event : StanzaMessageOutgoingEvent + + Returns + ------- + Return if encryption is not activated or any other + exception or error occurs + """ + account = event.conn.name + if account in self.disabled_accounts: + return + try: + # If we send a correction msg, the stanza is saved + # in correction_msg + if event.correction_msg: + event.msg_iq = event.correction_msg + if not event.msg_iq.getTag('body'): + return + state = self.get_omemo_state(account) + full_jid = str(event.msg_iq.getAttr('to')) + to_jid = gajim.get_jid_without_resource(full_jid) + if to_jid not in self.groupchat: + return + if not state.encryption.is_active(to_jid): + return + # Delete previous Message out of Correction Message Stanza + if event.msg_iq.getTag('replace', namespace=NS_CORRECT): + event.msg_iq.delChild('encrypted', attrs={'xmlns': NS_OMEMO}) + + plaintext = event.msg_iq.getBody() + msg_dict = state.create_gc_msg( + gajim.get_jid_from_account(account), + to_jid, + plaintext.encode('utf8')) + if not msg_dict: + return True + + self.gc_message[msg_dict['payload']] = plaintext + encrypted_node = OmemoMessage(msg_dict) + event.msg_iq.delChild('body') + event.msg_iq.addChild(node=encrypted_node) + + # XEP-xxxx: Explicit Message Encryption + if not event.msg_iq.getTag('encrypted', attrs={'xmlns': NS_EME}): + eme_node = Node('encrypted', attrs={'xmlns': NS_EME, + 'name': 'OMEMO', + 'namespace': NS_OMEMO}) + event.msg_iq.addChild(node=eme_node) + + # Add Message for devices that dont support OMEMO + support_msg = 'You received a message encrypted with ' \ + 'OMEMO but your client doesnt support OMEMO.' + event.msg_iq.setBody(support_msg) + + # Store Hint for MAM + store = Node('store', attrs={'xmlns': NS_HINTS}) + event.msg_iq.addChild(node=store) + if event.correction_msg: + event.correction_msg = event.msg_iq + event.msg_iq = None + self.print_msg_to_log(event.correction_msg) + else: + self.print_msg_to_log(event.msg_iq) + except Exception as e: + log.debug(e) + return True + + @log_calls('OmemoPlugin') + def handle_outgoing_event(self, event): + """ Handles a message outgoing event + + In this event we have no stanza. XHTML is set to None + so that it doesnt make its way into the stanza + + Parameters + ---------- + event : MessageOutgoingEvent + + Returns + ------- + Return if encryption is not activated + """ + account = event.account + if account in self.disabled_accounts: + return + state = self.get_omemo_state(account) + + if not state.encryption.is_active(event.jid): + return False + + event.xhtml = None + + @log_calls('OmemoPlugin') + def handle_outgoing_stanza(self, event): + """ Manipulates the outgoing stanza + + The body is getting encrypted + + Parameters + ---------- + event : StanzaMessageOutgoingEvent + + Returns + ------- + Return if encryption is not activated or any other + exception or error occurs + """ + account = event.conn.name + if account in self.disabled_accounts: + return + try: + if not event.msg_iq.getTag('body'): + return + + state = self.get_omemo_state(account) + full_jid = str(event.msg_iq.getAttr('to')) + to_jid = gajim.get_jid_without_resource(full_jid) + if not state.encryption.is_active(to_jid): + return + + # Delete previous Message out of Correction Message Stanza + if event.msg_iq.getTag('replace', namespace=NS_CORRECT): + event.msg_iq.delChild('encrypted', attrs={'xmlns': NS_OMEMO}) + + plaintext = event.msg_iq.getBody().encode('utf8') + + msg_dict = state.create_msg( + gajim.get_jid_from_account(account), to_jid, plaintext) + if not msg_dict: + return True + + encrypted_node = OmemoMessage(msg_dict) + + # Check if non-OMEMO resource is online + contacts = gajim.contacts.get_contacts(account, to_jid) + non_omemo_resource_online = False + for contact in contacts: + if contact.show == 'offline': + continue + if not contact.supports(NS_NOTIFY): + log.debug(contact.get_full_jid() + + ' => Contact doesnt support OMEMO, ' + 'adding Info Message to Body') + support_msg = 'You received a message encrypted with ' \ + 'OMEMO but your client doesnt support OMEMO.' + event.msg_iq.setBody(support_msg) + non_omemo_resource_online = True + if not non_omemo_resource_online: + event.msg_iq.delChild('body') + + event.msg_iq.addChild(node=encrypted_node) + + # XEP-xxxx: Explicit Message Encryption + if not event.msg_iq.getTag('encrypted', attrs={'xmlns': NS_EME}): + eme_node = Node('encrypted', attrs={'xmlns': NS_EME, + 'name': 'OMEMO', + 'namespace': NS_OMEMO}) + event.msg_iq.addChild(node=eme_node) + + # Store Hint for MAM + store = Node('store', attrs={'xmlns': NS_HINTS}) + event.msg_iq.addChild(node=store) + self.print_msg_to_log(event.msg_iq) + except Exception as e: + log.debug(e) + return True + + @log_calls('OmemoPlugin') + def handle_device_list_update(self, event): + """ Check if the passed event is a device list update and store the new + device ids. + + Parameters + ---------- + event : PEPReceivedEvent + + Returns + ------- + bool + True if the given event was a valid device list update event + + + See also + -------- + 4.2 Discovering peer support + http://conversations.im/xeps/multi-end.html#usecases-discovering + """ + + account = event.conn.name + if account in self.disabled_accounts: + return False + + if event.pep_type != 'headline': + return False + + devices_list = list(set(unpack_device_list_update(event.stanza, + event.conn.name))) + contact_jid = gajim.get_jid_without_resource(event.fjid) + if len(devices_list) == 0: + log.error(account + + ' => Received empty or invalid Devicelist from: ' + + contact_jid) + return False + + state = self.get_omemo_state(account) + my_jid = gajim.get_jid_from_account(account) + + if contact_jid == my_jid: + log.info(account + ' => Received own device list:' + str( + devices_list)) + state.set_own_devices(devices_list) + state.store.sessionStore.setActiveState(devices_list, my_jid) + + # remove contact from list, so on send button pressed + # we query for bundle and build a session + if contact_jid in self.query_for_bundles: + self.query_for_bundles.remove(contact_jid) + + if not state.own_device_id_published(): + # Our own device_id is not in the list, it could be + # overwritten by some other client + self.publish_own_devices_list(account) + else: + log.info(account + ' => Received device list for ' + + contact_jid + ':' + str(devices_list)) + state.set_devices(contact_jid, devices_list) + state.store.sessionStore.setActiveState(devices_list, contact_jid) + + # remove contact from list, so on send button pressed + # we query for bundle and build a session + if contact_jid in self.query_for_bundles: + self.query_for_bundles.remove(contact_jid) + + # Enable Encryption on receiving first Device List + if not state.encryption.exist(contact_jid): + if account in self.ui_list and \ + contact_jid in self.ui_list[account]: + log.debug(account + + ' => Switch encryption ON automatically ...') + self.ui_list[account][contact_jid].activate_omemo() + else: + log.debug(account + + ' => Switch encryption ON automatically ...') + self.omemo_enable_for(contact_jid, account) + + if account in self.ui_list and \ + contact_jid not in self.ui_list[account]: + + chat_control = gajim.interface.msg_win_mgr.get_control( + contact_jid, account) + + if chat_control: + self.connect_ui(chat_control) + + return True + + @log_calls('OmemoPlugin') + def publish_own_devices_list(self, account): + """ Check if the passed event is a device list update and store the new + device ids. + + Parameters + ---------- + account : str + the account name + """ + state = self.get_omemo_state(account) + devices_list = state.own_devices + devices_list.append(state.own_device_id) + devices_list = list(set(devices_list)) + state.set_own_devices(devices_list) + + log.debug(account + ' => Publishing own Devices: ' + str( + devices_list)) + iq = DeviceListAnnouncement(devices_list) + gajim.connections[account].connection.send(iq) + id_ = str(iq.getAttr('id')) + IQ_CALLBACK[id_] = lambda event: log.debug(event) + + @log_calls('OmemoPlugin') + def connect_ui(self, chat_control): + """ Method called from Gajim when a Chat Window is opened + + Parameters + ---------- + chat_control : ChatControl + Gajim ChatControl object + """ + account = chat_control.contact.account.name + if account in self.disabled_accounts: + return + contact_jid = chat_control.contact.jid + if account not in self.ui_list: + self.ui_list[account] = {} + state = self.get_omemo_state(account) + my_jid = gajim.get_jid_from_account(account) + omemo_enabled = state.encryption.is_active(contact_jid) + if omemo_enabled: + log.debug(account + " => Adding OMEMO ui for " + contact_jid) + self.ui_list[account][contact_jid] = Ui(self, chat_control, + omemo_enabled, state) + self.ui_list[account][contact_jid].new_fingerprints_available() + return + if contact_jid in state.device_ids or contact_jid == my_jid: + log.debug(account + " => Adding OMEMO ui for " + contact_jid) + self.ui_list[account][contact_jid] = Ui(self, chat_control, + omemo_enabled, state) + self.ui_list[account][contact_jid].new_fingerprints_available() + else: + log.warning(account + " => No devices for " + contact_jid) + + if chat_control.type_id == message_control.TYPE_GC: + self.ui_list[account][contact_jid] = Ui(self, chat_control, + omemo_enabled, state) + self.ui_list[account][contact_jid].sensitive(False) + + @log_calls('OmemoPlugin') + def disconnect_ui(self, chat_control): + """ Calls the removeUi method to remove all relatad UI objects. + + Parameters + ---------- + chat_control : ChatControl + Gajim ChatControl object + """ + contact_jid = chat_control.contact.jid + account = chat_control.contact.account.name + if account in self.disabled_accounts: + return + self.ui_list[account][contact_jid].removeUi() + + def are_keys_missing(self, account, contact_jid): + """ Checks if devicekeys are missing and querys the + bundles + + Parameters + ---------- + account : str + the account name + contact_jid : str + bare jid of the contact + + Returns + ------- + bool + Returns True if there are no trusted Fingerprints + """ + state = self.get_omemo_state(account) + my_jid = gajim.get_jid_from_account(account) + + # Fetch Bundles of own other Devices + if my_jid not in self.query_for_bundles: + + devices_without_session = state \ + .devices_without_sessions(my_jid) + + self.query_for_bundles.append(my_jid) + + if devices_without_session: + for device_id in devices_without_session: + self.fetch_device_bundle_information(account, my_jid, + device_id) + + # Fetch Bundles of contacts devices + if contact_jid not in self.query_for_bundles: + + devices_without_session = state \ + .devices_without_sessions(contact_jid) + + self.query_for_bundles.append(contact_jid) + + if devices_without_session: + for device_id in devices_without_session: + self.fetch_device_bundle_information(account, contact_jid, + device_id) + + if state.getTrustedFingerprints(contact_jid): + return False + else: + return True + + @staticmethod + def handle_iq_received(event): + """ Method called when an IQ is received + + Parameters + ---------- + event : RawIqReceived + """ + id_ = str(event.stanza.getAttr("id")) + if id_ in IQ_CALLBACK: + try: + IQ_CALLBACK[id_](event.stanza) + except: + raise + finally: + del IQ_CALLBACK[id_] + + @log_calls('OmemoPlugin') + def fetch_device_bundle_information(self, account, jid, device_id): + """ Fetch bundle information for specified jid, key, and create axolotl + session on success. + + Parameters + ---------- + account : str + The account name + jid : str + The jid to query for bundle information + device_id : int + The device_id for which we are missing an axolotl session + """ + log.info(account + ' => Fetch bundle device ' + str(device_id) + + '#' + jid) + iq = BundleInformationQuery(jid, device_id) + iq_id = str(iq.getAttr('id')) + IQ_CALLBACK[iq_id] = \ + lambda stanza: self.session_from_prekey_bundle(account, + stanza, jid, + device_id) + gajim.connections[account].connection.send(iq) + + @log_calls('OmemoPlugin') + def session_from_prekey_bundle(self, account, stanza, + recipient_id, device_id): + """ Starts a session from a PreKey bundle. + + This method tries to build an axolotl session when a PreKey bundle + is fetched. + + If a session can not be build it will fail silently but log the a + warning. + + See also + -------- + + 4.4 Building a session: + http://conversations.im/xeps/multi-end.html#usecases-building + + Parameters: + ----------- + account : str + The account name + stanza + The stanza object received from callback + recipient_id : str + The recipient jid + device_id : int + The device_id for which the bundle was queried + + """ + state = self.get_omemo_state(account) + bundle_dict = unpack_device_bundle(stanza, device_id) + if not bundle_dict: + log.warning('Failed to build Session with ' + recipient_id) + return + + if state.build_session(recipient_id, device_id, bundle_dict): + log.info(account + ' => session created for: ' + recipient_id) + # Trigger dialog to trust new Fingerprints if + # the Chat Window is Open + if account in self.ui_list and \ + recipient_id in self.ui_list[account]: + self.ui_list[account][recipient_id]. \ + new_fingerprints_available() + + @log_calls('OmemoPlugin') + def query_own_devicelist(self, account): + """ Query own devicelist from the server. + + Parameters + ---------- + account : str + the account name + """ + my_jid = gajim.get_jid_from_account(account) + iq = DevicelistQuery(my_jid) + gajim.connections[account].connection.send(iq) + log.info(account + ' => Querry own devicelist ...') + id_ = str(iq.getAttr("id")) + IQ_CALLBACK[id_] = lambda stanza: \ + self.handle_devicelist_result(account, stanza) + + @log_calls('OmemoPlugin') + def publish_bundle(self, account): + """ Publish our bundle information to the PEP node. + + Parameters + ---------- + account : str + the account name + + See also + -------- + 4.3 Announcing bundle information: + http://conversations.im/xeps/multi-end.html#usecases-announcing + """ + state = self.get_omemo_state(account) + iq = BundleInformationAnnouncement(state.bundle, state.own_device_id) + gajim.connections[account].connection.send(iq) + id_ = str(iq.getAttr("id")) + log.info(account + " => Publishing bundle ...") + IQ_CALLBACK[id_] = lambda stanza: \ + self.handle_publish_result(account, stanza) + + @staticmethod + def handle_publish_result(account, stanza): + """ Log if publishing our bundle was successful + + Parameters + ---------- + account : str + the account name + stanza + The stanza object received from callback + """ + if successful(stanza): + log.info(account + ' => Publishing bundle was successful') + else: + log.error(account + ' => Publishing bundle was NOT successful') + + @log_calls('OmemoPlugin') + def handle_devicelist_result(self, account, stanza): + """ If query was successful add own device to the list. + + Parameters + ---------- + account : str + the account name + stanza + The stanza object received from callback + """ + + my_jid = gajim.get_jid_from_account(account) + state = self.get_omemo_state(account) + + if successful(stanza): + devices_list = list(set(unpack_device_list_update(stanza, account))) + if len(devices_list) == 0: + log.error(account + ' => Devicelistquery was NOT successful') + self.publish_own_devices_list(account) + return False + contact_jid = stanza.getAttr('from') + if contact_jid == my_jid: + state.set_own_devices(devices_list) + state.store.sessionStore.setActiveState(devices_list, my_jid) + log.info(account + ' => Devicelistquery was successful') + # remove contact from list, so on send button pressed + # we query for bundle and build a session + if contact_jid in self.query_for_bundles: + self.query_for_bundles.remove(contact_jid) + + if not state.own_device_id_published(): + # Our own device_id is not in the list, it could be + # overwritten by some other client + self.publish_own_devices_list(account) + else: + log.error(account + ' => Devicelistquery was NOT successful') + self.publish_own_devices_list(account) + + @log_calls('OmemoPlugin') + def clear_device_list(self, account): + """ Clears the local devicelist of our own devices and publishes + a new one including only the current ID of this device + + Parameters + ---------- + account : str + the account name + """ + connection = gajim.connections[account].connection + if not connection: + return + state = self.get_omemo_state(account) + devices_list = [state.own_device_id] + state.set_own_devices(devices_list) + + log.info(account + ' => Clearing devices_list ' + str(devices_list)) + iq = DeviceListAnnouncement(devices_list) + connection.send(iq) + id_ = str(iq.getAttr('id')) + IQ_CALLBACK[id_] = lambda event: log.info(event) + + @staticmethod + def print_msg_to_log(stanza): + """ Prints a stanza in a fancy way to the log """ + log.debug('-'*15) + stanzastr = '\n' + stanza.__str__(fancy=True) + stanzastr = stanzastr[0:-1] + log.debug(stanzastr) + log.debug('-'*15) + + @log_calls('OmemoPlugin') + def omemo_enable_for(self, jid, account): + """ Used by the UI to enable OMEMO for a specified contact. + + To activate OMEMO check first if a Ui Object exists for the + Contact. If it exists use Ui.activate_omemo(). Only if there + is no Ui Object for the contact this method is to be used. + + Parameters + ---------- + jid : str + bare jid + account : str + the account name + """ + state = self.get_omemo_state(account) + state.encryption.activate(jid) + + @log_calls('OmemoPlugin') + def omemo_disable_for(self, jid, account): + """ Used by the UI to disable OMEMO for a specified contact. + + WARNING - OMEMO should only be disabled through + User interaction with the UI. + + Parameters + ---------- + jid : str + bare jid + account : str + the account name + """ + state = self.get_omemo_state(account) + state.encryption.deactivate(jid) diff --git a/omemo/xmpp.py b/omemo/xmpp.py index c4097a5177560212f2358492ad4578d30af3e740..43323a02f6e18007278eecec732e3076531d5dec 100644 --- a/omemo/xmpp.py +++ b/omemo/xmpp.py @@ -79,10 +79,14 @@ class OmemoMessage(Node): # , contact_jid, key, iv, payload, dev_id, my_dev_id): Node.__init__(self, 'encrypted', attrs={'xmlns': NS_OMEMO}) header = Node('header', attrs={'sid': msg_dict['sid']}) - for rid, key in msg_dict['keys'].items(): - header.addChild('key', attrs={'rid': rid}).addData(b64encode(key) - .decode('utf-8')) - + for rid, (key, prekey) in msg_dict['keys'].items(): + if prekey: + child = header.addChild('key', + attrs={'prekey': 'true', 'rid': rid}) + else: + child = header.addChild('key', + attrs={'rid': rid}) + child.addData(b64encode(key).decode('utf-8')) header.addChild('iv').addData(b64encode(msg_dict['iv']).decode('utf-8')) self.addChild(node=header) self.addChild('payload').addData(b64encode(msg_dict['payload'])