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
This commit is contained in:
Goffi 2019-12-30 20:59:46 +01:00
commit b5af781b82
2 changed files with 270 additions and 148 deletions

View file

@ -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 ##

View file

@ -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',