libervia-backend/libervia/backend/plugins/plugin_xep_0115.py
2026-06-08 14:26:15 +02:00

542 lines
21 KiB
Python

#!/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 <http://www.gnu.org/licenses/>.
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 <c/> 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: <presence/> 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 []