- LiveKit call E2EE: CallE2EEManager exchanges encryption keys via Matrix to-device events (m.rtc.encryption_keys) for interop with Element X - Olm bootstrapped in index.html before Flutter init; main.dart logs result - Encrypted messages shown with lock icon and informative fallback text - Profile screen: key restore dialog + security setup (cross-signing/backup) - Jitsi feature: welcome screen (public, no login), conference tab, full-screen embed via JitsiMeetExternalAPI, JitsiLink parser for all common link formats - Help tab: expandable cards for encryption, video calls, account management - Web security model: no session persistence — device ID only across visits - Media auth: MSC3916 authenticated endpoint for avatars (Synapse 1.120+) - Router: welcome route as public landing page; jitsi route as public - Manifest/index.html: M8Chat branding, dark theme colours Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
6.3 KiB
Dart
186 lines
6.3 KiB
Dart
// 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<ToDeviceEvent>? _toDeviceSub;
|
|
Uint8List? _localKey;
|
|
final int _localKeyIndex = 0;
|
|
|
|
BaseKeyProvider? get keyProvider => _keyProvider;
|
|
|
|
/// Create the key provider and start listening for incoming keys.
|
|
Future<BaseKeyProvider> 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<void> 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<void> 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 = <String, Object>{
|
|
'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<dynamic>? ?? [];
|
|
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<DeviceKeys> 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<dynamic>?;
|
|
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<void> 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<int>.generate(length, (_) => rng.nextInt(256)));
|
|
}
|
|
}
|