Improve security

This commit is contained in:
2026-03-07 15:34:23 +00:00
parent add685a326
commit 611de57bf8
31 changed files with 3617 additions and 58 deletions

View File

@@ -1,9 +1,13 @@
import asyncio
import base64
import json
import mimetypes
import os
import re
import time
import uuid
from urllib.parse import urlsplit
from pathlib import Path
from urllib.parse import parse_qs, urlparse, urlsplit
import aiohttp
from asgiref.sync import sync_to_async
@@ -16,9 +20,18 @@ from slixmpp.xmlstream import register_stanza_plugin
from slixmpp.xmlstream.stanzabase import ET
from core.clients import ClientBase, transport
from core.gateway.commands import (
GatewayCommandContext,
GatewayCommandRoute,
dispatch_gateway_command,
)
from core.messaging import ai, history, replies, reply_sync, utils
from core.models import (
ChatSession,
CodexPermissionRequest,
CodexRun,
DerivedTask,
ExternalSyncEvent,
Manipulation,
PatternMitigationAutoSettings,
PatternMitigationCorrection,
@@ -28,6 +41,7 @@ from core.models import (
Person,
PersonIdentifier,
User,
UserXmppOmemoState,
WorkspaceConversation,
)
from core.security.attachments import (
@@ -40,6 +54,7 @@ URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
EMOJI_ONLY_PATTERN = re.compile(
r"^[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F\u200D\u2640-\u2642\u2764]+$"
)
TOTP_BASE32_SECRET_RE = re.compile(r"^[A-Z2-7]{16,}$")
def _clean_url(value):
@@ -129,6 +144,135 @@ def _parse_greentext_reaction(body_text):
return {"quoted_text": quoted, "emoji": emoji}
def _omemo_plugin_available() -> bool:
try:
import importlib
return importlib.util.find_spec("slixmpp_omemo") is not None
except Exception:
return False
def _extract_sender_omemo_client_key(stanza) -> dict:
"""Extract OMEMO client key info from an encrypted stanza."""
ns = "eu.siacs.conversations.axolotl"
header = stanza.xml.find(f".//{{{ns}}}header")
if header is None:
return {"status": "no_omemo"}
sid = str(header.attrib.get("sid") or "").strip()
key_el = header.find(f"{{{ns}}}key")
rid = str(key_el.attrib.get("rid") or "").strip() if key_el is not None else ""
if sid or rid:
return {"status": "detected", "client_key": f"sid:{sid},rid:{rid}"}
return {"status": "no_omemo"}
# ---------------------------------------------------------------------------
# OMEMO storage + plugin implementation
# ---------------------------------------------------------------------------
try:
from omemo.storage import Just, Maybe, Nothing, Storage as _OmemoStorageBase
from slixmpp_omemo import XEP_0384 as _XEP_0384Base
from slixmpp_omemo.base_session_manager import TrustLevel as _OmemoTrustLevel
from slixmpp.plugins.base import register_plugin as _slixmpp_register_plugin
_OMEMO_AVAILABLE = True
except ImportError:
_OMEMO_AVAILABLE = False
_OmemoStorageBase = object
_XEP_0384Base = object
_OmemoTrustLevel = None
_slixmpp_register_plugin = None
if _OMEMO_AVAILABLE:
class _OmemoStorage(_OmemoStorageBase):
"""JSON-file-backed OMEMO key storage."""
def __init__(self, path: str) -> None:
super().__init__()
self._path = path
try:
with open(path) as f:
self._data: dict = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
self._data = {}
def _save(self) -> None:
os.makedirs(os.path.dirname(self._path), exist_ok=True)
with open(self._path, "w") as f:
json.dump(self._data, f)
async def _load(self, key: str) -> Maybe:
if key in self._data:
return Just(self._data[key])
return Nothing()
async def _store(self, key: str, value) -> None:
self._data[key] = value
self._save()
async def _delete(self, key: str) -> None:
self._data.pop(key, None)
self._save()
class _GiaOmemoPlugin(_XEP_0384Base):
"""Concrete XEP-0384 OMEMO plugin for the GIA XMPP gateway component.
Uses BTBV (blind trust before verification) appropriate for a
server-side bridge that processes messages on behalf of users.
"""
name = "xep_0384"
description = "OMEMO Encryption (GIA gateway)"
dependencies = {"xep_0004", "xep_0030", "xep_0060", "xep_0163", "xep_0280", "xep_0334"}
default_config = {
"fallback_message": "This message is OMEMO encrypted.",
"data_dir": "",
}
def plugin_init(self) -> None:
data_dir = str(self.config.get("data_dir") or "").strip()
if not data_dir:
data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
os.makedirs(data_dir, exist_ok=True)
self._storage_impl = _OmemoStorage(os.path.join(data_dir, "omemo.json"))
super().plugin_init()
@property
def storage(self) -> _OmemoStorageBase:
return self._storage_impl
@property
def _btbv_enabled(self) -> bool:
return True
async def _devices_blindly_trusted(self, blindly_trusted, identifier):
import logging
logging.getLogger(__name__).info(
"OMEMO: blindly trusted %d new device(s)", len(blindly_trusted)
)
async def _prompt_manual_trust(self, manually_trusted, identifier):
"""Auto-trust all undecided devices (gateway mode)."""
import logging
log = logging.getLogger(__name__)
log.info(
"OMEMO: auto-trusting %d undecided device(s) (gateway mode)",
len(manually_trusted),
)
session_manager = await self.get_session_manager()
for device in manually_trusted:
try:
await session_manager.set_trust(
device.bare_jid,
device.device_id,
device.identity_key,
_OmemoTrustLevel.BLINDLY_TRUSTED.value,
)
except Exception as exc:
log.warning("OMEMO set_trust failed for %s: %s", device.bare_jid, exc)
class XMPPComponent(ComponentXMPP):
"""
@@ -147,6 +291,8 @@ class XMPPComponent(ComponentXMPP):
self.log = logs.get_logger("XMPP")
super().__init__(jid, secret, server, port)
# Enable message IDs so the OMEMO plugin can associate encrypted stanzas.
self.use_message_ids = True
# Use one reconnect strategy (our backoff loop) to avoid reconnect churn.
self.auto_reconnect = False
# Register chat state plugins
@@ -297,6 +443,470 @@ class XMPPComponent(ComponentXMPP):
)
return plan
def _derived_omemo_fingerprint(self, jid: str) -> str:
import hashlib
return hashlib.sha256(f"xmpp-omemo-key:{jid}".encode()).hexdigest()[:32]
def _get_omemo_plugin(self):
"""Return the active XEP-0384 plugin instance, or None if not loaded."""
try:
return self["xep_0384"]
except Exception:
return None
async def _bootstrap_omemo_for_authentic_channel(self):
jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
omemo_plugin = self._get_omemo_plugin()
omemo_enabled = omemo_plugin is not None
status = "active" if omemo_enabled else "not_available"
reason = "OMEMO plugin active" if omemo_enabled else "xep_0384 plugin not loaded"
fingerprint = self._derived_omemo_fingerprint(jid)
if omemo_enabled:
try:
import asyncio as _asyncio
session_manager = await _asyncio.wait_for(
omemo_plugin.get_session_manager(), timeout=15.0
)
own_devices = await session_manager.get_own_device_information()
if own_devices:
key_bytes = own_devices[0].identity_key
fingerprint = ":".join(f"{b:02X}" for b in key_bytes)
except Exception as exc:
self.log.warning("OMEMO: could not read own device fingerprint: %s", exc)
self.log.info(
"OMEMO bootstrap: jid=%s enabled=%s status=%s fingerprint=%s",
jid, omemo_enabled, status, fingerprint,
)
transport.update_runtime_state(
"xmpp",
omemo_target_jid=jid,
omemo_fingerprint=fingerprint,
omemo_enabled=omemo_enabled,
omemo_status=status,
omemo_status_reason=reason,
)
async def _record_sender_omemo_state(self, user, *, sender_jid, recipient_jid, message_stanza):
parsed = _extract_sender_omemo_client_key(message_stanza)
status = str(parsed.get("status") or "no_omemo")
client_key = str(parsed.get("client_key") or "")
await sync_to_async(UserXmppOmemoState.objects.update_or_create)(
user=user,
defaults={
"status": status,
"latest_client_key": client_key,
"last_sender_jid": str(sender_jid or ""),
"last_target_jid": str(recipient_jid or ""),
},
)
_approval_event_prefix = "codex_approval"
_APPROVAL_PROVIDER_COMMANDS = {
".claude": "claude",
".codex": "codex_cli",
}
def _resolve_request_provider(self, request):
event = getattr(request, "external_sync_event", None)
if event is None:
return ""
return str(getattr(event, "provider", "") or "").strip()
_ACTION_TO_STATUS = {"approve": "approved", "reject": "denied"}
async def _apply_approval_decision(self, request, decision, sym):
status = self._ACTION_TO_STATUS.get(decision, decision)
request.status = status
await sync_to_async(request.save)(update_fields=["status"])
run = None
if request.codex_run_id:
run = await sync_to_async(CodexRun.objects.get)(pk=request.codex_run_id)
run.status = "approved_waiting_resume" if status == "approved" else status
await sync_to_async(run.save)(update_fields=["status"])
if request.external_sync_event_id:
evt = await sync_to_async(ExternalSyncEvent.objects.get)(pk=request.external_sync_event_id)
evt.status = "ok"
await sync_to_async(evt.save)(update_fields=["status"])
user = await sync_to_async(User.objects.get)(pk=request.user_id)
task = None
if run is not None and run.task_id:
task = await sync_to_async(DerivedTask.objects.get)(pk=run.task_id)
ikey = f"{self._approval_event_prefix}:{request.approval_key}:{status}"
await sync_to_async(ExternalSyncEvent.objects.get_or_create)(
idempotency_key=ikey,
defaults={
"user": user,
"task": task,
"provider": "codex_cli",
"status": "pending",
"payload": {},
"error": "",
},
)
async def _approval_list_pending(self, user, scope, sym):
requests = await sync_to_async(list)(
CodexPermissionRequest.objects.filter(
user=user, status="pending"
).order_by("-requested_at")[:20]
)
sym(f"pending={len(requests)}")
for req in requests:
sym(f" {req.approval_key}: {req.summary}")
async def _approval_status(self, user, approval_key, sym):
try:
req = await sync_to_async(
CodexPermissionRequest.objects.get
)(user=user, approval_key=approval_key)
sym(f"status={req.status} key={req.approval_key}")
except CodexPermissionRequest.DoesNotExist:
sym(f"approval_key_not_found:{approval_key}")
async def _handle_approval_command(self, user, body, sender_jid, sym):
command = body.strip()
for prefix, expected_provider in self._APPROVAL_PROVIDER_COMMANDS.items():
if command.startswith(prefix + " ") or command == prefix:
sub = command[len(prefix):].strip()
parts = sub.split()
if len(parts) >= 2 and parts[0] in ("approve", "reject"):
action, approval_key = parts[0], parts[1]
try:
req = await sync_to_async(
CodexPermissionRequest.objects.select_related(
"external_sync_event"
).get
)(user=user, approval_key=approval_key)
except CodexPermissionRequest.DoesNotExist:
sym(f"approval_key_not_found:{approval_key}")
return True
provider = self._resolve_request_provider(req)
if not provider.startswith(expected_provider):
sym(f"approval_key_not_for_provider:{approval_key} provider={provider}")
return True
await self._apply_approval_decision(req, action, sym)
sym(f"{action}d: {approval_key}")
return True
sym(f"usage: {prefix} approve|reject <key>")
return True
if not command.startswith(".approval"):
return False
rest = command[len(".approval"):].strip()
if rest.split() and rest.split()[0] in ("approve", "reject"):
parts = rest.split()
action = parts[0]
approval_key = parts[1] if len(parts) > 1 else ""
if not approval_key:
sym("usage: .approval approve|reject <key>")
return True
try:
req = await sync_to_async(
CodexPermissionRequest.objects.select_related(
"external_sync_event"
).get
)(user=user, approval_key=approval_key)
except CodexPermissionRequest.DoesNotExist:
sym(f"approval_key_not_found:{approval_key}")
return True
await self._apply_approval_decision(req, action, sym)
sym(f"{action}d: {approval_key}")
return True
if rest.startswith("list-pending"):
scope = rest[len("list-pending"):].strip() or "mine"
await self._approval_list_pending(user, scope, sym)
return True
if rest.startswith("status "):
approval_key = rest[len("status "):].strip()
await self._approval_status(user, approval_key, sym)
return True
sym(
"approval: .approval approve|reject <key> | "
".approval list-pending [all] | "
".approval status <key>"
)
return True
async def _handle_tasks_command(self, user, body, sym):
command = body.strip()
if not command.startswith(".tasks"):
return False
rest = command[len(".tasks"):].strip()
if rest.startswith("list"):
parts = rest.split()
status_filter = parts[1] if len(parts) > 1 else "open"
limit = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 10
tasks = await sync_to_async(list)(
DerivedTask.objects.filter(
user=user, status_snapshot=status_filter
).order_by("-id")[:limit]
)
if not tasks:
sym(f"no {status_filter} tasks")
else:
for t in tasks:
sym(f"#{t.reference_code} [{t.status_snapshot}] {t.title}")
return True
if rest.startswith("show "):
ref = rest[len("show "):].strip().lstrip("#")
try:
task = await sync_to_async(DerivedTask.objects.get)(
user=user, reference_code=ref
)
sym(f"#{task.reference_code} {task.title}")
sym(f"status: {task.status_snapshot}")
except DerivedTask.DoesNotExist:
sym(f"task_not_found:#{ref}")
return True
if rest.startswith("complete "):
ref = rest[len("complete "):].strip().lstrip("#")
try:
task = await sync_to_async(DerivedTask.objects.get)(
user=user, reference_code=ref
)
task.status_snapshot = "completed"
await sync_to_async(task.save)(update_fields=["status_snapshot"])
sym(f"completed #{ref}")
except DerivedTask.DoesNotExist:
sym(f"task_not_found:#{ref}")
return True
if rest.startswith("undo "):
ref = rest[len("undo "):].strip().lstrip("#")
try:
task = await sync_to_async(DerivedTask.objects.get)(
user=user, reference_code=ref
)
await sync_to_async(task.delete)()
sym(f"removed #{ref}")
except DerivedTask.DoesNotExist:
sym(f"task_not_found:#{ref}")
return True
sym(
"tasks: .tasks list [status] [limit] | "
".tasks show #<ref> | "
".tasks complete #<ref> | "
".tasks undo #<ref>"
)
return True
def _extract_totp_secret_candidate(self, command_text: str) -> str:
text = str(command_text or "").strip()
if not text:
return ""
lowered = text.lower()
if lowered.startswith("otpauth://"):
parsed = urlparse(text)
query = parse_qs(parsed.query or "")
return str((query.get("secret") or [""])[0] or "").strip()
if lowered.startswith(".totp"):
rest = text[len(".totp"):].strip()
if not rest:
return ""
parts = rest.split(maxsplit=1)
action = str(parts[0] or "").strip().lower()
if action in {"enroll", "set"} and len(parts) > 1:
return str(parts[1] or "").strip()
if action in {"status", "help"}:
return ""
return rest
compact = text.replace(" ", "").strip().upper()
if TOTP_BASE32_SECRET_RE.match(compact):
return compact
return ""
async def _handle_totp_command(self, user, body, sym):
command = str(body or "").strip()
lowered = command.lower()
if lowered.startswith(".totp status"):
exists = await sync_to_async(
lambda: __import__(
"django_otp.plugins.otp_totp.models",
fromlist=["TOTPDevice"],
)
.TOTPDevice.objects.filter(user=user, confirmed=True)
.exists()
)()
sym("totp: configured" if exists else "totp: not configured")
return True
if lowered == ".totp help":
sym("totp: .totp enroll <base32-secret|otpauth-uri> | .totp status")
return True
secret_candidate = self._extract_totp_secret_candidate(command)
if not secret_candidate:
if lowered.startswith(".totp"):
sym("usage: .totp enroll <base32-secret|otpauth-uri>")
return True
return False
normalized = str(secret_candidate).replace(" ", "").strip().upper()
try:
key_bytes = base64.b32decode(normalized, casefold=True)
except Exception:
sym("totp: invalid secret format")
return True
if len(key_bytes) < 10:
sym("totp: secret too short")
return True
def _save_device():
from django_otp.plugins.otp_totp.models import TOTPDevice
device = (
TOTPDevice.objects.filter(user=user)
.order_by("-id")
.first()
)
if device is None:
device = TOTPDevice(user=user, name="gateway")
device.key = key_bytes.hex()
device.confirmed = True
device.step = 30
device.t0 = 0
device.digits = 6
device.tolerance = 1
device.drift = 0
device.save()
return device.name
device_name = await sync_to_async(_save_device)()
sym(f"totp: enrolled for user={user.username} device={device_name}")
return True
async def _route_gateway_command(
self,
*,
sender_user,
body,
sender_jid,
recipient_jid,
local_message,
message_meta,
sym,
):
command_text = str(body or "").strip()
async def _contacts_handler(_ctx, emit):
persons = await sync_to_async(list)(Person.objects.filter(user=sender_user).order_by("name"))
if not persons:
emit("No contacts found.")
return True
emit("Contacts: " + ", ".join([p.name for p in persons]))
return True
async def _help_handler(_ctx, emit):
for line in self._gateway_help_lines():
emit(line)
return True
async def _whoami_handler(_ctx, emit):
emit(str(sender_user.__dict__))
return True
async def _approval_handler(_ctx, emit):
return await self._handle_approval_command(sender_user, command_text, sender_jid, emit)
async def _tasks_handler(_ctx, emit):
return await self._handle_tasks_command(sender_user, command_text, emit)
async def _totp_handler(_ctx, emit):
return await self._handle_totp_command(sender_user, command_text, emit)
routes = [
GatewayCommandRoute(
name="contacts",
scope_key="gateway.contacts",
matcher=lambda text: str(text or "").strip().lower() == ".contacts",
handler=_contacts_handler,
),
GatewayCommandRoute(
name="help",
scope_key="gateway.help",
matcher=lambda text: str(text or "").strip().lower() == ".help",
handler=_help_handler,
),
GatewayCommandRoute(
name="whoami",
scope_key="gateway.whoami",
matcher=lambda text: str(text or "").strip().lower() == ".whoami",
handler=_whoami_handler,
),
GatewayCommandRoute(
name="approval",
scope_key="gateway.approval",
matcher=lambda text: str(text or "").strip().lower().startswith(".approval")
or any(
str(text or "").strip().lower().startswith(prefix + " ")
or str(text or "").strip().lower() == prefix
for prefix in self._APPROVAL_PROVIDER_COMMANDS
),
handler=_approval_handler,
),
GatewayCommandRoute(
name="tasks",
scope_key="gateway.tasks",
matcher=lambda text: str(text or "").strip().lower().startswith(".tasks"),
handler=_tasks_handler,
),
GatewayCommandRoute(
name="totp",
scope_key="gateway.totp",
matcher=lambda text: bool(self._extract_totp_secret_candidate(text)),
handler=_totp_handler,
),
]
handled = await dispatch_gateway_command(
context=GatewayCommandContext(
user=sender_user,
source_message=local_message,
service="xmpp",
channel_identifier=str(sender_jid or ""),
sender_identifier=str(sender_jid or ""),
message_text=command_text,
message_meta=dict(message_meta or {}),
payload={
"sender_jid": str(sender_jid or ""),
"recipient_jid": str(recipient_jid or ""),
},
),
routes=routes,
emit=sym,
)
if not handled and command_text.startswith("."):
sym("No such command")
return handled
def _gateway_help_lines(self):
return [
"Gateway commands:",
" .contacts — list contacts",
" .whoami — show current user",
" .help — show this help",
" .totp enroll <secret|otpauth-uri> — enroll TOTP for this user",
" .totp status — show whether TOTP is configured",
"Approval commands:",
" .approval list-pending [all] — list pending approval requests",
" .approval approve <key> — approve a request",
" .approval reject <key> — reject a request",
" .approval status <key> — check request status",
"Task commands:",
" .tasks list [status] [limit] — list tasks",
" .tasks show #<ref> — show task details",
" .tasks complete #<ref> — mark task complete",
" .tasks undo #<ref> — remove task",
]
async def _handle_mitigation_command(self, sender_user, body, sym):
def parse_parts(raw):
return [part.strip() for part in raw.split("|")]
@@ -855,6 +1465,7 @@ class XMPPComponent(ComponentXMPP):
# This client connects as an external component, not a user client;
# XEP-0280 (carbons) is client-scoped and not valid here.
self.log.debug("Skipping carbons enable for component session")
await self._bootstrap_omemo_for_authentic_channel()
async def _reconnect_loop(self):
try:
@@ -1031,6 +1642,18 @@ class XMPPComponent(ComponentXMPP):
recipient_username = recipient_jid
recipient_domain = recipient_jid
# Attempt to decrypt OMEMO-encrypted messages before body extraction.
original_msg = msg
omemo_plugin = self._get_omemo_plugin()
if omemo_plugin:
try:
if omemo_plugin.is_encrypted(msg):
decrypted, _ = await omemo_plugin.decrypt_message(msg)
msg = decrypted
self.log.debug("OMEMO: decrypted message from %s", sender_jid)
except Exception as exc:
self.log.warning("OMEMO: decryption failed from %s: %s", sender_jid, exc)
# Extract message body
body = msg["body"] if msg["body"] else ""
parsed_reaction = _extract_xmpp_reaction(msg)
@@ -1157,36 +1780,55 @@ class XMPPComponent(ComponentXMPP):
self.log.warning(f"Unknown sender: {sender_username}")
return
# Record the sender's OMEMO state (uses the original, pre-decryption stanza).
try:
await self._record_sender_omemo_state(
sender_user,
sender_jid=sender_jid,
recipient_jid=recipient_jid,
message_stanza=original_msg,
)
except Exception as exc:
self.log.warning("OMEMO: failed to record sender state: %s", exc)
omemo_observation = _extract_sender_omemo_client_key(original_msg)
# Enforce mandatory encryption policy.
try:
from core.models import UserXmppSecuritySettings
sec_settings = await sync_to_async(
lambda: UserXmppSecuritySettings.objects.filter(user=sender_user).first()
)()
if sec_settings and sec_settings.require_omemo:
omemo_status = str(omemo_observation.get("status") or "")
if omemo_status != "detected":
sym(
"⚠ This gateway requires OMEMO encryption. "
"Your message was not delivered. "
"Please enable OMEMO in your XMPP client."
)
return
except Exception as exc:
self.log.warning("OMEMO policy check failed: %s", exc)
if recipient_jid == settings.XMPP_JID:
self.log.debug("Handling command message sent to gateway JID")
if body.startswith("."):
# Messaging the gateway directly
if body == ".contacts":
# Lookup Person objects linked to sender
persons = Person.objects.filter(user=sender_user)
if not persons.exists():
self.log.debug("No contacts found for %s", sender_username)
sym("No contacts found.")
return
# Construct contact list response
contact_names = [person.name for person in persons]
response_text = "Contacts: " + ", ".join(contact_names)
sym(response_text)
elif body == ".help":
sym("Commands: .contacts, .whoami, .mitigation help")
elif body.startswith(".mitigation"):
handled = await self._handle_mitigation_command(
sender_user,
body,
sym,
)
if not handled:
sym("Unknown mitigation command. Try .mitigation help")
elif body == ".whoami":
sym(str(sender_user.__dict__))
else:
sym("No such command")
if body.startswith(".") or self._extract_totp_secret_candidate(body):
await self._route_gateway_command(
sender_user=sender_user,
body=body,
sender_jid=sender_jid,
recipient_jid=recipient_jid,
local_message=None,
message_meta={
"xmpp": {
"sender_jid": str(sender_jid or ""),
"recipient_jid": str(recipient_jid or ""),
"omemo_status": str(omemo_observation.get("status") or ""),
"omemo_client_key": str(omemo_observation.get("client_key") or ""),
}
},
sym=sym,
)
else:
self.log.debug("Handling routed message to contact")
if "|" in recipient_username:
@@ -1357,7 +1999,14 @@ class XMPPComponent(ComponentXMPP):
reply_source_message_id=str(
reply_ref.get("reply_source_message_id") or ""
),
message_meta={},
message_meta={
"xmpp": {
"sender_jid": str(sender_jid or ""),
"recipient_jid": str(recipient_jid or ""),
"omemo_status": str(omemo_observation.get("status") or ""),
"omemo_client_key": str(omemo_observation.get("client_key") or ""),
}
},
)
self.log.debug("Stored outbound XMPP message in history")
await self.ur.message_received(
@@ -1513,6 +2162,32 @@ class XMPPComponent(ComponentXMPP):
msg.xml.append(oob_element)
self.log.debug("Sending XMPP message: %s", msg.xml)
# Attempt OMEMO encryption for text-only messages (not attachments).
if not attachment_url:
omemo_plugin = self._get_omemo_plugin()
if omemo_plugin:
try:
from slixmpp.jid import JID as _JID
encrypted_msgs, enc_errors = await omemo_plugin.encrypt_message(
msg, _JID(recipient_jid)
)
if enc_errors:
self.log.debug(
"OMEMO: non-critical encryption errors for %s: %s",
recipient_jid, enc_errors,
)
if encrypted_msgs:
for enc_msg in encrypted_msgs.values():
enc_msg.send()
self.log.debug("OMEMO: sent encrypted message to %s", recipient_jid)
return msg_id
except Exception as exc:
self.log.debug(
"OMEMO: encryption not available for %s, sending plaintext: %s",
recipient_jid, exc,
)
msg.send()
return msg_id
@@ -1834,6 +2509,23 @@ class XMPPClient(ClientBase):
self.client.register_plugin("xep_0085") # Chat State Notifications
self.client.register_plugin("xep_0363") # HTTP File Upload
self._omemo_plugin_registered = False
if _OMEMO_AVAILABLE:
try:
data_dir = str(getattr(settings, "XMPP_OMEMO_DATA_DIR", "") or "").strip()
if not data_dir:
data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
# Register our concrete plugin class under the "xep_0384" name so
# that slixmpp's dependency resolver finds it.
_slixmpp_register_plugin(_GiaOmemoPlugin)
self.client.register_plugin("xep_0384", pconfig={"data_dir": data_dir})
self._omemo_plugin_registered = True
self.log.info("OMEMO: xep_0384 plugin registered, data_dir=%s", data_dir)
except Exception as exc:
self.log.warning("OMEMO: failed to register xep_0384 plugin: %s", exc)
else:
self.log.warning("OMEMO: slixmpp_omemo not available, OMEMO disabled")
def start(self):
if not self._enabled or self.client is None:
return