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) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-03-24 09:08:09 +02:00
parent c11dd73ce3
commit c604b5f644
2 changed files with 57 additions and 1 deletions

40
bot.py
View File

@@ -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(