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(

View File

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