fix(voice): poll for EC key rotation post-connect, set all key indices

Element Call rotates its encryption key when a new participant joins the
LiveKit room. Previously the bot fetched only the pre-join key and set it
at index 0, while EC was already encrypting with the rotated key (index 1).

Changes:
- After connecting to LiveKit, poll the Matrix timeline up to 5s (10×0.5s)
  to detect the post-join key rotation
- Set ALL known caller key indices (not just 0) so the Rust FFI cryptor
  has the correct key regardless of which index EC is currently using
- Also set via caller_identity (belt+suspenders) if different from LK identity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-02-22 08:20:44 +02:00
parent 8b143a2ac4
commit cf519595d6

View File

@@ -257,6 +257,24 @@ class VoiceSession:
logger.info("Connected (E2EE=HKDF), remote=%d",
len(self.lk_room.remote_participants))
# Element Call rotates its encryption key when bot joins the LiveKit room.
# We fetched the pre-join key above; now wait for EC to generate and publish
# the post-join rotated key via Matrix timeline.
pre_max_idx = max(self._caller_all_keys.keys()) if self._caller_all_keys else -1
logger.info("Polling for EC key rotation (pre-join max_idx=%d)...", pre_max_idx)
for _attempt in range(10): # poll up to 5s (10 × 0.5s)
await asyncio.sleep(0.5)
await self._fetch_encryption_key_http()
new_max = max(self._caller_all_keys.keys()) if self._caller_all_keys else -1
if new_max > pre_max_idx:
self._caller_key = self._caller_all_keys[new_max]
logger.info("Key rotated: index %d%d (%d bytes)",
pre_max_idx, new_max, len(self._caller_key))
break
logger.debug("Key rotation poll %d: max_idx still %d", _attempt + 1, new_max)
else:
logger.warning("No key rotation after 5s — using pre-join key[%d]", pre_max_idx)
# Set per-participant keys via key provider
kp = self.lk_room.e2ee_manager.key_provider
@@ -279,21 +297,24 @@ class VoiceSession:
if remote_identity:
break
# Set caller's per-participant key (HKDF info=caller_identity = matching EC JS).
if self._caller_key and remote_identity:
# Set ALL known caller keys (per-participant, HKDF info=remote_identity).
# EC may have already rotated (index 0→1) by the time bot connects.
if self._caller_all_keys and remote_identity:
try:
kp.set_key(remote_identity, self._caller_key, 0)
logger.info("Set caller key for %s (index=0, %d bytes)", remote_identity, len(self._caller_key))
# Also set via caller_identity (belt+suspenders if identities differ)
for idx, k in sorted(self._caller_all_keys.items()):
kp.set_key(remote_identity, k, idx)
logger.info("Set caller key[%d] for %s (%d bytes)", idx, remote_identity, len(k))
# Belt+suspenders: also set via matrix identity if different from LK identity
if self._caller_identity and self._caller_identity != remote_identity:
kp.set_key(self._caller_identity, self._caller_key, 0)
logger.info("Also set caller key via identity %s", self._caller_identity)
for idx, k in sorted(self._caller_all_keys.items()):
kp.set_key(self._caller_identity, k, idx)
logger.info("Also set all caller keys via identity %s", self._caller_identity)
except Exception as e:
logger.warning("Failed to set caller per-participant key: %s", e)
elif not self._caller_key:
logger.warning("No caller E2EE key — incoming audio will be silence")
logger.warning("Failed to set caller per-participant keys: %s", e)
elif not self._caller_all_keys:
logger.warning("No caller E2EE keys — incoming audio will be silence")
elif not remote_identity:
logger.warning("No remote participant found — caller key not set")
logger.warning("No remote participant found — caller keys not set")
if remote_identity:
logger.info("Linking to remote participant: %s", remote_identity)