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:
40
bot.py
40
bot.py
@@ -40,6 +40,7 @@ from nio import (
|
|||||||
KeyVerificationMac,
|
KeyVerificationMac,
|
||||||
ToDeviceError,
|
ToDeviceError,
|
||||||
)
|
)
|
||||||
|
from nio.events.to_device import UnknownToDeviceEvent
|
||||||
from nio.crypto.attachments import decrypt_attachment
|
from nio.crypto.attachments import decrypt_attachment
|
||||||
from livekit import api, rtc
|
from livekit import api, rtc
|
||||||
from voice import VoiceSession
|
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, KeyVerificationKey)
|
||||||
self.client.add_to_device_callback(self.on_key_verification, KeyVerificationMac)
|
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_key_verification, KeyVerificationCancel)
|
||||||
|
self.client.add_to_device_callback(self.on_to_device_unknown, UnknownToDeviceEvent)
|
||||||
|
|
||||||
# Cache display name for mention matching in group rooms
|
# Cache display name for mention matching in group rooms
|
||||||
try:
|
try:
|
||||||
@@ -3851,6 +3853,44 @@ class Bot:
|
|||||||
logger.info("Verification complete for %s with %s", txn_id, v["sender"])
|
logger.info("Verification complete for %s with %s", txn_id, v["sender"])
|
||||||
self._verifications.pop(txn_id, None)
|
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):
|
async def on_megolm(self, room, event: MegolmEvent):
|
||||||
"""Request keys for undecryptable messages."""
|
"""Request keys for undecryptable messages."""
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
18
voice.py
18
voice.py
@@ -598,7 +598,7 @@ class VoiceSession:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("call.member state key fetch failed: %s", 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"
|
url = f"{homeserver}/_matrix/client/v3/rooms/{self.room_id}/messages"
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as http:
|
async with httpx.AsyncClient(timeout=10.0) as http:
|
||||||
@@ -614,6 +614,22 @@ class VoiceSession:
|
|||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
for evt in events:
|
for evt in events:
|
||||||
evt_type = evt.get("type", "")
|
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":
|
if evt_type == "io.element.call.encryption_keys":
|
||||||
sender = evt.get("sender", "")
|
sender = evt.get("sender", "")
|
||||||
if sender == user_id:
|
if sender == user_id:
|
||||||
|
|||||||
Reference in New Issue
Block a user