fix: send bot E2EE key via Olm-encrypted to-device message

Element X only reads encryption keys from encrypted to-device
messages, not room events or call.member state. Bot now sends
its key via Olm-encrypted to-device to all call participants,
matching Element Call's encryptAndSendToDevice behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-03-24 12:57:28 +02:00
parent 776b1af3a0
commit b8f62ac38f

99
bot.py
View File

@@ -3705,6 +3705,105 @@ class Bot:
except Exception:
logger.exception("Failed to update call.member state with E2EE key in %s", room_id)
# To-device: send Olm-encrypted key to all other call participants
# Element X reads keys from encrypted to-device messages only
await self._send_e2ee_key_to_device(room_id, key_b64)
async def _send_e2ee_key_to_device(self, room_id: str, key_b64: str):
"""Send bot's E2EE key as Olm-encrypted to-device message to call participants."""
try:
import httpx
import uuid as _uuid
token = self.client.access_token
# Find other call participants from call.member state events
state_url = f"{HOMESERVER}/_matrix/client/v3/rooms/{room_id}/state"
async with httpx.AsyncClient(timeout=10.0) as http:
resp = await http.get(state_url, headers={"Authorization": f"Bearer {token}"})
if resp.status_code != 200:
return
targets = []
for evt in resp.json():
if evt.get("type") != CALL_MEMBER_TYPE:
continue
sender = evt.get("sender", "")
if sender == BOT_USER:
continue
content_evt = evt.get("content", {})
if not content_evt:
continue
device = content_evt.get("device_id", "")
if device:
targets.append((sender, device))
if not targets:
return
# Payload matching Element Call's to-device format
payload_content = {
"keys": {"index": 0, "key": key_b64},
"room_id": room_id,
"member": {"claimed_device_id": BOT_DEVICE_ID},
}
# Olm-encrypt for each target device
messages = {}
olm = self.client.olm
if not olm:
logger.warning("No Olm instance — cannot send encrypted to-device key")
return
for user_id, device_id in targets:
# Get the device from the device store
device = olm.device_store.get(user_id, device_id) if hasattr(olm, 'device_store') else None
if not device:
# Need to fetch device keys first
logger.info("Fetching device keys for %s/%s", user_id, device_id)
await self.client.keys_query()
device = olm.device_store.get(user_id, device_id) if hasattr(olm, 'device_store') else None
if not device:
logger.warning("Device %s/%s not found in store, sending unencrypted", user_id, device_id)
messages.setdefault(user_id, {})[device_id] = payload_content
continue
# Find or create Olm session
session = olm.session_store.get(device.curve25519)
if not session:
# Claim one-time key and create session
logger.info("Claiming one-time key for %s/%s", user_id, device_id)
claim_resp = await self.client.keys_claim(user_id, device_id)
session = olm.session_store.get(device.curve25519)
if session:
encrypted = olm._olm_encrypt(
session, device, ENCRYPTION_KEYS_TYPE, payload_content
)
messages.setdefault(user_id, {})[device_id] = encrypted
logger.info("Olm-encrypted E2EE key for %s/%s", user_id, device_id)
else:
logger.warning("No Olm session for %s/%s, sending unencrypted", user_id, device_id)
messages.setdefault(user_id, {})[device_id] = payload_content
# Send via PUT /sendToDevice
event_type = "m.room.encrypted" if any(
"ciphertext" in v.get(list(v.keys())[0], {}) if v else False
for v in messages.values()
) else ENCRYPTION_KEYS_TYPE
txn_id = str(_uuid.uuid4())
send_url = f"{HOMESERVER}/_matrix/client/v3/sendToDevice/{event_type}/{txn_id}"
async with httpx.AsyncClient(timeout=10.0) as http:
resp = await http.put(
send_url,
headers={"Authorization": f"Bearer {token}"},
json={"messages": messages},
)
resp.raise_for_status()
logger.info("Sent E2EE key via to-device (%s) to %d target(s): %s",
event_type, len(targets), targets)
except Exception:
logger.exception("Failed to send E2EE key via to-device")
async def _route_verification(self, room, event: UnknownEvent):
"""Route in-room verification events from UnknownEvent."""
source = event.source or {}