From f714e8ae8a1d2d67768e5dfa33023acc79b09676 Mon Sep 17 00:00:00 2001 From: sylvain Date: Sun, 15 Mar 2026 19:06:47 +0000 Subject: [PATCH] feat: OMEMO E2E encryption for all agents (XMPP direct messages) - Add omemo_storage.py: SQLite key/value backend for python-omemo - Rewrite xmpp_client.py OMEMO support: real XEP_0384 plugin subclass with BTBV (Blind Trust Before Verification / TOFU for bots) - Async decrypt in _on_message handler, async encrypt in _send_omemo - MUC messages remain plaintext (OMEMO MUC not widely supported) - base_agent.py: pass data_dir to XMPPClient for OMEMO storage - setup.py: bump omemo extra to slixmpp-omemo>=2.0 Co-Authored-By: Claude Sonnet 4.6 --- agents_core/base_agent.py | 4 + agents_core/omemo_storage.py | 49 +++++++++++ agents_core/xmpp_client.py | 161 ++++++++++++++++++++++++++++++----- setup.py | 2 +- 4 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 agents_core/omemo_storage.py diff --git a/agents_core/base_agent.py b/agents_core/base_agent.py index 94a151b..4485c80 100644 --- a/agents_core/base_agent.py +++ b/agents_core/base_agent.py @@ -135,6 +135,9 @@ class BaseAgent(ABC): if not admin_jids and xc.get("admin_jid"): admin_jids = [xc["admin_jid"]] + # Répertoire de données (pour le stockage OMEMO) + data_dir = os.path.dirname(self.config.get("queue_db", "data/queue.db")) + return XMPPClient( jid=xc["jid"], password=xc["password"], @@ -142,6 +145,7 @@ class BaseAgent(ABC): muc_room=xc.get("muc_room"), muc_nick=self.config["agent_id"], use_omemo=xc.get("use_omemo", False), + data_dir=data_dir, ) def _setup_llm(self) -> LLMClient: diff --git a/agents_core/omemo_storage.py b/agents_core/omemo_storage.py new file mode 100644 index 0000000..acdfec6 --- /dev/null +++ b/agents_core/omemo_storage.py @@ -0,0 +1,49 @@ +""" +Backend de stockage SQLite pour OMEMO (python-omemo / slixmpp-omemo). +Implémente l'interface omemo.storage.Storage via une table clé/valeur JSON. +""" +import json +import logging +import sqlite3 +from typing import Any + +from omemo.storage import Just, Maybe, Nothing, Storage + +logger = logging.getLogger(__name__) + + +class SQLiteStorage(Storage): + """Stockage OMEMO persistant dans une base SQLite.""" + + def __init__(self, db_path: str): + super().__init__() + self._db_path = db_path + self._conn = sqlite3.connect(db_path, check_same_thread=False) + self._conn.execute( + "CREATE TABLE IF NOT EXISTS omemo_kv " + "(key TEXT PRIMARY KEY, value TEXT NOT NULL)" + ) + self._conn.commit() + logger.debug(f"[OMEMO] Storage initialisé : {db_path}") + + async def _load(self, key: str) -> Maybe: + cur = self._conn.execute( + "SELECT value FROM omemo_kv WHERE key=?", (key,) + ) + row = cur.fetchone() + if row is None: + return Nothing() + return Just(json.loads(row[0])) + + async def _store(self, key: str, value: Any) -> None: + self._conn.execute( + "INSERT OR REPLACE INTO omemo_kv (key, value) VALUES (?, ?)", + (key, json.dumps(value)), + ) + self._conn.commit() + + async def _delete(self, key: str) -> None: + self._conn.execute( + "DELETE FROM omemo_kv WHERE key=?", (key,) + ) + self._conn.commit() diff --git a/agents_core/xmpp_client.py b/agents_core/xmpp_client.py index cff6705..7f72e48 100644 --- a/agents_core/xmpp_client.py +++ b/agents_core/xmpp_client.py @@ -1,7 +1,10 @@ """ Client XMPP avec support OMEMO, MUC (groupe), messages directs, et multi-utilisateurs. """ +import asyncio +import functools import logging +import os import threading from typing import Callable, Optional @@ -15,7 +18,7 @@ class XMPPClient: Client XMPP simplifié avec : - Messages directs (1-to-1) - Groupes MUC (Multi-User Chat) - - OMEMO (si slixmpp-omemo installé) + - OMEMO chiffrement E2E (messages directs uniquement, MUC en clair) - Multi-utilisateurs : liste de JIDs autorisés à envoyer des commandes - Callback unique pour les messages entrants """ @@ -28,6 +31,7 @@ class XMPPClient: muc_room: Optional[str] = None, muc_nick: Optional[str] = None, use_omemo: bool = False, + data_dir: Optional[str] = None, ): self.jid = jid self.password = password @@ -51,6 +55,28 @@ class XMPPClient: self._thread: Optional[threading.Thread] = None self._connected = threading.Event() + # Stockage OMEMO partagé entre reconnexions + self._omemo_storage = None + if use_omemo: + self._omemo_storage = self._init_omemo_storage(data_dir) + + # ────────────────────────────────────────────── + # OMEMO storage + # ────────────────────────────────────────────── + + def _init_omemo_storage(self, data_dir: Optional[str]): + """Initialise le backend SQLite OMEMO.""" + try: + from .omemo_storage import SQLiteStorage + d = data_dir or "data" + os.makedirs(d, exist_ok=True) + db_path = os.path.join(d, "omemo.db") + logger.info(f"[OMEMO] Initialisation du stockage : {db_path}") + return SQLiteStorage(db_path) + except Exception as e: + logger.warning(f"[OMEMO] Impossible d'initialiser le stockage : {e}") + return None + # ────────────────────────────────────────────── # API publique # ────────────────────────────────────────────── @@ -90,6 +116,7 @@ class XMPPClient: muc_room=self.muc_room, muc_nick=self.muc_nick, use_omemo=self.use_omemo, + omemo_storage=self._omemo_storage, on_message=self._on_message, on_connected=self._connected.set, ) @@ -103,6 +130,8 @@ class XMPPClient: logger.info(f"[XMPP] Groupe rejoint : {self.muc_room}") if self.admin_jids: logger.info(f"[XMPP] Admins autorisés : {', '.join(sorted(self.admin_jids))}") + if self.use_omemo and self._omemo_storage: + logger.info("[XMPP] OMEMO activé (messages directs chiffrés)") if first and self._on_ready_cb: first = False try: @@ -194,36 +223,85 @@ class _SlixClient(ClientXMPP): """Implémentation interne slixmpp.""" def __init__(self, jid, password, muc_room, muc_nick, - use_omemo, on_message, on_connected): + use_omemo, omemo_storage, on_message, on_connected): super().__init__(jid, password) self._muc_room = muc_room self._muc_nick = muc_nick self._on_message_cb = on_message self._on_connected_cb = on_connected + self._use_omemo = use_omemo and omemo_storage is not None + self._omemo_storage = omemo_storage + # Requis par encrypt_message de slixmpp-omemo + self.use_message_ids = True + + # Plugins de base self.register_plugin("xep_0030") # Service Discovery self.register_plugin("xep_0045") # MUC self.register_plugin("xep_0085") # Chat state self.register_plugin("xep_0199") # Ping keepalive - if use_omemo: + if self._use_omemo: self._setup_omemo() - self.add_event_handler("session_start", self._on_session_start) - self.add_event_handler("message", self._on_message) + self.add_event_handler("session_start", self._on_session_start) + self.add_event_handler("message", self._on_message) self.add_event_handler("groupchat_message", self._on_muc_message) - self.add_event_handler("disconnected", self._on_disconnected) + self.add_event_handler("disconnected", self._on_disconnected) self.add_event_handler("connection_failed", self._on_disconnected) def _setup_omemo(self): + """Configure OMEMO avec stockage SQLite et BTBV (Blind Trust Before Verification).""" try: + from slixmpp.plugins.base import register_plugin as slixmpp_register_plugin + from slixmpp_omemo import XEP_0384, TrustLevel + + storage = self._omemo_storage + + # Plugins requis par XEP-0384 + self.register_plugin("xep_0004") # Data Forms + self.register_plugin("xep_0060") # PubSub + self.register_plugin("xep_0163") # PEP + self.register_plugin("xep_0280") # Message Carbons + self.register_plugin("xep_0334") # Message Processing Hints + + # Sous-classe concrète de XEP_0384 avec notre stockage SQLite et BTBV + class _AgentOmemo(XEP_0384): + name = "xep_0384" + + @property + def storage(self_plugin): + return storage + + @property + def legacy_storage(self_plugin): + return None + + @property + def _btbv_enabled(self_plugin) -> bool: + # BTBV = Blind Trust Before Verification + # Fait confiance automatiquement aux nouveaux appareils (TOFU) + return True + + async def _prompt_manual_trust(self_plugin, undecided, identifier): + # Pour un bot, on auto-valide tous les appareils indécis + sm = await self_plugin.get_session_manager() + for device in undecided: + await sm.set_trust( + device.bare_jid, + device.identity_key, + TrustLevel.TRUSTED.value, + ) + + # Enregistrement dans le registre global slixmpp + slixmpp_register_plugin(_AgentOmemo) self.register_plugin("xep_0384") - logger.info("[XMPP] OMEMO activé") + logger.info("[XMPP] OMEMO initialisé (BTBV activé)") except Exception as e: logger.warning(f"[XMPP] OMEMO non disponible : {e}") + self._use_omemo = False def start(self): - import asyncio loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self.loop = loop @@ -245,13 +323,30 @@ class _SlixClient(ClientXMPP): if hasattr(self, 'loop') and self.loop and self.loop.is_running(): self.loop.call_soon_threadsafe(self.loop.stop) - def _on_message(self, msg): - if msg["type"] in ("chat", "normal") and msg["body"]: - body = msg["body"].strip() - if body: - self._on_message_cb(str(msg["from"]), body, is_muc=False) + async def _on_message(self, msg): + """Gère les messages directs, avec déchiffrement OMEMO si nécessaire.""" + if msg["type"] not in ("chat", "normal"): + return + + body: Optional[str] = None + + if self._use_omemo and self["xep_0384"].is_encrypted(msg): + try: + decrypted, _ = await self["xep_0384"].decrypt_message(msg) + body = decrypted.get("body", "").strip() + logger.debug(f"[OMEMO] Message déchiffré de {msg['from']}") + except Exception as e: + logger.warning(f"[OMEMO] Déchiffrement échoué (de {msg['from']}) : {e}") + return + else: + raw = msg["body"] + body = raw.strip() if raw else "" + + if body: + self._on_message_cb(str(msg["from"]), body, is_muc=False) def _on_muc_message(self, msg): + """Messages MUC — toujours en clair (OMEMO MUC non supporté).""" if msg["mucnick"] == self._muc_nick: return if msg["body"]: @@ -260,10 +355,38 @@ class _SlixClient(ClientXMPP): self._on_message_cb(str(msg["from"]), body, is_muc=True) def send_xmpp_message(self, to: str, body: str, is_muc: bool = False): - import functools - msg_type = "groupchat" if is_muc else "chat" - fn = functools.partial(self.send_message, mto=to, mbody=body, mtype=msg_type) - if hasattr(self, 'loop') and self.loop and self.loop.is_running(): - self.loop.call_soon_threadsafe(fn) + """Envoie un message. Chiffre avec OMEMO pour les messages directs si activé.""" + if not hasattr(self, 'loop') or not self.loop or not self.loop.is_running(): + self.send_message(mto=to, mbody=body, mtype="groupchat" if is_muc else "chat") + return + + if self._use_omemo and not is_muc: + # Envoi chiffré OMEMO (async) + asyncio.run_coroutine_threadsafe( + self._send_omemo(to, body), self.loop + ) else: - fn() + # Envoi en clair (sync via call_soon_threadsafe) + fn = functools.partial( + self.send_message, + mto=to, mbody=body, + mtype="groupchat" if is_muc else "chat", + ) + self.loop.call_soon_threadsafe(fn) + + async def _send_omemo(self, to: str, body: str): + """Coroutine : chiffre et envoie un message OMEMO.""" + from slixmpp import JID as SlixJID + try: + stanza = self.make_message(mto=to, mbody=body, mtype="chat") + encrypted, errors = await self["xep_0384"].encrypt_message( + stanza, SlixJID(to) + ) + if errors: + logger.warning(f"[OMEMO] Erreurs non-critiques : {errors}") + for enc_stanza in encrypted.values(): + enc_stanza.send() + logger.debug(f"[OMEMO] Message chiffré envoyé à {to}") + except Exception as e: + logger.warning(f"[OMEMO] Chiffrement échoué ({e}), envoi en clair") + self.send_message(mto=to, mbody=body, mtype="chat") diff --git a/setup.py b/setup.py index 61d4f61..2e586d1 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( "requests>=2.28", ], extras_require={ - "omemo": ["slixmpp-omemo>=1.0"], + "omemo": ["slixmpp-omemo>=2.0"], }, python_requires=">=3.10", )