feat: Phase 3 — E2EE calls, Jitsi conferencing, help tab, web security model
- 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>
This commit is contained in:
185
lib/features/calls/data/call_e2ee.dart
Normal file
185
lib/features/calls/data/call_e2ee.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-03
|
||||
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-04-10
|
||||
// LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt
|
||||
// endpoint, then connects a LiveKit Room using that token.
|
||||
//
|
||||
// The JWT endpoint is defined in AppConfig.livekitJwtUrl and uses the Matrix
|
||||
// access token as Bearer auth. The LiveKit server URL is the same host as the
|
||||
// Matrix server (as configured on chat.m8chat.au).
|
||||
// E2EE: Frame encryption is enabled via livekit_client's E2EEOptions.
|
||||
// Encryption keys are exchanged with Element X via m.rtc.encryption_keys
|
||||
// to-device events (see call_e2ee.dart).
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
@@ -19,6 +19,7 @@ import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/auth/auth_state.dart';
|
||||
import '../../../core/config/app_config.dart';
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import 'call_e2ee.dart';
|
||||
|
||||
part 'livekit_service.g.dart';
|
||||
|
||||
@@ -67,6 +68,7 @@ class LiveKitService {
|
||||
Room? _activeRoom;
|
||||
String? _activeMatrixRoomId;
|
||||
String? _membershipId;
|
||||
CallE2EEManager? _e2eeManager;
|
||||
|
||||
Room? get activeRoom => _activeRoom;
|
||||
|
||||
@@ -111,14 +113,37 @@ class LiveKitService {
|
||||
_activeMatrixRoomId = matrixRoomId;
|
||||
debugPrint('[LiveKit] Call member state event sent');
|
||||
|
||||
// Step 3 — connect to LiveKit
|
||||
final room = Room();
|
||||
// Step 3 — initialise E2EE key exchange
|
||||
_e2eeManager = CallE2EEManager(
|
||||
client: client,
|
||||
matrixRoomId: matrixRoomId,
|
||||
);
|
||||
final keyProvider = await _e2eeManager!.init();
|
||||
|
||||
// Step 4 — connect to LiveKit with frame encryption
|
||||
// COOP/COEP headers on the server enable SharedArrayBuffer for the
|
||||
// E2EE web worker. Without them the worker crashes.
|
||||
final room = Room(
|
||||
roomOptions: RoomOptions(
|
||||
e2eeOptions: E2EEOptions(keyProvider: keyProvider),
|
||||
),
|
||||
);
|
||||
try {
|
||||
await room.connect(livekitUrl, jwt);
|
||||
_activeRoom = room;
|
||||
|
||||
// Step 5 — set our local key and share with other participants
|
||||
final localIdentity = room.localParticipant?.identity;
|
||||
debugPrint('[LiveKit] Connected, local identity: $localIdentity');
|
||||
if (localIdentity != null) {
|
||||
await _e2eeManager!.setLocalKey(localIdentity);
|
||||
}
|
||||
await _e2eeManager!.sendKeyToParticipants();
|
||||
|
||||
return LiveKitConnected(room: room);
|
||||
} on Exception catch (e) {
|
||||
// Clean up the state event on failure
|
||||
await _e2eeManager?.dispose();
|
||||
_e2eeManager = null;
|
||||
await _clearCallMemberEvent(client, matrixRoomId, userId);
|
||||
await room.disconnect();
|
||||
await room.dispose();
|
||||
@@ -136,6 +161,10 @@ class LiveKitService {
|
||||
_activeMatrixRoomId = null;
|
||||
_membershipId = null;
|
||||
|
||||
// Clean up E2EE key exchange
|
||||
await _e2eeManager?.dispose();
|
||||
_e2eeManager = null;
|
||||
|
||||
// Step 1: Leave LiveKit — Element X will see us disappear from the SFU
|
||||
if (room != null) {
|
||||
await room.disconnect();
|
||||
@@ -255,6 +284,7 @@ class LiveKitService {
|
||||
}
|
||||
|
||||
/// Clear the call.member state event (remove our membership).
|
||||
/// Element X expects empty content `{}` — not `{'memberships': []}`.
|
||||
Future<void> _clearCallMemberEvent(
|
||||
Client client,
|
||||
String matrixRoomId,
|
||||
@@ -265,8 +295,9 @@ class LiveKitService {
|
||||
matrixRoomId,
|
||||
_kCallMemberEventType,
|
||||
userId,
|
||||
{'memberships': []},
|
||||
{},
|
||||
);
|
||||
debugPrint('[LiveKit] Call member event cleared for $userId');
|
||||
} catch (e) {
|
||||
debugPrint('[LiveKit] Failed to clear call member event: $e');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user