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:
2026-04-27 05:28:14 +10:00
parent a241d0a7ab
commit b95471b0b4
27 changed files with 2076 additions and 226 deletions

View 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)));
}
}