changeset 3237:b0c57c9a4bd8

plugin XEP-0384: OMEMO trust policy: OMEMO trust policy can now be specified. For now there are 2 policies: - `manual`: each new device fingerprint must be explicitly trusted or not before the device can be used, and the message sent - `BTBV` (Blind Trust Before Verification): each new device fingerprint is automically trusted, until user manually trust or not a device, in which case the behaviour becomes the same as for `manual` for the entity. When using the Trust UI, user can put the entity back to blind trust if they wish. A message is send as feedback to user when a new device is/must be trusted, trying to explain clearly what's happening to the user. Devices which have been automically trusted are marked, so user can know which ones may cause security issue.
author Goffi <goffi@goffi.org>
date Fri, 27 Mar 2020 10:02:14 +0100
parents 9477f3197981
children 199fc4c551e9
files sat/core/sat_main.py sat/plugins/plugin_xep_0384.py
diffstat 2 files changed, 200 insertions(+), 58 deletions(-) [+]
line wrap: on
line diff
--- a/sat/core/sat_main.py	Fri Mar 27 09:55:16 2020 +0100
+++ b/sat/core/sat_main.py	Fri Mar 27 10:02:14 2020 +0100
@@ -882,7 +882,8 @@
     def _encryptionTrustUIGet(self, to_jid_s, namespace, profile_key):
         client = self.getClient(profile_key)
         to_jid = jid.JID(to_jid_s)
-        d = client.encryption.getTrustUI(to_jid, namespace=namespace or None)
+        d = defer.ensureDeferred(
+            client.encryption.getTrustUI(to_jid, namespace=namespace or None))
         d.addCallback(lambda xmlui: xmlui.toXml())
         return d
 
--- a/sat/plugins/plugin_xep_0384.py	Fri Mar 27 09:55:16 2020 +0100
+++ b/sat/plugins/plugin_xep_0384.py	Fri Mar 27 10:02:14 2020 +0100
@@ -16,6 +16,11 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import logging
+import random
+import base64
+from functools import partial
+from xml.sax.saxutils import quoteattr
 from sat.core.i18n import _, D_
 from sat.core.constants import Const as C
 from sat.core.log import getLogger
@@ -25,11 +30,7 @@
 from twisted.words.protocols.jabber import jid
 from twisted.words.protocols.jabber import error as jabber_error
 from sat.memory import persistent
-from functools import partial
 from sat.tools import xml_tools
-import logging
-import random
-import base64
 try:
     import omemo
     from omemo import exceptions as omemo_excpt
@@ -69,6 +70,11 @@
 KEY_DEVICE_ID = "DEVICE_ID"
 KEY_SESSION = "SESSION"
 KEY_TRUST = "TRUST"
+# devices which have been automatically trusted by policy like BTBV
+KEY_AUTO_TRUST = "AUTO_TRUST"
+# list of peer bare jids where trust UI has been used at least once
+# this is useful to activate manual trust with BTBV policy
+KEY_MANUAL_TRUST = "MANUAL_TRUST"
 KEY_ACTIVE_DEVICES = "DEVICES"
 KEY_INACTIVE_DEVICES = "INACTIVE_DEVICES"
 KEY_ALL_JIDS = "ALL_JIDS"
@@ -76,6 +82,9 @@
 # expressed in seconds, reset on each new MUC message
 MUC_CACHE_TTL = 60 * 5
 
+PARAM_CATEGORY = "Security"
+PARAM_NAME = "omemo_policy"
+
 
 # we want to manage log emitted by omemo module ourselves
 
@@ -111,15 +120,11 @@
 
 class OmemoStorage(omemo.Storage):
 
-    def __init__(self, client, device_id, all_jids, persistent_dict):
-        """
-        @param persistent_dict(persistent.LazyPersistentBinaryDict): object which will
-            store data in SàT database
-        """
+    def __init__(self, client, device_id, all_jids):
         self.own_bare_jid_s = client.jid.userhost()
         self.device_id = device_id
         self.all_jids = all_jids
-        self.data = persistent_dict
+        self.data = client._xep_0384_data
 
     @property
     def is_async(self):
@@ -262,7 +267,7 @@
         return d
 
     def deleteJID(self, callback, bare_jid):
-        """Retrieve all (in)actives of bare_jid, and delete all related keys"""
+        """Retrieve all (in)actives devices of bare_jid, and delete all related keys"""
         d_list = []
 
         key = '\n'.join([KEY_ACTIVE_DEVICES, bare_jid])
@@ -392,6 +397,27 @@
 
 class OMEMO:
 
+    params = """
+    <params>
+    <individual>
+    <category name="{category_name}" label="{category_label}">
+        <param name="{param_name}" label={param_label} type="list" security="3">
+            <option value="manual" label={opt_manual_lbl} />
+            <option value="btbv" label={opt_btbv_lbl} selected="true" />
+        </param>
+     </category>
+    </individual>
+    </params>
+    """.format(
+        category_name=PARAM_CATEGORY,
+        category_label=D_("Security"),
+        param_name=PARAM_NAME,
+        param_label=quoteattr(D_("OMEMO default trust policy")),
+        opt_manual_lbl=quoteattr(D_("Manual trust (more secure)")),
+        opt_btbv_lbl=quoteattr(
+            D_("Blind Trust Before Verification (more user friendly)")),
+    )
+
     def __init__(self, host):
         log.info(_("OMEMO plugin initialization (omemo module v{version})").format(
             version=omemo.__version__))
@@ -402,6 +428,7 @@
                 "minimum required, please update.").format(v=OMEMO_MIN_VER))
             raise exceptions.CancelError("module is too old")
         self.host = host
+        host.memory.updateParams(self.params)
         self._p_hints = host.plugins["XEP-0334"]
         self._p_carbons = host.plugins["XEP-0280"]
         self._p = host.plugins["XEP-0060"]
@@ -450,22 +477,55 @@
         self.text_cmds.feedBack(client, feedback, mess_data)
         return False
 
-    @defer.inlineCallbacks
-    def trustUICb(self, xmlui_data, trust_data, expect_problems=None,
-                  profile=C.PROF_KEY_NONE):
+    async def trustUICb(
+            self, xmlui_data, trust_data, expect_problems=None, profile=C.PROF_KEY_NONE):
         if C.bool(xmlui_data.get('cancelled', 'false')):
-            defer.returnValue({})
+            return {}
         client = self.host.getClient(profile)
         session = client._xep_0384_session
+        stored_data = client._xep_0384_data
+        manual_trust = await stored_data.get(KEY_MANUAL_TRUST, set())
+        auto_trusted_cache = {}
         answer = xml_tools.XMLUIResult2DataFormResult(xmlui_data)
+        blind_trust = C.bool(answer.get('blind_trust', C.BOOL_FALSE))
         for key, value in answer.items():
             if key.startswith('trust_'):
                 trust_id = key[6:]
             else:
                 continue
             data = trust_data[trust_id]
+            if blind_trust:
+                # user request to restore blind trust for this entity
+                # so if the entity is present in manual trust, we remove it
+                if data["jid"].full() in manual_trust:
+                    manual_trust.remove(data["jid"].full())
+                    await stored_data.aset(KEY_MANUAL_TRUST, manual_trust)
+            elif data["jid"].full() not in manual_trust:
+                # validating this trust UI implies that we activate manual mode for
+                # this entity (used for BTBV policy)
+                manual_trust.add(data["jid"].full())
+                await stored_data.aset(KEY_MANUAL_TRUST, manual_trust)
             trust = C.bool(value)
-            yield session.setTrust(
+
+            if not trust:
+                # if device is not trusted, we check if it must be removed from auto
+                # trusted devices list
+                bare_jid_s = data['jid'].userhost()
+                key = f"{KEY_AUTO_TRUST}\n{bare_jid_s}"
+                if bare_jid_s not in auto_trusted_cache:
+                    auto_trusted_cache[bare_jid_s] = await stored_data.get(
+                        key, default=set())
+                auto_trusted = auto_trusted_cache[bare_jid_s]
+                if data['device'] in auto_trusted:
+                    # as we don't trust this device anymore, we can remove it from the
+                    # list of automatically trusted devices
+                    auto_trusted.remove(data['device'])
+                    await stored_data.aset(key, auto_trusted)
+                    log.info(D_(
+                        "device {device} from {peer_jid} is not an auto-trusted device "
+                        "anymore").format(device=data['device'], peer_jid=bare_jid_s))
+
+            await session.setTrust(
                 data["jid"],
                 data["device"],
                 data["ik"],
@@ -475,10 +535,9 @@
                 expect_problems.setdefault(data['jid'].userhost(), set()).add(
                     data['device']
                 )
-        defer.returnValue({})
+        return {}
 
-    @defer.inlineCallbacks
-    def getTrustUI(self, client, entity_jid=None, trust_data=None, submit_id=None):
+    async def getTrustUI(self, client, entity_jid=None, trust_data=None, submit_id=None):
         """Generate a XMLUI to manage trust
 
         @param entity_jid(None, jid.JID): jid of entity to manage
@@ -502,6 +561,7 @@
             raise ValueError("A bare jid is expected")
 
         session = client._xep_0384_session
+        stored_data = client._xep_0384_data
 
         if trust_data is None:
             cache = client._xep_0384_cache.setdefault(entity_jid, {})
@@ -511,14 +571,14 @@
             else:
                 trust_jids = [entity_jid]
             for trust_jid in trust_jids:
-                trust_session_data = yield session.getTrustForJID(trust_jid)
+                trust_session_data = await 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,
+                            bundles, missing = await self.getBundles(client,
                                                                      trust_jid,
                                                                      [device_id])
                             if device_id not in bundles:
@@ -545,10 +605,11 @@
                         }
 
         if submit_id is None:
-            submit_id = self.host.registerCallback(partial(self.trustUICb,
-                                                           trust_data=trust_data),
-                                                   with_data=True,
-                                                   one_shot=True)
+            submit_id = self.host.registerCallback(
+                lambda data, profile: defer.ensureDeferred(
+                    self.trustUICb(data, trust_data=trust_data, profile=profile)),
+                with_data=True,
+                one_shot=True)
         xmlui = xml_tools.XMLUI(
             panel_type = C.XMLUI_FORM,
             title = D_("OMEMO trust management"),
@@ -573,8 +634,25 @@
         xmlui.addEmpty()
         xmlui.addEmpty()
 
+        if entity_jid is not None:
+            omemo_policy = self.host.memory.getParamA(
+                PARAM_NAME, PARAM_CATEGORY, profile_key=client.profile
+            )
+            if omemo_policy == 'btbv':
+                xmlui.addLabel(D_("Automatically trust new devices?"))
+                # blind trust is always disabled when UI is requested
+                # as submitting UI is a verification which should disable it.
+                xmlui.addBool("blind_trust", value=C.BOOL_FALSE)
+                xmlui.addEmpty()
+                xmlui.addEmpty()
+
+        auto_trust_cache = {}
 
         for trust_id, data in trust_data.items():
+            bare_jid_s = data['jid'].userhost()
+            if bare_jid_s not in auto_trust_cache:
+                key = f"{KEY_AUTO_TRUST}\n{bare_jid_s}"
+                auto_trust_cache[bare_jid_s] = await stored_data.get(key, set())
             xmlui.addLabel(D_("Contact"))
             xmlui.addJid(data['jid'])
             xmlui.addLabel(D_("Device ID"))
@@ -586,11 +664,15 @@
             xmlui.addLabel(D_("Trust this device?"))
             xmlui.addBool("trust_{}".format(trust_id),
                           value=C.boolConst(data.get('trusted', False)))
+            if data['device'] in auto_trust_cache[bare_jid_s]:
+                xmlui.addEmpty()
+                xmlui.addLabel(D_("(automatically trusted)"))
+
 
             xmlui.addEmpty()
             xmlui.addEmpty()
 
-        defer.returnValue(xmlui)
+        return xmlui
 
     @defer.inlineCallbacks
     def profileConnected(self, client):
@@ -607,6 +689,7 @@
         client._xep_0384_ready = defer.Deferred()
         # we first need to get devices ids (including our own)
         persistent_dict = persistent.LazyPersistentBinaryDict("XEP-0384", client.profile)
+        client._xep_0384_data = persistent_dict
         # all known devices of profile
         devices = yield self.getDevices(client)
         # and our own device id
@@ -630,7 +713,7 @@
 
         all_jids = yield persistent_dict.get(KEY_ALL_JIDS, set())
 
-        omemo_storage = OmemoStorage(client, device_id, all_jids, persistent_dict)
+        omemo_storage = OmemoStorage(client, device_id, all_jids)
         omemo_session = yield OmemoSession.create(client, omemo_storage, device_id)
         client._xep_0384_cache = {}
         client._xep_0384_session = omemo_session
@@ -862,8 +945,59 @@
 
     ## triggers
 
-    @defer.inlineCallbacks
-    def handleProblems(self, client, feedback_jid, bundles, expect_problems, problems):
+    async def policyBTBV(self, client, feedback_jid, expect_problems, undecided):
+        session = client._xep_0384_session
+        stored_data = client._xep_0384_data
+        for pb in undecided.values():
+            peer_jid = jid.JID(pb.bare_jid)
+            device = pb.device
+            ik = pb.ik
+            key = f"{KEY_AUTO_TRUST}\n{pb.bare_jid}"
+            auto_trusted = await stored_data.get(key, default=set())
+            auto_trusted.add(device)
+            await stored_data.aset(key, auto_trusted)
+            await session.setTrust(peer_jid, device, ik, True)
+
+        user_msg =  D_(
+            "Not all destination devices are trusted, unknown devices will be blind "
+            "trusted due to the OMEMO Blind Trust Before Verification policy. If you "
+            "want a more secure workflow, please activate \"manual\" OMEMO policy in "
+            "settings' \"Security\" tab.\nFollowing fingerprint have been automatically "
+            "trusted:\n{devices}"
+        ).format(
+            devices = ', '.join(
+                f"- {pb.device} ({pb.bare_jid}): {pb.ik.hex().upper()}"
+                for pb in undecided.values()
+            )
+        )
+        client.feedback(feedback_jid, user_msg)
+
+    async def policyManual(self, client, feedback_jid, expect_problems, undecided):
+        trust_data = {}
+        for trust_id, data in undecided.items():
+            trust_data[trust_id] = {
+                'jid': jid.JID(data.bare_jid),
+                'device':  data.device,
+                'ik': data.ik}
+
+        user_msg =  D_("Not all destination devices are trusted, we can't encrypt "
+                       "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(feedback_jid, user_msg)
+        xmlui = await self.getTrustUI(client, trust_data=trust_data, submit_id="")
+
+        answer = await xml_tools.deferXMLUI(
+            self.host,
+            xmlui,
+            action_extra={
+                "meta_encryption_trust": NS_OMEMO,
+            },
+            profile=client.profile)
+        await self.trustUICb(answer, trust_data, expect_problems, client.profile)
+
+    async def handleProblems(
+        self, client, feedback_jid, bundles, expect_problems, problems):
         """Try to solve problems found by EncryptMessage
 
         @param feedback_jid(jid.JID): bare jid where the feedback message must be sent
@@ -910,7 +1044,7 @@
                 if problem.device in entity_cache:
                     entity_bundles[problem.device] = entity_cache[problem.device]
                 else:
-                    found_bundles, missing = yield self.getBundles(
+                    found_bundles, missing = await self.getBundles(
                         client, pb_entity, [problem.device])
                     entity_cache.update(bundles)
                     entity_bundles.update(found_bundles)
@@ -942,31 +1076,38 @@
                    peer=peer_jid.full(), devices=", ".join(devices_s)))
 
         if undecided:
-            trust_data = {}
-            for trust_id, data in undecided.items():
-                trust_data[trust_id] = {
-                    'jid': jid.JID(data.bare_jid),
-                    'device':  data.device,
-                    'ik': data.ik}
+            omemo_policy = self.host.memory.getParamA(
+                PARAM_NAME, PARAM_CATEGORY, profile_key=client.profile
+            )
+            if omemo_policy == 'btbv':
+                # we first separate entities which have been trusted manually
+                manual_trust = await client._xep_0384_data.get(KEY_MANUAL_TRUST)
+                if manual_trust:
+                    manual_undecided = {}
+                    for hash_, pb in undecided.items():
+                        if pb.bare_jid in manual_trust:
+                            manual_undecided[hash_] = pb
+                    for hash_ in manual_undecided:
+                        del undecided[hash_]
+                else:
+                    manual_undecided = None
 
-            user_msg =  D_("Not all destination devices are trusted, we can't encrypt "
-                           "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(feedback_jid, user_msg)
-            xmlui = yield self.getTrustUI(client, trust_data=trust_data, submit_id="")
+                if undecided:
+                    # we do the automatic trust here
+                    await self.policyBTBV(
+                        client, feedback_jid, expect_problems, undecided)
+                if manual_undecided:
+                    # here user has to manually trust new devices from entities already
+                    # verified
+                    await self.policyManual(
+                        client, feedback_jid, expect_problems, manual_undecided)
+            elif omemo_policy == 'manual':
+                await self.policyManual(
+                    client, feedback_jid, expect_problems, undecided)
+            else:
+                raise exceptions.InternalError(f"Unexpected OMEMO policy: {omemo_policy}")
 
-            answer = yield xml_tools.deferXMLUI(
-                self.host,
-                xmlui,
-                action_extra={
-                    "meta_encryption_trust": NS_OMEMO,
-                },
-                profile=client.profile)
-            yield self.trustUICb(answer, trust_data, expect_problems, client.profile)
-
-    @defer.inlineCallbacks
-    def encryptMessage(self, client, entity_bare_jids, message, feedback_jid=None):
+    async def encryptMessage(self, client, entity_bare_jids, message, feedback_jid=None):
         if feedback_jid is None:
             if len(entity_bare_jids) != 1:
                 log.error(
@@ -985,14 +1126,14 @@
                     raise exceptions.InternalError(msg)
                 # encryptMessage may fail, in case of e.g. trust issue or missing bundle
                 try:
-                    encrypted = yield omemo_session.encryptMessage(
+                    encrypted = await omemo_session.encryptMessage(
                         entity_bare_jids,
                         message,
                         bundles,
                         expect_problems = expect_problems)
                 except omemo_excpt.EncryptionProblemsException as e:
                     # we know the problem to solve, we can try to fix them
-                    yield self.handleProblems(
+                    await self.handleProblems(
                         client,
                         feedback_jid=feedback_jid,
                         bundles=bundles,
@@ -1240,8 +1381,8 @@
             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)
+        encryption_data = yield defer.ensureDeferred(self.encryptMessage(
+            client, to_jids, body, feedback_jid=feedback_jid))
 
         encrypted_elt = message_elt.addElement((NS_OMEMO, 'encrypted'))
         header_elt = encrypted_elt.addElement('header')