plugin misc XID: XMPP Decentralized ID implementation:
XMPP Decentralized ID (XID) implementation. All the spec but private key synchronisation is implemented, and private key can be synchronised with import/export method which through a URI usable with QR Code as explained in the spec. rel 477
This commit is contained in:
parent
836a9c962e
commit
9d4c2bace9
2 changed files with 1104 additions and 0 deletions
919
libervia/backend/plugins/plugin_misc_xid/__init__.py
Normal file
919
libervia/backend/plugins/plugin_misc_xid/__init__.py
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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 <message/> 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 <message/> element.
|
||||
@param challenge_elt: <challenge/> 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 <message/> element.
|
||||
@param response_elt: <response/> 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 []
|
||||
185
libervia/backend/plugins/plugin_misc_xid/models.py
Normal file
185
libervia/backend/plugins/plugin_misc_xid/models.py
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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 <xid> element or its parent.
|
||||
|
||||
@param xid_elt: The <xid> 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 <xid> 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("<xid> 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 <xid> element from this instance's data.
|
||||
|
||||
@return: <xid> 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 <challenge/> element.
|
||||
|
||||
@param elt: <challenge/> 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 <challenge/>")
|
||||
return cls(
|
||||
xid=jid.JID(xid_attr),
|
||||
timestamp=parse_xmpp_date(timestamp_attr),
|
||||
nonce=nonce,
|
||||
)
|
||||
|
||||
def to_element(self) -> domish.Element:
|
||||
"""Build a <challenge/> element.
|
||||
|
||||
@return: <challenge/> 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 <response/> element.
|
||||
|
||||
@param elt: <response/> 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 <response/>")
|
||||
return cls(
|
||||
xid=jid.JID(xid_attr),
|
||||
timestamp=parse_xmpp_date(timestamp_attr),
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
def to_element(self) -> domish.Element:
|
||||
"""Build a <response/> element.
|
||||
|
||||
@return: <response/> 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)
|
||||
Loading…
Reference in a new issue