cli (xid): Set of commands to handle XMPP Decentralized ID (XID):

Implement commands to get, create, revoke, verify, import/export XID, and sign/verify a
file or inline data.

rel 477
This commit is contained in:
Goffi 2026-06-06 12:34:00 +02:00
commit a8ba049d6a

445
libervia/cli/cmd_xid.py Normal file
View file

@ -0,0 +1,445 @@
#!/usr/bin/env python3
# Libervia CLI
# 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/>.
"""CLI commands for XID (XMPP Decentralized ID)."""
from pathlib import Path
import sys
from rich.table import Table
from libervia.backend.core.i18n import _
from libervia.backend.plugins.plugin_misc_xid.models import ListXID, XID as XIDModel
from libervia.backend.tools.common.date_utils import date_fmt
from libervia.cli.constants import Const as C
from . import base
__commands__ = ["XID"]
class XIDGet(base.CommandBase):
"""Get XIDs published by an entity (self or remote)."""
def __init__(self, host) -> None:
extra_outputs = {"default": self.default_output}
super().__init__(
host,
"get",
help=_("get XIDs of an entity (self or remote)"),
use_output=C.OUTPUT_MODEL,
extra_outputs=extra_outputs,
use_verbose=True,
)
def add_parser_options(self) -> None:
self.parser.add_argument(
"entity",
nargs="?",
default="",
help=_("bare JID of the entity (default to own XIDs)"),
)
self.parser.add_argument(
"-a",
"--all",
action="store_true",
help=_("show all published XIDs instead of only the current"),
)
def default_output(self, xids: ListXID) -> None:
if not xids:
entity = self.args.entity or _("self")
self.console.print(_("No XID published by {entity}.").format(entity=entity))
return
if len(xids) == 1 and not self.verbosity:
# If we have a single XID to print, we don't use a table.
self.console.print(xids[0].xid)
return
table = Table(title=_("Published XIDs"))
table.add_column("🪪 " + _("XID"))
if self.verbosity:
table.add_column("📦 " + _("Item ID"))
table.add_column("📅 " + _("Created"))
table.add_column("🔒 " + _("Private Key Known"))
for xid in xids:
row = [str(xid.xid)]
if self.verbosity:
row.extend(
[
xid.item_id or "",
date_fmt(xid.created, fmt="medium"),
"✅" if xid.has_private_key else "❌",
]
)
table.add_row(*row)
self.console.print(table)
async def start(self) -> None:
try:
xids_s = await self.host.bridge.xid_get(
self.args.entity, self.args.all, self.host.profile
)
xids = ListXID.model_validate_json(xids_s)
except Exception as e:
self.disp(f"Failed to get XIDs: {e}", error=True)
self.host.quit(C.EXIT_BRIDGE_ERRBACK)
return
await self.output(xids)
self.host.quit()
class XIDCreate(base.CommandBase):
"""Create a new XID key pair and publish it."""
def __init__(self, host) -> None:
extra_outputs = {"default": self.default_output}
super().__init__(
host,
"create",
help=_("create a new XID and publish it"),
use_output=C.OUTPUT_MODEL,
extra_outputs=extra_outputs,
)
def add_parser_options(self) -> None:
self.parser.add_argument(
"-i",
"--item-id",
default="current",
help=_("PEP item ID (default: current)"),
)
def default_output(self, xid: XIDModel) -> None:
self.console.print("[bold]XID created:[/bold]")
self.console.print(f" XID: {xid.xid}")
self.console.print(f" Created: {date_fmt(xid.created, fmt='medium')}")
async def start(self) -> None:
try:
xid_s = await self.host.bridge.xid_create(
self.host.profile, self.args.item_id
)
xid = XIDModel.model_validate_json(xid_s)
except Exception as e:
self.disp(f"Failed to create XID: {e}", error=True)
self.host.quit(C.EXIT_BRIDGE_ERRBACK)
return
await self.output(xid)
self.host.quit()
class XIDRevoke(base.CommandBase):
"""Revoke a XID."""
def __init__(self, host) -> None:
super().__init__(host, "revoke", help=_("revoke a XID"))
def add_parser_options(self) -> None:
self.parser.add_argument(
"xid",
help=_("XID to revoke"),
)
self.parser.add_argument(
"-r",
"--reason",
help=_("human-readable reason for revocation"),
)
self.parser.add_argument(
"-f",
"--force",
action="store_true",
help=_("revoke without confirmation"),
)
async def start(self) -> None:
if not self.args.force:
await self.host.confirm_or_quit(
_("Are you sure you want to revoke {xid}?").format(xid=self.args.xid)
)
try:
reason = self.args.reason or ""
await self.host.bridge.xid_revoke(self.args.xid, reason, self.host.profile)
except Exception as e:
self.disp(f"Failed to revoke XID: {e}", error=True)
self.host.quit(C.EXIT_BRIDGE_ERRBACK)
return
self.disp(_("XID revoked successfully"))
self.host.quit()
class XIDVerify(base.CommandBase):
"""Verify an entity's XID via challenge/response."""
def __init__(self, host) -> None:
super().__init__(host, "verify", help=_("verify an entity's XID ownership"))
def add_parser_options(self) -> None:
self.parser.add_argument("entity", help=_("bare JID of the entity"))
self.parser.add_argument("xid", help=_("XID to verify"))
async def start(self) -> None:
try:
valid = await self.host.bridge.xid_verify(
self.args.entity, self.args.xid, self.host.profile
)
except Exception as e:
self.disp(f"Failed to verify XID: {e}", error=True)
self.host.quit(C.EXIT_BRIDGE_ERRBACK)
return
if valid:
self.console.print(
f"[bold green]✅ XID {self.args.xid} verified for "
f"{self.args.entity}[/bold green]"
)
self.host.quit()
else:
self.console.print(
f"[bold red]❌ XID {self.args.xid} verification failed "
f"for {self.args.entity}[/bold red]"
)
self.host.quit(C.EXIT_DATA_ERROR)
class XIDSignatureSign(base.CommandBase):
"""Sign a file or data with a XID private key."""
def __init__(self, host) -> None:
super().__init__(host, "sign", help=_("sign data with a XID private key"))
def add_parser_options(self) -> None:
self.parser.add_argument(
"-x",
"--xid",
default=None,
help=_("XID whose private key to use (default to current)"),
)
self.parser.add_argument(
"file_path_or_data",
nargs="?",
default="-",
help=_("file to sign, or inline data with --data; defaults to stdin (-)"),
)
self.parser.add_argument(
"-d",
"--data",
action="store_true",
help=_("treat positional argument as inline data instead of file path"),
)
self.parser.add_argument(
"-o",
"--output",
type=Path,
help=_("output file for the signature (hex)"),
)
async def start(self) -> None:
xid = self.args.xid or ""
input_value = self.args.file_path_or_data
is_inline = self.args.data or input_value == "-"
try:
if is_inline and input_value != "-":
sig_hex = await self.host.bridge.xid_signature_sign_data(
xid, input_value, self.host.profile
)
elif input_value == "-":
data = sys.stdin.buffer.read().decode("utf-8", errors="replace")
sig_hex = await self.host.bridge.xid_signature_sign_data(
xid, data, self.host.profile
)
else:
sig_hex = await self.host.bridge.xid_signature_sign_file(
xid, input_value, self.host.profile
)
except Exception as e:
self.disp(f"Failed to sign data: {e}", error=True)
self.host.quit(C.EXIT_BRIDGE_ERRBACK)
return
if self.args.output:
self.args.output.parent.mkdir(parents=True, exist_ok=True)
self.args.output.write_text(sig_hex)
self.disp(f"Signature written to {self.args.output}")
else:
self.disp(sig_hex)
self.host.quit()
class XIDSignatureCheck(base.CommandBase):
"""Verify a signature against a XID's public key."""
def __init__(self, host) -> None:
super().__init__(host, "check", help=_("verify a signature with a XID"))
def add_parser_options(self) -> None:
self.parser.add_argument("xid", help=_("XID to verify against"))
self.parser.add_argument(
"file_path_or_data",
nargs="?",
default="-",
help=_("file to verify, or inline data with --data; defaults to stdin (-)"),
)
self.parser.add_argument(
"-d",
"--data",
action="store_true",
help=_("treat positional argument as inline data instead of file path"),
)
sig_group = self.parser.add_mutually_exclusive_group(required=True)
sig_group.add_argument(
"-s",
"--signature",
help=_("inline hex-encoded signature"),
)
sig_group.add_argument(
"-f",
"--signature-file",
type=Path,
help=_("file containing hex-encoded signature"),
)
async def start(self) -> None:
input_value = self.args.file_path_or_data
is_inline = self.args.data or input_value == "-"
# Read signature
if self.args.signature is not None:
sig_hex = self.args.signature
else:
sig_hex = self.args.signature_file.read_text().strip()
try:
if is_inline and input_value != "-":
valid = await self.host.bridge.xid_signature_check_data(
self.args.xid, input_value, sig_hex
)
elif input_value == "-":
text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
valid = await self.host.bridge.xid_signature_check_data(
self.args.xid, text, sig_hex
)
else:
valid = await self.host.bridge.xid_signature_check_file(
self.args.xid, input_value, sig_hex, self.host.profile
)
except Exception as e:
self.disp(f"Failed to verify signature: {e}", error=True)
self.host.quit(C.EXIT_BRIDGE_ERRBACK)
return
if valid:
self.console.print("[bold green]✅ Signature is valid[/bold green]")
self.host.quit()
else:
self.console.print("[bold red]❌ Signature is invalid[/bold red]")
self.host.quit(C.EXIT_DATA_ERROR)
class XIDSignature(base.CommandBase):
"""Sign or verify data with a XID."""
subcommands = (
XIDSignatureSign,
XIDSignatureCheck,
)
def __init__(self, host) -> None:
super().__init__(
host,
"signature",
use_profile=False,
help=_("sign or verify data with a XID"),
aliases=["sign"],
)
class XIDExport(base.CommandBase):
"""Generate an xmpp: URI for private key export."""
def __init__(self, host) -> None:
super().__init__(host, "export", help=_("export XID private key as xmpp: URI"))
def add_parser_options(self) -> None:
self.parser.add_argument(
"xid", nargs="?", default=None, help=_("XID to export (default to current)")
)
async def start(self) -> None:
xid = self.args.xid or ""
try:
uri = await self.host.bridge.xid_export(xid, self.host.profile)
except Exception as e:
self.disp(f"Failed to export XID: {e}", error=True)
self.host.quit(C.EXIT_BRIDGE_ERRBACK)
return
self.console.print(uri)
self.host.quit()
class XIDImport(base.CommandBase):
"""Import a XID private key from an xmpp: URI."""
def __init__(self, host) -> None:
super().__init__(host, "import", help=_("import XID private key from xmpp: URI"))
def add_parser_options(self) -> None:
self.parser.add_argument(
"uri",
help=_("xmpp: URI containing xid-private and xid-created parameters"),
)
async def start(self) -> None:
try:
await self.host.bridge.xid_import(self.args.uri, self.host.profile)
except Exception as e:
self.disp(f"Failed to import XID: {e}", error=True)
self.host.quit(C.EXIT_BRIDGE_ERRBACK)
return
self.disp(_("XID private key imported successfully"))
self.host.quit()
class XID(base.CommandBase):
"""Top-level XID command."""
subcommands: tuple[type[base.CommandBase], ...] = (
XIDGet,
XIDCreate,
XIDRevoke,
XIDVerify,
XIDSignature,
XIDExport,
XIDImport,
)
def __init__(self, host) -> None:
super().__init__(
host, "xid", use_profile=False, help=_("XMPP Decentralized ID management")
)