From 9cf4afc928d3308d0716bf26723a4cd2ae445617 Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Sun, 22 Feb 2026 07:31:14 +0200 Subject: [PATCH] fix(e2ee): pass caller_key as shared_key at connect time Per-participant set_key() for remote identities doesn't work for incoming decryption in this Rust FFI build (set_shared_key() after connect is also ignored in per-participant mode). Solution: initialize with caller_key as shared_key (true shared-key mode) so the Rust FFI uses it for incoming decryption. Then override outgoing encryption via set_key(bot_identity, bot_key) after connect. Co-Authored-By: Claude Opus 4.6 --- voice.py | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/voice.py b/voice.py index 8dd29f1..faa6fcc 100644 --- a/voice.py +++ b/voice.py @@ -69,14 +69,16 @@ def _generate_lk_jwt(room_id, user_id, device_id): KDF_HKDF = 1 -def _build_e2ee_options() -> rtc.E2EEOptions: +def _build_e2ee_options(caller_key: bytes = b"") -> rtc.E2EEOptions: """Build HKDF E2EE options matching Element Call's key derivation. - No shared_key — initializes in per-participant mode so set_key() works. + Pass caller_key as shared_key to initialize in true shared-key mode. + This ensures the Rust FFI decrypts incoming frames using caller's key. + Outgoing encryption is overridden via set_key(bot_identity, bot_key) after connect. Element Call uses: ratchetWindowSize=16, keyringSize=256, salt="LKFrameEncryptionKey" """ key_opts = rtc.KeyProviderOptions( - shared_key=b"", + shared_key=caller_key, ratchet_window_size=16, ratchet_salt=b"LKFrameEncryptionKey", failure_tolerance=-1, @@ -223,9 +225,10 @@ class VoiceSession: break await asyncio.sleep(0.1) - # Connect with E2EE in per-participant mode (no shared_key) - # so set_key() calls work correctly for both directions - e2ee_opts = _build_e2ee_options() + # Connect with caller_key as shared_key so Rust FFI decrypts + # incoming audio in true shared-key mode. Outgoing encryption + # is overridden to bot_key via set_key(bot_identity) after connect. + e2ee_opts = _build_e2ee_options(self._caller_key or b"") room_opts = rtc.RoomOptions(e2ee=e2ee_opts) self.lk_room = rtc.Room() @@ -267,25 +270,12 @@ class VoiceSession: if remote_identity: break - # Set caller's key(s) — decrypts incoming audio - # Use all collected keys with their correct indices (Element Call may rotate) - # Also set as shared key fallback: Rust FFI may not use per-participant - # key for remote participants in all code paths. + # Caller key was passed as shared_key at connect time — no further + # per-participant set_key needed for decryption. if self._caller_key: - caller_id = remote_identity or self._caller_identity - if caller_id: - keys_to_set = self._caller_all_keys or {0: self._caller_key} - for idx, key in keys_to_set.items(): - kp.set_key(caller_id, key, idx) - logger.info("Set caller key[%d] for %s (%d bytes)", idx, caller_id, len(key)) - # Shared key fallback: use highest-index caller key - max_idx = max(keys_to_set.keys()) - kp.set_shared_key(keys_to_set[max_idx], max_idx) - logger.info("Set shared key fallback[%d] (%d bytes)", max_idx, len(keys_to_set[max_idx])) - else: - logger.warning("Have caller key but no caller identity") + logger.info("Caller key active as shared_key (%d bytes, index 0)", len(self._caller_key)) else: - logger.warning("No caller E2EE key available") + logger.warning("No caller E2EE key — incoming audio will be silence") if remote_identity: logger.info("Linking to remote participant: %s", remote_identity)