hermes-agent/plugins/platforms/xchat/crypto.py

373 lines
15 KiB
Python

"""Crypto core for the X Chat platform adapter.
A thin, network-free wrapper around the ``chat_xdk`` binding. Everything
that touches the Chat XDK lives here so it can be unit-tested with a fake
``Chat`` object and so the adapter/API layers stay import-light. The SDK is
lazy-installed at first use via ``tools.lazy_deps`` (feature key
``platform.xchat``).
Responsibilities:
* key management -> :meth:`XChatCrypto.load_keys` /
:meth:`XChatCrypto.generate_and_register_payload`
* session identity -> :meth:`XChatCrypto.set_identity`
* signing-key roster -> :meth:`XChatCrypto.set_signing_keys`
* message encryption -> :meth:`XChatCrypto.encrypt_text` (with optional
attachments) and :meth:`XChatCrypto.encrypt_reply`
* event decryption -> :meth:`XChatCrypto.decrypt_batch` (decrypt_events)
and :meth:`XChatCrypto.decrypt_one` (decrypt_event)
* media encryption -> :meth:`XChatCrypto.encrypt_media` /
:meth:`XChatCrypto.decrypt_media` (stream cipher
under the conversation key)
* conversation keys -> :meth:`XChatCrypto.prepare_conversation_key_change`
+ :meth:`XChatCrypto.verify_key_binding` (initiate
brand-new conversations / rotate keys)
The decrypted-event dict shape follows the Chat XDK: ``{"type": "Message",
"id": ..., "sender_id": ..., "conversation_id": ..., "content": {"text":
...}}`` for messages, ``{"type": "KeyChange", ...}`` for conversation-key
rotations.
"""
from __future__ import annotations
import base64
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
def _as_dict(obj: Any) -> dict[str, Any]:
"""Decrypted events come back as native objects; normalise to a dict."""
if isinstance(obj, dict):
return obj
if hasattr(obj, "model_dump"):
return obj.model_dump()
try:
return dict(obj)
except Exception:
return {}
def _load_chat_xdk():
"""Import (lazy-installing if needed) and return the ``chat_xdk`` module."""
try:
import chat_xdk # type: ignore[import-not-found]
return chat_xdk
except ImportError:
pass
# Lazy-install path — same pattern as the telegram/matrix platform plugins.
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("platform.xchat", prompt=False)
import chat_xdk # type: ignore[import-not-found]
return chat_xdk
def _load_chat_class():
"""Import (lazy-installing if needed) and return ``chat_xdk.Chat``."""
return _load_chat_xdk().Chat
def detect_mime_type(data: bytes) -> Optional[str]:
"""MIME sniff on PLAINTEXT bytes (Chat XDK helper)."""
try:
return _load_chat_xdk().detect_mime_type(bytes(data))
except Exception:
return None
def detect_image_dimensions(data: bytes) -> Optional[tuple[int, int]]:
"""(width, height) of PLAINTEXT image bytes, or None (Chat XDK helper)."""
try:
dims = _load_chat_xdk().detect_image_dimensions(bytes(data))
except Exception:
return None
if not dims:
return None
try:
# Bindings return either a (w, h) tuple or an object with attributes.
if isinstance(dims, (tuple, list)):
return int(dims[0]), int(dims[1])
return int(dims.width), int(dims.height)
except Exception:
return None
class XChatCrypto:
"""Wraps a single unlocked ``chat_xdk.Chat`` instance for one bot identity."""
def __init__(self, chat: Any = None) -> None:
# ``chat`` injection keeps unit tests free of the native SDK.
self.chat = chat if chat is not None else _load_chat_class()()
self.signing_key_version: str = "1"
self._identity_set = False
# -- Key management -----------------------------------------------------
def load_keys(self, private_keys_b64: str, signing_key_version: str = "1") -> None:
"""Import an existing private-key blob (from ``export_keys``) and adopt it.
``private_keys_b64`` is the base64 blob produced during registration
(``hermes xchat setup``). Raises on a malformed blob.
"""
blob = base64.b64decode(private_keys_b64.strip())
self.chat.import_keys(blob, version=signing_key_version)
self.signing_key_version = str(signing_key_version)
def set_identity(self, user_id: str) -> None:
"""Set the session identity — every later encrypt call signs as this user."""
self.chat.set_identity(str(user_id), self.signing_key_version)
self._identity_set = True
def set_cache_keys(self, enabled: bool = True) -> None:
"""Opt in to the SDK's verified conversation-key cache."""
self.chat.set_cache_keys(enabled)
def set_signing_keys(self, signing_keys: list[dict[str, str]]) -> None:
"""Replace the SDK's participant signing-key store (full roster each call)."""
self.chat.set_signing_keys(signing_keys)
def generate_and_register_payload(self) -> dict[str, Any]:
"""Generate fresh keypairs for a brand-new bot identity.
Returns the registration body for ``POST /2/users/{id}/public_keys``
plus the exported private-key blob (base64) to persist locally.
Used by ``hermes xchat setup`` only — the adapter never generates keys.
"""
reg = self.chat.generate_keypairs()
version = str(reg.version) if getattr(reg, "version", None) is not None else "1"
body = {
"public_key": {
"public_key": reg.public_key.public_key,
"signing_public_key": reg.public_key.signing_public_key,
"identity_public_key_signature": reg.public_key.identity_public_key_signature,
"signing_public_key_signature": reg.public_key.signing_public_key_signature,
"registration_method": reg.public_key.registration_method,
},
"version": version,
"generate_version": bool(getattr(reg, "generate_version", False)),
}
exported = self.chat.export_keys()
blob_b64 = base64.b64encode(bytes(exported)).decode("ascii") if exported else ""
return {"registration": body, "version": version, "private_keys_b64": blob_b64}
def verify_key_binding(
self,
identity_public_key_b64: str,
signing_public_key_b64: str,
identity_public_key_signature_b64: str,
) -> bool:
"""Verify a fetched public-key record's identity↔signing binding.
MUST be called on every record before wrapping a conversation key to
it (``prepare_conversation_key_change`` encrypts to whatever you pass
— a substituted identity key would silently receive the key).
"""
try:
return bool(
self.chat.verify_key_binding(
identity_public_key_b64,
signing_public_key_b64,
identity_public_key_signature_b64,
)
)
except Exception:
return False
# -- Conversation-key setup (initiation / rotation) -----------------------
def prepare_conversation_key_change(
self,
public_keys: list[dict[str, str]],
*,
conversation_id: Optional[str] = None,
) -> dict[str, Any]:
"""Generate + wrap a fresh conversation key for every participant.
``public_keys``: ``[{"user_id", "public_key", "key_version"}, ...]``
(verified via :meth:`verify_key_binding` first). Returns the API body
for ``POST /2/chat/conversations/{id}/keys`` plus the raw key:
``{"body": {...}, "conversation_key": bytes,
"conversation_key_version": str}``
"""
prepared = _as_dict(
self.chat.prepare_conversation_key_change(
public_keys, conversation_id=conversation_id
)
)
body = {
"conversation_key_version": prepared["conversation_key_version"],
"conversation_participant_keys": [
{
"user_id": pk["user_id"],
"encrypted_conversation_key": pk["encrypted_key"],
"public_key_version": pk["public_key_version"],
}
for pk in (prepared.get("participant_keys") or [])
],
"action_signatures": [
{
"message_id": sig["message_id"],
"encoded_message_event_detail": sig["encoded_message_event_detail"],
"message_event_signature": {
"signature": sig["signature"],
"public_key_version": sig["public_key_version"],
"signature_version": sig["signature_version"],
},
}
for sig in (prepared.get("action_signatures") or [])
],
}
return {
"body": body,
"conversation_key": prepared.get("conversation_key"),
"conversation_key_version": str(prepared["conversation_key_version"]),
}
# -- Decryption ----------------------------------------------------------
def decrypt_batch(self, events_b64: list[str]) -> dict[str, Any]:
"""Batch path — initial backlog load and KeyChange processing.
``decrypt_events`` extracts conversation keys from any KeyChange
events in the batch (feeding the SDK's key cache when enabled), then
decrypts every message. Signing keys come from the
``set_signing_keys`` store.
NOTE: the events endpoint returns KeyChange events SEPARATELY in
``meta.conversation_key_events`` — callers must prepend those to the
batch or no conversation key is ever extracted.
"""
result = self.chat.decrypt_events(events_b64, None)
messages = [
{"event": _as_dict(m.get("event") if isinstance(m, dict) else m)}
for m in (result.get("messages") or [])
]
conv_keys = result.get("conversation_keys") or {}
return {
"messages": messages,
"conversation_keys": conv_keys,
"latest_key_version": (
str(conv_keys.get("latest_version"))
if conv_keys.get("latest_version") is not None
else None
),
"errors": result.get("errors") or {},
}
def decrypt_one(
self, event_b64: str, conversation_keys: Optional[dict[str, bytes]] = None
) -> dict[str, Any]:
"""Single-event path — per-poll decryption with cached conversation keys."""
return _as_dict(self.chat.decrypt_event(event_b64, conversation_keys, None))
# -- Encryption ----------------------------------------------------------
def encrypt_text(
self,
conversation_id: str,
text: str,
*,
attachments: Optional[list[dict[str, Any]]] = None,
conversation_key: Optional[bytes] = None,
conversation_key_version: Optional[str] = None,
) -> dict[str, str]:
"""Encrypt + sign ``text`` (optionally with media attachments).
Returns the X API send-message body. The conversation key is resolved
from the SDK's verified-key cache (``set_cache_keys``) unless an
explicit ``conversation_key`` + version pair is given (used right
after key initiation, before any KeyChange event has been polled).
Raises ``ValueError`` when no verified key is available.
"""
kwargs: dict[str, Any] = {}
if attachments:
kwargs["attachments"] = attachments
if conversation_key is not None:
kwargs["conversation_key"] = conversation_key
kwargs["conversation_key_version"] = conversation_key_version
payload = self.chat.encrypt_message(str(conversation_id), text, **kwargs)
return {
"message_id": payload.message_id,
"encoded_message_create_event": payload.encrypted_content,
"encoded_message_event_signature": payload.encoded_event_signature,
}
def encrypt_reply(
self,
conversation_id: str,
text: str,
reply_to_event: dict[str, Any],
*,
attachments: Optional[list[dict[str, Any]]] = None,
) -> dict[str, str]:
"""Encrypt + sign ``text`` as a native threaded reply.
``reply_to_event`` is the DECRYPTED event dict of the message being
replied to (from :meth:`decrypt_one` / :meth:`decrypt_batch`).
Falls back to :meth:`encrypt_text` semantics on SDK versions without
reply support.
"""
kwargs: dict[str, Any] = {}
if attachments:
kwargs["attachments"] = attachments
payload = self.chat.encrypt_reply(
str(conversation_id), text, reply_to_event=reply_to_event, **kwargs
)
return {
"message_id": payload.message_id,
"encoded_message_create_event": payload.encrypted_content,
"encoded_message_event_signature": payload.encoded_event_signature,
}
# -- Media (stream cipher under the conversation key) ---------------------
def encrypt_media(self, plaintext: bytes, conversation_key: bytes) -> bytes:
"""Encrypt attachment bytes for upload (whole payload in memory)."""
return bytes(self.chat.encrypt_stream(bytes(plaintext), conversation_key))
def decrypt_media(self, ciphertext: bytes, conversation_key: bytes) -> bytes:
"""Decrypt a downloaded attachment blob.
The key MUST be the conversation key for the *event's* key version —
after a rotation, the latest key cannot decrypt older media.
"""
return bytes(self.chat.decrypt_stream(bytes(ciphertext), conversation_key))
def message_text(event: dict[str, Any]) -> Optional[str]:
"""Pull the plain text out of a decrypted Message/MessageEdit event.
Edits matter: the feed sometimes returns an edited message only as the
edit event (the original is dropped), so skipping edits would make the
message invisible to the bot forever.
"""
if event.get("type") not in ("Message", "MessageEdit"):
return None
content = event.get("content") or {}
if isinstance(content, dict):
return content.get("text")
return None
def message_attachments(event: dict[str, Any]) -> list[dict[str, Any]]:
"""Media attachment descriptors from a decrypted Message/MessageEdit event.
Each entry carries ``media_hash_key`` (+ optional filename/width/height/
filesize_bytes). Returns [] for non-message events or text-only messages.
"""
if event.get("type") not in ("Message", "MessageEdit"):
return []
content = event.get("content") or {}
if not isinstance(content, dict):
return []
raw = content.get("attachments") or event.get("attachments") or []
out: list[dict[str, Any]] = []
for att in raw if isinstance(raw, (list, tuple)) else []:
att = _as_dict(att)
if att.get("media_hash_key"):
out.append(att)
return out