Files
m8chat-app2/lib/features/profile/presentation/security_setup_dialog.dart
help4bis b95471b0b4 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>
2026-04-27 05:28:14 +10:00

417 lines
14 KiB
Dart

// 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'),
),
],
},
);
}
}