Skip to content

fix(matrix): refresh stale peer device lists before encrypted sends#61126

Open
chrisplough wants to merge 1 commit into
NousResearch:mainfrom
chrisplough:fix/matrix-stale-peer-device-lists
Open

fix(matrix): refresh stale peer device lists before encrypted sends#61126
chrisplough wants to merge 1 commit into
NousResearch:mainfrom
chrisplough:fix/matrix-stale-peer-device-lists

Conversation

@chrisplough

Copy link
Copy Markdown

What does this PR do?

Fixes a Matrix E2EE bug where a peer who rotates their device can permanently lose the ability to decrypt the bot's messages, because mautrix-python never re-fetches the peer's device list once the homeserver's one-shot notification is missed.

Problem. mautrix-python only invalidates its cached device list for another user when the homeserver reports that user in device_lists.changed in a /sync response (OlmMachine.handle_device_lists is the only code path). It has no catch-up (/keys/changes after a gap) and no manual invalidation path. If that single signal is missed, the cache is stale forever: outbound megolm sessions are shared only to the peer's dead device, and every message the bot sends is undecryptable for that peer. The peer's client shows the classic "unable to decrypt" and their key-share requests fail with "No one-time keys nor device keys got when trying to share keys".

The signal gets missed in realistic, recurring ways:

  • The gateway crashes or is killed between consuming a /sync response (sync token advances) and fully applying its device_lists section. The entry is spent and is not replayed on the next sync.
  • The homeserver fails to emit device_lists.changed for the rotation. This is a known long-standing bug class in the Conduit family: legacy Conduit (open since 2022, famedly/conduit#325), conduwuit (unmaintained), and Tuwunel before 1.6.1 (device_lists.changed never included in /sync responses after cross-signing key upload or device key changes matrix-construct/tuwunel#377, fixed 2026-05). On those servers the tracked-but-stale state is reached on every peer rotation.
  • Whatever the path into it, the failing state is the same and self-sustaining: a peer that is tracked in the crypto store with a stale device list is never re-queried, because _share_group_session only fetches keys for users whose cached device dict is absent entirely. Multiple users in [Bug]: Matrix gateway unable to decrypt message #13891 are sitting in exactly this state today.

Why own-device fixes don't cover this. #14956 (and _verify_device_keys_on_server generally) handles the bot's OWN device keys being stale or missing on the server. This bug is the opposite direction: the bot's device is perfectly healthy; what is stale is the bot's cached view of the PEER's devices when encrypting TO them. That is why several users in #13891 still report the exact "No one-time keys nor device keys got when trying to share keys" symptom after #14956 landed.

Precedent. matrix-rust-sdk hit the same class and added an explicit invalidation API, mark_all_tracked_users_as_dirty (matrix-rust-sdk PR: matrix-org/matrix-rust-sdk#3965). mautrix-python has no equivalent, so this fix performs a throttled proactive re-query at the send boundary instead.

The fix. All outbound events that mautrix may encrypt (text, media, edits, reactions, emotes/notices) already funnel through Client.send_message_event. This PR routes the adapter's six call sites through a single new chokepoint, MatrixAdapter._send_room_event(), which first runs _refresh_encrypted_room_devices():

  • Throttled: at most one refresh per room per matrix.device_refresh_seconds (config.yaml, default 300, 0 disables). Bridged to the internal MATRIX_DEVICE_REFRESH_SECONDS env var by _apply_yaml_config, same as the other matrix keys; docs point at config.yaml.
  • Skips unencrypted rooms and the bot's own user; queries all other room members with crypto._fetch_keys(members, include_untracked=True). include_untracked=True is load-bearing: it also repairs members mautrix never tracked (crypto-store recreation case).
  • Holds the OlmMachine's _fetch_keys_lock around the query (hasattr-guarded, degrades gracefully if a future mautrix renames it) so the out-of-band /keys/query cannot race a sync-driven one.
  • Best-effort and non-blocking: any failure is swallowed with debug-level logging and the send proceeds unchanged. No behavior change for rooms and servers where the sync signal works; the refresh finds no delta and mautrix keeps the existing group session.

When a rotation IS detected, mautrix's own _process_fetched_keys -> on_devices_changed drops the outbound group session, so the very next send re-shares keys to the peer's current device set. No new session churn otherwise.

Related Issue

Fixes #13891

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • plugins/platforms/matrix/adapter.py
    • New _refresh_encrypted_room_devices() (throttled, locked, best-effort peer device-list re-query).
    • New _send_room_event() chokepoint; the six send_message_event call sites (send() incl. its E2EE retry, edit_message(), _upload_and_send(), _send_reaction(), _send_simple_message()) now route through it.
    • __init__ reads device_refresh_seconds from config.extra with the established MATRIX_DEVICE_REFRESH_SECONDS env bridge fallback; _apply_yaml_config bridges the config.yaml key.
    • Module docstring documents the new setting alongside the existing aliases.
  • hermes_cli/config.py: matrix.device_refresh_seconds: 300 added to DEFAULT_CONFIG (additive key, no _config_version bump needed per AGENTS.md).
  • website/docs/user-guide/messaging/matrix.md: one bullet in the E2EE section describing the refresh and the config knob.
  • tests/gateway/test_matrix_device_refresh.py: 18 new tests (see below).

Out of scope on purpose: _standalone_send() (cron delivery) is a raw Client-Server API plaintext sender with no E2EE, so it is not part of this bug class.

How to Test

Reproduce on current main. The deterministic variant simulates the tracked-but-stale state directly (it is reachable through any of the paths above; this makes it reproducible in minutes on any homeserver):

  1. Set up two accounts on one homeserver: Hermes with MATRIX_E2EE_MODE=required, and a peer account (Element or a second Hermes instance) sharing an encrypted room. Exchange a few messages so the peer is tracked in the crypto store.
  2. Stop the gateway. Rotate the peer's device (log the peer out and back in). Now poison one step deterministically: in the Hermes profile's crypto.db, the peer's cached device row still lists only the old (now dead) device — verify with sqlite3 ... "SELECT device_id FROM crypto_device WHERE user_id='@peer:...'". (If your stop/rotate timing happened to deliver the device_lists.changed, delete the peer's new-device row to restore the stale state — this is exactly the state crash-loss or a Conduit-family server leaves behind.)
  3. Start the gateway and send a message to the room. On main, the peer cannot decrypt it, and never will: mautrix encrypts to the dead device on every send, and the peer's key-share requests log "No one-time keys nor device keys got when trying to share keys". Nothing in main ever re-queries.
  4. Conduit-family live variant (no poisoning): the same two accounts on legacy Conduit or Tuwunel < 1.6.1; rotate the peer while the gateway stays up — those servers fail to emit device_lists.changed for local users.
  5. With this PR, the next encrypted send (at most device_refresh_seconds later) re-queries the peer's devices, mautrix drops the outbound session, and the message re-shares to the current device set. The peer decrypts normally.

Test suite: pytest tests/gateway/test_matrix_device_refresh.py -q (18 passed) covering: refresh before an encrypted send once the interval elapsed; throttling within the interval; device_refresh_seconds: 0 disables; a _fetch_keys exception never breaks the send; tracked and untracked members both requeried with include_untracked=True and own user excluded; unencrypted rooms skipped; _fetch_keys_lock held around the query; every sibling send path (text, edit, media, reaction, emote/notice, E2EE retry) routed through the chokepoint; config precedence (extra > env bridge > default) and the _apply_yaml_config bridge. Existing matrix suites (tests/gateway/test_matrix*.py, tests/hermes_cli/test_config.py, tests/hermes_cli/test_setup_matrix_e2ee.py) pass unchanged.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass (matrix + config areas run locally; see How to Test)
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS (production gateways + test suite)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A (matrix keys live in DEFAULT_CONFIG and the matrix docs page, matching the existing matrix settings; cli-config.yaml.example has no matrix block)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A (pure asyncio + mautrix API calls, no OS-specific primitives)
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

Debug log line after a rotation is picked up:

Matrix: refreshed device lists for 1 member(s) of !room:example.org before encrypted send

Disclosure: this fix was developed and validated in production during an AI-assisted engineering session — a Hermes deployment doing agent-to-agent E2EE hit the tracked-but-stale state during a churn-heavy rollout (repeated device rotations while gateways restarted), and this refresh cured it immediately and durably. For honesty: we could not pin our incident on the homeserver — a controlled test on Tuwunel 1.8.0 showed it emits device_lists.changed correctly — which is precisely the point of fixing this client-side: the stale state arises from more than one path, and mautrix has no recovery from any of them. Ported to current main and adapted to the contribution rubric; a human reviewed everything before submission.

mautrix-python only re-fetches a peer's device list when the homeserver
reports that peer in device_lists.changed during /sync
(OlmMachine.handle_device_lists); there is no /keys/changes catch-up or
manual invalidation path. If that signal is missed — gateway downtime
during a peer's device rotation, crypto-store recreation, or a
Conduit-family homeserver that fails to emit it — the cached device list
is stale forever: outbound megolm sessions are shared only to the peer's
dead device and the peer can never decrypt ("No one-time keys nor device
keys got when trying to share keys" on their side). Own-device recovery
does not cover this; the failing direction is encrypting TO a rotated
peer.

Route every encrypted send path (text, media, edits, reactions,
emotes/notices) through a single _send_room_event() chokepoint that
performs a throttled, best-effort /keys/query (include_untracked=True,
under the OlmMachine's _fetch_keys_lock) for the room's other members
before sharing the megolm session. Throttle is configurable via
matrix.device_refresh_seconds in config.yaml (default 300, 0 disables),
bridged to MATRIX_DEVICE_REFRESH_SECONDS like the other matrix keys.
Refresh failures never block the send; logging is debug-level only.

Same class of fix as matrix-rust-sdk's mark_all_tracked_users_as_dirty
(matrix-rust-sdk#3965).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/bug Something isn't working comp/plugins Plugin system and bundled plugins platform/matrix Matrix adapter (E2EE) P3 Low — cosmetic, nice to have labels Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Matrix gateway unable to decrypt message

2 participants