fix(matrix): harden e2ee access-token handling (#3562)

* fix(matrix): harden e2ee access-token handling

* fix: patch nio mock in e2ee maintenance sync loop test

The sync_loop now imports nio for SyncError checking (from PR #3280),
so the test needs to inject a fake nio module via sys.modules.

---------

Co-authored-by: Cortana <andrew+cortana@chalkley.org>
This commit is contained in:
Teknium 2026-03-28 12:13:35 -07:00 committed by GitHub
parent be39292633
commit be322efdf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 320 additions and 20 deletions

View file

@ -161,22 +161,49 @@ class MatrixAdapter(BasePlatformAdapter):
# Authenticate.
if self._access_token:
client.access_token = self._access_token
# Resolve user_id if not set.
if not self._user_id:
resp = await client.whoami()
if isinstance(resp, nio.WhoamiResponse):
self._user_id = resp.user_id
client.user_id = resp.user_id
logger.info("Matrix: authenticated as %s", self._user_id)
else:
logger.error(
"Matrix: whoami failed — check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER"
# With access-token auth, always resolve whoami so we validate the
# token and learn the device_id. The device_id matters for E2EE:
# without it, matrix-nio can send plain messages but may fail to
# decrypt inbound encrypted events or encrypt outbound room sends.
resp = await client.whoami()
if isinstance(resp, nio.WhoamiResponse):
resolved_user_id = getattr(resp, "user_id", "") or self._user_id
resolved_device_id = getattr(resp, "device_id", "")
if resolved_user_id:
self._user_id = resolved_user_id
# restore_login() is the matrix-nio path that binds the access
# token to a specific device and loads the crypto store.
if resolved_device_id and hasattr(client, "restore_login"):
client.restore_login(
self._user_id or resolved_user_id,
resolved_device_id,
self._access_token,
)
await client.close()
return False
else:
if self._user_id:
client.user_id = self._user_id
if resolved_device_id:
client.device_id = resolved_device_id
client.access_token = self._access_token
if self._encryption:
logger.warning(
"Matrix: access-token login did not restore E2EE state; "
"encrypted rooms may fail until a device_id is available"
)
logger.info(
"Matrix: using access token for %s%s",
self._user_id or "(unknown user)",
f" (device {resolved_device_id})" if resolved_device_id else "",
)
else:
client.user_id = self._user_id
logger.info("Matrix: using access token for %s", self._user_id)
logger.error(
"Matrix: whoami failed — check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER"
)
await client.close()
return False
elif self._password and self._user_id:
resp = await client.login(
self._password,
@ -194,13 +221,18 @@ class MatrixAdapter(BasePlatformAdapter):
return False
# If E2EE is enabled, load the crypto store.
if self._encryption and hasattr(client, "olm"):
if self._encryption and getattr(client, "olm", None):
try:
if client.should_upload_keys:
await client.keys_upload()
logger.info("Matrix: E2EE crypto initialized")
except Exception as exc:
logger.warning("Matrix: crypto init issue: %s", exc)
elif self._encryption:
logger.warning(
"Matrix: E2EE requested but crypto store is not loaded; "
"encrypted rooms may fail"
)
# Register event callbacks.
client.add_event_callback(self._on_room_message, nio.RoomMessageText)
@ -230,6 +262,7 @@ class MatrixAdapter(BasePlatformAdapter):
)
# Build DM room cache from m.direct account data.
await self._refresh_dm_cache()
await self._run_e2ee_maintenance()
else:
logger.warning("Matrix: initial sync returned %s", type(resp).__name__)
@ -301,13 +334,48 @@ class MatrixAdapter(BasePlatformAdapter):
relates_to["m.in_reply_to"] = {"event_id": reply_to}
msg_content["m.relates_to"] = relates_to
resp = await self._client.room_send(
chat_id,
"m.room.message",
msg_content,
)
async def _room_send_once(*, ignore_unverified_devices: bool = False):
return await asyncio.wait_for(
self._client.room_send(
chat_id,
"m.room.message",
msg_content,
ignore_unverified_devices=ignore_unverified_devices,
),
timeout=45,
)
try:
resp = await _room_send_once(ignore_unverified_devices=False)
except Exception as exc:
retryable = isinstance(exc, asyncio.TimeoutError)
olm_unverified = getattr(nio, "OlmUnverifiedDeviceError", None)
send_retry = getattr(nio, "SendRetryError", None)
if isinstance(olm_unverified, type) and isinstance(exc, olm_unverified):
retryable = True
if isinstance(send_retry, type) and isinstance(exc, send_retry):
retryable = True
if not retryable:
logger.error("Matrix: failed to send to %s: %s", chat_id, exc)
return SendResult(success=False, error=str(exc))
logger.warning(
"Matrix: initial encrypted send to %s failed (%s); "
"retrying after E2EE maintenance with ignored unverified devices",
chat_id,
exc,
)
await self._run_e2ee_maintenance()
try:
resp = await _room_send_once(ignore_unverified_devices=True)
except Exception as retry_exc:
logger.error("Matrix: failed to send to %s after retry: %s", chat_id, retry_exc)
return SendResult(success=False, error=str(retry_exc))
if isinstance(resp, nio.RoomSendResponse):
last_event_id = resp.event_id
logger.info("Matrix: sent event %s to %s", last_event_id, chat_id)
else:
err = getattr(resp, "message", str(resp))
logger.error("Matrix: failed to send to %s: %s", chat_id, err)
@ -565,6 +633,9 @@ class MatrixAdapter(BasePlatformAdapter):
getattr(resp, "message", resp),
)
await asyncio.sleep(5)
continue
await self._run_e2ee_maintenance()
except asyncio.CancelledError:
return
except Exception as exc:
@ -573,6 +644,38 @@ class MatrixAdapter(BasePlatformAdapter):
logger.warning("Matrix: sync error: %s — retrying in 5s", exc)
await asyncio.sleep(5)
async def _run_e2ee_maintenance(self) -> None:
"""Run matrix-nio E2EE housekeeping between syncs.
Hermes uses a custom sync loop instead of matrix-nio's sync_forever(),
so we need to explicitly drive the key management work that sync_forever()
normally handles for encrypted rooms.
"""
client = self._client
if not client or not self._encryption or not getattr(client, "olm", None):
return
tasks = [asyncio.create_task(client.send_to_device_messages())]
if client.should_upload_keys:
tasks.append(asyncio.create_task(client.keys_upload()))
if client.should_query_keys:
tasks.append(asyncio.create_task(client.keys_query()))
if client.should_claim_keys:
users = client.get_users_for_key_claiming()
if users:
tasks.append(asyncio.create_task(client.keys_claim(users)))
for task in asyncio.as_completed(tasks):
try:
await task
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Matrix: E2EE maintenance task failed: %s", exc)
# ------------------------------------------------------------------
# Event callbacks
# ------------------------------------------------------------------