Improve security
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user