// Version: 1.0.1 | Created: 2026-04-11 | Updated: 2026-07-03 // 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 showSecuritySetupDialog( BuildContext context, Client client) async { final result = await showDialog( 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 _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 _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 _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 _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'), ), ], }, ); } }