diff --git a/libervia/backend/plugins/plugin_misc_xid/__init__.py b/libervia/backend/plugins/plugin_misc_xid/__init__.py new file mode 100644 index 00000000..ac7edb6a --- /dev/null +++ b/libervia/backend/plugins/plugin_misc_xid/__init__.py @@ -0,0 +1,919 @@ +#!/usr/bin/env python3 + +# Libervia plugin for XMPP Decentralized ID (XID) +# 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 . + +import os +from pathlib import Path +import time +from typing import TYPE_CHECKING, cast + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from twisted.internet import defer, reactor +from twisted.internet.interfaces import IReactorCore +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 disco, itx_xmpp, pubsub +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 SatXMPPClient, SatXMPPEntity +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.memory import persistent +from libervia.backend.plugins.plugin_xep_0060 import XEP_0060 +from libervia.backend.tools.common.uri import build_xmpp_uri, parse_xmpp_uri +from libervia.backend.tools.utils import xmpp_date + +from .models import Challenge, ListXID, Response, XID, XIDClientData, XIDStorage + +if TYPE_CHECKING: + from libervia.backend.core.main import LiberviaBackend + +log = getLogger(__name__) + +IMPORT_NAME = "XID" + +PLUGIN_INFO = { + C.PI_NAME: "XMPP Decentralized ID", + C.PI_IMPORT_NAME: IMPORT_NAME, + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0163"], + C.PI_RECOMMENDATIONS: ["XEP-0384"], + C.PI_MAIN: "XIDPlugin", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""DNS-independent XMPP entity identifier."""), +} + +NS_XID = "urn:xmpp:xid:0" +PEP_NODE = "urn:xmpp:xid" +PEP_NODE_REVOKED = "urn:xmpp:xid:revoked" +XID_DOMAIN = "id.internal" + +ED25519_PREFIX = "00" +CHALLENGE_NONCE_BYTES = 16 + + +class XIDPlugin: + """Main plugin class for XID support.""" + + # TODO: implement private-key automatic synchronisation. + + def get_handler(self, client: SatXMPPEntity) -> "XIDHandler": + return XIDHandler() + + def __init__(self, host: "LiberviaBackend") -> None: + log.info(_("Plugin XID initialization")) + self._ps = cast(XEP_0060, host.plugins["XEP-0060"]) + + host.bridge.add_method( + "xid_create", + ".plugin", + in_sign="ss", + out_sign="s", + method=self._create, + async_=True, + ) + host.bridge.add_method( + "xid_get", + ".plugin", + in_sign="sbs", + out_sign="s", + method=self._get, + async_=True, + ) + host.bridge.add_method( + "xid_revoke", + ".plugin", + in_sign="sss", + out_sign="", + method=self._revoke, + async_=True, + ) + host.bridge.add_method( + "xid_verify", + ".plugin", + in_sign="sss", + out_sign="s", + method=self._verify, + async_=True, + ) + host.bridge.add_method( + "xid_signature_sign_file", + ".plugin", + in_sign="sss", + out_sign="s", + method=self._sign_file, + async_=True, + ) + host.bridge.add_method( + "xid_signature_sign_data", + ".plugin", + in_sign="sss", + out_sign="s", + method=self._sign_data, + async_=True, + ) + host.bridge.add_method( + "xid_signature_check_file", + ".plugin", + in_sign="ssss", + out_sign="b", + method=self._signature_check_file, + async_=True, + ) + host.bridge.add_method( + "xid_signature_check_data", + ".plugin", + in_sign="sss", + out_sign="b", + method=self._signature_check_data, + async_=True, + ) + host.bridge.add_method( + "xid_export", + ".plugin", + in_sign="ss", + out_sign="s", + method=self._export, + async_=True, + ) + host.bridge.add_method( + "xid_import", + ".plugin", + in_sign="ss", + out_sign="", + method=self._import, + async_=True, + ) + host.register_namespace("xid", NS_XID) + host.trigger.add("message_received", self._message_received_trigger) + + async def profile_connecting(self, client: SatXMPPEntity) -> None: + """Initialize XID storage before client is ready.""" + if client.is_component: + return + storage = persistent.PersistentBinaryDict(IMPORT_NAME, client.profile) + await storage.load() + + xids = {} + for xid_s, value in storage.items(): + xids[xid_s] = XIDStorage.model_validate(value) + + xid_data = XIDClientData(_storage=storage, xids=xids) + client.set_plugin_data(C.PI_IMPORT_NAME, xid_data) + + async def profile_connected(self, client: SatXMPPEntity) -> None: + """Ensure a current XID exists.""" + if client.is_component: + return + + try: + items, __ = await self._ps.get_items( + client, client.jid.userhostJID(), PEP_NODE, item_ids=["current"] + ) + except exceptions.NotFound: + items = None + + if items: + try: + xid_model = XID.from_element(items[0], item_id="current") + except exceptions.NotFound: + xid_model = None + if xid_model: + xid_data = client.get_plugin_data(C.PI_IMPORT_NAME, XIDClientData) + xid_storage = xid_data.xids.get(str(xid_model.xid)) + if xid_storage is not None and xid_storage.private_key is not None: + log.debug(f"XID {xid_model.xid} found with private key.") + return + log.warning( + f"XID {xid_model.xid} published but private key not found in storage." + ) + # TODO: Auto sync private key. + return + + log.info(_("No current XID found, creating one…")) + try: + await self.create(client) + except Exception: + log.exception("Failed to create XID") + else: + log.info("XID created and published successfully.") + + @staticmethod + def get_local_part(public_key: bytes) -> str: + """Convert Ed25519 public key to a XID localpart. + + @param public_key: Ed25519 public key. + @return: XID local part. + """ + return ED25519_PREFIX + public_key.hex() + + @staticmethod + def xid_to_public_key(xid: jid.JID) -> bytes: + """Extract public key bytes from a XID. + + @param xid: XID to extract the public key from. + @return: 32 bytes of public key. + @raise ValueError: if XID is malformed. + """ + localpart = xid.user + if localpart is None: + raise ValueError("XID has no localpart") + if not localpart.startswith(ED25519_PREFIX): + raise ValueError( + f"XID localpart {localpart!r} does not start with Ed25519 prefix " + f"({ED25519_PREFIX!r})" + ) + key_hex = localpart[2:] + if len(key_hex) != 64: + raise ValueError(f"XID public key hex has wrong length: {len(key_hex)}") + return bytes.fromhex(key_hex) + + @staticmethod + def private_key_to_public_key(private_key: Ed25519PrivateKey) -> bytes: + """Derive public key bytes from a private key.""" + public_key = private_key.public_key() + return public_key.public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + + async def _get_private_key( + self, client: SatXMPPEntity, xid: jid.JID + ) -> Ed25519PrivateKey | None: + """Retrieve the private key for a XID from storage. + + @return: Private key or None if not found. + """ + xid_data = client.get_plugin_data(C.PI_IMPORT_NAME, XIDClientData) + xid_s = str(xid) + xid_storage = xid_data.xids.get(xid_s) + if xid_storage is None or xid_storage.private_key is None: + return None + private_key_hex = G.host.memory.decrypt_value(client, xid_storage.private_key) + return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(private_key_hex)) + + async def _store_private_key( + self, client: SatXMPPEntity, xid: jid.JID, private_key: Ed25519PrivateKey + ) -> None: + """Store a private key associated with a XID. + + @param client: client instance. + @param xid: XID. + @param private_key: Ed25519 private key. + """ + xid_data = client.get_plugin_data(C.PI_IMPORT_NAME, XIDClientData) + private_key_hex = private_key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ).hex() + encrypted = G.host.memory.encrypt_value(client, private_key_hex) + xid_s = str(xid) + xid_storage = XIDStorage(private_key=encrypted) + xid_data.xids[xid_s] = xid_storage + await xid_data._storage.aset(xid_s, xid_storage.model_dump()) + + def _create(self, profile: str, item_id: str) -> defer.Deferred: + client = G.host.get_client(profile) + d = defer.ensureDeferred(self.create(client, item_id)) + d.addCallback(lambda result: result.model_dump_json()) + return d + + async def create(self, client: SatXMPPEntity, item_id: str = "current") -> XID: + """Generate a new XID key pair and publish it. + + @param client: client instance. + @param item_id: PEP item ID ("current" for main XID). + @return: XID model. + @raise exceptions.ConflictError: if item_id is "current" and already published. + """ + if item_id == "current": + try: + items, __ = await self._ps.get_items( + client, client.jid.userhostJID(), PEP_NODE, item_ids=["current"] + ) + except exceptions.NotFound: + items = None + if items: + raise exceptions.ConflictError(_("A current XID is already published")) + + private_key = Ed25519PrivateKey.generate() + public_key = self.private_key_to_public_key(private_key) + localpart = self.get_local_part(public_key) + xid = jid.JID(f"{localpart}@{XID_DOMAIN}") + created = time.time() + + await self._store_private_key(client, xid, private_key) + + xid_model = XID(xid=xid, created=created, has_private_key=True, item_id=item_id) + item = pubsub.Item(id=item_id, payload=xid_model.to_element()) + await self._ps.publish( + client, + client.jid.userhostJID(), + PEP_NODE, + [item], + extra={ + self._ps.EXTRA_PUBLISH_OPTIONS: { + "pubsub#access_model": "presence", + }, + self._ps.EXTRA_AUTOCREATE: True, + }, + ) + + return xid_model + + def _revoke(self, xid: str, reason: str, profile: str) -> defer.Deferred: + client = G.host.get_client(profile) + try: + xid_jid = jid.JID(xid) + except Exception: + raise exceptions.DataError(f"Invalid XID: {xid}") + return defer.ensureDeferred(self.revoke(client, xid_jid, reason or None)) + + async def revoke( + self, client: SatXMPPEntity, xid: jid.JID, reason: str | None + ) -> None: + """Revoke a XID. + + @param client: client instance. + @param xid: XID to revoke. + @param reason: optional human-readable reason for revocation. + """ + if xid.host != XID_DOMAIN: + raise exceptions.DataError( + f'XID domain mismatch: expected "{XID_DOMAIN}", got "{xid.host}"' + ) + items, __ = await self._ps.get_items(client, client.jid.userhostJID(), PEP_NODE) + created = None + for candidate in items: + try: + xid_in_item = XID.from_element(candidate) + except exceptions.NotFound: + continue + if xid_in_item.xid == xid: + item = candidate + created = xid_in_item.created + break + else: + raise exceptions.NotFound(f"XID {xid} not found in published XIDs") + + item_id = item["id"] + + await self._ps.retract_items( + client, client.jid.userhostJID(), PEP_NODE, [item_id] + ) + revoked_elt = domish.Element((NS_XID, "revoked")) + revoked_elt["created"] = xmpp_date(created) + revoked_elt["revoked"] = xmpp_date() + revoked_elt.addContent(str(xid)) + if reason: + reason_elt = revoked_elt.addElement((NS_XID, "reason")) + reason_elt.addContent(reason) + revoked_item = pubsub.Item(payload=revoked_elt) + await self._ps.publish( + client, + client.jid.userhostJID(), + PEP_NODE_REVOKED, + [revoked_item], + extra={ + self._ps.EXTRA_PUBLISH_OPTIONS: { + "pubsub#access_model": "presence", + }, + self._ps.EXTRA_AUTOCREATE: True, + }, + ) + + def _get(self, entity: str, all: bool, profile: str) -> defer.Deferred: + client = G.host.get_client(profile) + d = defer.ensureDeferred(self.get(client, entity or None, all)) + d.addCallback(lambda result: ListXID(result).model_dump_json()) + return d + + async def get( + self, client: SatXMPPEntity, entity: str | None, all: bool + ) -> list[XID]: + """Get XIDs published by an entity. + + @param client: client instance. + @param entity: bare JID of the entity. + None to use profile own JID. + @param all: If True, return all XIDs; otherwise only current. + @return: List of XID entries. + """ + if entity: + target = jid.JID(entity).userhostJID() + else: + target = client.jid.userhostJID() + + try: + items, __ = await self._ps.get_items( + client, target, PEP_NODE, item_ids=None if all else ["current"] + ) + except exceptions.NotFound: + return [] + + if not items: + return [] + + result = [] + for item_elt in items: + try: + xid_model = XID.from_element(item_elt, item_id=item_elt["id"] or None) + except exceptions.NotFound: + continue + has_private_key = ( + await self._get_private_key(client, xid_model.xid) is not None + ) + result.append( + XID( + xid=xid_model.xid, + created=xid_model.created, + has_private_key=has_private_key, + item_id=xid_model.item_id, + ) + ) + + return result + + def _verify(self, entity_jid: str, xid: str, profile: str) -> defer.Deferred: + client = G.host.get_client(profile) + d = defer.ensureDeferred(self.verify(client, entity_jid, jid.JID(xid))) + d.addCallback(lambda result: "true" if result else "false") + return d + + async def verify(self, client: SatXMPPEntity, entity_jid: str, xid: jid.JID) -> bool: + """Verify an entity's XID via challenge/response. + + Sends a challenge to the entity as specified in the XEP, and waits for a response. + + @param client: client instance. + @param entity_jid: bare JID of the entity to verify. + @param xid: XID to verify. + @return: True if verified successfully. + @raise exceptions.TimeOutError: if no response is received. + """ + nonce = os.urandom(CHALLENGE_NONCE_BYTES).hex() + timestamp = time.time() + challenge = Challenge(xid=xid, timestamp=timestamp, nonce=nonce) + + d = defer.Deferred() + xid_data = client.get_plugin_data(C.PI_IMPORT_NAME, XIDClientData) + xid_data.pending_challenges[nonce] = d + + msg = domish.Element((None, "message")) + msg["to"] = entity_jid + msg["type"] = "chat" + msg.addChild(challenge.to_element()) + client.send(msg) + + d.addTimeout(30, cast(IReactorCore, reactor)) + try: + return await d + except defer.TimeoutError: + raise exceptions.TimeOutError( + f"No response received for challenge to {entity_jid}" + ) + finally: + xid_data.pending_challenges.pop(nonce, None) + + def _message_received_trigger( + self, + client: SatXMPPClient, + message_elt: domish.Element, + post_treat: defer.Deferred, + ) -> bool: + """Trigger handler for incoming messages containing XID challenge/response. + + @param client: client instance. + @param message_elt: the element. + @param post_treat: not used here + @return: False to stop propagation if a XID element was found, True otherwise. + """ + for element in message_elt.elements(NS_XID): + if element.name == "challenge": + self._handle_challenge(client, message_elt, element) + return False + elif element.name == "response": + self._handle_response(client, message_elt, element) + return False + if "challenge" in message_elt.toXml(): + from pudb.remote import set_trace + + set_trace(term_size=(253, 69)) + return True + + def _handle_challenge( + self, + client: SatXMPPClient, + message_elt: domish.Element, + challenge_elt: domish.Element, + ) -> None: + """Handle incoming challenge from another entity. + + Signs the nonce with the corresponding private key and sends a response. + + @param client: client entity that received the challenge. + @param message_elt: the element. + @param challenge_elt: element. + """ + challenge = Challenge.from_element(challenge_elt) + from_jid = jid.JID(message_elt["from"]) + + # Check if the requesting entity is our own server or someone from our roster with + # presence access + if from_jid.host != client.jid.host: + if not client.roster.is_subscribed_from(from_jid.userhostJID()): + log.warning( + f"Ignoring challenge from {from_jid!r}, " + "not our server and not subscribed to our presence" + ) + return + + log.debug(f"Received challenge for XID {challenge.xid} from {from_jid!r}") + + private_key = self._get_private_key_sync(client, challenge.xid) + if private_key is None: + log.warning(f"No private key for XID {challenge.xid}, ignoring challenge") + return + + signature = private_key.sign(challenge.nonce.encode()) + signature_hex = signature.hex() + + response = Response( + xid=challenge.xid, + timestamp=challenge.timestamp, + signature=signature_hex, + ) + + msg = domish.Element((None, "message")) + msg["to"] = message_elt["from"] + msg["type"] = "chat" + msg.addChild(response.to_element()) + client.send(msg) + log.debug(f"Sent response to challenge for XID {challenge.xid}") + + @staticmethod + def _get_private_key_sync( + client: SatXMPPClient, xid: jid.JID + ) -> Ed25519PrivateKey | None: + """Synchronously retrieve the private key for a XID from storage. + + @return: Private key or None if not found. + """ + xid_data = client.get_plugin_data(C.PI_IMPORT_NAME, XIDClientData) + xid_storage = xid_data.xids.get(str(xid)) + if xid_storage is None or xid_storage.private_key is None: + return None + private_key_hex = G.host.memory.decrypt_value(client, xid_storage.private_key) + return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(private_key_hex)) + + def _handle_response( + self, + client: SatXMPPClient, + message_elt: domish.Element, + response_elt: domish.Element, + ) -> None: + """Handle incoming response to a challenge we sent. + + @param client: client entity that sent the challenge. + @param message_elt: the element. + @param response_elt: element. + """ + response = Response.from_element(response_elt) + log.debug(f"Received response for XID {response.xid}") + + xid_data = client.get_plugin_data(C.PI_IMPORT_NAME, XIDClientData) + + for nonce, deferred in xid_data.pending_challenges.items(): + if deferred.called: + continue + public_key_bytes = self.xid_to_public_key(response.xid) + public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) + try: + public_key.verify(bytes.fromhex(response.signature), nonce.encode()) + deferred.callback(True) + return + except Exception: + continue + + log.warning(f"No matching pending challenge for response to XID {response.xid}") + + def _sign_file(self, xid: str, file_path: str, profile: str) -> defer.Deferred: + client = G.host.get_client(profile) + d = defer.ensureDeferred( + self.sign(client, jid.JID(xid) if xid else None, file_path=file_path) + ) + return d + + def _sign_data(self, xid: str, data: str, profile: str) -> defer.Deferred: + client = G.host.get_client(profile) + d = defer.ensureDeferred( + self.sign(client, jid.JID(xid) if xid else None, data=data) + ) + return d + + async def sign( + self, + client: SatXMPPEntity, + xid: jid.JID | None, + file_path: str | None = None, + data: str | None = None, + ) -> str: + """Sign a file or inline data. + + @param client: client instance. + @param xid: XID whose private key to use (None for current). + @param file_path: path to file to sign. + mutually exclusive with ``data``. + @param data: inline data to sign. + mutually exclusive with ``file_path``. + @return: hex-encoded signature. + @raise exceptions.DataError: if both or neither file_path and data are given. + """ + if (file_path is None) == (data is None): + raise exceptions.DataError( + '"file_path" and "data" are mutually exclusive, exactly one must be ' + "provided" + ) + + if file_path is not None: + normalized = Path(file_path).expanduser().resolve() + text = normalized.read_text(errors="replace") + else: + assert data is not None + text = data + + xid = xid or await self.get_current_xid(client) + private_key = await self._get_private_key(client, xid) + if private_key is None: + raise exceptions.NotFound(f"No private key found for XID {xid}") + + signature = private_key.sign(text.encode()) + return signature.hex() + + async def get_current_xid(self, client: SatXMPPEntity) -> jid.JID: + """Get the current published XID. + + @param client: client instance. + @return: XID as JID. + @raise exceptions.NotFound: if no current XID is published. + """ + items, __ = await self._ps.get_items( + client, + client.jid.userhostJID(), + PEP_NODE, + item_ids=["current"], + ) + if not items: + raise exceptions.NotFound("No current XID published") + try: + xid_item = XID.from_element(items[0]) + except exceptions.NotFound: + raise exceptions.DataError("Invalid XID item payload") + return xid_item.xid + + def _signature_check_file( + self, xid: str, file_path: str, signature_hex: str, profile: str + ) -> defer.Deferred: + return defer.ensureDeferred( + self.signature_check( + jid.JID(xid), file_path=file_path, signature_hex=signature_hex + ) + ) + + def _signature_check_data( + self, xid: str, data: str, signature_hex: str + ) -> defer.Deferred: + return defer.ensureDeferred( + self.signature_check(jid.JID(xid), data=data, signature_hex=signature_hex) + ) + + async def signature_check( + self, + xid: jid.JID, + *, + file_path: str | None = None, + data: str | None = None, + signature_hex: str, + ) -> bool: + """Verify file or inline data signature. + + @param xid: XID whose public key is used for verification. + @param file_path: path to file with original data. + mutually exclusive with ``data``. + @param data: inline original data. + mutually exclusive with ``file_path``. + @param signature_hex: hex-encoded signature. + @return: True if signature is valid. + """ + if (file_path is None) == (data is None): + raise exceptions.DataError( + _( + "file_path and data are mutually exclusive, exactly one must be " + "provided" + ) + ) + + if file_path is not None: + normalized = Path(file_path).expanduser().resolve() + text = normalized.read_text(errors="replace") + else: + assert data is not None + text = data + + public_key_bytes = self.xid_to_public_key(xid) + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PublicKey, + ) + + public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) + signature = bytes.fromhex(signature_hex) + + try: + public_key.verify(signature, text.encode()) + return True + except Exception: + return False + + def _export(self, xid: str, profile: str) -> defer.Deferred: + client = G.host.get_client(profile) + return defer.ensureDeferred(self.export(client, jid.JID(xid) if xid else None)) + + async def export(self, client: SatXMPPEntity, xid: jid.JID | None) -> str: + """Generate an xmpp: URI for private key export. + + @param client: client instance. + @param xid: XID to export, None for current. + @return: xmpp: URI as specified in the XEP. + """ + if xid is None: + items, __ = await self._ps.get_items( + client, + client.jid.userhostJID(), + PEP_NODE, + item_ids=["current"], + ) + if not items: + raise exceptions.NotFound("No current XID published") + try: + xid_item = XID.from_element(items[0]) + except exceptions.NotFound: + raise exceptions.DataError("Invalid XID item payload") + else: + items, __ = await self._ps.get_items( + client, + client.jid.userhostJID(), + PEP_NODE, + ) + xid_item = None + for item in items: + try: + candidate = XID.from_element(item) + except exceptions.NotFound: + continue + if candidate.xid == xid: + xid_item = candidate + break + else: + raise exceptions.NotFound(f"No published item for XID {xid}") + + xid = xid_item.xid + created = xmpp_date(xid_item.created) + + private_key = await self._get_private_key(client, xid) + if private_key is None: + raise exceptions.NotFound(f"No private key found for XID {xid}") + + private_key_hex = private_key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ).hex() + + return build_xmpp_uri( + path=str(xid), + xid_private=private_key_hex, + xid_created=created, + ) + + def _import(self, uri_str: str, profile: str) -> defer.Deferred: + client = G.host.get_client(profile) + return defer.ensureDeferred(self.import_xid(client, uri_str)) + + async def import_xid(self, client: SatXMPPEntity, uri_str: str) -> None: + """Import a XID private key from an xmpp: URI. + + @param client: client instance. + @param uri_str: xmpp: URI containing xid-private and xid-created parameters. + @raise ValueError: if URI is malformed or verifications fail. + """ + try: + parsed = parse_xmpp_uri(uri_str) + except ValueError as e: + raise ValueError(f"Invalid XMPP URI: {e}") + + xid_from_uri = parsed.get("path", "") + if not xid_from_uri: + raise ValueError("URI missing XID (public key)") + try: + xid_jid = jid.JID(xid_from_uri) + except (ValueError, RuntimeError): + raise ValueError(f"Invalid XID in URI path: {xid_from_uri!r}") + if xid_jid.host != XID_DOMAIN: + raise ValueError( + f'XID domain mismatch: expected "{XID_DOMAIN}", ' + f'got {xid_jid.host if xid_jid.host else "no domain"}' + ) + + public_key_hex = xid_jid.user + + private_key_hex = parsed.get("xid-private") + created_xep = parsed.get("xid-created") + + if not private_key_hex: + raise ValueError("URI missing 'xid-private' parameter") + if not created_xep: + raise ValueError("URI missing 'xid-created' parameter") + + try: + private_key = Ed25519PrivateKey.from_private_bytes( + bytes.fromhex(private_key_hex) + ) + except Exception as e: + raise ValueError(f"Invalid private key in URI: {e}") + + derived_public_key_bytes = self.private_key_to_public_key(private_key) + derived_public_key_hex = ED25519_PREFIX + derived_public_key_bytes.hex() + + if derived_public_key_hex != public_key_hex: + raise ValueError( + f"Public key mismatch: derived {derived_public_key_hex!r} " + f"does not match URI {public_key_hex!r}" + ) + + try: + items, __ = await self._ps.get_items( + client, + client.jid.userhostJID(), + PEP_NODE, + ) + except exceptions.NotFound: + raise ValueError( + "No XID published on own PEP node; cannot verify imported key" + ) + + if not items: + raise ValueError( + f"XID {xid_jid!r} not found in own PEP node; " + "cannot import private key for unknown XID" + ) + for item in items: + try: + xid_in_item = XID.from_element(item) + except exceptions.NotFound: + continue + if xid_in_item.xid == xid_jid: + break + else: + raise ValueError( + f"XID {xid_jid!r} not found in own PEP node; cannot import private key " + "for unknown XID" + ) + + await self._store_private_key(client, xid_jid, private_key) + log.info(f"XID {xid_jid!r} private key imported successfully") + + +@implementer(itx_xmpp.IDisco) +class XIDHandler(XMPPHandler): + + def getDiscoInfo(self, requestor: str, target: str, nodeIdentifier: str = "") -> list: + return [disco.DiscoFeature(NS_XID)] + + def getDiscoItems( + self, requestor: str, target: str, nodeIdentifier: str = "" + ) -> list: + return [] diff --git a/libervia/backend/plugins/plugin_misc_xid/models.py b/libervia/backend/plugins/plugin_misc_xid/models.py new file mode 100644 index 00000000..111b0a83 --- /dev/null +++ b/libervia/backend/plugins/plugin_misc_xid/models.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 + +# Libervia plugin for XMPP Decentralized ID (XID) +# 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 collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Self + +from pydantic import BaseModel, Field, RootModel +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from twisted.words.xish import domish + +from libervia.backend.core import exceptions +from libervia.backend.core.log import getLogger +from libervia.backend.memory import persistent +from libervia.backend.models.types import JIDType +from libervia.backend.tools.utils import parse_xmpp_date, xmpp_date + +log = getLogger(__name__) + +NS_XID = "urn:xmpp:xid:0" + + +class XID(BaseModel): + xid: JIDType = Field(description='XID (i.e., JID in the form "XXX@id.internal")') + created: float = Field(description="POSIX timestamp of XID creation") + has_private_key: bool = Field( + default=False, description="True if the private key is known and stored" + ) + item_id: str | None = Field(default=None, description="PEP item ID") + + @classmethod + def from_element(cls, xid_elt: domish.Element, item_id: str | None = None) -> Self: + """Create a XID instance from a element or its parent. + + @param xid_elt: The element in NS_XID namespace, or a parent element. + @param item_id: PEP item ID where the XID is published. + @return: XID instance. + @raise exceptions.NotFound: If the element is not found. + """ + if xid_elt.uri != NS_XID or xid_elt.name != "xid": + child_xid_elt = next(xid_elt.elements(NS_XID, "xid"), None) + if child_xid_elt is None: + raise exceptions.NotFound(" element not found") + xid_elt = child_xid_elt + xid = jid.JID(str(xid_elt)) + created = parse_xmpp_date(xid_elt["created"]) + return cls(xid=xid, created=created, item_id=item_id) + + def to_element(self) -> domish.Element: + """Build the element from this instance's data. + + @return: element in NS_XID namespace. + """ + xid_elt = domish.Element((NS_XID, "xid")) + xid_elt["created"] = xmpp_date(self.created) + xid_elt.addContent(str(self.xid)) + return xid_elt + + +class ListXID(RootModel): + root: list[XID] + + def __iter__(self) -> Iterator[XID]: # type: ignore + return iter(self.root) + + def __len__(self) -> int: + return len(self.root) + + def __getitem__(self, index: int) -> XID: + return self.root[index] + + +class Challenge(BaseModel): + """Model for a XID challenge element.""" + + xid: JIDType = Field(description="XID being challenged") + timestamp: float = Field(description="POSIX timestamp when challenge was issued") + nonce: str = Field(description="hex-encoded random nonce") + + @classmethod + def from_element(cls, elt: domish.Element) -> Self: + """Create a Challenge from a element. + + @param elt: element in NS_XID namespace. + @return: Challenge instance. + """ + xid_attr = elt.getAttribute("xid") + timestamp_attr = elt.getAttribute("timestamp") + nonce = str(elt) + if xid_attr is None or timestamp_attr is None: + raise ValueError(f"Missing required attribute on ") + return cls( + xid=jid.JID(xid_attr), + timestamp=parse_xmpp_date(timestamp_attr), + nonce=nonce, + ) + + def to_element(self) -> domish.Element: + """Build a element. + + @return: element in NS_XID namespace. + """ + challenge_elt = domish.Element((NS_XID, "challenge")) + challenge_elt["xid"] = str(self.xid) + challenge_elt["timestamp"] = xmpp_date(self.timestamp) + challenge_elt.addContent(self.nonce) + return challenge_elt + + +class Response(BaseModel): + """Model for a XID response element.""" + + xid: JIDType = Field(description="XID being verified") + timestamp: float = Field(description="POSIX timestamp from the challenge") + signature: str = Field(description="hex-encoded Ed25519 signature of the nonce") + + @classmethod + def from_element(cls, elt: domish.Element) -> Self: + """Create a Response from a element. + + @param elt: element in NS_XID namespace. + @return: Response instance. + """ + xid_attr = elt.getAttribute("xid") + timestamp_attr = elt.getAttribute("timestamp") + signature = str(elt) + if xid_attr is None or timestamp_attr is None: + raise ValueError(f"Missing required attribute on ") + return cls( + xid=jid.JID(xid_attr), + timestamp=parse_xmpp_date(timestamp_attr), + signature=signature, + ) + + def to_element(self) -> domish.Element: + """Build a element. + + @return: element in NS_XID namespace. + """ + response_elt = domish.Element((NS_XID, "response")) + response_elt["xid"] = str(self.xid) + response_elt["timestamp"] = xmpp_date(self.timestamp) + response_elt.addContent(self.signature) + return response_elt + + +class XIDStorage(BaseModel): + """Persistent storage for a XID. + + Only ``private_key`` is stored for now, but other values can easily added as needed in + the future by extending this model with default values. + """ + + private_key: str | None = Field( + default=None, + description=( + "Encrypted Ed25519 private key (hex-encoded raw key, encrypted with " + "profile password via [G.host.memory.encrypt_value])" + ), + ) + + +@dataclass +class XIDClientData: + """Per-client data for XID plugin.""" + + _storage: persistent.PersistentBinaryDict + xids: dict[str, XIDStorage] = field(default_factory=dict) + pending_challenges: dict[str, defer.Deferred] = field(default_factory=dict)