diff libervia/backend/plugins/plugin_xep_0353.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_xep_0353.py@38819c69aa39
children bc60875cb3b8
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0353.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,263 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Jingle Message Initiation (XEP-0353)
+# Copyright (C) 2009-2021 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 twisted.internet import defer
+from twisted.internet import reactor
+from twisted.words.protocols.jabber import error, jid
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+
+log = getLogger(__name__)
+
+
+NS_JINGLE_MESSAGE = "urn:xmpp:jingle-message:0"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle Message Initiation",
+    C.PI_IMPORT_NAME: "XEP-0353",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: [C.PLUG_MODE_CLIENT],
+    C.PI_PROTOCOLS: ["XEP-0353"],
+    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0334"],
+    C.PI_MAIN: "XEP_0353",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Jingle Message Initiation"""),
+}
+
+
+class XEP_0353:
+    def __init__(self, host):
+        log.info(_("plugin {name} initialization").format(name=PLUGIN_INFO[C.PI_NAME]))
+        self.host = host
+        host.register_namespace("jingle-message", NS_JINGLE_MESSAGE)
+        self._j = host.plugins["XEP-0166"]
+        self._h = host.plugins["XEP-0334"]
+        host.trigger.add(
+            "XEP-0166_initiate_elt_built",
+            self._on_initiate_trigger,
+            # this plugin set the resource, we want it to happen first to other trigger
+            # can get the full peer JID
+            priority=host.trigger.MAX_PRIORITY,
+        )
+        host.trigger.add("message_received", self._on_message_received)
+
+    def get_handler(self, client):
+        return Handler()
+
+    def profile_connecting(self, client):
+        # mapping from session id to deferred used to wait for destinee answer
+        client._xep_0353_pending_sessions = {}
+
+    def build_message_data(self, client, peer_jid, verb, session_id):
+        mess_data = {
+            "from": client.jid,
+            "to": peer_jid,
+            "uid": "",
+            "message": {},
+            "type": C.MESS_TYPE_CHAT,
+            "subject": {},
+            "extra": {},
+        }
+        client.generate_message_xml(mess_data)
+        message_elt = mess_data["xml"]
+        verb_elt = message_elt.addElement((NS_JINGLE_MESSAGE, verb))
+        verb_elt["id"] = session_id
+        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
+        return mess_data
+
+    async def _on_initiate_trigger(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        iq_elt: domish.Element,
+        jingle_elt: domish.Element,
+    ) -> bool:
+        peer_jid = session["peer_jid"]
+        if peer_jid.resource:
+            return True
+
+        try:
+            infos = await self.host.memory.disco.get_infos(client, peer_jid)
+        except error.StanzaError as e:
+            if e.condition == "service-unavailable":
+                categories = {}
+            else:
+                raise e
+        else:
+            categories = {c for c, __ in infos.identities}
+        if "component" in categories:
+            # we don't use message initiation with components
+            return True
+
+        if peer_jid.userhostJID() not in client.roster:
+            # if the contact is not in our roster, we need to send a directed presence
+            # according to XEP-0353 §3.1
+            await client.presence.available(peer_jid)
+
+        mess_data = self.build_message_data(client, peer_jid, "propose", session["id"])
+        message_elt = mess_data["xml"]
+        for content_data in session["contents"].values():
+            # we get the full element build by the application plugin
+            jingle_description_elt = content_data["application_data"]["desc_elt"]
+            # and copy it to only keep the root <description> element, no children
+            description_elt = domish.Element(
+                (jingle_description_elt.uri, jingle_description_elt.name),
+                defaultUri=jingle_description_elt.defaultUri,
+                attribs=jingle_description_elt.attributes,
+                localPrefixes=jingle_description_elt.localPrefixes,
+            )
+            message_elt.propose.addChild(description_elt)
+        response_d = defer.Deferred()
+        # we wait for 2 min before cancelling the session init
+        # FIXME: let's application decide timeout?
+        response_d.addTimeout(2 * 60, reactor)
+        client._xep_0353_pending_sessions[session["id"]] = response_d
+        await client.send_message_data(mess_data)
+        try:
+            accepting_jid = await response_d
+        except defer.TimeoutError:
+            log.warning(
+                _("Message initiation with {peer_jid} timed out").format(
+                    peer_jid=peer_jid
+                )
+            )
+        else:
+            if iq_elt["to"] != accepting_jid.userhost():
+                raise exceptions.InternalError(
+                    f"<jingle> 'to' attribute ({iq_elt['to']!r}) must not differ "
+                    f"from bare JID of the accepting entity ({accepting_jid!r}), this "
+                    "may be a sign of an internal bug, a hack attempt, or a MITM attack!"
+                )
+            iq_elt["to"] = accepting_jid.full()
+            session["peer_jid"] = accepting_jid
+        del client._xep_0353_pending_sessions[session["id"]]
+        return True
+
+    async def _on_message_received(self, client, message_elt, post_treat):
+        for elt in message_elt.elements():
+            if elt.uri == NS_JINGLE_MESSAGE:
+                if elt.name == "propose":
+                    return await self._handle_propose(client, message_elt, elt)
+                elif elt.name == "retract":
+                    return self._handle_retract(client, message_elt, elt)
+                elif elt.name == "proceed":
+                    return self._handle_proceed(client, message_elt, elt)
+                elif elt.name == "accept":
+                    return self._handle_accept(client, message_elt, elt)
+                elif elt.name == "reject":
+                    return self._handle_accept(client, message_elt, elt)
+                else:
+                    log.warning(f"invalid element: {elt.toXml}")
+                    return True
+        return True
+
+    async def _handle_propose(self, client, message_elt, elt):
+        peer_jid = jid.JID(message_elt["from"])
+        session_id = elt["id"]
+        if peer_jid.userhostJID() not in client.roster:
+            app_ns = elt.description.uri
+            try:
+                application = self._j.get_application(app_ns)
+                human_name = getattr(application.handler, "human_name", application.name)
+            except (exceptions.NotFound, AttributeError):
+                if app_ns.startswith("urn:xmpp:jingle:apps:"):
+                    human_name = app_ns[21:].split(":", 1)[0].replace("-", " ").title()
+                else:
+                    splitted_ns = app_ns.split(":")
+                    if len(splitted_ns) > 1:
+                        human_name = splitted_ns[-2].replace("- ", " ").title()
+                    else:
+                        human_name = app_ns
+
+            confirm_msg = D_(
+                "Somebody not in your contact list ({peer_jid}) wants to do a "
+                '"{human_name}" session with you, this would leak your presence and '
+                "possibly you IP (internet localisation), do you accept?"
+            ).format(peer_jid=peer_jid, human_name=human_name)
+            confirm_title = D_("Invitation from an unknown contact")
+            accept = await xml_tools.defer_confirm(
+                self.host,
+                confirm_msg,
+                confirm_title,
+                profile=client.profile,
+                action_extra={
+                    "type": C.META_TYPE_NOT_IN_ROSTER_LEAK,
+                    "session_id": session_id,
+                    "from_jid": peer_jid.full(),
+                },
+            )
+            if not accept:
+                mess_data = self.build_message_data(
+                    client, client.jid.userhostJID(), "reject", session_id
+                )
+                await client.send_message_data(mess_data)
+                # we don't sent anything to sender, to avoid leaking presence
+                return False
+            else:
+                await client.presence.available(peer_jid)
+        session_id = elt["id"]
+        mess_data = self.build_message_data(client, peer_jid, "proceed", session_id)
+        await client.send_message_data(mess_data)
+        return False
+
+    def _handle_retract(self, client, message_elt, proceed_elt):
+        log.warning("retract is not implemented yet")
+        return False
+
+    def _handle_proceed(self, client, message_elt, proceed_elt):
+        try:
+            session_id = proceed_elt["id"]
+        except KeyError:
+            log.warning(f"invalid proceed element in message_elt: {message_elt}")
+            return True
+        try:
+            response_d = client._xep_0353_pending_sessions[session_id]
+        except KeyError:
+            log.warning(
+                _(
+                    "no pending session found with id {session_id}, did it timed out?"
+                ).format(session_id=session_id)
+            )
+            return True
+
+        response_d.callback(jid.JID(message_elt["from"]))
+        return False
+
+    def _handle_accept(self, client, message_elt, accept_elt):
+        pass
+
+    def _handle_reject(self, client, message_elt, accept_elt):
+        pass
+
+
+@implementer(iwokkel.IDisco)
+class Handler(xmlstream.XMPPHandler):
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_MESSAGE)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []