#!/usr/bin/env python3 # Libervia plugin for managing xep-0115 # Copyright (C) 2009-2026 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 . from base64 import b64encode from dataclasses import dataclass, field from hashlib import sha1 from typing import TYPE_CHECKING, Literal from twisted.internet import defer, error from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber.xmlstream import XMPPHandler from twisted.words.xish import domish from tx_xmpp import data_form, disco, itx_xmpp from zope.interface import implementer from libervia.backend import G 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 log = getLogger(__name__) if TYPE_CHECKING: from libervia.backend.core.main import LiberviaBackend PRESENCE = "/presence" NS_ENTITY_CAPABILITY = "http://jabber.org/protocol/caps" NS_CAPS_OPTIMIZE = "http://jabber.org/protocol/caps#optimize" CAPABILITY_UPDATE = PRESENCE + '/c[@xmlns="' + NS_ENTITY_CAPABILITY + '"]' IMPORT_NAME = "XEP-0115" PLUGIN_INFO = { C.PI_NAME: "XEP 0115 Plugin", C.PI_IMPORT_NAME: IMPORT_NAME, C.PI_TYPE: "XEP", C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: ["XEP-0115"], C.PI_DEPENDENCIES: [], C.PI_MAIN: "XEP_0115", C.PI_HANDLER: "yes", C.PI_DESCRIPTION: _("""Implementation of entity capabilities"""), } @dataclass class CapsClientData: """Per-client data for XEP-0115 capabilities.""" #: True if caps optimisation is available caps_optimize: bool = False #: True if a presence with caps has been sent caps_sent: bool = False #: Our own capability hash caps_hash: str | None = None #: The element built for this client's presence caps_elt: domish.Element | None = field(default=None) class ByteIdentity: """This class manage identity as bytes (needed for i;octet sort) It is used for the hash generation. """ def __init__(self, identity: disco.DiscoIdentity): assert isinstance(identity, disco.DiscoIdentity) self.category = identity.category.encode("utf-8") self.idType = identity.type.encode("utf-8") self.name = identity.name.encode("utf-8") if identity.name else b"" self.lang = identity.lang.encode("utf-8") if identity.lang else b"" def __bytes__(self): return b"%s/%s/%s/%s" % (self.category, self.idType, self.lang, self.name) class XEP_0115: def __init__(self, host: "LiberviaBackend") -> None: self.host = host log.info(_("Plugin XEP_0115 initialization")) host.trigger.add("Presence send", self._presence_trigger) host.trigger.add("disco_info", self._disco_info_trigger) def get_handler(self, client: SatXMPPEntity) -> "XEP_0115_handler": return XEP_0115_handler(self) async def _prepare_caps(self, client: SatXMPPEntity) -> None: """Generate our own capability hash for a client. Hash is generated per client instance as disco infos/identities may change (plugins loaded/unloaded, config changes, etc.). @param client: the client entity to generate the hash for. @raises exceptions.InternalError: The client's own disco info is malformed. """ data = client.get_plugin_data(IMPORT_NAME, CapsClientData) # optimize check data.caps_optimize = await G.host.has_feature(client, NS_CAPS_OPTIMIZE) if data.caps_optimize: log.info(_("Caps optimisation enabled")) data.caps_sent = False else: log.warning(_("Caps optimisation not available")) old_hash = data.caps_hash _infos = await client.discoHandler.info(client.jid, client.jid, "") disco_infos = disco.DiscoInfo() for item in _infos: disco_infos.append(item) # Validate our own disco info before hashing. if not self._validate_caps(client.jid, "", "", disco_infos): raise exceptions.InternalError( "Our own disco#info response is malformed. Caps cannot be generated." ) cap_hash = self.generate_hash(disco_infos) if cap_hash != old_hash: log.info(f"Capability hash changed: [{old_hash}] -> [{cap_hash}]") data.caps_hash = cap_hash log.info(f"Our capability hash has been generated: [{cap_hash}]") log.debug("Generating capability domish.Element") c_elt = domish.Element((NS_ENTITY_CAPABILITY, "c")) c_elt["hash"] = "sha-1" c_elt["node"] = C.APP_URL c_elt["ver"] = cap_hash data.caps_elt = c_elt if data.caps_optimize: data.caps_sent = False if cap_hash not in G.host.memory.disco.hashes: G.host.memory.disco.hashes[cap_hash] = disco_infos G.host.memory.update_entity_data( client, client.jid, C.ENTITY_CAP_HASH, cap_hash ) def _disco_info_trigger( self, requestor: jid.JID | None, target: jid.JID | None, nodeIdentifier: str, ) -> tuple[Literal[True], None] | tuple[Literal[False], defer.Deferred[list]]: """Callback for the ``disco_info`` trigger. Intercepts caps node queries to return the cached disco info directly. @param requestor: entity that sent the disco request @param target: entity the disco request was sent to @param nodeIdentifier: optional node being queried @return: ``(False, deferred)`` with cached disco items for known caps queries, or ``(True, None)`` to let normal flow continue """ # Check if this is a caps node query (format: node#ver) if nodeIdentifier and "#" in nodeIdentifier: parts = nodeIdentifier.rsplit("#", 1) if len(parts) == 2: caps_node, cap_ver = parts # Only respond if this matches our caps node if caps_node == C.APP_URL and cap_ver in G.host.memory.disco.hashes: disco_infos = G.host.memory.disco.hashes[cap_ver] # Return all items from the cached disco info return False, defer.succeed(list(disco_infos)) return True, None def _presence_add_elt(self, client: SatXMPPEntity, obj: domish.Element) -> None: data = client.get_plugin_data(IMPORT_NAME, CapsClientData) if data.caps_optimize: if data.caps_sent: return data.caps_sent = True if data.caps_elt is not None: obj.addChild(data.caps_elt) def _presence_trigger( self, client: SatXMPPEntity, obj: domish.Element, presence_d: defer.Deferred[domish.Element], ) -> bool: data = client.get_plugin_data(IMPORT_NAME, CapsClientData) if data.caps_elt is None: presence_d.addCallback( lambda __: defer.ensureDeferred(self._prepare_caps(client)) ) presence_d.addCallback(lambda __: self._presence_add_elt(client, obj)) presence_d.addErrback(G.host.log_errback) return True def _update(self, presence_elt: domish.Element, client: SatXMPPEntity) -> None: defer.ensureDeferred(self.update(client, presence_elt)) async def update(self, client: SatXMPPEntity, presence_elt: domish.Element) -> None: """ Manage the capabilities of the entity Check if we know the version of this capabilities and get the capabilities if necessary @param presence_elt: element to parse. """ from_jid = jid.JID(presence_elt["from"]) c_elem = next(presence_elt.elements(NS_ENTITY_CAPABILITY, "c"), None) if c_elem is None: # Presence without caps, nothing to do return try: c_ver = c_elem["ver"] c_hash = c_elem["hash"] c_node = c_elem["node"] except KeyError: log.warning(_("Received invalid capabilities tag: %s") % c_elem.toXml()) return if c_ver in G.host.memory.disco.hashes: # we already know the hash, we update the jid entity log.debug( "hash [%(hash)s] already in cache, updating entity [%(jid)s]" % {"hash": c_ver, "jid": from_jid.full()} ) G.host.memory.update_entity_data(client, from_jid, C.ENTITY_CAP_HASH, c_ver) return if c_hash != "sha-1": # unknown hash method log.warning( _( "Unknown hash method for entity capabilities: [{hash_method}] " "(entity: {entity_jid}, node: {node})" ).format(hash_method=c_hash, entity_jid=from_jid, node=c_node) ) return try: # Per XEP-0115 §6.2, include the caps node in the disco#info request caps_node = f"{c_node}#{c_ver}" disco_infos = await G.host.get_disco_infos(client, from_jid, node=caps_node) except error.ConnectionDone: return except Exception as e: log.error( _("Couldn't retrieve disco info for {jid}: {error}").format( jid=from_jid.full(), error=str(e) ) ) return # Validate disco info structure per XEP-0115 §5.4 if not self._validate_caps(from_jid, c_ver, c_node, disco_infos): return # disco is validated, we can compute hash cap_hash = self.generate_hash(disco_infos) # Compare computed hash with received verification string if cap_hash != c_ver: log.warning( _( "Computed hash differs from received hash for {jid}:\n" "received: [{received}]\n" "computed: [{computed}]\n" "(node: {node})" ).format( jid=from_jid.full(), received=c_ver, computed=cap_hash, node=c_node, ) ) # It's invalid, we exit early and won't cache. return # We're all good, we can cache the disco info G.host.memory.disco.hashes[cap_hash] = disco_infos G.host.memory.update_entity_data(client, from_jid, C.ENTITY_CAP_HASH, cap_hash) log.debug(f"Caps validated and cached for {from_jid}: {cap_hash}") def generate_hash(self, services: disco.DiscoInfo) -> str: """Generate a unique hash for given service Hash algorithm is the one described in XEP-0115 §5.1 (Generation Method). @param services: disco infos as returned by discoHandler.info() """ # Step 1: initialize empty string S s = [] # Step 2: sort identities by category, then type, then xml:lang (i;octet # collation) byte_identities = [ ByteIdentity(service) for service in services if isinstance(service, disco.DiscoIdentity) ] # i;octet (RFC 4790 §9.3) sorting. # Python's bytes comparison is equivalent to i;octet byte_identities.sort(key=lambda i: (i.category, i.idType, i.lang)) # Step 3: append each identity formatted as cat/type/lang/name followed by '<' for identity in byte_identities: s.append(bytes(identity)) s.append(b"<") # features byte_features = [ service.encode("utf-8") for service in services if isinstance(service, disco.DiscoFeature) ] # Step 4: sort the features byte_features.sort() # Step 5: append each feature followed by '<' for feature in byte_features: s.append(feature) s.append(b"<") # Step 6: sort forms by FORM_TYPE; Step 7: process each form's fields ext = list(services.extensions.values()) ext.sort(key=lambda f: f.formNamespace.encode("utf-8")) for extension in ext: # Step 7.a: append FORM_TYPE field's value (formNamespace) followed by '<' s.append(extension.formNamespace.encode("utf-8")) s.append(b"<") # Step 7.b: sort fields by "var" attribute fields = extension.fieldList fields.sort(key=lambda f: f.var.encode("utf-8")) for field in fields: # Step 7.c: "For each field other than FORM_TYPE" if field.var == "FORM_TYPE": continue # Step 7.c.a: append var attribute followed by '<' s.append(field.var.encode("utf-8")) s.append(b"<") # Step 7.c.b: sort values (use raw XML character data per spec) values = [v.encode("utf-8") for v in field.raw_values] values.sort() # Step 7.c.c: append each value followed by '<' for value in values: s.append(value) s.append(b"<") # Step 9: compute verification string (SHA-1 -> Base64) cap_hash = b64encode(sha1(b"".join(s)).digest()).decode("utf-8") log.debug(_("Capability hash generated: [{cap_hash}]").format(cap_hash=cap_hash)) return cap_hash def _validate_caps( self, from_jid: jid.JID, c_ver: str, c_node: str, disco_infos: disco.DiscoInfo, ) -> bool: """Validate capabilities disco info structure. @param from_jid: The JID that sent the presence with caps. @param c_ver: The verification string from the caps element. @param c_node: The node attribute from the caps element. @param disco_infos: The fetched disco#info response. @return: True if disco info is well-formed, False otherwise. """ # §5.4 step 3.c: duplicate identities with same category/type/lang/name seen_identities = set() for item in disco_infos: if isinstance(item, disco.DiscoIdentity): lang = item.lang or "" identity_key = (item.category, item.type, lang) if identity_key in seen_identities: log.warning( _( "Duplicate identity found in caps for {jid}: category={cat}," " type={typ}, lang={lang}" ).format( jid=from_jid.full(), cat=item.category, typ=item.type, lang=lang, ) ) return False seen_identities.add(identity_key) # §5.4 step 3.d: duplicate features seen_features = set() for item in disco_infos: if isinstance(item, disco.DiscoFeature): if item in seen_features: log.warning( _("Duplicate feature found in caps for {jid}: {feat}").format( jid=from_jid.full(), feat=item ) ) return False seen_features.add(item) # §5.4 step 3.e: duplicate FORM_TYPEs or FORM_TYPE with multiple values seen_form_types = set() for form_type, form in disco_infos.extensions.items(): if form_type in seen_form_types: log.warning( _("Duplicate FORM_TYPE found in caps for {jid}: {ft}").format( jid=from_jid.full(), ft=form_type ) ) return False form_type_field = None for field in form.fieldList: if field.var == "FORM_TYPE": form_type_field = field break if form_type_field is None: # §5.4 step 3.f: ignore forms without FORM_TYPE field log.debug( _( "Form without FORM_TYPE field in caps for {jid}, ignoring form" ).format(jid=from_jid.full()) ) continue if len(form_type_field.values) != 1: log.warning( _( "FORM_TYPE field has multiple values in caps for {jid}, ignoring" " form" ).format(jid=from_jid.full()) ) continue seen_form_types.add(form_type) return True @implementer(itx_xmpp.IDisco) class XEP_0115_handler(XMPPHandler): def __init__(self, plugin_parent: XEP_0115) -> None: self.plugin_parent = plugin_parent @property def client(self) -> SatXMPPEntity: return self.parent # type: ignore[return-value] def _caps_node_cb(self, disco_infos, ver): """Callback for caps node query.""" G.host.memory.disco.hashes[ver] = disco_infos log.debug(f"Cached disco info for caps hash {ver}") def _caps_node_eb(self, failure_, server_jid, ver): """Errback for caps node query.""" log.warning(f"Failed to query caps node for {server_jid} (ver={ver}): {failure_}") def connectionInitialized(self) -> None: assert self.xmlstream is not None # Check if server advertised caps in stream features (XEP-0115 §6.3) # xmlstream.features is a dict mapping (ns, name) to element caps_key = (NS_ENTITY_CAPABILITY, "c") if caps_key in self.xmlstream.features: c_elt = self.xmlstream.features[caps_key] ver = c_elt.getAttribute("ver") node = c_elt.getAttribute("node") if ver: server_jid = self.client.server_jid G.host.memory.update_entity_data( self.client, server_jid, C.ENTITY_CAP_HASH, ver ) # If we already have the disco info, we're done if ver in G.host.memory.disco.hashes: log.debug( f"Cached server caps from stream feature: {server_jid} ver={ver}" ) else: # Query the caps node to get disco info (XEP-0115 §6.2) caps_node = f"{node}#{ver}" if node else ver log.debug(f"Querying caps node {caps_node} for server {server_jid}") # We chain to `conn_deferred` to avoid race condition and be sure that # we have `client.disco` set (see # https://www.goffi.org/tickets/view/484) d = self.client.conn_deferred d.addCallback( lambda __: self.client.disco.requestInfo( server_jid, nodeIdentifier=caps_node ) ) d.addCallback(self._caps_node_cb, ver) d.addErrback(self._caps_node_eb, server_jid, ver) self.xmlstream.addObserver( CAPABILITY_UPDATE, self.plugin_parent._update, client=self.client ) def getDiscoInfo( self, requestor: SatXMPPEntity, target: SatXMPPEntity, nodeIdentifier: str = "", ) -> list[disco.DiscoFeature | disco.DiscoIdentity | data_form.Form]: """ Handle disco#info requests, including caps node queries. When nodeIdentifier is in the format 'node#ver' (e.g., 'http://libervia.org#abc123'), this is a caps node query per XEP-0115 §6.2 (Discovering Capabilities). We return the cached disco info for that verification string. @param requestor: The entity that sent the request. @param target: The entity the request was sent to. @param nodeIdentifier: The optional node being queried. @return: List of disco items (features, identities, extensions). """ # For non-caps queries, return our caps features return [ disco.DiscoFeature(NS_ENTITY_CAPABILITY), disco.DiscoFeature(NS_CAPS_OPTIMIZE), ] def getDiscoItems( self, requestor: SatXMPPEntity, target: SatXMPPEntity, nodeIdentifier: str = "", ) -> list[disco.DiscoItem]: return []