From c604b5f64408a726e9f1c959157e55f84e206420 Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Tue, 24 Mar 2026 09:08:09 +0200 Subject: [PATCH] fix: handle Element X to-device encryption key delivery Element X (26.03.3+) sends io.element.call.encryption_keys as to-device messages, not room timeline events. Added UnknownToDeviceEvent callback to catch these and deliver keys to active voice sessions. Also added m.room.encrypted decryption attempt in timeline scan as fallback for older Element versions that send encrypted timeline events. Co-Authored-By: Claude Opus 4.6 (1M context) --- bot.py | 40 ++++++++++++++++++++++++++++++++++++++++ voice.py | 18 +++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/bot.py b/bot.py index d61237d..5f645ff 100644 --- a/bot.py +++ b/bot.py @@ -40,6 +40,7 @@ from nio import ( KeyVerificationMac, ToDeviceError, ) +from nio.events.to_device import UnknownToDeviceEvent from nio.crypto.attachments import decrypt_attachment from livekit import api, rtc from voice import VoiceSession @@ -1356,6 +1357,7 @@ class Bot: self.client.add_to_device_callback(self.on_key_verification, KeyVerificationKey) self.client.add_to_device_callback(self.on_key_verification, KeyVerificationMac) self.client.add_to_device_callback(self.on_key_verification, KeyVerificationCancel) + self.client.add_to_device_callback(self.on_to_device_unknown, UnknownToDeviceEvent) # Cache display name for mention matching in group rooms try: @@ -3851,6 +3853,44 @@ class Bot: logger.info("Verification complete for %s with %s", txn_id, v["sender"]) self._verifications.pop(txn_id, None) + async def on_to_device_unknown(self, event: UnknownToDeviceEvent): + """Handle unknown to-device events — catches Element X encryption key delivery.""" + source = getattr(event, "source", {}) or {} + evt_type = source.get("type", getattr(event, "type", "")) + content = source.get("content", {}) + sender = source.get("sender", getattr(event, "sender", "")) + + if evt_type == ENCRYPTION_KEYS_TYPE: + device_id = content.get("device_id", "") + room_id = content.get("room_id", content.get("call_id", "")) + keys_list = content.get("keys", []) + logger.info("Got to-device encryption_keys from %s (device=%s, room=%s, keys=%d)", + sender, device_id, room_id, len(keys_list)) + + # Try to find the active voice session for this room + vs = None + if room_id and room_id in self.voice_sessions: + vs = self.voice_sessions[room_id] + elif self.voice_sessions: + # Element X may not include room_id — use the only active session + if len(self.voice_sessions) == 1: + vs = next(iter(self.voice_sessions.values())) + logger.info("Using single active voice session for to-device key") + + if vs and keys_list: + for k in keys_list: + key_b64 = k.get("key", "") + key_index = k.get("index", 0) + if key_b64: + key_b64 += "=" * (-len(key_b64) % 4) + key_bytes = base64.urlsafe_b64decode(key_b64) + vs.on_encryption_key(sender, device_id, key_bytes, key_index) + logger.info("Delivered to-device encryption key (index=%d) to voice session", key_index) + elif not vs: + logger.warning("Got to-device encryption_keys but no matching voice session (room=%s)", room_id) + else: + logger.debug("Unknown to-device event: type=%s sender=%s", evt_type, sender) + async def on_megolm(self, room, event: MegolmEvent): """Request keys for undecryptable messages.""" logger.warning( diff --git a/voice.py b/voice.py index 8b38fd2..80a4581 100644 --- a/voice.py +++ b/voice.py @@ -598,7 +598,7 @@ class VoiceSession: except Exception as e: logger.debug("call.member state key fetch failed: %s", e) - # Legacy: timeline scan + # Legacy: timeline scan (decrypts m.room.encrypted events to find encryption_keys) url = f"{homeserver}/_matrix/client/v3/rooms/{self.room_id}/messages" try: async with httpx.AsyncClient(timeout=10.0) as http: @@ -614,6 +614,22 @@ class VoiceSession: now_ms = int(time.time() * 1000) for evt in events: evt_type = evt.get("type", "") + # Decrypt m.room.encrypted events to find encryption_keys inside + if evt_type == "m.room.encrypted" and self.nio_client.olm: + try: + from nio import Event as NioEvent + parsed = NioEvent.parse_encrypted_event(evt) + if parsed: + decrypted = self.nio_client.decrypt_event(parsed) + if hasattr(decrypted, "source") and decrypted.source: + dec_type = decrypted.source.get("type", "") + if dec_type == "io.element.call.encryption_keys": + evt = decrypted.source + evt_type = dec_type + logger.info("Decrypted m.room.encrypted -> %s from %s", + dec_type, evt.get("sender", "")) + except Exception as dec_err: + logger.debug("Failed to decrypt timeline event: %s", dec_err) if evt_type == "io.element.call.encryption_keys": sender = evt.get("sender", "") if sender == user_id: