changeset 3972:5fbdf986670c

plugin pte: Pubsub Target Encryption implementation: This plugin lets encrypt a few items for a specific set of entities. rel 382
author Goffi <goffi@goffi.org>
date Mon, 31 Oct 2022 13:46:51 +0100 (2022-10-31)
parents 9b1d74a6b48c
children 570254d5a798
files sat/plugins/plugin_sec_pte.py sat/plugins/plugin_xep_0277.py sat/plugins/plugin_xep_0384.py
diffstat 3 files changed, 194 insertions(+), 14 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/plugins/plugin_sec_pte.py	Mon Oct 31 13:46:51 2022 +0100
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Pubsub Targeted Encryption
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program 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 Affero General Public License for more details.
+
+# 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/>.
+
+from typing import Any, Dict, List, Optional
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid, xmlstream
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from wokkel import rsm
+from zope.interface import implementer
+
+from sat.core import exceptions
+from sat.core.constants import Const as C
+from sat.core.core_types import SatXMPPEntity
+from sat.core.i18n import _
+from sat.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "PTE"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Pubsub Targeted Encryption",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0384"],
+    C.PI_MAIN: "PTE",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Encrypt some items to specific entities"""),
+}
+NS_PTE = "urn:xmpp:pte:0"
+
+
+class PTE:
+    namespace = NS_PTE
+
+    def __init__(self, host):
+        log.info(_("Pubsub Targeted Encryption plugin initialization"))
+        host.registerNamespace("pte", NS_PTE)
+        self.host = host
+        self._o = host.plugins["XEP-0384"]
+        host.trigger.add("XEP-0060_publish", self._publish_trigger)
+        host.trigger.add("XEP-0060_items", self._items_trigger)
+
+    def getHandler(self, client):
+        return PTE_Handler()
+
+    async def _publish_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        items: Optional[List[domish.Element]],
+        options: Optional[dict],
+        sender: jid.JID,
+        extra: Dict[str, Any]
+    ) -> bool:
+        if not items or extra.get("encrypted_for") is None:
+            return True
+        encrypt_data = extra["encrypted_for"]
+        try:
+            targets = {jid.JID(t) for t in encrypt_data["targets"]}
+        except (KeyError, RuntimeError):
+            raise exceptions.DataError(f"Invalid encryption data: {encrypt_data}")
+        for item in items:
+            log.debug(
+                f"encrypting item {item.getAttribute('id', '')} for "
+                f"{', '.join(t.full() for t in targets)}"
+            )
+            encryption_type = encrypt_data.get("type", self._o.NS_TWOMEMO)
+            if encryption_type != self._o.NS_TWOMEMO:
+                raise NotImplementedError("only TWOMEMO is supported for now")
+            await self._o.encrypt(
+                client,
+                self._o.NS_TWOMEMO,
+                item,
+                targets,
+                is_muc_message=False,
+                stanza_id=None
+            )
+            item_elts = list(item.elements())
+            if len(item_elts) != 1:
+                raise ValueError(
+                    f"there should be exactly one item payload: {item.toXml()}"
+                )
+            encrypted_payload = item_elts[0]
+            item.children.clear()
+            encrypted_elt = item.addElement((NS_PTE, "encrypted"))
+            encrypted_elt["by"] = sender.userhost()
+            encrypted_elt["type"] = encryption_type
+            encrypted_elt.addChild(encrypted_payload)
+
+        return True
+
+    async def _items_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        items: List[domish.Element],
+        rsm_response: rsm.RSMResponse,
+        extra: Dict[str, Any],
+    ) -> bool:
+        if not extra.get(C.KEY_DECRYPT, True):
+            return True
+        if service is None:
+            service = client.jid.userhostJID()
+        for item in items:
+            payload = item.firstChildElement()
+            if (payload is not None
+                and payload.name == "encrypted"
+                and payload.uri == NS_PTE):
+                encrypted_elt = payload
+                item.children.clear()
+                try:
+                    encryption_type = encrypted_elt.getAttribute("type")
+                    encrypted_by = jid.JID(encrypted_elt["by"])
+                except (KeyError, RuntimeError):
+                    raise exceptions.DataError(
+                        f"invalid <encrypted> element: {encrypted_elt.toXml()}"
+                    )
+                if encryption_type!= self._o.NS_TWOMEMO:
+                    raise NotImplementedError("only TWOMEMO is supported for now")
+                log.debug(f"decrypting item {item.getAttribute('id', '')}")
+
+                # FIXME: we do use _message_received_trigger now to decrypt the stanza, a
+                #   cleaner separated decrypt method should be used
+                encrypted_elt["from"] = encrypted_by.full()
+                if not await self._o._message_received_trigger(
+                    client,
+                    encrypted_elt,
+                    defer.Deferred()
+                ) or not encrypted_elt.children:
+                    raise exceptions.EncryptionError("can't decrypt the message")
+
+                item.addChild(encrypted_elt.firstChildElement())
+
+                extra.setdefault("encrypted", {})[item["id"]] = {
+                    "type": NS_PTE,
+                    "algorithm": encryption_type
+                }
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class PTE_Handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_PTE)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- a/sat/plugins/plugin_xep_0277.py	Mon Oct 31 04:09:38 2022 +0100
+++ b/sat/plugins/plugin_xep_0277.py	Mon Oct 31 13:46:51 2022 +0100
@@ -984,7 +984,7 @@
             return None
 
         extra = {}
-        for key in ("encrypted", "signed"):
+        for key in ("encrypted", "encrypted_for", "signed"):
             value = data.get(key)
             if value is not None:
                 extra[key] = value
--- a/sat/plugins/plugin_xep_0384.py	Mon Oct 31 04:09:38 2022 +0100
+++ b/sat/plugins/plugin_xep_0384.py	Mon Oct 31 13:46:51 2022 +0100
@@ -22,7 +22,7 @@
 import logging
 import time
 from typing import \
-    Any, Dict, FrozenSet, List, Literal, NamedTuple, Optional, Set, Type, cast
+    Any, Dict, FrozenSet, List, Literal, NamedTuple, Optional, Set, Type, Union, cast
 import uuid
 import xml.etree.ElementTree as ET
 from xml.sax.saxutils import quoteattr
@@ -1581,7 +1581,7 @@
         # added, the messageReceived trigger is also used for twomemo.
         sat.trigger.add(
             "messageReceived",
-            self.__message_received_trigger,
+            self._message_received_trigger,
             priority=100050
         )
         sat.trigger.add(
@@ -2098,7 +2098,7 @@
                 frozenset(applied_trust_updates)
             )
 
-    async def __message_received_trigger(
+    async def _message_received_trigger(
         self,
         client: SatXMPPClient,
         message_elt: domish.Element,
@@ -2113,13 +2113,12 @@
             encrypted.
         @return: Whether to continue the message received flow.
         """
-
         muc_plaintext_cache_key: Optional[MUCPlaintextCacheKey] = None
 
         sender_jid = jid.JID(message_elt["from"])
         feedback_jid: jid.JID
 
-        message_type = message_elt.getAttribute("type", "unknown")
+        message_type = message_elt.getAttribute("type", C.MESS_TYPE_NORMAL)
         is_muc_message = message_type == C.MESS_TYPE_GROUPCHAT
         if is_muc_message:
             if self.__xep_0045 is None:
@@ -2420,12 +2419,12 @@
             return True
 
         # All pre-checks done, we can start encrypting!
-        await self.__encrypt(
+        await self.encrypt(
             client,
             twomemo.twomemo.NAMESPACE,
             stanza,
             recipient_bare_jid,
-            stanza.getAttribute("type", "unkown") == C.MESS_TYPE_GROUPCHAT,
+            stanza.getAttribute("type", C.MESS_TYPE_NORMAL) == C.MESS_TYPE_GROUPCHAT,
             stanza.getAttribute("id", None)
         )
 
@@ -2462,7 +2461,7 @@
         is_muc_message = mess_data["type"] == C.MESS_TYPE_GROUPCHAT
         stanza_id = mess_data["uid"]
 
-        await self.__encrypt(
+        await self.encrypt(
             client,
             oldmemo.oldmemo.NAMESPACE,
             stanza,
@@ -2474,12 +2473,12 @@
         # Add a store hint
         self.__xep_0334.addHintElements(stanza, [ "store" ])
 
-    async def __encrypt(
+    async def encrypt(
         self,
         client: SatXMPPClient,
         namespace: Literal["urn:xmpp:omemo:2", "eu.siacs.conversations.axolotl"],
         stanza: domish.Element,
-        recipient_jid: jid.JID,
+        recipient_jids: Union[jid.JID, Set[jid.JID]],
         is_muc_message: bool,
         stanza_id: Optional[str]
     ) -> None:
@@ -2488,8 +2487,9 @@
         @param namespace: The namespace of the OMEMO version to use.
         @param stanza: The stanza. Twomemo will encrypt the whole stanza using SCE,
             oldmemo will encrypt only the body. The stanza is modified by this call.
-        @param recipient_jid: The JID of the recipient. Can be a bare (aka "userhost") JID
-            but doesn't have to.
+        @param recipient_jid: The JID of the recipients.
+            Can be a bare (aka "userhost") JIDs but doesn't have to.
+            A single JID can be used.
         @param is_muc_message: Whether the stanza is a message stanza to a MUC room.
         @param stanza_id: The id of this stanza. Especially relevant for message stanzas
             to MUC rooms such that the outgoing plaintext can be cached for MUC message
@@ -2499,6 +2499,11 @@
             hint to the stanza if applicable! This can be done before or after this call,
             the order doesn't matter.
         """
+        if isinstance(recipient_jids, jid.JID):
+            recipient_jids = {recipient_jids}
+        if not recipient_jids:
+            raise exceptions.InternalError("At least one JID must be specified")
+        recipient_jid = next(iter(recipient_jids))
 
         muc_plaintext_cache_key: Optional[MUCPlaintextCacheKey] = None
 
@@ -2506,6 +2511,10 @@
         feedback_jid: jid.JID
 
         if is_muc_message:
+            if len(recipient_jids) != 1:
+                raise exceptions.InternalError(
+                    'Only one JID can be set when "is_muc_message" is set'
+                )
             if self.__xep_0045 is None:
                 raise exceptions.InternalError(
                     "Encryption of MUC message requested, but plugin XEP-0045 is not"
@@ -2531,7 +2540,7 @@
                 message_uid=stanza_id
             )
         else:
-            recipient_bare_jids = { recipient_jid.userhost() }
+            recipient_bare_jids = {r.userhost() for r in recipient_jids}
             feedback_jid = recipient_jid.userhostJID()
 
         log.debug(