diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index a7ff851..35c4000 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -254,7 +254,7 @@ class MatrixChannel(BaseChannel): store_path=store_path, # Where tokens are saved config=AsyncClientConfig( store_sync_tokens=True, # Auto-persists next_batch tokens - encryption_enabled=True, + encryption_enabled=self.config.e2ee_enabled, ), ) @@ -265,6 +265,14 @@ class MatrixChannel(BaseChannel): self._register_event_callbacks() self._register_response_callbacks() + if self.config.e2ee_enabled: + logger.info("Matrix E2EE is enabled.") + else: + logger.warning( + "Matrix E2EE is disabled; encrypted room messages may be undecryptable and " + "encrypted-device verification is not applied on send." + ) + if self.config.device_id: try: self.client.load_store() @@ -316,13 +324,17 @@ class MatrixChannel(BaseChannel): if not self.client: return + room_send_kwargs: dict[str, Any] = { + "room_id": msg.chat_id, + "message_type": "m.room.message", + "content": _build_matrix_text_content(msg.content), + } + if self.config.e2ee_enabled: + # TODO(matrix): Add explicit config for strict verified-device sending mode. + room_send_kwargs["ignore_unverified_devices"] = True + try: - await self.client.room_send( - room_id=msg.chat_id, - message_type="m.room.message", - content=_build_matrix_text_content(msg.content), - ignore_unverified_devices=True, - ) + await self.client.room_send(**room_send_kwargs) finally: await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index f0ee410..0861073 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -72,6 +72,8 @@ class MatrixConfig(Base): access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" + # Enable Matrix E2EE support (encryption + encrypted room handling). + e2ee_enabled: bool = True # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. sync_stop_grace_seconds: int = 2 # Max attachment size accepted from inbound Matrix media events. diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index f55fd0f..6ea955d 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -14,6 +14,8 @@ from nanobot.channels.matrix import ( ) from nanobot.config.schema import MatrixConfig +_ROOM_SEND_UNSET = object() + class _DummyTask: def __init__(self) -> None: @@ -73,16 +75,16 @@ class _FakeAsyncClient: room_id: str, message_type: str, content: dict[str, object], - ignore_unverified_devices: bool, + ignore_unverified_devices: object = _ROOM_SEND_UNSET, ) -> None: - self.room_send_calls.append( - { - "room_id": room_id, - "message_type": message_type, - "content": content, - "ignore_unverified_devices": ignore_unverified_devices, - } - ) + call: dict[str, object] = { + "room_id": room_id, + "message_type": message_type, + "content": content, + } + if ignore_unverified_devices is not _ROOM_SEND_UNSET: + call["ignore_unverified_devices"] = ignore_unverified_devices + self.room_send_calls.append(call) if self.raise_on_send: raise RuntimeError("send failed") @@ -149,6 +151,7 @@ async def test_start_skips_load_store_when_device_id_missing( await channel.start() assert len(clients) == 1 + assert clients[0].config.encryption_enabled is True assert clients[0].load_store_called is False assert len(clients[0].callbacks) == 3 assert len(clients[0].response_callbacks) == 3 @@ -156,6 +159,40 @@ async def test_start_skips_load_store_when_device_id_missing( await channel.stop() +@pytest.mark.asyncio +async def test_start_disables_e2ee_when_configured( + monkeypatch, tmp_path +) -> None: + clients: list[_FakeAsyncClient] = [] + + def _fake_client(*args, **kwargs) -> _FakeAsyncClient: + client = _FakeAsyncClient(*args, **kwargs) + clients.append(client) + return client + + def _fake_create_task(coro): + coro.close() + return _DummyTask() + + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr( + "nanobot.channels.matrix.AsyncClientConfig", + lambda **kwargs: SimpleNamespace(**kwargs), + ) + monkeypatch.setattr("nanobot.channels.matrix.AsyncClient", _fake_client) + monkeypatch.setattr( + "nanobot.channels.matrix.asyncio.create_task", _fake_create_task + ) + + channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=False), MessageBus()) + await channel.start() + + assert len(clients) == 1 + assert clients[0].config.encryption_enabled is False + + await channel.stop() + + @pytest.mark.asyncio async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None: channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus()) @@ -632,9 +669,24 @@ async def test_send_clears_typing_after_send() -> None: "body": "Hi", "m.mentions": {}, } + assert client.room_send_calls[0]["ignore_unverified_devices"] is True assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) +@pytest.mark.asyncio +async def test_send_omits_ignore_unverified_devices_when_e2ee_disabled() -> None: + channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi") + ) + + assert len(client.room_send_calls) == 1 + assert "ignore_unverified_devices" not in client.room_send_calls[0] + + @pytest.mark.asyncio async def test_send_stops_typing_keepalive_task() -> None: channel = MatrixChannel(_make_config(), MessageBus())