diff libervia/backend/plugins/plugin_sec_oxps.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_sec_oxps.py@c23cad65ae99
children 0d7bb4df2343
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_sec_oxps.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,788 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Pubsub 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/>.
+
+import base64
+import dataclasses
+import secrets
+import time
+from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
+from collections import OrderedDict
+
+import shortuuid
+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 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 _
+from libervia.backend.core.log import getLogger
+from libervia.backend.memory import persistent
+from libervia.backend.tools import utils
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common.async_utils import async_lru
+
+from .plugin_xep_0373 import NS_OX, get_gpg_provider
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "OXPS"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "OpenPGP for XMPP Pubsub",
+    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-0334", "XEP-0373"],
+    C.PI_MAIN: "PubsubEncryption",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Pubsub e2e encryption via OpenPGP"""),
+}
+NS_OXPS = "urn:xmpp:openpgp:pubsub:0"
+
+KEY_REVOKED = "revoked"
+CACHE_MAX = 5
+
+
+@dataclasses.dataclass
+class SharedSecret:
+    id: str
+    key: str
+    timestamp: float
+    # bare JID of who has generated the secret
+    origin: jid.JID
+    revoked: bool = False
+    shared_with: Set[jid.JID] = dataclasses.field(default_factory=set)
+
+
+class PubsubEncryption:
+    namespace = NS_OXPS
+
+    def __init__(self, host):
+        log.info(_("OpenPGP for XMPP Pubsub plugin initialization"))
+        host.register_namespace("oxps", NS_OXPS)
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        self._h = host.plugins["XEP-0334"]
+        self._ox = host.plugins["XEP-0373"]
+        host.trigger.add("XEP-0060_publish", self._publish_trigger)
+        host.trigger.add("XEP-0060_items", self._items_trigger)
+        host.trigger.add(
+            "message_received",
+            self._message_received_trigger,
+        )
+        host.bridge.add_method(
+            "ps_secret_share",
+            ".plugin",
+            in_sign="sssass",
+            out_sign="",
+            method=self._ps_secret_share,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_secret_revoke",
+            ".plugin",
+            in_sign="sssass",
+            out_sign="",
+            method=self._ps_secret_revoke,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_secret_rotate",
+            ".plugin",
+            in_sign="ssass",
+            out_sign="",
+            method=self._ps_secret_rotate,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_secrets_list",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._ps_secrets_list,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return PubsubEncryption_Handler()
+
+    async def profile_connecting(self, client):
+        client.__storage = persistent.LazyPersistentBinaryDict(
+            IMPORT_NAME, client.profile
+        )
+        # cache to avoid useless DB access, and to avoid race condition by ensuring that
+        # the same shared_secrets instance is always used for a given node.
+        client.__cache = OrderedDict()
+        self.gpg_provider = get_gpg_provider(self.host, client)
+
+    async def load_secrets(
+        self,
+        client: SatXMPPEntity,
+        node_uri: str
+    ) -> Optional[Dict[str, SharedSecret]]:
+        """Load shared secret from databse or cache
+
+        A cache is used per client to avoid usueless db access, as shared secrets are
+        often needed several times in a row. Cache is also necessary to avoir race
+        condition, when updating a secret, by ensuring that the same instance is used
+        for all updates during a session.
+
+        @param node_uri: XMPP URI of the encrypted pubsub node
+        @return shared secrets, or None if no secrets are known yet
+        """
+        try:
+            shared_secrets = client.__cache[node_uri]
+        except KeyError:
+            pass
+        else:
+            client.__cache.move_to_end(node_uri)
+            return shared_secrets
+
+        secrets_as_dict = await client.__storage.get(node_uri)
+
+        if secrets_as_dict is None:
+            return None
+        else:
+            shared_secrets = {
+                s["id"]: SharedSecret(
+                    id=s["id"],
+                    key=s["key"],
+                    timestamp=s["timestamp"],
+                    origin=jid.JID(s["origin"]),
+                    revoked=s["revoked"],
+                    shared_with={jid.JID(w) for w in s["shared_with"]}
+                ) for s in secrets_as_dict
+            }
+            client.__cache[node_uri] = shared_secrets
+            while len(client.__cache) > CACHE_MAX:
+                client.__cache.popitem(False)
+            return shared_secrets
+
+    def __secrect_dict_factory(self, data: List[Tuple[str, Any]]) -> Dict[str, Any]:
+        ret = {}
+        for k, v in data:
+            if k == "origin":
+                v = v.full()
+            elif k == "shared_with":
+                v = [j.full() for j in v]
+            ret[k] = v
+        return ret
+
+    async def store_secrets(
+        self,
+        client: SatXMPPEntity,
+        node_uri: str,
+        shared_secrets: Dict[str, SharedSecret]
+    ) -> None:
+        """Store shared secrets to database
+
+        Shared secrets are serialised before being stored.
+        If ``node_uri`` is not in cache, the shared_secrets instance is also put in cache/
+
+        @param node_uri: XMPP URI of the encrypted pubsub node
+        @param shared_secrets: shared secrets to store
+        """
+        if node_uri not in client.__cache:
+            client.__cache[node_uri] = shared_secrets
+            while len(client.__cache) > CACHE_MAX:
+                client.__cache.popitem(False)
+
+        secrets_as_dict = [
+            dataclasses.asdict(s, dict_factory=self.__secrect_dict_factory)
+            for s in shared_secrets.values()
+        ]
+        await client.__storage.aset(node_uri, secrets_as_dict)
+
+    def generate_secret(self, client: SatXMPPEntity) -> SharedSecret:
+        """Generate a new shared secret"""
+        log.info("Generating a new shared secret.")
+        secret_key = secrets.token_urlsafe(64)
+        secret_id = shortuuid.uuid()
+        return SharedSecret(
+            id = secret_id,
+            key = secret_key,
+            timestamp = time.time(),
+            origin = client.jid.userhostJID()
+        )
+
+    def _ps_secret_revoke(
+        self,
+        service: str,
+        node: str,
+        secret_id: str,
+        recipients: List[str],
+        profile_key: str
+    ) -> defer.Deferred:
+        return defer.ensureDeferred(
+            self.revoke(
+                self.host.get_client(profile_key),
+                jid.JID(service) if service else None,
+                node,
+                secret_id,
+                [jid.JID(r) for r in recipients] or None,
+            )
+        )
+
+    async def revoke(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        secret_id: str,
+        recipients: Optional[Iterable[jid.JID]] = None
+    ) -> None:
+        """Revoke a secret and notify entities
+
+        @param service: pubsub/PEP service where the node is
+        @param node: node name
+        @param secret_id: ID of the secret to revoke (must have been generated by
+            ourselves)
+        recipients: JIDs of entities to send the revocation notice to. If None, all
+            entities known to have the shared secret will be notified.
+            Use empty list if you don't want to notify anybody (not recommended)
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if not shared_secrets:
+            raise exceptions.NotFound(f"No shared secret is known for {node_uri}")
+        try:
+            shared_secret = shared_secrets[secret_id]
+        except KeyError:
+            raise exceptions.NotFound(
+                f"No shared secret with ID {secret_id!r} has been found for {node_uri}"
+            )
+        else:
+            if shared_secret.origin != client.jid.userhostJID():
+                raise exceptions.PermissionError(
+                    f"The shared secret {shared_secret.id} originate from "
+                    f"{shared_secret.origin}, not you ({client.jid.userhostJID()}). You "
+                    "can't revoke it"
+                )
+            shared_secret.revoked = True
+        await self.store_secrets(client, node_uri, shared_secrets)
+        log.info(
+            f"shared secret {secret_id!r} for {node_uri} has been revoked."
+        )
+        if recipients is None:
+            recipients = shared_secret.shared_with
+        if recipients:
+            for recipient in recipients:
+                await self.send_revoke_notification(
+                    client, service, node, shared_secret.id, recipient
+                )
+            log.info(
+                f"shared secret {shared_secret.id} revocation notification for "
+                f"{node_uri} has been send to {''.join(str(r) for r in recipients)}"
+            )
+        else:
+            log.info(
+                "Due to empty recipients list, no revocation notification has been sent "
+                f"for shared secret {shared_secret.id} for {node_uri}"
+            )
+
+    async def send_revoke_notification(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        secret_id: str,
+        recipient: jid.JID
+    ) -> None:
+        revoke_elt = domish.Element((NS_OXPS, "revoke"))
+        revoke_elt["jid"] = service.full()
+        revoke_elt["node"] = node
+        revoke_elt["id"] = secret_id
+        signcrypt_elt, payload_elt = self._ox.build_signcrypt_element([recipient])
+        payload_elt.addChild(revoke_elt)
+        openpgp_elt = await self._ox.build_openpgp_element(
+            client, signcrypt_elt, {recipient}
+        )
+        message_elt = domish.Element((None, "message"))
+        message_elt["from"] = client.jid.full()
+        message_elt["to"] = recipient.full()
+        message_elt.addChild((openpgp_elt))
+        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
+        client.send(message_elt)
+
+    def _ps_secret_share(
+        self,
+        recipient: str,
+        service: str,
+        node: str,
+        secret_ids: List[str],
+        profile_key: str
+    ) -> defer.Deferred:
+        return defer.ensureDeferred(
+            self.share_secrets(
+                self.host.get_client(profile_key),
+                jid.JID(recipient),
+                jid.JID(service) if service else None,
+                node,
+                secret_ids or None,
+            )
+        )
+
+    async def share_secret(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        shared_secret: SharedSecret,
+        recipient: jid.JID
+    ) -> None:
+        """Create and send <shared-secret> element"""
+        if service is None:
+            service = client.jid.userhostJID()
+        shared_secret_elt = domish.Element((NS_OXPS, "shared-secret"))
+        shared_secret_elt["jid"] = service.full()
+        shared_secret_elt["node"] = node
+        shared_secret_elt["id"] = shared_secret.id
+        shared_secret_elt["timestamp"] = utils.xmpp_date(shared_secret.timestamp)
+        if shared_secret.revoked:
+            shared_secret_elt["revoked"] = C.BOOL_TRUE
+        # TODO: add type attribute
+        shared_secret_elt.addContent(shared_secret.key)
+        signcrypt_elt, payload_elt = self._ox.build_signcrypt_element([recipient])
+        payload_elt.addChild(shared_secret_elt)
+        openpgp_elt = await self._ox.build_openpgp_element(
+            client, signcrypt_elt, {recipient}
+        )
+        message_elt = domish.Element((None, "message"))
+        message_elt["from"] = client.jid.full()
+        message_elt["to"] = recipient.full()
+        message_elt.addChild((openpgp_elt))
+        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
+        client.send(message_elt)
+        shared_secret.shared_with.add(recipient)
+
+    async def share_secrets(
+        self,
+        client: SatXMPPEntity,
+        recipient: jid.JID,
+        service: Optional[jid.JID],
+        node: str,
+        secret_ids: Optional[List[str]] = None,
+    ) -> None:
+        """Share secrets of a pubsub node with a recipient
+
+        @param recipient: who to share secrets with
+        @param service: pubsub/PEP service where the node is
+        @param node: node name
+        @param secret_ids: IDs of the secrets to share, or None to share all known secrets
+            (disabled or not)
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            # no secret shared yet, let's generate one
+            shared_secret = self.generate_secret(client)
+            shared_secrets = {shared_secret.id: shared_secret}
+            await self.store_secrets(client, node_uri, shared_secrets)
+        if secret_ids is None:
+            # we share all secrets of the node
+            to_share = shared_secrets.values()
+        else:
+            try:
+                to_share = [shared_secrets[s_id] for s_id in secret_ids]
+            except KeyError as e:
+                raise exceptions.NotFound(
+                    f"no shared secret found with given ID: {e}"
+                )
+        for shared_secret in to_share:
+            await self.share_secret(client, service, node, shared_secret, recipient)
+        await self.store_secrets(client, node_uri, shared_secrets)
+
+    def _ps_secret_rotate(
+        self,
+        service: str,
+        node: str,
+        recipients: List[str],
+        profile_key: str,
+    ) -> defer.Deferred:
+        return defer.ensureDeferred(
+            self.rotate_secret(
+                self.host.get_client(profile_key),
+                jid.JID(service) if service else None,
+                node,
+                [jid.JID(r) for r in recipients] or None
+            )
+        )
+
+    async def rotate_secret(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        recipients: Optional[List[jid.JID]] = None
+    ) -> None:
+        """Revoke all current known secrets, create and share a new one
+
+        @param service: pubsub/PEP service where the node is
+        @param node: node name
+        @param recipients: who must receive the new shared secret
+            if None, all recipients known to have last active shared secret will get the
+            new secret
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            shared_secrets = {}
+        for shared_secret in shared_secrets.values():
+            if not shared_secret.revoked:
+                await self.revoke(client, service, node, shared_secret.id)
+                shared_secret.revoked = True
+
+        if recipients is None:
+            if shared_secrets:
+                # we get recipients from latests shared secret's shared_with list,
+                # regarless of deprecation (cause all keys may be deprecated)
+                recipients = list(sorted(
+                    shared_secrets.values(),
+                    key=lambda s: s.timestamp,
+                    reverse=True
+                )[0].shared_with)
+            else:
+                recipients = []
+
+        shared_secret = self.generate_secret(client)
+        shared_secrets[shared_secret.id] = shared_secret
+        # we send notification to last entities known to already have the shared secret
+        for recipient in recipients:
+            await self.share_secret(client, service, node, shared_secret, recipient)
+        await self.store_secrets(client, node_uri, shared_secrets)
+
+    def _ps_secrets_list(
+        self,
+        service: str,
+        node: str,
+        profile_key: str
+    ) -> defer.Deferred:
+        d = defer.ensureDeferred(
+            self.list_shared_secrets(
+                self.host.get_client(profile_key),
+                jid.JID(service) if service else None,
+                node,
+            )
+        )
+        d.addCallback(lambda ret: data_format.serialise(ret))
+        return d
+
+    async def list_shared_secrets(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+    ) -> List[Dict[str, Any]]:
+        """Retrieve for shared secrets of a pubsub node
+
+        @param service: pubsub/PEP service where the node is
+        @param node: node name
+        @return: shared secrets data
+        @raise exceptions.NotFound: no shared secret found for this node
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            raise exceptions.NotFound(f"No shared secrets found for {node_uri}")
+        return [
+            dataclasses.asdict(s, dict_factory=self.__secrect_dict_factory)
+            for s in shared_secrets.values()
+        ]
+
+    async def handle_revoke_elt(
+        self,
+        client: SatXMPPEntity,
+        sender: jid.JID,
+        revoke_elt: domish.Element
+    ) -> None:
+        """Parse a <revoke> element and update local secrets
+
+        @param sender: bare jid of the entity who has signed the secret
+        @param revoke: <revoke/> element
+        """
+        try:
+            service = jid.JID(revoke_elt["jid"])
+            node = revoke_elt["node"]
+            secret_id = revoke_elt["id"]
+        except (KeyError, RuntimeError) as e:
+            log.warning(
+                f"ignoring invalid <revoke> element: {e}\n{revoke_elt.toXml()}"
+            )
+            return
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            log.warning(
+                f"Can't revoke shared secret {secret_id}: no known shared secrets for "
+                f"{node_uri}"
+            )
+            return
+
+        if any(s.origin != sender for s in shared_secrets.values()):
+            log.warning(
+                f"Rejecting shared secret revocation signed by invalid entity ({sender}):"
+                f"\n{revoke_elt.toXml}"
+            )
+            return
+
+        try:
+            shared_secret = shared_secrets[secret_id]
+        except KeyError:
+            log.warning(
+                f"Can't revoke shared secret {secret_id}: this secret ID is unknown for "
+                f"{node_uri}"
+            )
+            return
+
+        shared_secret.revoked = True
+        await self.store_secrets(client, node_uri, shared_secrets)
+        log.info(f"Shared secret {secret_id} has been revoked for {node_uri}")
+
+    async def handle_shared_secret_elt(
+        self,
+        client: SatXMPPEntity,
+        sender: jid.JID,
+        shared_secret_elt: domish.Element
+    ) -> None:
+        """Parse a <shared-secret> element and update local secrets
+
+        @param sender: bare jid of the entity who has signed the secret
+        @param shared_secret_elt: <shared-secret/> element
+        """
+        try:
+            service = jid.JID(shared_secret_elt["jid"])
+            node = shared_secret_elt["node"]
+            secret_id = shared_secret_elt["id"]
+            timestamp = utils.parse_xmpp_date(shared_secret_elt["timestamp"])
+            # TODO: handle "type" attribute
+            revoked = C.bool(shared_secret_elt.getAttribute("revoked", C.BOOL_FALSE))
+        except (KeyError, RuntimeError, ValueError) as e:
+            log.warning(
+                f"ignoring invalid <shared-secret> element: "
+                f"{e}\n{shared_secret_elt.toXml()}"
+            )
+            return
+        key = str(shared_secret_elt)
+        if not key:
+            log.warning(
+                "ignoring <shared-secret> element with empty key: "
+                f"{shared_secret_elt.toXml()}"
+            )
+            return
+        shared_secret = SharedSecret(
+            id=secret_id, key=key, timestamp=timestamp, origin=sender, revoked=revoked
+        )
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            shared_secrets = {}
+            # no known shared secret yet for this node, we have to trust first user who
+            # send it
+        else:
+            if any(s.origin != sender for s in shared_secrets.values()):
+                log.warning(
+                    f"Rejecting shared secret signed by invalid entity ({sender}):\n"
+                    f"{shared_secret_elt.toXml}"
+                )
+                return
+
+        shared_secrets[shared_secret.id] = shared_secret
+        await self.store_secrets(client, node_uri, shared_secrets)
+        log.info(
+            f"shared secret {shared_secret.id} added for {node_uri} [{client.profile}]"
+        )
+
+    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 not extra.get("encrypted"):
+            return True
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            shared_secrets = {}
+            shared_secret = None
+        else:
+            current_secrets = [s for s in shared_secrets.values() if not s.revoked]
+            if not current_secrets:
+                shared_secret = None
+            elif len(current_secrets) > 1:
+                log.warning(
+                    f"more than one active shared secret found for node {node!r} at "
+                    f"{service}, using the most recent one"
+                )
+                current_secrets.sort(key=lambda s: s.timestamp, reverse=True)
+                shared_secret = current_secrets[0]
+            else:
+                shared_secret = current_secrets[0]
+
+        if shared_secret is None:
+            if any(s.origin != client.jid.userhostJID() for s in shared_secrets.values()):
+                raise exceptions.PermissionError(
+                    "there is no known active shared secret, and you are not the "
+                    "creator of previous shared secrets, we can't encrypt items at "
+                    f"{node_uri} ."
+                )
+            shared_secret = self.generate_secret(client)
+            shared_secrets[shared_secret.id] = shared_secret
+            await self.store_secrets(client, node_uri, shared_secrets)
+            # TODO: notify other entities
+
+        for item in items:
+            item_elts = list(item.elements())
+            if len(item_elts) != 1:
+                raise ValueError(
+                    f"there should be exactly one item payload: {item.toXml()}"
+                )
+            item_payload = item_elts[0]
+            log.debug(f"encrypting item {item.getAttribute('id', '')}")
+            encrypted_item = self.gpg_provider.encrypt_symmetrically(
+                item_payload.toXml().encode(), shared_secret.key
+            )
+            item.children.clear()
+            encrypted_elt = domish.Element((NS_OXPS, "encrypted"))
+            encrypted_elt["key"] = shared_secret.id
+            encrypted_elt.addContent(base64.b64encode(encrypted_item).decode())
+            item.addChild(encrypted_elt)
+
+        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()
+        shared_secrets = None
+        for item in items:
+            payload = item.firstChildElement()
+            if (payload is not None
+                and payload.name == "encrypted"
+                and payload.uri == NS_OXPS):
+                encrypted_elt = payload
+                secret_id = encrypted_elt.getAttribute("key")
+                if not secret_id:
+                    log.warning(
+                        f'"key" attribute is missing from encrypted item: {item.toXml()}'
+                    )
+                    continue
+                if shared_secrets is None:
+                    node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+                    shared_secrets = await self.load_secrets(client, node_uri)
+                    if shared_secrets is None:
+                        log.warning(
+                            f"No known shared secret for {node_uri}, can't decrypt"
+                        )
+                        return True
+                try:
+                    shared_secret = shared_secrets[secret_id]
+                except KeyError:
+                    log.warning(
+                        f"No key known for encrypted item {item['id']!r} (shared secret "
+                        f"id: {secret_id!r})"
+                    )
+                    continue
+                log.debug(f"decrypting item {item.getAttribute('id', '')}")
+                decrypted = self.gpg_provider.decrypt_symmetrically(
+                    base64.b64decode(str(encrypted_elt)),
+                    shared_secret.key
+                )
+                decrypted_elt = xml_tools.parse(decrypted)
+                item.children.clear()
+                item.addChild(decrypted_elt)
+                extra.setdefault("encrypted", {})[item["id"]] = {"type": NS_OXPS}
+        return True
+
+    async def _message_received_trigger(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element,
+        post_treat: defer.Deferred
+    ) -> bool:
+        sender = jid.JID(message_elt["from"]).userhostJID()
+        # there may be an openpgp element if OXIM is not activate, in this case we have to
+        # decrypt it here
+        openpgp_elt = next(message_elt.elements(NS_OX, "openpgp"), None)
+        if openpgp_elt is not None:
+            try:
+                payload_elt, __ = await self._ox.unpack_openpgp_element(
+                    client,
+                    openpgp_elt,
+                    "signcrypt",
+                    sender
+                )
+            except Exception as e:
+                log.warning(f"Can't decrypt element: {e}\n{message_elt.toXml()}")
+                return False
+            message_elt.children.remove(openpgp_elt)
+            for c in payload_elt.children:
+                message_elt.addChild(c)
+
+        shared_secret_elt = next(message_elt.elements(NS_OXPS, "shared-secret"), None)
+        if shared_secret_elt is None:
+            # no <shared-secret>, we check if there is a <revoke> element
+            revoke_elt = next(message_elt.elements(NS_OXPS, "revoke"), None)
+            if revoke_elt is None:
+                return True
+            else:
+                await self.handle_revoke_elt(client, sender, revoke_elt)
+        else:
+            await self.handle_shared_secret_elt(client, sender, shared_secret_elt)
+
+        return False
+
+
+@implementer(iwokkel.IDisco)
+class PubsubEncryption_Handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_OXPS)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []