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: