Server features are now checked to find capabilities data and use it for disco caching. This is §6.3 of the XEP.
508 lines
19 KiB
Python
508 lines
19 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
|
|
|
|
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.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)
|
|
|
|
def get_handler(self, client: SatXMPPEntity) -> "XEP_0115_handler":
|
|
return XEP_0115_handler(self)
|
|
|
|
async def _prepare_caps(self, client: SatXMPPEntity) -> None:
|
|
# we have to calculate hash for client
|
|
# because disco infos/identities may change between clients
|
|
|
|
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"))
|
|
|
|
# hash generation
|
|
assert data.caps_hash is None, "caps_hash already set"
|
|
_infos = await client.discoHandler.info(client.jid, client.jid, "")
|
|
disco_infos = disco.DiscoInfo()
|
|
for item in _infos:
|
|
disco_infos.append(item)
|
|
cap_hash = self.generate_hash(disco_infos)
|
|
data.caps_hash = cap_hash
|
|
log.info(
|
|
"Our capability hash has been generated: [{cap_hash}]".format(
|
|
cap_hash=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 _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))
|
|
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"))
|
|
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
|
|
values = [v.encode("utf-8") for v in field.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}"
|
|
)
|
|
d = 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).
|
|
"""
|
|
# 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 self.plugin_parent.host.memory.disco.hashes
|
|
):
|
|
disco_infos = self.plugin_parent.host.memory.disco.hashes[cap_ver]
|
|
# Return all items from the cached disco info
|
|
return list(disco_infos)
|
|
|
|
# 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 []
|