diff --git a/bot.py b/bot.py index 7179b2a..aec0cbe 100644 --- a/bot.py +++ b/bot.py @@ -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 {}