// Version: 1.0.0 | Created: 2026-04-10 // E2EE key exchange for LiveKit calls via Matrix to-device events. // Implements the m.rtc.encryption_keys protocol for interop with Element X. import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:matrix/matrix.dart' show Client, DeviceKeys, ToDeviceEvent; /// Event type used by Element X / Element Call for key exchange. const kEncryptionKeysEventType = 'm.rtc.encryption_keys'; /// Manages E2EE key generation, distribution, and reception for LiveKit calls. /// /// Flow: /// 1. [init] creates a per-participant [BaseKeyProvider] and generates a local key. /// 2. After LiveKit connects, call [setLocalKey] with the local participant identity. /// 3. Call [sendKeyToParticipants] to share our key via encrypted to-device events. /// 4. Incoming keys from Element X are received via [Client.onToDeviceEvent] and /// set on the [BaseKeyProvider] automatically. class CallE2EEManager { CallE2EEManager({ required Client client, required String matrixRoomId, }) : _client = client, _matrixRoomId = matrixRoomId; final Client _client; final String _matrixRoomId; BaseKeyProvider? _keyProvider; StreamSubscription? _toDeviceSub; Uint8List? _localKey; final int _localKeyIndex = 0; BaseKeyProvider? get keyProvider => _keyProvider; /// Create the key provider and start listening for incoming keys. Future init() async { _keyProvider = await BaseKeyProvider.create(sharedKey: false); // Generate a random 32-byte encryption key for our media tracks. _localKey = _secureRandomBytes(32); // Listen for incoming encryption keys from other call participants. _toDeviceSub = _client.onToDeviceEvent.stream.listen(_onToDeviceEvent); debugPrint('[E2EE] Key provider created, local key generated ' '(${base64Encode(_localKey!).substring(0, 8)}…)'); return _keyProvider!; } /// Set our local key on the provider using the LiveKit participant identity. /// Call this AFTER the LiveKit Room is connected and localParticipant is available. Future setLocalKey(String localIdentity) async { if (_keyProvider == null || _localKey == null) return; await _keyProvider!.setRawKey( _localKey!, participantId: localIdentity, keyIndex: _localKeyIndex, ); debugPrint('[E2EE] Local key set for identity=$localIdentity'); } /// Send our encryption key to all other call members via encrypted to-device. Future sendKeyToParticipants() async { if (_localKey == null) return; final room = _client.getRoomById(_matrixRoomId); if (room == null) { debugPrint('[E2EE] Room $_matrixRoomId not found — cannot send keys'); return; } // Find other participants from the MSC3401 call.member state events. final memberEvents = room.states['org.matrix.msc3401.call.member'] ?? {}; final content = { 'keys': [ {'index': _localKeyIndex, 'key': base64Encode(_localKey!)}, ], 'call_id': '', 'device_id': _client.deviceID ?? '', 'room_id': _matrixRoomId, }; for (final entry in memberEvents.entries) { final userId = entry.key; if (userId == _client.userID) continue; // Parse device_id from the call.member event to target the correct device. final memberships = entry.value.content['memberships'] as List? ?? []; if (memberships.isEmpty) continue; await _client.userDeviceKeysLoading; final deviceKeyMap = _client.userDeviceKeys[userId]?.deviceKeys; if (deviceKeyMap == null || deviceKeyMap.isEmpty) { debugPrint('[E2EE] No device keys for $userId — skipping'); continue; } // Send to all devices of this user that are in the call. final List targets = []; for (final m in memberships) { final deviceId = m['device_id'] as String?; if (deviceId != null && deviceKeyMap.containsKey(deviceId)) { targets.add(deviceKeyMap[deviceId]!); } } // Fallback: if no specific device matched, send to all devices. if (targets.isEmpty) { targets.addAll(deviceKeyMap.values); } try { await _client.sendToDeviceEncrypted( targets, kEncryptionKeysEventType, content, ); debugPrint( '[E2EE] Sent key to $userId (${targets.length} device(s))'); } catch (e) { debugPrint('[E2EE] Failed to send key to $userId: $e'); } } } /// Handle incoming to-device encryption key events from other participants. void _onToDeviceEvent(ToDeviceEvent event) { if (event.type != kEncryptionKeysEventType) return; // Only process keys for our active call room. final eventRoomId = event.content['room_id'] as String?; if (eventRoomId != null && eventRoomId != _matrixRoomId) return; final keys = event.content['keys'] as List?; if (keys == null || keys.isEmpty) { debugPrint( '[E2EE] Received $kEncryptionKeysEventType with empty keys ' 'from ${event.sender}'); return; } final senderId = event.sender; debugPrint('[E2EE] Received encryption key(s) from $senderId'); for (final keyEntry in keys) { final index = (keyEntry['index'] as num?)?.toInt() ?? 0; final keyB64 = keyEntry['key'] as String?; if (keyB64 == null) continue; final keyBytes = base64Decode(keyB64); // The participant identity in LiveKit should match the Matrix user ID // (set by lk-jwt-service). Log both for debugging. _keyProvider?.setRawKey( keyBytes, participantId: senderId, keyIndex: index, ); debugPrint( '[E2EE] Key set for participant=$senderId index=$index ' '(${keyB64.substring(0, 8)}…)'); } } Future dispose() async { await _toDeviceSub?.cancel(); _toDeviceSub = null; _localKey = null; _keyProvider = null; debugPrint('[E2EE] Disposed'); } static Uint8List _secureRandomBytes(int length) { final rng = Random.secure(); return Uint8List.fromList( List.generate(length, (_) => rng.nextInt(256))); } }