fix: generate and publish E2EE key, always connect with encryption

Element Call encrypts media by default. Bot must:
1. Generate its own 32-byte E2EE key
2. Publish it to room state (io.element.call.encryption_keys)
3. Connect to LiveKit with HKDF E2EE enabled
4. Use caller's key when received, own key as fallback

This fixes: Nicht verschlüsselt warning, silent audio (encrypted
frames couldn't be decoded by VAD/STT)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-02-21 17:06:34 +02:00
parent 74758a3f13
commit 753d6543d4

View File

@@ -102,6 +102,26 @@ class VoiceSession:
logger.info("E2EE key received from %s:%s (index=%d, %d bytes)",
sender, device_id, index, len(key))
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())
@@ -129,20 +149,29 @@ class VoiceSession:
user_id = self.nio_client.user_id
jwt = _generate_lk_jwt(self.room_id, user_id, self.device_id)
# Wait up to 10s for E2EE encryption key from Element Call
# Generate our own E2EE key and publish it to the room
# Element Call requires ALL participants to publish keys
import secrets
our_key = secrets.token_bytes(32)
await self._publish_e2ee_key(our_key)
logger.info("Published our E2EE key (%d bytes)", len(our_key))
# Wait up to 10s for caller's E2EE encryption key
for _ in range(100):
if self._e2ee_key:
break
await asyncio.sleep(0.1)
if not self._e2ee_key:
logger.warning("No E2EE key received after 10s, connecting without encryption")
# Connect with E2EE if key available
e2ee_opts = None
# Use caller's key if available, otherwise use our own
shared_key = self._e2ee_key or our_key
if self._e2ee_key:
e2ee_opts = _build_e2ee_options(self._e2ee_key)
logger.info("E2EE enabled with HKDF (%d byte key)", len(self._e2ee_key))
logger.info("Using caller's E2EE key (%d bytes)", len(self._e2ee_key))
else:
logger.warning("No caller key received after 10s, using our own key")
e2ee_opts = _build_e2ee_options(shared_key)
logger.info("E2EE enabled with HKDF")
room_opts = rtc.RoomOptions(e2ee=e2ee_opts)
self.lk_room = rtc.Room()
@@ -159,8 +188,7 @@ class VoiceSession:
logger.info("Track sub: %s %s kind=%s", p.identity, pub.sid, t.kind)
await self.lk_room.connect(self.lk_url, jwt, options=room_opts)
logger.info("Connected (E2EE=%s), remote=%d",
"HKDF" if self._e2ee_key else "off",
logger.info("Connected (E2EE=HKDF), remote=%d",
len(self.lk_room.remote_participants))
# Find the remote participant to link to