diff --git a/sat/memory/encryption.py b/sat/memory/encryption.py index 629759d2..d71ab427 100644 --- a/sat/memory/encryption.py +++ b/sat/memory/encryption.py @@ -416,14 +416,13 @@ class EncryptionHandler(object): def setEncryptionFlag(self, mess_data): """Set "encryption" key in mess_data if session with destinee is encrypted""" - - if mess_data["type"] == "groupchat": - # FIXME: to change when group chat encryption will be handled - return - to_jid = mess_data['to'] encryption = self._sessions.get(to_jid.userhostJID()) if encryption is not None: + if mess_data["type"] == "groupchat" and encryption['plugin'].directed: + raise exceptions.InternalError( + f"encryption flag must not be set for groupchat if encryption algorithm " + f"({encryption['plugin'].name}) is directed!") mess_data[C.MESS_KEY_ENCRYPTION] = encryption ## Misc ## diff --git a/sat/plugins/plugin_xep_0384.py b/sat/plugins/plugin_xep_0384.py index 5b961021..309f90f8 100644 --- a/sat/plugins/plugin_xep_0384.py +++ b/sat/plugins/plugin_xep_0384.py @@ -20,7 +20,7 @@ from sat.core.i18n import _, D_ 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 @@ PLUGIN_INFO = { 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_TRUST = "TRUST" 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 @@ class OmemoSession(object): 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 @@ class OMEMO(object): 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 @@ class OMEMO(object): 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 @@ class OMEMO(object): @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 @@ class OMEMO(object): ## 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 @@ class OMEMO(object): "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 @@ class OMEMO(object): "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 @@ class OMEMO(object): 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 @@ class OMEMO(object): # 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 @@ class OMEMO(object): # 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 @@ class OMEMO(object): 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 @@ class OMEMO(object): 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']) - 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 - 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) - } + 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 - 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: - 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) + 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: - 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) + 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: + # 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) + } + + 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: + 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 @@ class OMEMO(object): 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 @@ class OMEMO(object): 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 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 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' header_elt.addElement( 'iv',