Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/chat-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ nanobot plugins enable matrix
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
| `sasVerification` | Auto-complete SAS device verification requests from allowed users (default `false`). Useful for Element X, which does not expose manual trust for third-party devices. |
| `sasVerification` | Complete Element-initiated SAS device verification for allowed users (default `false`). This does not add cross-signing, clear Element's cross-signing trust warning, or let the bot initiate verification. |
| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |


Expand Down
179 changes: 175 additions & 4 deletions nanobot/channels/matrix/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
SyncError,
SyncResponse,
ToDeviceError,
ToDeviceMessage,
UnknownToDeviceEvent,
UploadError,
)
from nio.crypto.attachments import decrypt_attachment
Expand Down Expand Up @@ -75,6 +77,9 @@
_ATTACH_UPLOAD_FAILED = "[attachment: {} - upload failed]"
_DEFAULT_ATTACH_NAME = "attachment"
_MSGTYPE_MAP = {"m.image": "image", "m.audio": "audio", "m.video": "video", "m.file": "file"}
_SAS_METHOD = "m.sas.v1"
_SAS_REQUEST_MAX_AGE_MS = 10 * 60 * 1000
_SAS_REQUEST_MAX_FUTURE_MS = 5 * 60 * 1000

MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia)
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
Expand Down Expand Up @@ -200,6 +205,15 @@ class _StreamBuf:
event_id: str | None = None
last_edit: float = 0.0


@dataclass(frozen=True)
class _SasVerificationRequest:
"""An allowed Element verification request awaiting SAS completion."""

sender: str
device_id: str
timestamp_ms: int

def _render_markdown_html(text: str) -> str | None:
"""Render markdown to sanitized HTML; returns None for plain text."""
try:
Expand Down Expand Up @@ -321,6 +335,7 @@ def __init__(
self._server_upload_limit_bytes: int | None = None
self._server_upload_limit_checked = False
self._stream_bufs: dict[str, _StreamBuf] = {}
self._sas_verification_requests: dict[str, _SasVerificationRequest] = {}
self._started_at_ms: int = 0
self._media_download_semaphore = asyncio.Semaphore(
max(1, int(self.config.max_concurrent_media_downloads))
Expand Down Expand Up @@ -696,7 +711,7 @@ def _register_to_device_callbacks(self) -> None:
client = self._callback_registrar()
client.add_to_device_callback(
self._on_key_verification_event,
(KeyVerificationEvent,),
(KeyVerificationEvent, UnknownToDeviceEvent),
)

def _register_response_callbacks(self) -> None:
Expand All @@ -709,23 +724,161 @@ def _register_response_callbacks(self) -> None:
def _is_sas_sender_allowed(self, sender: str) -> bool:
return bool(sender and self.is_allowed(sender))

async def _on_key_verification_event(self, event: KeyVerificationEvent) -> None:
async def _on_key_verification_event(
self,
event: KeyVerificationEvent | UnknownToDeviceEvent,
) -> None:
try:
await self._handle_key_verification_event(event)
except asyncio.CancelledError:
raise
except Exception:
self.logger.exception("Matrix SAS verification handling failed")

async def _handle_key_verification_event(self, event: KeyVerificationEvent) -> None:
@staticmethod
def _unknown_verification_content(
event: UnknownToDeviceEvent,
) -> tuple[str, dict[str, object]] | None:
event_type = event.type
source = event.source
content = source.get("content")
if not isinstance(content, dict):
return None
return event_type, cast(dict[str, object], content)

@staticmethod
def _content_string(content: dict[str, object], key: str) -> str:
value = content.get(key)
return value if isinstance(value, str) else ""

def _prune_sas_verification_requests(self, now_ms: int) -> None:
oldest_allowed = now_ms - _SAS_REQUEST_MAX_AGE_MS
self._sas_verification_requests = {
transaction_id: request
for transaction_id, request in self._sas_verification_requests.items()
if request.timestamp_ms >= oldest_allowed
}

async def _send_sas_control_message(
self,
*,
event_type: str,
sender: str,
device_id: str,
content: dict[str, object],
) -> bool:
if not self.client:
return False
response = await self.client.to_device(
ToDeviceMessage(
type=event_type,
recipient=sender,
recipient_device=device_id,
content=content,
)
)
if isinstance(response, ToDeviceError):
self.logger.warning("Matrix SAS {} failed for {}: {}", event_type, sender, response)
return False
return True

async def _handle_unknown_verification_event(
self,
event: UnknownToDeviceEvent,
sender: str,
) -> None:
parsed = self._unknown_verification_content(event)
if parsed is None:
return
event_type, content = parsed
if event_type not in {
"m.key.verification.request",
"m.key.verification.ready",
"m.key.verification.done",
}:
return

transaction_id = self._content_string(content, "transaction_id")
if not transaction_id:
return

if event_type == "m.key.verification.request":
from_device = self._content_string(content, "from_device")
methods = content.get("methods")
timestamp = content.get("timestamp")
if (
not from_device
or not isinstance(methods, list)
or _SAS_METHOD not in methods
or isinstance(timestamp, bool)
or not isinstance(timestamp, int)
):
return

now_ms = int(time.time() * 1000)
if not (
now_ms - _SAS_REQUEST_MAX_AGE_MS
<= timestamp
<= now_ms + _SAS_REQUEST_MAX_FUTURE_MS
):
self.logger.info("Ignoring expired Matrix SAS request from {}", sender)
return

self._prune_sas_verification_requests(now_ms)
request = _SasVerificationRequest(sender, from_device, timestamp)
existing = self._sas_verification_requests.get(transaction_id)
if existing is not None and existing != request:
self.logger.warning(
"Ignoring conflicting Matrix SAS transaction {} from {}",
transaction_id,
sender,
)
return

own_device = str(self.client.device_id or "") if self.client else ""
if not own_device:
return
sent = await self._send_sas_control_message(
event_type="m.key.verification.ready",
sender=sender,
device_id=from_device,
content={
"from_device": own_device,
"methods": [_SAS_METHOD],
"transaction_id": transaction_id,
},
)
if sent:
self._sas_verification_requests[transaction_id] = request
return

if event_type == "m.key.verification.done":
request = self._sas_verification_requests.get(transaction_id)
if request is not None and request.sender == sender:
self._sas_verification_requests.pop(transaction_id, None)
self.logger.info("Matrix SAS verification finished with {}", sender)

# Ready is deliberately ignored: this channel does not initiate verification.

async def _handle_key_verification_event(
self,
event: KeyVerificationEvent | UnknownToDeviceEvent,
) -> None:
if not (self.config.e2ee_enabled and self.config.sas_verification):
return
if not self.client:
return

sender = str(getattr(event, "sender", "") or "")
if not self._is_sas_sender_allowed(sender):
return

if isinstance(event, UnknownToDeviceEvent):
await self._handle_unknown_verification_event(event, sender)
return

transaction_id = str(getattr(event, "transaction_id", "") or "")
if not transaction_id or not self._is_sas_sender_allowed(sender):
if not transaction_id:
return

if isinstance(event, KeyVerificationStart):
Expand Down Expand Up @@ -756,9 +909,27 @@ async def _handle_key_verification_event(self, event: KeyVerificationEvent) -> N
sas = getattr(self.client, "key_verifications", {}).get(transaction_id)
if sas is not None and getattr(sas, "verified", False):
self.logger.info("Matrix SAS verification completed for {}", sender)
request = self._sas_verification_requests.get(transaction_id)
other_device = str(getattr(getattr(sas, "other_olm_device", None), "id", ""))
if (
request is not None
and request.sender == sender
and request.device_id == other_device
):
sent = await self._send_sas_control_message(
event_type="m.key.verification.done",
sender=sender,
device_id=request.device_id,
content={"transaction_id": transaction_id},
)
if sent:
self._sas_verification_requests.pop(transaction_id, None)
return

if isinstance(event, KeyVerificationCancel):
request = self._sas_verification_requests.get(transaction_id)
if request is not None and request.sender == sender:
self._sas_verification_requests.pop(transaction_id, None)
self.logger.info(
"Matrix SAS verification cancelled by {}: {}",
sender,
Expand Down
Loading
Loading