6128953e28
Vorbereitung fuer Schnitt-Doc Schritt 3 (verification-Modul), ohne den Verschluesselungs-heiligen Code anzufassen: megolm_crypto (Export/Import- Krypto-Helfer), encryption_section (Enums + _EncryptionSection), recovery_key_dialogs (_ChangeRecoveryKeyDialog/_ShowKeyBody/_ConfirmKeyBody), fingerprint_passphrase (_FingerprintGroup/_PassphraseDialog). Rekonstruktion zeichenidentisch (1468 Code-Zeilen, ordinaler Vergleich), analyze sauber, 29 Tests gruen, App-Start ok (auth_log 17:43:14 loggedIn). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
485 lines
17 KiB
Dart
485 lines
17 KiB
Dart
part of '../../settings_modal.dart';
|
||
|
||
class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget {
|
||
final PyramidTheme pt;
|
||
const _ChangeRecoveryKeyDialog({required this.pt});
|
||
|
||
@override
|
||
ConsumerState<_ChangeRecoveryKeyDialog> createState() =>
|
||
_ChangeRecoveryKeyDialogState();
|
||
}
|
||
|
||
class _ChangeRecoveryKeyDialogState
|
||
extends ConsumerState<_ChangeRecoveryKeyDialog> {
|
||
_RecoveryStep _step = _RecoveryStep.loading;
|
||
String? _generatedKey;
|
||
String? _error;
|
||
bool _copied = false;
|
||
// Completer signals when bootstrap reaches `done`
|
||
final _doneCompleter = Completer<void>();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_startBootstrap();
|
||
}
|
||
|
||
Future<void> _startBootstrap() async {
|
||
try {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
final enc = client.encryption;
|
||
if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.');
|
||
await client.roomsLoading;
|
||
await client.accountDataLoading;
|
||
await client.userDeviceKeysLoading;
|
||
enc.bootstrap(onUpdate: (bs) {
|
||
if (!mounted) return;
|
||
_onBootstrapUpdate(bs);
|
||
});
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_step = _RecoveryStep.error;
|
||
_error = e.toString().split('\n').first;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// After newSsss() calls checkCrossSigning(), the bootstrap immediately
|
||
// transitions to askWipeCrossSigning (or askSetupCrossSigning). All post-
|
||
// newSsss states must therefore auto-advance unconditionally — guarding on
|
||
// _step would leave them stuck.
|
||
void _onBootstrapUpdate(Bootstrap bs) {
|
||
switch (bs.state) {
|
||
case BootstrapState.askWipeSsss:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true);
|
||
});
|
||
case BootstrapState.askBadSsss:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true);
|
||
});
|
||
case BootstrapState.askUseExistingSsss:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false);
|
||
});
|
||
case BootstrapState.askUnlockSsss:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss();
|
||
});
|
||
case BootstrapState.askNewSsss:
|
||
// Only generate once
|
||
if (_step == _RecoveryStep.loading) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||
if (bs.state != BootstrapState.askNewSsss) return;
|
||
try {
|
||
await bs.newSsss();
|
||
// newSsss() internally calls checkCrossSigning() which already
|
||
// transitions to askWipeCrossSigning; capture key after it returns.
|
||
final key = bs.newSsssKey?.recoveryKey;
|
||
if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; });
|
||
} catch (e) {
|
||
if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; });
|
||
}
|
||
});
|
||
}
|
||
case BootstrapState.askWipeCrossSigning:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false);
|
||
});
|
||
case BootstrapState.askSetupCrossSigning:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askSetupCrossSigning) {
|
||
bs.askSetupCrossSigning(
|
||
setupMasterKey: true,
|
||
setupSelfSigningKey: true,
|
||
setupUserSigningKey: true,
|
||
);
|
||
}
|
||
});
|
||
case BootstrapState.askWipeOnlineKeyBackup:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true);
|
||
});
|
||
case BootstrapState.askSetupOnlineKeyBackup:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true);
|
||
});
|
||
case BootstrapState.done:
|
||
if (!_doneCompleter.isCompleted) _doneCompleter.complete();
|
||
// Only auto-advance to done if user already clicked through (applying).
|
||
// If still on showKey/confirmKey, the user must finish reading first.
|
||
if (mounted && _step == _RecoveryStep.applying) {
|
||
setState(() => _step = _RecoveryStep.done);
|
||
}
|
||
case BootstrapState.error:
|
||
if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler');
|
||
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; });
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen.
|
||
void _onKeySaved() {
|
||
setState(() => _step = _RecoveryStep.confirmKey);
|
||
}
|
||
|
||
// Called when user submits the correct key confirmation.
|
||
// Bootstrap may still be running (cross-signing + key backup); wait for done.
|
||
Future<void> _onKeyConfirmed() async {
|
||
setState(() => _step = _RecoveryStep.applying);
|
||
try {
|
||
await _doneCompleter.future.timeout(const Duration(seconds: 90));
|
||
if (mounted) setState(() => _step = _RecoveryStep.done);
|
||
} catch (e) {
|
||
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; });
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey;
|
||
|
||
return Dialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border),
|
||
),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 440, minWidth: 320),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.key_rounded, size: 18, color: pt.accent),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'Wiederherstellungsschlüssel',
|
||
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
if (canClose)
|
||
IconButton(
|
||
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
|
||
onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Divider(color: pt.border, height: 1),
|
||
Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: _buildBody(pt),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBody(PyramidTheme pt) {
|
||
return switch (_step) {
|
||
_RecoveryStep.loading => const Center(
|
||
child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()),
|
||
),
|
||
_RecoveryStep.showKey => _ShowKeyBody(
|
||
pt: pt,
|
||
generatedKey: _generatedKey ?? '',
|
||
copied: _copied,
|
||
onCopy: () {
|
||
Clipboard.setData(ClipboardData(text: _generatedKey ?? ''));
|
||
setState(() => _copied = true);
|
||
},
|
||
onKeySaved: _onKeySaved,
|
||
),
|
||
_RecoveryStep.confirmKey => _ConfirmKeyBody(
|
||
pt: pt,
|
||
expectedKey: _generatedKey ?? '',
|
||
onConfirmed: _onKeyConfirmed,
|
||
),
|
||
_RecoveryStep.applying => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator.adaptive(),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||
),
|
||
],
|
||
),
|
||
_RecoveryStep.done => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'Wiederherstellungsschlüssel wurde erfolgreich geändert.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4),
|
||
),
|
||
const SizedBox(height: 20),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
onPressed: () => Navigator.of(context).pop(true),
|
||
child: const Text('Fertig'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
_RecoveryStep.error => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
_error ?? 'Ein unbekannter Fehler ist aufgetreten.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
),
|
||
const SizedBox(height: 20),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: pt.fg, side: BorderSide(color: pt.border),
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
onPressed: () => Navigator.of(context).pop(false),
|
||
child: const Text('Schließen'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
};
|
||
}
|
||
}
|
||
|
||
class _ShowKeyBody extends StatelessWidget {
|
||
final PyramidTheme pt;
|
||
final String generatedKey;
|
||
final bool copied;
|
||
final VoidCallback onCopy;
|
||
final VoidCallback onKeySaved;
|
||
|
||
const _ShowKeyBody({
|
||
required this.pt,
|
||
required this.generatedKey,
|
||
required this.copied,
|
||
required this.onCopy,
|
||
required this.onKeySaved,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Dein neuer Wiederherstellungsschlüssel',
|
||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: pt.bg3,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
child: SelectableText(
|
||
generatedKey,
|
||
style: TextStyle(
|
||
color: pt.accent, fontSize: 13,
|
||
fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
IconButton(
|
||
onPressed: onCopy,
|
||
icon: Icon(
|
||
copied ? Icons.check_rounded : Icons.copy_rounded,
|
||
size: 18,
|
||
color: copied ? PyramidColors.success : pt.fgDim,
|
||
),
|
||
tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: Colors.orange.withAlpha(28),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: Colors.orange.withAlpha(80)),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.',
|
||
style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
onPressed: onKeySaved,
|
||
child: const Text('Schlüssel gespeichert – weiter'),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ConfirmKeyBody extends StatefulWidget {
|
||
final PyramidTheme pt;
|
||
final String expectedKey;
|
||
final VoidCallback onConfirmed;
|
||
|
||
const _ConfirmKeyBody({
|
||
required this.pt,
|
||
required this.expectedKey,
|
||
required this.onConfirmed,
|
||
});
|
||
|
||
@override
|
||
State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState();
|
||
}
|
||
|
||
class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> {
|
||
final _controller = TextEditingController();
|
||
bool _mismatch = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _submit() {
|
||
final entered = _controller.text.trim();
|
||
if (entered == widget.expectedKey) {
|
||
widget.onConfirmed();
|
||
} else {
|
||
setState(() => _mismatch = true);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Schlüssel bestätigen',
|
||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
controller: _controller,
|
||
autofocus: true,
|
||
style: TextStyle(
|
||
color: pt.fg, fontSize: 13,
|
||
fontFamily: 'monospace', letterSpacing: 0.4,
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: 'EsTX XXXX XXXX …',
|
||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
||
filled: true,
|
||
fillColor: pt.bg3,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: pt.border),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.border),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5),
|
||
),
|
||
errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null,
|
||
),
|
||
onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); },
|
||
onSubmitted: (_) => _submit(),
|
||
),
|
||
const SizedBox(height: 20),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
onPressed: _submit,
|
||
child: const Text('Aktivieren'),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|