From 236185d4f5f43e8cf48a1614ed2ba9d713a8b3d2 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:08:59 +0800 Subject: [PATCH] fix(matrix): bind and bound SAS requests --- nanobot/channels/matrix/runtime.py | 23 ++++++- .../matrix/tests/test_matrix_channel.py | 61 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/matrix/runtime.py b/nanobot/channels/matrix/runtime.py index 751074b06..11b7303ed 100644 --- a/nanobot/channels/matrix/runtime.py +++ b/nanobot/channels/matrix/runtime.py @@ -80,6 +80,7 @@ _MSGTYPE_MAP = {"m.image": "image", "m.audio": "audio", "m.video": "video", "m.f _SAS_METHOD = "m.sas.v1" _SAS_REQUEST_MAX_AGE_MS = 10 * 60 * 1000 _SAS_REQUEST_MAX_FUTURE_MS = 5 * 60 * 1000 +_SAS_REQUEST_MAX_PENDING = 256 MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia) MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia @@ -759,6 +760,16 @@ class MatrixChannel(BaseChannel): if request.timestamp_ms >= oldest_allowed } + def _remember_sas_verification_request( + self, + transaction_id: str, + request: _SasVerificationRequest, + ) -> None: + self._sas_verification_requests[transaction_id] = request + while len(self._sas_verification_requests) > _SAS_REQUEST_MAX_PENDING: + oldest_transaction_id = next(iter(self._sas_verification_requests)) + self._sas_verification_requests.pop(oldest_transaction_id) + async def _send_sas_control_message( self, *, @@ -849,7 +860,7 @@ class MatrixChannel(BaseChannel): }, ) if sent: - self._sas_verification_requests[transaction_id] = request + self._remember_sas_verification_request(transaction_id, request) return if event_type == "m.key.verification.done": @@ -882,6 +893,16 @@ class MatrixChannel(BaseChannel): return if isinstance(event, KeyVerificationStart): + request = self._sas_verification_requests.get(transaction_id) + from_device = str(getattr(event, "from_device", "") or "") + if request is not None and ( + request.sender != sender or request.device_id != from_device + ): + self.logger.warning( + "Ignoring Matrix SAS start for transaction {} from unexpected device", + transaction_id, + ) + return if "emoji" not in (getattr(event, "short_authentication_string", None) or []): self.logger.info( "Ignoring Matrix SAS verification from {} without emoji support", diff --git a/nanobot/channels/matrix/tests/test_matrix_channel.py b/nanobot/channels/matrix/tests/test_matrix_channel.py index 2c9f71d92..372505857 100644 --- a/nanobot/channels/matrix/tests/test_matrix_channel.py +++ b/nanobot/channels/matrix/tests/test_matrix_channel.py @@ -241,10 +241,12 @@ class _FakeKeyVerificationStart: *, sender: str = "@alice:matrix.org", transaction_id: str = "tx1", + from_device: str = "ALICEDEVICE", short_authentication_string: list[str] | None = None, ) -> None: self.sender = sender self.transaction_id = transaction_id + self.from_device = from_device self.short_authentication_string = short_authentication_string or ["emoji"] @@ -477,6 +479,65 @@ async def test_sas_verification_request_sends_ready_to_allowed_device(monkeypatc assert channel._sas_verification_requests["tx1"].device_id == "ALICEDEVICE" +@pytest.mark.asyncio +async def test_sas_verification_requests_are_bounded(monkeypatch) -> None: + monkeypatch.setattr(matrix_module.time, "time", lambda: 1_000.0) + channel = MatrixChannel( + _make_config( + allow_from=["@alice:matrix.org"], + e2ee_enabled=True, + sas_verification=True, + ), + MessageBus(), + ) + client = _FakeAsyncClient("", "", "", None) + client.device_id = "BOTDEVICE" + channel.client = client + + for index in range(matrix_module._SAS_REQUEST_MAX_PENDING + 1): + await channel._handle_key_verification_event(_unknown_verification_event( + "m.key.verification.request", + transaction_id=f"tx-{index}", + from_device="ALICEDEVICE", + methods=["m.sas.v1"], + timestamp=1_000_000, + )) + + assert len(channel._sas_verification_requests) == matrix_module._SAS_REQUEST_MAX_PENDING + assert "tx-0" not in channel._sas_verification_requests + assert f"tx-{matrix_module._SAS_REQUEST_MAX_PENDING}" in channel._sas_verification_requests + + +@pytest.mark.asyncio +async def test_sas_verification_start_must_match_requested_device(monkeypatch) -> None: + _patch_key_verification_events(monkeypatch) + monkeypatch.setattr(matrix_module.time, "time", lambda: 1_000.0) + channel = MatrixChannel( + _make_config( + allow_from=["@alice:matrix.org"], + e2ee_enabled=True, + sas_verification=True, + ), + MessageBus(), + ) + client = _FakeAsyncClient("", "", "", None) + client.device_id = "BOTDEVICE" + channel.client = client + + await channel._handle_key_verification_event(_unknown_verification_event( + "m.key.verification.request", + from_device="ALICEDEVICE", + methods=["m.sas.v1"], + timestamp=1_000_000, + )) + await channel._handle_key_verification_event( + _FakeKeyVerificationStart(from_device="OTHERDEVICE") + ) + + assert client.accept_key_verification_calls == [] + assert channel._sas_verification_requests["tx1"].device_id == "ALICEDEVICE" + + @pytest.mark.asyncio @pytest.mark.parametrize( ("sender", "methods", "timestamp"),