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,213 @@
// Version: 1.0.0 | Created: 2026-04-11
// Dialog for entering a recovery key to restore encrypted message keys
// from the server-side key backup (SSSS / Secure Secret Storage).
import 'package:flutter/material.dart';
import 'package:matrix/encryption/ssss.dart';
import 'package:matrix/matrix.dart';
/// Shows a dialog that asks the user for their recovery key, unlocks SSSS,
/// and restores all Megolm session keys from the server backup.
///
/// Returns `true` if keys were restored successfully, `false` otherwise.
Future<bool> showKeyRestoreDialog(BuildContext context, Client client) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _KeyRestoreDialog(client: client),
);
return result ?? false;
}
class _KeyRestoreDialog extends StatefulWidget {
const _KeyRestoreDialog({required this.client});
final Client client;
@override
State<_KeyRestoreDialog> createState() => _KeyRestoreDialogState();
}
enum _RestorePhase { input, unlocking, downloading, done, error }
class _KeyRestoreDialogState extends State<_KeyRestoreDialog> {
final _controller = TextEditingController();
_RestorePhase _phase = _RestorePhase.input;
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _restore() async {
final key = _controller.text.trim();
if (key.isEmpty) return;
final enc = widget.client.encryption;
if (enc == null) {
setState(() {
_phase = _RestorePhase.error;
_error = 'Encryption is not available.';
});
return;
}
// Phase 1: Unlock SSSS with the recovery key
setState(() => _phase = _RestorePhase.unlocking);
try {
final ssssKey = enc.ssss.open();
await ssssKey.unlock(keyOrPassphrase: key);
debugPrint('[KeyRestore] SSSS unlocked successfully');
} on InvalidPassphraseException {
setState(() {
_phase = _RestorePhase.error;
_error = 'Invalid recovery key. Please check and try again.';
});
return;
} on Exception catch (e) {
setState(() {
_phase = _RestorePhase.error;
_error = 'Could not unlock: ${e.toString().split('\n').first}';
});
return;
}
// Phase 2: Download and decrypt all keys from backup
setState(() => _phase = _RestorePhase.downloading);
try {
await enc.keyManager.loadAllKeys();
debugPrint('[KeyRestore] All keys loaded from backup');
setState(() => _phase = _RestorePhase.done);
} on MatrixException catch (e) {
if (e.errcode == 'M_NOT_FOUND') {
setState(() {
_phase = _RestorePhase.error;
_error = 'No key backup found on the server for this account.';
});
} else {
setState(() {
_phase = _RestorePhase.error;
_error = 'Server error: ${e.errorMessage}';
});
}
} on Exception catch (e) {
setState(() {
_phase = _RestorePhase.error;
_error = 'Failed to download keys: ${e.toString().split('\n').first}';
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(switch (_phase) {
_RestorePhase.input => 'Restore Message Keys',
_RestorePhase.unlocking => 'Unlocking...',
_RestorePhase.downloading => 'Downloading Keys...',
_RestorePhase.done => 'Keys Restored',
_RestorePhase.error => 'Restore Failed',
}),
content: switch (_phase) {
_RestorePhase.input => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter your recovery key to decrypt old messages. '
'This is the key you were given when you set up '
'encryption in Element.',
),
const SizedBox(height: 16),
TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'EsTc oRgW rqHN...',
labelText: 'Recovery key',
border: OutlineInputBorder(),
),
maxLines: 3,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _restore(),
),
],
),
_RestorePhase.unlocking => const _ProgressContent(
message: 'Verifying recovery key...',
),
_RestorePhase.downloading => const _ProgressContent(
message: 'Downloading and decrypting message keys...\n'
'This may take a moment.',
),
_RestorePhase.done => const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green, size: 48),
SizedBox(height: 16),
Text(
'Message keys have been restored. '
'Go back to your rooms — previously encrypted '
'messages should now be readable.',
),
],
),
_RestorePhase.error => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(_error ?? 'An unknown error occurred.'),
],
),
},
actions: switch (_phase) {
_RestorePhase.input => [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _restore,
child: const Text('Restore'),
),
],
_RestorePhase.unlocking || _RestorePhase.downloading => [],
_RestorePhase.done => [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Done'),
),
],
_RestorePhase.error => [
TextButton(
onPressed: () =>
setState(() => _phase = _RestorePhase.input),
child: const Text('Try again'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Close'),
),
],
},
);
}
}
class _ProgressContent extends StatelessWidget {
const _ProgressContent({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(message, textAlign: TextAlign.center),
],
);
}
}

View File

@@ -1,4 +1,4 @@
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-04-11
// Profile screen. Shows current user info and logout button.
import 'package:flutter/material.dart';
@@ -10,6 +10,8 @@ import '../../../core/config/app_config.dart';
import '../../../core/network/matrix_client.dart';
import '../../../shared/utils/matrix_id.dart';
import '../../../shared/widgets/matrix_avatar.dart';
import 'key_restore_dialog.dart';
import 'security_setup_dialog.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key, this.embedded = false});
@@ -85,14 +87,57 @@ class ProfileScreen extends ConsumerWidget {
ListTile(
leading: const Icon(Icons.lock_outline),
title: const Text('End-to-end encryption'),
subtitle: const Text('Setup coming in Phase 3'),
subtitle: Text(
client.encryptionEnabled
? 'Active — messages are encrypted'
: 'Not available in this browser',
),
enabled: false,
),
ListTile(
leading: const Icon(Icons.verified_user_outlined),
title: const Text('Verify devices'),
subtitle: const Text('Cross-signing setup — coming in Phase 2'),
enabled: false,
leading: const Icon(Icons.key_outlined),
title: const Text('Restore message keys'),
subtitle: const Text(
'Enter your recovery key to decrypt old messages',
),
enabled: client.encryptionEnabled,
onTap: client.encryptionEnabled
? () async {
final restored =
await showKeyRestoreDialog(context, client);
if (restored && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Keys restored — reopen rooms '
'to see decrypted messages.'),
),
);
}
}
: null,
),
ListTile(
leading: const Icon(Icons.shield_outlined),
title: const Text('Security setup'),
subtitle: Text(
client.encryption?.crossSigning.enabled == true
? 'Cross-signing active — device verified'
: 'Set up cross-signing and key backup',
),
enabled: client.encryptionEnabled,
onTap: client.encryptionEnabled
? () async {
final completed =
await showSecuritySetupDialog(context, client);
if (completed && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Security setup complete.'),
),
);
}
}
: null,
),
ListTile(
leading: const Icon(Icons.password_outlined),

View File

@@ -0,0 +1,416 @@
// Version: 1.0.0 | Created: 2026-04-11
// Security setup dialog: drives the Matrix SDK Bootstrap to set up
// SSSS, cross-signing, and online key backup in one flow.
// For new users: creates everything, shows recovery key.
// For existing users: unlocks with recovery key, restores message keys.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:matrix/encryption/ssss.dart';
import 'package:matrix/encryption/utils/bootstrap.dart';
import 'package:matrix/matrix.dart';
/// Shows the security setup dialog. Returns true if setup completed.
Future<bool> showSecuritySetupDialog(
BuildContext context, Client client) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _SecuritySetupDialog(client: client),
);
return result ?? false;
}
class _SecuritySetupDialog extends StatefulWidget {
const _SecuritySetupDialog({required this.client});
final Client client;
@override
State<_SecuritySetupDialog> createState() => _SecuritySetupDialogState();
}
enum _Phase {
checking,
needsRecoveryKey,
working,
showRecoveryKey,
restoring,
done,
error,
}
class _SecuritySetupDialogState extends State<_SecuritySetupDialog> {
final _keyController = TextEditingController();
_Phase _phase = _Phase.checking;
String? _error;
String? _recoveryKey;
String _statusMessage = 'Checking security setup...';
late Bootstrap _bootstrap;
@override
void initState() {
super.initState();
_start();
}
@override
void dispose() {
_keyController.dispose();
super.dispose();
}
Future<void> _start() async {
final enc = widget.client.encryption;
if (enc == null) {
_setError('Encryption is not available in this browser.');
return;
}
_bootstrap = enc.bootstrap(onUpdate: (_) {});
try {
await _driveBootstrap();
} catch (e) {
_setError(e.toString().split('\n').first);
}
}
Future<void> _driveBootstrap() async {
while (_bootstrap.state != BootstrapState.done &&
_bootstrap.state != BootstrapState.error) {
debugPrint('[Security] Bootstrap state: ${_bootstrap.state}');
switch (_bootstrap.state) {
case BootstrapState.loading:
setState(() {
_phase = _Phase.working;
_statusMessage = 'Setting up...';
});
// Wait for the bootstrap to advance via onUpdate
await Future.delayed(const Duration(milliseconds: 200));
case BootstrapState.askWipeSsss:
// Existing SSSS found — don't wipe, use it
setState(() {
_phase = _Phase.working;
_statusMessage = 'Found existing security setup...';
});
_bootstrap.wipeSsss(false);
case BootstrapState.askUseExistingSsss:
// Use the existing SSSS
_bootstrap.useExistingSsss(true);
case BootstrapState.openExistingSsss:
// Need the recovery key from the user
if (!_bootstrap.newSsssKey!.isUnlocked) {
setState(() => _phase = _Phase.needsRecoveryKey);
return; // Wait for user input
}
// Already unlocked, continue
setState(() {
_phase = _Phase.working;
_statusMessage = 'Verifying security keys...';
});
await _bootstrap.openExistingSsss();
case BootstrapState.askBadSsss:
// Ignore bad secrets and continue
_bootstrap.ignoreBadSecrets(true);
case BootstrapState.askUnlockSsss:
// Old keys need unlocking — this is a migration scenario
if (_bootstrap.oldSsssKeys != null) {
for (final key in _bootstrap.oldSsssKeys!.values) {
if (!key.isUnlocked) {
setState(() => _phase = _Phase.needsRecoveryKey);
return; // Wait for user input
}
}
}
_bootstrap.unlockedSsss();
case BootstrapState.askNewSsss:
// Create new SSSS (no passphrase — recovery key only)
setState(() {
_phase = _Phase.working;
_statusMessage = 'Creating security keys...';
});
await _bootstrap.newSsss();
case BootstrapState.askSetupCrossSigning:
setState(() => _statusMessage = 'Setting up cross-signing...');
await _bootstrap.askSetupCrossSigning(
setupMasterKey: true,
setupSelfSigningKey: true,
setupUserSigningKey: true,
);
case BootstrapState.askWipeCrossSigning:
// Don't wipe existing cross-signing
await _bootstrap.wipeCrossSigning(false);
case BootstrapState.askSetupOnlineKeyBackup:
setState(() => _statusMessage = 'Creating key backup...');
await _bootstrap.askSetupOnlineKeyBackup(true);
case BootstrapState.askWipeOnlineKeyBackup:
// Don't wipe existing backup
_bootstrap.wipeOnlineKeyBackup(false);
case BootstrapState.done:
case BootstrapState.error:
break; // Exit loop
}
}
if (_bootstrap.state == BootstrapState.error) {
_setError('Setup failed. Please try again.');
return;
}
// Success — show recovery key if we created new SSSS
_recoveryKey = _bootstrap.newSsssKey?.recoveryKey;
if (_recoveryKey != null && _phase != _Phase.needsRecoveryKey) {
// New setup — show the recovery key
setState(() => _phase = _Phase.showRecoveryKey);
} else {
// Existing setup — restore keys from backup
await _restoreKeys();
}
}
/// Called when the user enters their recovery key for existing SSSS.
Future<void> _unlockWithRecoveryKey() async {
final key = _keyController.text.trim();
if (key.isEmpty) return;
setState(() {
_phase = _Phase.working;
_statusMessage = 'Verifying recovery key...';
});
try {
// Unlock whichever SSSS key is waiting
if (_bootstrap.newSsssKey != null && !_bootstrap.newSsssKey!.isUnlocked) {
await _bootstrap.newSsssKey!.unlock(keyOrPassphrase: key);
}
if (_bootstrap.oldSsssKeys != null) {
for (final ssssKey in _bootstrap.oldSsssKeys!.values) {
if (!ssssKey.isUnlocked) {
await ssssKey.unlock(keyOrPassphrase: key);
}
}
}
} on InvalidPassphraseException {
_setError('Invalid recovery key. Please check and try again.');
return;
} on Exception catch (e) {
_setError('Could not unlock: ${e.toString().split('\n').first}');
return;
}
// Continue the bootstrap
try {
await _driveBootstrap();
} catch (e) {
_setError(e.toString().split('\n').first);
}
}
/// Download all keys from the online key backup after SSSS is unlocked.
Future<void> _restoreKeys() async {
setState(() {
_phase = _Phase.restoring;
_statusMessage = 'Downloading message keys from backup...';
});
try {
await widget.client.encryption?.keyManager.loadAllKeys();
debugPrint('[Security] All keys loaded from backup');
} on MatrixException catch (e) {
if (e.errcode != 'M_NOT_FOUND') {
debugPrint('[Security] Key restore error: ${e.errorMessage}');
}
// M_NOT_FOUND just means no backup exists yet — not an error
} on Exception catch (e) {
debugPrint('[Security] Key restore error: $e');
}
setState(() => _phase = _Phase.done);
}
void _setError(String message) {
setState(() {
_phase = _Phase.error;
_error = message;
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(switch (_phase) {
_Phase.checking => 'Security Setup',
_Phase.needsRecoveryKey => 'Enter Recovery Key',
_Phase.working || _Phase.restoring => 'Setting Up...',
_Phase.showRecoveryKey => 'Save Your Recovery Key',
_Phase.done => 'Security Setup Complete',
_Phase.error => 'Setup Error',
}),
content: SizedBox(
width: 400,
child: switch (_phase) {
_Phase.checking || _Phase.working || _Phase.restoring => Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(_statusMessage, textAlign: TextAlign.center),
],
),
_Phase.needsRecoveryKey => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter your recovery key to unlock your encrypted '
'messages. This is the key shown when you first set '
'up encryption in Element.',
),
const SizedBox(height: 16),
TextField(
controller: _keyController,
decoration: const InputDecoration(
hintText: 'EsTc oRgW rqHN...',
labelText: 'Recovery key or passphrase',
border: OutlineInputBorder(),
),
maxLines: 3,
onSubmitted: (_) => _unlockWithRecoveryKey(),
),
],
),
_Phase.showRecoveryKey => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Your recovery key has been created. Save it somewhere '
'safe — you will need it to restore your messages on '
'new devices.',
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
child: SelectableText(
_recoveryKey ?? '',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 14,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.warning_amber, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
'If you lose this key, you will not be able to '
'read your encrypted messages on new devices.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
),
_Phase.done => const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.verified, color: Colors.green, size: 48),
SizedBox(height: 16),
Text(
'Security is set up. Cross-signing is active and '
'your message keys are backed up. Previously '
'encrypted messages should now be readable.',
),
],
),
_Phase.error => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(_error ?? 'An unknown error occurred.'),
],
),
},
),
actions: switch (_phase) {
_Phase.checking || _Phase.working || _Phase.restoring => [],
_Phase.needsRecoveryKey => [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _unlockWithRecoveryKey,
child: const Text('Unlock'),
),
],
_Phase.showRecoveryKey => [
TextButton(
onPressed: () {
Clipboard.setData(
ClipboardData(text: _recoveryKey ?? ''));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Recovery key copied to clipboard')),
);
},
child: const Text('Copy'),
),
ElevatedButton(
onPressed: () async {
await _restoreKeys();
},
child: const Text('I saved it — continue'),
),
],
_Phase.done => [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Done'),
),
],
_Phase.error => [
TextButton(
onPressed: () {
setState(() => _phase = _Phase.needsRecoveryKey);
},
child: const Text('Try again'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Close'),
),
],
},
);
}
}