fix: use timeline events for E2EE key exchange (not state events)

Element Call distributes encryption keys as timeline events, not room
state events. Changed bot to publish keys via room_send and fetch from
/messages endpoint instead of /state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-02-21 17:28:56 +02:00
parent 4e1e372ca2
commit 6e1e9839cc
2 changed files with 76 additions and 108 deletions

View File

@@ -103,60 +103,45 @@ class VoiceSession:
sender, device_id, index, len(key))
async def _fetch_encryption_key_http(self) -> bytes | None:
"""Fetch encryption key from room state via Matrix HTTP API."""
"""Fetch encryption key from room timeline (NOT state) via Matrix HTTP API.
Element Call distributes encryption keys as timeline events, not state.
"""
import httpx
homeserver = str(self.nio_client.homeserver)
token = self.nio_client.access_token
url = f"{homeserver}/_matrix/client/v3/rooms/{self.room_id}/state"
url = f"{homeserver}/_matrix/client/v3/rooms/{self.room_id}/messages"
try:
async with httpx.AsyncClient(timeout=10.0) as http:
resp = await http.get(url, headers={"Authorization": f"Bearer {token}"})
resp = await http.get(
url,
headers={"Authorization": f"Bearer {token}"},
params={"dir": "b", "limit": "50"},
)
resp.raise_for_status()
events = resp.json()
data = resp.json()
events = data.get("chunk", [])
user_id = self.nio_client.user_id
for evt in events:
evt_type = evt.get("type", "")
if evt_type == "io.element.call.encryption_keys":
sender = evt.get("sender", "")
user_id = self.nio_client.user_id
if sender == user_id:
continue # skip our own key
content = evt.get("content", {})
state_key = evt.get("state_key", "")
logger.info("Found encryption_keys event: sender=%s state_key=%s content=%s",
sender, state_key, content)
logger.info("Found encryption_keys timeline event: sender=%s content=%s",
sender, content)
for k in content.get("keys", []):
key_b64 = k.get("key", "")
if key_b64:
key_b64 += "=" * (-len(key_b64) % 4)
import base64 as b64
return b64.urlsafe_b64decode(key_b64)
# Log all state event types for debugging
types = [e.get("type", "") for e in events]
logger.info("Room state event types: %s", types)
logger.info("No encryption_keys events in last %d timeline events", len(events))
except Exception as e:
logger.warning("HTTP encryption key fetch failed: %s", e)
return None
async def _publish_e2ee_key(self, key: bytes):
"""Publish our E2EE key to room state so Element Call shares its key with us."""
import base64 as b64
key_b64 = b64.urlsafe_b64encode(key).decode().rstrip("=")
content = {
"call_id": "",
"device_id": self.device_id,
"keys": [{"index": 0, "key": key_b64}],
}
user_id = self.nio_client.user_id
state_key = f"{user_id}:{self.device_id}"
try:
ENCRYPTION_KEYS_TYPE = "io.element.call.encryption_keys"
await self.nio_client.room_put_state(
self.room_id, ENCRYPTION_KEYS_TYPE, content, state_key=state_key,
)
logger.info("Published E2EE key (state_key=%s)", state_key)
except Exception:
logger.exception("Failed to publish E2EE key")
async def start(self):
self._task = asyncio.create_task(self._run())
@@ -184,30 +169,31 @@ class VoiceSession:
user_id = self.nio_client.user_id
jwt = _generate_lk_jwt(self.room_id, user_id, self.device_id)
# Actively fetch encryption key from room state via HTTP API
# Element Call publishes keys as state events during active calls
# Check timeline for caller's encryption key
caller_key = await self._fetch_encryption_key_http()
if caller_key:
self._e2ee_key = caller_key
logger.info("Got caller E2EE key via HTTP (%d bytes)", len(caller_key))
else:
# Wait up to 10s for key via sync handler
logger.info("No key in room state yet, waiting for sync...")
for _ in range(100):
logger.info("Got caller E2EE key via timeline (%d bytes)", len(caller_key))
if not self._e2ee_key:
# Wait up to 15s for key via sync handler (bot.py forwards
# encryption_keys timeline events to on_encryption_key)
logger.info("No key in timeline yet, waiting for sync...")
for _ in range(150):
if self._e2ee_key:
break
await asyncio.sleep(0.1)
if self._e2ee_key:
shared_key = self._e2ee_key
logger.info("Using caller's E2EE key (%d bytes)", len(shared_key))
# Publish the SAME key so Element Call sees us as encrypted
await self._publish_e2ee_key(shared_key)
logger.info("Using E2EE key (%d bytes)", len(shared_key))
e2ee_opts = _build_e2ee_options(shared_key)
else:
logger.warning("No E2EE key available — connecting WITHOUT encryption")
e2ee_opts = None
# Generate our own key as fallback — bot.py already published one
import secrets
shared_key = secrets.token_bytes(32)
logger.warning("No caller E2EE key received — using generated key")
e2ee_opts = _build_e2ee_options(shared_key)
room_opts = rtc.RoomOptions(e2ee=e2ee_opts)
self.lk_room = rtc.Room()