diff sat/plugins/plugin_xep_0384.py @ 3104:118d91c932a7

plugin XEP-0384: OMEMO for MUC implementation: - encryption is now allowed for group chats - when an encryption is requested for a MUC, real jids or all occupants are used to encrypt the message - a cache for plain text message sent to MUC is used, because for security reason we can't encrypt message for our own device with OMEMO (that would prevent ratchet and break the prefect forward secrecy). Thus, message sent in MUC are cached for 5 min, and the decrypted version is used when found. We don't send immediately the plain text message to frontends and history because we want to keep the same MUC behaviour as for plain text, and receiving a message means that it was received and sent back by MUC service - <origin-id> is used to identify messages sent by our device - a feedback_jid is now use to use correct entity for feedback message in case of problem: with a room we have to send feedback message to the room and not the the emitter - encryptMessage now only accepts list in the renamed "entity_bare_jids" argument
author Goffi <goffi@goffi.org>
date Mon, 30 Dec 2019 20:59:46 +0100
parents 518208085dfb
children 9d0df638c8b4
line wrap: on
line diff
--- a/sat/plugins/plugin_xep_0384.py	Mon Dec 30 20:44:05 2019 +0100
+++ b/sat/plugins/plugin_xep_0384.py	Mon Dec 30 20:59:46 2019 +0100
@@ -20,7 +20,7 @@
 from sat.core.constants import Const as C
 from sat.core.log import getLogger
 from sat.core import exceptions
-from twisted.internet import defer
+from twisted.internet import defer, reactor
 from twisted.words.xish import domish
 from twisted.words.protocols.jabber import jid
 from twisted.words.protocols.jabber import error
@@ -55,6 +55,7 @@
     C.PI_TYPE: "SEC",
     C.PI_PROTOCOLS: ["XEP-0384"],
     C.PI_DEPENDENCIES: ["XEP-0163", "XEP-0280", "XEP-0334", "XEP-0060"],
+    C.PI_RECOMMENDATIONS: ["XEP-0045", "XEP-0359"],
     C.PI_MAIN: "OMEMO",
     C.PI_HANDLER: "no",
     C.PI_DESCRIPTION: _("""Implementation of OMEMO"""),
@@ -71,6 +72,9 @@
 KEY_ACTIVE_DEVICES = "DEVICES"
 KEY_INACTIVE_DEVICES = "INACTIVE_DEVICES"
 KEY_ALL_JIDS = "ALL_JIDS"
+# time before plaintext cache for MUC is expired
+# expressed in seconds, reset on each new MUC message
+MUC_CACHE_TTL = 60 * 5
 
 
 # we want to manage log emitted by omemo module ourselves
@@ -331,10 +335,7 @@
             entities => devices => bundles map
         @return D(dict): encryption data
         """
-        if isinstance(bare_jids, jid.JID):
-            bare_jids = bare_jids.userhost()
-        else:
-            bare_jids = [e.userhost() for e in bare_jids]
+        bare_jids = [e.userhost() for e in bare_jids]
         if bundles is not None:
             bundles = {e.userhost(): v for e, v in bundles.items()}
         encrypt_mess_p = self._session.encryptMessage(
@@ -390,6 +391,8 @@
         self._p_hints = host.plugins["XEP-0334"]
         self._p_carbons = host.plugins["XEP-0280"]
         self._p = host.plugins["XEP-0060"]
+        self._m = host.plugins.get("XEP-0045")
+        self._sid = host.plugins.get("XEP-0359")
         host.trigger.add("MessageReceived", self._messageReceivedTrigger, priority=100050)
         host.trigger.add("sendMessageData", self._sendMessageDataTrigger)
         self.host.registerEncryptionPlugin(self, "OMEMO", NS_OMEMO, 100)
@@ -452,38 +455,43 @@
         if trust_data is None:
             cache = client._xep_0384_cache.setdefault(entity_jid, {})
             trust_data = {}
-            trust_session_data = yield session.getTrustForJID(entity_jid)
-            bare_jid_s = entity_jid.userhost()
-            for device_id, trust_info in trust_session_data['active'].items():
-                if trust_info is None:
-                    # device has never been (un)trusted, we have to retrieve its
-                    # fingerprint (i.e. identity key or "ik") through public bundle
-                    if device_id not in cache:
-                        bundles, missing = yield self.getBundles(client,
-                                                                 entity_jid,
-                                                                 [device_id])
-                        if device_id not in bundles:
-                            log.warning(_(
-                                "Can't find bundle for device {device_id} of user "
-                                "{bare_jid}, ignoring").format(device_id=device_id,
-                                                                bare_jid=bare_jid_s))
-                            continue
-                        cache[device_id] = bundles[device_id]
-                    # TODO: replace False below by None when undecided
-                    #       trusts are handled
-                    trust_info = {
-                        "key": cache[device_id].ik,
-                        "trusted": False
-                    }
+            if self._m is not None and self._m.isJoinedRoom(client, entity_jid):
+                trust_jids = self.getJIDsForRoom(client, entity_jid)
+            else:
+                trust_jids = [entity_jid]
+            for trust_jid in trust_jids:
+                trust_session_data = yield session.getTrustForJID(trust_jid)
+                bare_jid_s = trust_jid.userhost()
+                for device_id, trust_info in trust_session_data['active'].items():
+                    if trust_info is None:
+                        # device has never been (un)trusted, we have to retrieve its
+                        # fingerprint (i.e. identity key or "ik") through public bundle
+                        if device_id not in cache:
+                            bundles, missing = yield self.getBundles(client,
+                                                                     trust_jid,
+                                                                     [device_id])
+                            if device_id not in bundles:
+                                log.warning(_(
+                                    "Can't find bundle for device {device_id} of user "
+                                    "{bare_jid}, ignoring").format(device_id=device_id,
+                                                                    bare_jid=bare_jid_s))
+                                continue
+                            cache[device_id] = bundles[device_id]
+                        # TODO: replace False below by None when undecided
+                        #       trusts are handled
+                        trust_info = {
+                            "key": cache[device_id].ik,
+                            "trusted": False
+                        }
 
-                ik = trust_info["key"]
-                trust_id = str(hash((bare_jid_s, device_id, ik)))
-                trust_data[trust_id] = {
-                    "jid": entity_jid,
-                    "device": device_id,
-                    "ik": ik,
-                    "trusted": trust_info["trusted"],
-                    }
+                    ik = trust_info["key"]
+                    trust_id = str(hash((bare_jid_s, device_id, ik)))
+                    trust_data[trust_id] = {
+                        "jid": trust_jid,
+                        "device": device_id,
+                        "ik": ik,
+                        "trusted": trust_info["trusted"],
+                        }
 
         if submit_id is None:
             submit_id = self.host.registerCallback(partial(self.trustUICb,
@@ -535,6 +543,14 @@
 
     @defer.inlineCallbacks
     def profileConnected(self, client):
+        if self._m is not None:
+            # we keep plain text message for MUC messages we send
+            # as we can't encrypt for our own device
+            client._xep_0384_muc_cache = {}
+            # and we keep them only for some time, in case something goes wrong
+            # with the MUC
+            client._xep_0384_muc_cache_timer = None
+
         # FIXME: is _xep_0384_ready needed? can we use profileConnecting?
         #        Workflow should be checked
         client._xep_0384_ready = defer.Deferred()
@@ -785,10 +801,10 @@
     ## triggers
 
     @defer.inlineCallbacks
-    def handleProblems(self, client, entity, bundles, expect_problems, problems):
+    def handleProblems(self, client, feedback_jid, bundles, expect_problems, problems):
         """Try to solve problems found by EncryptMessage
 
-        @param entity(jid.JID): bare jid of the destinee
+        @param feedback_jid(jid.JID): bare jid where the feedback message must be sent
         @param bundles(dict): bundles data as used in EncryptMessage
             already filled with known bundles, missing bundles
             need to be added to it
@@ -857,7 +873,7 @@
                   "the message will not be readable on this/those device(s)").format(
                     devices=", ".join(devices_s), peer=peer_jid.full()))
             client.feedback(
-                entity,
+                feedback_jid,
                 D_("You're destinee {peer} has missing encryption data on some of "
                    "his/her device(s) (bundle on device {devices}), the message won't  "
                    "be readable on this/those device.").format(
@@ -875,7 +891,7 @@
                            "message in such a situation. Please indicate if you trust "
                            "those devices or not in the trust manager before we can "
                            "send this message")
-            client.feedback(entity, user_msg)
+            client.feedback(feedback_jid, user_msg)
             xmlui = yield self.getTrustUI(client, trust_data=trust_data, submit_id="")
 
             answer = yield xml_tools.deferXMLUI(
@@ -888,7 +904,13 @@
             yield self.trustUICb(answer, trust_data, expect_problems, client.profile)
 
     @defer.inlineCallbacks
-    def encryptMessage(self, client, entity_bare_jid, message):
+    def encryptMessage(self, client, entity_bare_jids, message, feedback_jid=None):
+        if feedback_jid is None:
+            if len(entity_bare_jids) != 1:
+                log.error(
+                    "feedback_jid must be provided when message is encrypted for more "
+                    "than one entities")
+                feedback_jid = entity_bare_jids[0]
         omemo_session = client._xep_0384_session
         expect_problems = {}
         bundles = {}
@@ -902,7 +924,7 @@
                 # encryptMessage may fail, in case of e.g. trust issue or missing bundle
                 try:
                     encrypted = yield omemo_session.encryptMessage(
-                        entity_bare_jid,
+                        entity_bare_jids,
                         message,
                         bundles,
                         expect_problems = expect_problems)
@@ -910,7 +932,7 @@
                     # we know the problem to solve, we can try to fix them
                     yield self.handleProblems(
                         client,
-                        entity=entity_bare_jid,
+                        feedback_jid=feedback_jid,
                         bundles=bundles,
                         expect_problems=expect_problems,
                         problems=e.problems)
@@ -918,19 +940,17 @@
                 else:
                     break
         except Exception as e:
-            msg = _("Can't encrypt message for {entity}: {reason}".format(
-                entity=entity_bare_jid.full(), reason=e))
+            msg = _("Can't encrypt message for {entities}: {reason}".format(
+                entities=', '.join(e.full() for e in entity_bare_jids), reason=e))
             log.warning(msg)
             extra = {C.MESS_EXTRA_INFO: C.EXTRA_INFO_ENCR_ERR}
-            client.feedback(entity_bare_jid, msg, extra)
+            client.feedback(feedback_jid, msg, extra)
             raise e
 
         defer.returnValue(encrypted)
 
     @defer.inlineCallbacks
     def _messageReceivedTrigger(self, client, message_elt, post_treat):
-        if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT:
-            defer.returnValue(True)
         try:
             encrypted_elt = next(message_elt.elements(NS_OMEMO, "encrypted"))
         except StopIteration:
@@ -938,93 +958,150 @@
             defer.returnValue(True)
 
         # we have an encrypted message let's decrypt it
+
         from_jid = jid.JID(message_elt['from'])
-        if from_jid.userhostJID() == client.jid.userhostJID():
-            feedback_jid = jid.JID(message_elt['to'])
+
+        if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT:
+            # with group chat, we must get the real jid for decryption
+            # and use the room as feedback_jid
+
+            if self._m is None:
+                # plugin XEP-0045 (MUC) is not available
+                defer.returnValue(True)
+
+            room_jid = from_jid.userhostJID()
+            feedback_jid = room_jid
+            if self._sid is not None:
+                mess_id = self._sid.getOriginId(message_elt)
+            else:
+                mess_id = None
+
+            if mess_id is None:
+                mess_id = message_elt.getAttribute('id')
+            cache_key = (room_jid, mess_id)
+
+            try:
+                room = self._m.getRoom(client, room_jid)
+            except exceptions.NotFound:
+                log.warning(
+                    f"Received an OMEMO encrypted msg from a room {room_jid} which has "
+                    f"not been joined, ignoring")
+                defer.returnValue(True)
+
+            user = room.getUser(from_jid.resource)
+            if user is None:
+                log.warning(f"Can't find user {user} in room {room_jid}, ignoring")
+                defer.returnValue(True)
+            if not user.entity:
+                log.warning(
+                    f"Real entity of user {user} in room {room_jid} can't be established,"
+                    f" OMEMO encrypted message can't be decrypted")
+                defer.returnValue(True)
+
+            # now we have real jid of the entity, we use it instead of from_jid
+            from_jid = user.entity.userhostJID()
+
         else:
-            feedback_jid = from_jid
-        try:
-            omemo_session = client._xep_0384_session
-        except AttributeError:
-            # on startup, message can ve received before session actually exists
-            # so we need to synchronise here
-            yield client._xep_0384_ready
-            omemo_session = client._xep_0384_session
+            # we have a one2one message, we can user "from" and "to" normally
+
+            if from_jid.userhostJID() == client.jid.userhostJID():
+                feedback_jid = jid.JID(message_elt['to'])
+            else:
+                feedback_jid = from_jid
+
+
+        if (message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT
+            and mess_id is not None
+            and cache_key in client._xep_0384_muc_cache):
+            plaintext = client._xep_0384_muc_cache.pop(cache_key)
+            if not client._xep_0384_muc_cache:
+                client._xep_0384_muc_cache_timer.cancel()
+                client._xep_0384_muc_cache_timer = None
+        else:
+            try:
+                omemo_session = client._xep_0384_session
+            except AttributeError:
+                # on startup, message can ve received before session actually exists
+                # so we need to synchronise here
+                yield client._xep_0384_ready
+                omemo_session = client._xep_0384_session
 
-        device_id = client._xep_0384_device_id
-        try:
-            header_elt = next(encrypted_elt.elements(NS_OMEMO, 'header'))
-            iv_elt = next(header_elt.elements(NS_OMEMO, 'iv'))
-        except StopIteration:
-            log.warning(_("Invalid OMEMO encrypted stanza, ignoring: {xml}")
-                .format(xml=message_elt.toXml()))
-            defer.returnValue(False)
-        try:
-            s_device_id = header_elt['sid']
-        except KeyError:
-            log.warning(_("Invalid OMEMO encrypted stanza, missing sender device ID, "
-                          "ignoring: {xml}")
-                .format(xml=message_elt.toXml()))
-            defer.returnValue(False)
-        try:
-            key_elt = next((e for e in header_elt.elements(NS_OMEMO, 'key')
-                            if int(e['rid']) == device_id))
-        except StopIteration:
-            log.warning(_("This OMEMO encrypted stanza has not been encrypted "
-                          "for our device (device_id: {device_id}, fingerprint: "
-                          "{fingerprint}): {xml}").format(
-                          device_id=device_id,
-                          fingerprint=omemo_session.public_bundle.ik.hex().upper(),
-                          xml=encrypted_elt.toXml()))
-            user_msg = (D_("An OMEMO message from {sender} has not been encrypted for "
-                           "our device, we can't decrypt it").format(
-                           sender=from_jid.full()))
-            extra = {C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR}
-            client.feedback(feedback_jid, user_msg, extra)
-            defer.returnValue(False)
-        except ValueError as e:
-            log.warning(_("Invalid recipient ID: {msg}".format(msg=e)))
-            defer.returnValue(False)
-        is_pre_key = C.bool(key_elt.getAttribute('prekey', 'false'))
-        payload_elt = next(encrypted_elt.elements(NS_OMEMO, 'payload'), None)
-        additional_information = {
-            "from_storage": bool(message_elt.delay)
-        }
+            device_id = client._xep_0384_device_id
+            try:
+                header_elt = next(encrypted_elt.elements(NS_OMEMO, 'header'))
+                iv_elt = next(header_elt.elements(NS_OMEMO, 'iv'))
+            except StopIteration:
+                log.warning(_("Invalid OMEMO encrypted stanza, ignoring: {xml}")
+                    .format(xml=message_elt.toXml()))
+                defer.returnValue(False)
+            try:
+                s_device_id = header_elt['sid']
+            except KeyError:
+                log.warning(_("Invalid OMEMO encrypted stanza, missing sender device ID, "
+                              "ignoring: {xml}")
+                    .format(xml=message_elt.toXml()))
+                defer.returnValue(False)
+            try:
+                key_elt = next((e for e in header_elt.elements(NS_OMEMO, 'key')
+                                if int(e['rid']) == device_id))
+            except StopIteration:
+                log.warning(_("This OMEMO encrypted stanza has not been encrypted "
+                              "for our device (device_id: {device_id}, fingerprint: "
+                              "{fingerprint}): {xml}").format(
+                              device_id=device_id,
+                              fingerprint=omemo_session.public_bundle.ik.hex().upper(),
+                              xml=encrypted_elt.toXml()))
+                user_msg = (D_("An OMEMO message from {sender} has not been encrypted for "
+                               "our device, we can't decrypt it").format(
+                               sender=from_jid.full()))
+                extra = {C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR}
+                client.feedback(feedback_jid, user_msg, extra)
+                defer.returnValue(False)
+            except ValueError as e:
+                log.warning(_("Invalid recipient ID: {msg}".format(msg=e)))
+                defer.returnValue(False)
+            is_pre_key = C.bool(key_elt.getAttribute('prekey', 'false'))
+            payload_elt = next(encrypted_elt.elements(NS_OMEMO, 'payload'), None)
+            additional_information = {
+                "from_storage": bool(message_elt.delay)
+            }
 
-        kwargs = {
-            "bare_jid": from_jid.userhostJID(),
-            "device": s_device_id,
-            "iv": base64.b64decode(bytes(iv_elt)),
-            "message": base64.b64decode(bytes(key_elt)),
-            "is_pre_key_message": is_pre_key,
-            "ciphertext": base64.b64decode(bytes(payload_elt))
-                if payload_elt is not None else None,
-            "additional_information":  additional_information,
-        }
-        try:
+            kwargs = {
+                "bare_jid": from_jid.userhostJID(),
+                "device": s_device_id,
+                "iv": base64.b64decode(bytes(iv_elt)),
+                "message": base64.b64decode(bytes(key_elt)),
+                "is_pre_key_message": is_pre_key,
+                "ciphertext": base64.b64decode(bytes(payload_elt))
+                    if payload_elt is not None else None,
+                "additional_information":  additional_information,
+            }
+
             try:
-                plaintext = yield omemo_session.decryptMessage(**kwargs)
-            except omemo_excpt.TrustException:
-                post_treat.addCallback(client.encryption.markAsUntrusted)
-                kwargs['allow_untrusted'] = True
-                plaintext = yield omemo_session.decryptMessage(**kwargs)
-            else:
-                post_treat.addCallback(client.encryption.markAsTrusted)
-            plaintext = plaintext.decode()
-        except Exception as e:
-            log.warning(_("Can't decrypt message: {reason}\n{xml}").format(
-                reason=e, xml=message_elt.toXml()))
-            user_msg = (D_("An OMEMO message from {sender} can't be decrypted: {reason}")
-                .format(sender=from_jid.full(), reason=e))
-            extra = {C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR}
-            client.feedback(feedback_jid, user_msg, extra)
-            defer.returnValue(False)
-        finally:
-            if omemo_session.republish_bundle:
-                # we don't wait for the Deferred (i.e. no yield) on purpose
-                # there is no need to block the whole message workflow while
-                # updating the bundle
-                self.setBundle(client, omemo_session.public_bundle, device_id)
+                try:
+                    plaintext = yield omemo_session.decryptMessage(**kwargs)
+                except omemo_excpt.TrustException:
+                    post_treat.addCallback(client.encryption.markAsUntrusted)
+                    kwargs['allow_untrusted'] = True
+                    plaintext = yield omemo_session.decryptMessage(**kwargs)
+                else:
+                    post_treat.addCallback(client.encryption.markAsTrusted)
+                plaintext = plaintext.decode()
+            except Exception as e:
+                log.warning(_("Can't decrypt message: {reason}\n{xml}").format(
+                    reason=e, xml=message_elt.toXml()))
+                user_msg = (D_(
+                    "An OMEMO message from {sender} can't be decrypted: {reason}")
+                    .format(sender=from_jid.full(), reason=e))
+                extra = {C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR}
+                client.feedback(feedback_jid, user_msg, extra)
+                defer.returnValue(False)
+            finally:
+                if omemo_session.republish_bundle:
+                    # we don't wait for the Deferred (i.e. no yield) on purpose
+                    # there is no need to block the whole message workflow while
+                    # updating the bundle
+                    self.setBundle(client, omemo_session.public_bundle, device_id)
 
         message_elt.children.remove(encrypted_elt)
         if plaintext:
@@ -1032,13 +1109,36 @@
         post_treat.addCallback(client.encryption.markAsEncrypted)
         defer.returnValue(True)
 
+    def getJIDsForRoom(self, client, room_jid):
+        if self._m is None:
+            exceptions.InternalError("XEP-0045 plugin missing, can't encrypt for group chat")
+        room = self._m.getRoom(client, room_jid)
+        return [u.entity.userhostJID() for u in room.roster.values()]
+
+    def _expireMUCCache(self, client):
+        client._xep_0384_muc_cache_timer = None
+        for (room_jid, uid), msg in client._xep_0384_muc_cache.items():
+            client.feedback(
+                room_jid,
+                D_("Our message with UID {uid} has not been received in time, it has "
+                   "probably been lost. The message was: {msg!r}").format(
+                    uid=uid, msg=str(msg)))
+
+        client._xep_0384_muc_cache.clear()
+        log.warning("Cache for OMEMO MUC has expired")
+
     @defer.inlineCallbacks
     def _sendMessageDataTrigger(self, client, mess_data):
         encryption = mess_data.get(C.MESS_KEY_ENCRYPTION)
         if encryption is None or encryption['plugin'].namespace != NS_OMEMO:
             return
         message_elt = mess_data["xml"]
-        to_jid = mess_data["to"].userhostJID()
+        if mess_data['type'] == C.MESS_TYPE_GROUPCHAT:
+            feedback_jid = room_jid = mess_data['to']
+            to_jids = self.getJIDsForRoom(client, room_jid)
+        else:
+            feedback_jid = to_jid = mess_data["to"].userhostJID()
+            to_jids = [to_jid]
         log.debug("encrypting message")
         body = None
         for child in list(message_elt.children):
@@ -1056,20 +1156,43 @@
             log.warning("No message found")
             return
 
-        encryption_data = yield self.encryptMessage(client, to_jid, str(body))
+        body = str(body)
+
+        if mess_data['type'] == C.MESS_TYPE_GROUPCHAT:
+            key = (room_jid, mess_data['uid'])
+            # XXX: we can't encrypt message for our own device for security reason
+            #      so we keep the plain text version in cache until we receive the
+            #      message. We don't send it directly to bridge to keep a workflow
+            #      similar to plain text MUC, so when we see it in frontend we know
+            #      that it has been sent correctly.
+            client._xep_0384_muc_cache[key] = body
+            timer = client._xep_0384_muc_cache_timer
+            if timer is None:
+                client._xep_0384_muc_cache_timer = reactor.callLater(
+                    MUC_CACHE_TTL, self._expireMUCCache, client)
+            else:
+                timer.reset(MUC_CACHE_TTL)
+            # we use origin-id when possible, to identifiy the message in a stable way
+            if self._sid is not None:
+                self._sid.addOriginId(message_elt, mess_data['uid'])
+
+        encryption_data = yield self.encryptMessage(
+            client, to_jids, body, feedback_jid=feedback_jid)
 
         encrypted_elt = message_elt.addElement((NS_OMEMO, 'encrypted'))
         header_elt = encrypted_elt.addElement('header')
         header_elt['sid'] = str(encryption_data['sid'])
-        bare_jid_s = to_jid.userhost()
+
+        for to_jid in to_jids:
+            bare_jid_s = to_jid.userhost()
 
-        for rid, data in encryption_data['keys'][bare_jid_s].items():
-            key_elt = header_elt.addElement(
-                'key',
-                content=b64enc(data['data']))
-            key_elt['rid'] = str(rid)
-            if data['pre_key']:
-                key_elt['prekey'] = 'true'
+            for rid, data in encryption_data['keys'][bare_jid_s].items():
+                key_elt = header_elt.addElement(
+                    'key',
+                    content=b64enc(data['data']))
+                key_elt['rid'] = str(rid)
+                if data['pre_key']:
+                    key_elt['prekey'] = 'true'
 
         header_elt.addElement(
             'iv',