From 6128953e28f3dfabd9123d0f026c670a45769ed8 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Mon, 6 Jul 2026 17:43:53 +0200 Subject: [PATCH] refactor: settings_encryption.dart (1470 Zeilen) in 4 thematische part-Dateien unter encryption/ geteilt (M2, zeichenidentisch verifiziert) 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 --- .../encryption/encryption_section.dart | 659 ++++++++ .../encryption/fingerprint_passphrase.dart | 204 +++ .../settings/encryption/megolm_crypto.dart | 129 ++ .../encryption/recovery_key_dialogs.dart | 484 ++++++ lib/widgets/settings/settings_encryption.dart | 1470 ----------------- lib/widgets/settings_modal.dart | 5 +- 6 files changed, 1480 insertions(+), 1471 deletions(-) create mode 100644 lib/widgets/settings/encryption/encryption_section.dart create mode 100644 lib/widgets/settings/encryption/fingerprint_passphrase.dart create mode 100644 lib/widgets/settings/encryption/megolm_crypto.dart create mode 100644 lib/widgets/settings/encryption/recovery_key_dialogs.dart delete mode 100644 lib/widgets/settings/settings_encryption.dart diff --git a/lib/widgets/settings/encryption/encryption_section.dart b/lib/widgets/settings/encryption/encryption_section.dart new file mode 100644 index 0000000..92535fb --- /dev/null +++ b/lib/widgets/settings/encryption/encryption_section.dart @@ -0,0 +1,659 @@ +part of '../../settings_modal.dart'; + +// ───────────────────────────────────────────────────────────────────────────── + +enum _EncState { idle, loading, needsKey, unlocking, done, error } + +enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error } + +class _EncryptionSection extends ConsumerStatefulWidget { + final PyramidTheme pt; + final WidgetRef ref; + const _EncryptionSection({required this.pt, required this.ref}); + + @override + ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState(); +} + +class _EncryptionSectionState extends ConsumerState<_EncryptionSection> { + _EncState _state = _EncState.idle; + Bootstrap? _bootstrap; + final _keyCtrl = TextEditingController(); + String? _error; + + // Status loaded from encryption object + bool? _crossSigningEnabled; + bool? _crossSigningCached; + bool? _keyBackupEnabled; + bool _statusLoaded = false; + + // Key file export / import + bool _exportLoading = false; + bool _importLoading = false; + String? _keyFileMsg; + bool _keyFileMsgIsError = false; + bool _diagUploading = false; + String? _diagMsg; + bool _diagMsgIsError = false; + + @override + void initState() { + super.initState(); + _loadStatus(); + } + + @override + void dispose() { + _keyCtrl.dispose(); + super.dispose(); + } + + Future _loadStatus() async { + try { + final client = await ref.read(matrixClientProvider.future); + final enc = client.encryption; + if (enc == null) return; + final cached = await enc.crossSigning.isCached().catchError((_) => false); + if (!mounted) return; + setState(() { + _crossSigningEnabled = enc.crossSigning.enabled; + _crossSigningCached = cached; + _keyBackupEnabled = enc.keyManager.enabled; + _statusLoaded = true; + }); + } catch (_) {} + } + + Future _start() async { + setState(() { _state = _EncState.loading; _error = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + if (client.encryption == null) { + setState(() { _state = _EncState.error; _error = 'Verschlüsselung nicht verfügbar.'; }); + return; + } + final enc = client.encryption!; + final cached = await enc.keyManager.isCached().catchError((_) => false); + if (cached) { + await enc.keyManager.loadAllKeys().catchError((_) {}); + if (mounted) setState(() { _state = _EncState.done; _error = null; }); + await _loadStatus(); + return; + } + + await client.roomsLoading; + await client.accountDataLoading; + await client.userDeviceKeysLoading; + + final completer = Completer(); + _bootstrap = enc.bootstrap(onUpdate: (bs) { + if (!mounted) return; + switch (bs.state) { + case BootstrapState.askWipeSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(false)); + case BootstrapState.askBadSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(true)); + case BootstrapState.openExistingSsss: + if (!completer.isCompleted) completer.complete(true); + case BootstrapState.askNewSsss: + if (!completer.isCompleted) completer.complete(false); + case BootstrapState.error: + if (!completer.isCompleted) completer.complete(false); + if (mounted) setState(() { _state = _EncState.error; _error = 'Bootstrap-Fehler.'; }); + default: + break; + } + }); + + final needsKey = await completer.future + .timeout(const Duration(seconds: 30), onTimeout: () => false); + if (!mounted) return; + setState(() => _state = needsKey ? _EncState.needsKey : _EncState.done); + await _loadStatus(); + } catch (e) { + if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); + } + } + + Future _unlock() async { + final bs = _bootstrap; + if (bs == null || bs.newSsssKey == null) return; + final key = _keyCtrl.text.trim(); + if (key.isEmpty) { setState(() => _error = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; } + setState(() { _state = _EncState.unlocking; _error = null; }); + try { + await bs.newSsssKey!.unlock(keyOrPassphrase: key); + await bs.openExistingSsss(); + final client = await ref.read(matrixClientProvider.future); + if (bs.encryption.crossSigning.enabled) { + await client.encryption!.crossSigning.selfSign(recoveryKey: key); + } + await client.encryption!.keyManager.loadAllKeys(); + if (mounted) setState(() => _state = _EncState.done); + await _loadStatus(); + } on InvalidPassphraseException catch (_) { + if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Falscher Schlüssel.'; }); + } on FormatException catch (_) { + if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Ungültiges Format.'; }); + } catch (e) { + if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); + } + } + + Future _resetSecurity() async { + final confirm = await showDialog( + context: context, + builder: (ctx) { + final pt = widget.pt; + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Sicherheit zurücksetzen?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + 'Dies löscht deine aktuellen Cross-Signing-Schlüssel und den Online-Schlüssel-Backup. Nicht gesicherte Sitzungen können Nachrichten möglicherweise nicht mehr entschlüsseln.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Zurücksetzen'), + ), + ], + ); + }, + ); + if (confirm != true || !mounted) return; + setState(() { _state = _EncState.loading; _error = null; }); + 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; + final completer = Completer(); + enc.bootstrap(onUpdate: (bs) { + if (!mounted) return; + switch (bs.state) { + case BootstrapState.askWipeSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(true)); + case BootstrapState.askBadSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(false)); + case BootstrapState.askNewSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss()); + case BootstrapState.askWipeCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeCrossSigning(true)); + case BootstrapState.askSetupCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupCrossSigning( + setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true)); + case BootstrapState.askWipeOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(true)); + case BootstrapState.askSetupOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true)); + case BootstrapState.done: + if (!completer.isCompleted) completer.complete(); + case BootstrapState.error: + if (!completer.isCompleted) completer.completeError('Bootstrap-Fehler'); + default: + break; + } + }); + await completer.future.timeout(const Duration(seconds: 60)); + if (mounted) setState(() => _state = _EncState.done); + await _loadStatus(); + } catch (e) { + if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); + } + } + + Future _showPassphraseDialog({required bool forExport}) { + return showDialog( + context: context, + builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport), + ); + } + + Future _exportKeys() async { + final passphrase = await _showPassphraseDialog(forExport: true); + if (passphrase == null || !mounted) return; + + setState(() { _exportLoading = true; _keyFileMsg = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + final db = client.database; + + final stored = await db.getAllInboundGroupSessions(); + final sessions = >[]; + + for (final s in stored) { + try { + final content = jsonDecode(s.content) as Map; + final sessionKey = content['session_key'] as String?; + if (sessionKey == null || sessionKey.isEmpty) continue; + + final claimedKeys = s.senderClaimedKeys.isNotEmpty + ? (jsonDecode(s.senderClaimedKeys) as Map) + .map((k, v) => MapEntry(k, v.toString())) + : {}; + + sessions.add({ + 'algorithm': 'm.megolm.v1.aes-sha2', + 'forwarding_curve25519_key_chain': [], + 'room_id': s.roomId, + 'sender_key': s.senderKey, + 'sender_claimed_keys': claimedKeys, + 'session_id': s.sessionId, + 'session_key': sessionKey, + }); + } catch (_) { continue; } + } + + if (sessions.isEmpty) throw Exception('Keine exportierbaren Sitzungsschlüssel gefunden.'); + + final encrypted = await compute(_encryptMegolmKeys, { + 'passphrase': passphrase, + 'sessions': sessions, + }); + + final dir = await getApplicationDocumentsDirectory(); + final now = DateTime.now(); + final stamp = '${now.year}' + '${now.month.toString().padLeft(2, '0')}' + '${now.day.toString().padLeft(2, '0')}'; + final file = File('${dir.path}/pyramid_keys_$stamp.txt'); + await file.writeAsString(encrypted); + + if (mounted) { + setState(() { + _exportLoading = false; + _keyFileMsgIsError = false; + _keyFileMsg = '${sessions.length} Schlüssel exportiert → ${file.path}'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _exportLoading = false; + _keyFileMsgIsError = true; + _keyFileMsg = 'Export fehlgeschlagen: ${e.toString().split('\n').first}'; + }); + } + } + } + + Future _uploadDiagnostics() async { + setState(() { _diagUploading = true; _diagMsg = null; }); + try { + final notifier = ref.read(e2eeDiagnosticsProvider.notifier); + final msg = await notifier.upload( + serverUrl: 'https://dashboard.steggi-matrix.work', + // Eng begrenzter Diagnose-Token (haengt nur an den Diagnose-Log an, + // KEINE Admin-Rechte). Bewusst NICHT der Matrix-Server-Admin-Token – + // der darf niemals im Client landen. Passendes Gegenstueck in + // /home/steggi/matrix/server.py (DIAG_TOKEN) auf dem Pi. + token: 'pyr-diag-6AyELgODRv-4rF4dcau3kSa5YbgZqUcn', + ); + if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; }); + } catch (e) { + if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); }); + } + } + + Future _importKeys() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['txt', 'key', 'keys'], + allowMultiple: false, + ); + if (result == null || result.files.isEmpty || result.files.first.path == null) return; + if (!mounted) return; + + final passphrase = await _showPassphraseDialog(forExport: false); + if (passphrase == null || !mounted) return; + + setState(() { _importLoading = true; _keyFileMsg = null; }); + try { + final fileContent = await File(result.files.first.path!).readAsString(); + final trimmed = fileContent.trim(); + + if (!trimmed.startsWith(_kMegolmHeader) || !trimmed.endsWith(_kMegolmFooter)) { + throw Exception('Ungültiges Dateiformat – kein Megolm-Schlüsseldatei-Header.'); + } + + final lines = trimmed.split('\n'); + // Strip \r so Windows line endings (\r\n) don't corrupt the base64 string. + final b64 = lines.skip(1).take(lines.length - 2).join('').replaceAll(RegExp(r'\s'), ''); + final padded = b64 + '=' * ((4 - b64.length % 4) % 4); + final dataBytes = base64.decode(padded); + + final sessions = await compute(_decryptMegolmKeys, { + 'passphrase': passphrase, + 'data': dataBytes.toList(), + }); + + final client = await ref.read(matrixClientProvider.future); + final km = client.encryption?.keyManager; + if (km == null) throw Exception('Verschlüsselung nicht initialisiert.'); + + var imported = 0; + for (final raw in sessions) { + try { + final session = raw as Map; + final roomId = session['room_id'] as String?; + final sessionId = session['session_id'] as String?; + final senderKey = session['sender_key'] as String?; + if (roomId == null || sessionId == null || senderKey == null) continue; + + final claimedKeys = (session['sender_claimed_keys'] as Map?) + ?.map((k, v) => MapEntry(k.toString(), v.toString())) ?? + {}; + + await km.setInboundGroupSession( + roomId, sessionId, senderKey, + Map.from(session), + forwarded: true, + senderClaimedKeys: claimedKeys, + ); + imported++; + } catch (_) { continue; } + } + + if (mounted) { + setState(() { + _importLoading = false; + _keyFileMsgIsError = false; + _keyFileMsg = '$imported von ${sessions.length} Schlüssel importiert.'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _importLoading = false; + _keyFileMsgIsError = true; + _keyFileMsg = 'Import fehlgeschlagen: ${e.toString().split('\n').first}'; + }); + } + } + } + + Future _changeRecoveryKey() async { + final changed = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _ChangeRecoveryKeyDialog(pt: widget.pt), + ); + if (changed == true && mounted) { + await _loadStatus(); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Verschlüsselung', + subtitle: 'Ende-zu-Ende-Verschlüsselung & Sicherheit verwalten.', + pt: pt), + + // ── Status overview ─────────────────────────────────────────────── + if (_statusLoaded) ...[ + _SettingsGroup(title: 'Sicherheitsstatus', pt: pt, children: [ + _SettingsRow( + title: 'Cross-Signing', + desc: _crossSigningEnabled == true + ? (_crossSigningCached == true ? 'Eingerichtet & verifiziert' : 'Eingerichtet, Schlüssel nicht im Cache') + : 'Nicht eingerichtet', + pt: pt, + control: Icon( + _crossSigningEnabled == true && _crossSigningCached == true + ? Icons.verified_rounded + : (_crossSigningEnabled == true ? Icons.warning_amber_rounded : Icons.cancel_outlined), + size: 16, + color: _crossSigningEnabled == true && _crossSigningCached == true + ? PyramidColors.success + : (_crossSigningEnabled == true ? pt.away : pt.danger), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Online-Schlüsselbackup', + desc: _keyBackupEnabled == true ? 'Aktiv – Sitzungsschlüssel gesichert' : 'Nicht aktiviert', + pt: pt, + control: Icon( + _keyBackupEnabled == true ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, + size: 16, + color: _keyBackupEnabled == true ? PyramidColors.success : pt.fgDim, + ), + ), + ]), + const SizedBox(height: 20), + _FingerprintGroup(pt: pt), + const SizedBox(height: 20), + ], + + // ── Key recovery / unlock ───────────────────────────────────────── + _SettingsGroup(title: 'Wiederherstellung', pt: pt, children: [ + _SettingsRow( + title: 'Schlüssel wiederherstellen', + desc: 'Nachrichten mit dem Wiederherstellungsschlüssel entschlüsseln', + pt: pt, + showArrow: true, + onTap: _state == _EncState.loading ? null : _start, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Wiederherstellungsschlüssel ändern', + desc: 'Neuen Schlüssel generieren, notieren und aktivieren', + pt: pt, + showArrow: true, + onTap: _state == _EncState.loading ? null : _changeRecoveryKey, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Sicherheit neu einrichten', + desc: 'Cross-Signing & Backup zurücksetzen und neu aufsetzen', + pt: pt, + showArrow: true, + destructive: true, + onTap: _state == _EncState.loading ? null : _resetSecurity, + ), + ]), + const SizedBox(height: 20), + + // ── Key file export / import ─────────────────────────────────────── + _SettingsGroup(title: 'Schlüsseldatei', pt: pt, children: [ + _SettingsRow( + title: 'Schlüssel exportieren', + desc: 'Sitzungsschlüssel als verschlüsselte Datei sichern (kompatibel mit Element)', + pt: pt, + showArrow: !_exportLoading, + control: _exportLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : null, + onTap: (_exportLoading || _importLoading) ? null : _exportKeys, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Schlüssel importieren', + desc: 'Sitzungsschlüssel aus exportierter Datei laden', + pt: pt, + showArrow: !_importLoading, + control: _importLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : null, + onTap: (_exportLoading || _importLoading) ? null : _importKeys, + ), + ]), + + if (_keyFileMsg != null) ...[ + const SizedBox(height: 12), + _StatusCard( + pt: pt, + icon: _keyFileMsgIsError + ? Icons.error_outline_rounded + : Icons.check_circle_outline_rounded, + color: _keyFileMsgIsError ? pt.danger : PyramidColors.success, + text: _keyFileMsg!, + ), + ], + const SizedBox(height: 20), + + // ── E2EE Diagnostics ────────────────────────────────────────────── + Consumer(builder: (context, ref, _) { + final count = ref.watch(e2eeDiagnosticsProvider).length; + return _SettingsGroup(title: 'Diagnose', pt: pt, children: [ + _SettingsRow( + title: 'Entschlüsselungsfehler hochladen', + desc: count == 0 + ? 'Keine Fehler gesammelt' + : '$count Fehler gesammelt – zum Server hochladen', + pt: pt, + showArrow: !_diagUploading && count > 0, + control: _diagUploading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : count > 0 + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: pt.danger.withAlpha(30), + borderRadius: BorderRadius.circular(10), + ), + child: Text('$count', style: TextStyle(color: pt.danger, fontSize: 12)), + ) + : null, + onTap: (_diagUploading || count == 0) ? null : _uploadDiagnostics, + ), + ]); + }), + + if (_diagMsg != null) ...[ + const SizedBox(height: 12), + _StatusCard( + pt: pt, + icon: _diagMsgIsError + ? Icons.error_outline_rounded + : Icons.cloud_done_rounded, + color: _diagMsgIsError ? pt.danger : PyramidColors.success, + text: _diagMsg!, + ), + ], + const SizedBox(height: 20), + + // ── Inline state UI ─────────────────────────────────────────────── + if (_state == _EncState.loading) + const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive())), + + if (_state == _EncState.done) ...[ + _StatusCard( + pt: pt, + icon: Icons.check_circle_outline_rounded, + color: PyramidColors.success, + text: 'Alle Schlüssel importiert. Nachrichten werden entschlüsselt.'), + const SizedBox(height: 12), + ], + + if (_state == _EncState.needsKey || _state == _EncState.unlocking) ...[ + _StatusCard( + pt: pt, + icon: Icons.lock_outline_rounded, + color: pt.accent, + text: 'Wiederherstellungsschlüssel erforderlich.'), + const SizedBox(height: 16), + TextField( + controller: _keyCtrl, + readOnly: _state == _EncState.unlocking, + autofocus: true, + autocorrect: false, + style: TextStyle(color: pt.fg, fontSize: 13, fontFamily: 'monospace'), + decoration: InputDecoration( + hintText: 'EsXX XXXX XXXX XXXX …', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), + labelText: 'Wiederherstellungsschlüssel', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + errorText: _error, + errorMaxLines: 2, + filled: true, + fillColor: pt.bg2, + prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + cursorColor: pt.accent, + onSubmitted: (_) => _unlock(), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: _PrimaryBtn( + label: _state == _EncState.unlocking ? 'Entschlüssele…' : 'Nachrichten entschlüsseln', + icon: Icons.lock_open_outlined, + pt: pt, + loading: _state == _EncState.unlocking, + onPressed: _unlock, + ), + ), + const SizedBox(height: 12), + ], + + if (_state == _EncState.error) ...[ + _StatusCard( + pt: pt, icon: Icons.error_outline_rounded, color: PyramidColors.danger, + text: _error ?? 'Unbekannter Fehler.'), + const SizedBox(height: 12), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: pt.fg, + side: BorderSide(color: pt.border), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _start, + icon: const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Erneut versuchen'), + ), + ], + + if (_state == _EncState.idle && !_statusLoaded) + _StatusCard( + pt: pt, + icon: Icons.shield_outlined, + color: pt.fgMuted, + text: 'Lade Sicherheitsstatus…'), + ], + ), + ); + } +} + diff --git a/lib/widgets/settings/encryption/fingerprint_passphrase.dart b/lib/widgets/settings/encryption/fingerprint_passphrase.dart new file mode 100644 index 0000000..ca9007c --- /dev/null +++ b/lib/widgets/settings/encryption/fingerprint_passphrase.dart @@ -0,0 +1,204 @@ +part of '../../settings_modal.dart'; + +class _FingerprintGroup extends ConsumerWidget { + final PyramidTheme pt; + const _FingerprintGroup({required this.pt}); + + static String _fmt(String key) { + final clean = key.replaceAll(RegExp(r'\s'), ''); + final groups = []; + for (var i = 0; i < clean.length; i += 4) { + groups.add(clean.substring(i, (i + 4).clamp(0, clean.length))); + } + return groups.join(' '); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientAsync = ref.watch(matrixClientProvider); + final client = clientAsync.valueOrNull; + if (client == null) return const SizedBox.shrink(); + + final fingerprint = _fmt(client.fingerprintKey); + final deviceId = client.deviceID ?? ''; + + return _SettingsGroup(title: 'Dieses Gerät', pt: pt, children: [ + _SettingsRow( + title: 'Geräte-ID', + desc: deviceId, + pt: pt, + control: IconButton( + icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + tooltip: 'Kopieren', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)), + ), + ), + _Divider(pt: pt), + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Ed25519-Fingerabdruck', + style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), + const Spacer(), + IconButton( + icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + tooltip: 'Fingerabdruck kopieren', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + onPressed: () => Clipboard.setData(ClipboardData(text: client.fingerprintKey)), + ), + ], + ), + const SizedBox(height: 4), + SelectableText( + fingerprint, + style: TextStyle( + color: pt.fgMuted, fontSize: 11, + fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, + ), + ), + ], + ), + ), + ]); + } +} + +// Passphrase dialog (for key export/import) +class _PassphraseDialog extends StatefulWidget { + final PyramidTheme pt; + final bool forExport; + const _PassphraseDialog({required this.pt, required this.forExport}); + + @override + State<_PassphraseDialog> createState() => _PassphraseDialogState(); +} + +class _PassphraseDialogState extends State<_PassphraseDialog> { + final _ctrl1 = TextEditingController(); + final _ctrl2 = TextEditingController(); + bool _obscure = true; + String? _error; + + @override + void dispose() { + _ctrl1.dispose(); + _ctrl2.dispose(); + super.dispose(); + } + + void _submit() { + final pass = _ctrl1.text.trim(); + if (pass.isEmpty) { + setState(() => _error = 'Bitte ein Passwort eingeben.'); + return; + } + if (widget.forExport && pass != _ctrl2.text.trim()) { + setState(() => _error = 'Passwörter stimmen nicht überein.'); + return; + } + Navigator.pop(context, pass); + } + + InputDecoration _inputDeco(String label, PyramidTheme pt) => InputDecoration( + labelText: label, + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + errorText: _error, + filled: true, + fillColor: pt.bg3, + prefixIcon: Icon(Icons.lock_outline_rounded, color: pt.fgDim, size: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + isDense: true, + errorMaxLines: 2, + suffixIcon: IconButton( + icon: Icon( + _obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: 18, + color: pt.fgDim, + ), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ); + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border), + ), + title: Text( + widget.forExport ? 'Schlüssel exportieren' : 'Schlüssel importieren', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), + ), + content: SizedBox( + width: 340, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.forExport + ? 'Wähle ein Passwort zum Verschlüsseln der Schlüsseldatei.' + : 'Gib das Passwort ein, mit dem die Datei verschlüsselt wurde.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 16), + TextField( + controller: _ctrl1, + obscureText: _obscure, + autofocus: true, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: _inputDeco('Passwort', pt).copyWith( + // Only show error on confirm field if export, else here + errorText: widget.forExport ? null : _error, + ), + cursorColor: pt.accent, + onSubmitted: widget.forExport ? null : (_) => _submit(), + ), + if (widget.forExport) ...[ + const SizedBox(height: 12), + TextField( + controller: _ctrl2, + obscureText: _obscure, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: _inputDeco('Passwort bestätigen', pt), + cursorColor: pt.accent, + onSubmitted: (_) => _submit(), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _submit, + child: Text(widget.forExport ? 'Exportieren' : 'Importieren'), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/encryption/megolm_crypto.dart b/lib/widgets/settings/encryption/megolm_crypto.dart new file mode 100644 index 0000000..426487b --- /dev/null +++ b/lib/widgets/settings/encryption/megolm_crypto.dart @@ -0,0 +1,129 @@ +part of '../../settings_modal.dart'; + +// ── Megolm key export / import – top-level so compute() can use them ───────── + +const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----'; +const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----'; + +// PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int} +Uint8List _runPbkdf2(Map args) { + final pass = args['pass'] as Uint8List; + final salt = args['salt'] as Uint8List; + final iterations = args['iter'] as int; + const blockLen = 64; // SHA-512 digest size + const dkLen = 64; + + final hmac = mcrypto.Hmac(mcrypto.sha512, pass); + final blockCount = (dkLen / blockLen).ceil(); + final dk = Uint8List(blockCount * blockLen); + + for (var i = 1; i <= blockCount; i++) { + final saltI = Uint8List(salt.length + 4); + saltI.setRange(0, salt.length, salt); + ByteData.sublistView(saltI, salt.length).setUint32(0, i); + + var u = Uint8List.fromList(hmac.convert(saltI).bytes); + final block = Uint8List.fromList(u); + + for (var j = 1; j < iterations; j++) { + u = Uint8List.fromList(hmac.convert(u).bytes); + for (var k = 0; k < blockLen; k++) { block[k] ^= u[k]; } + } + dk.setRange((i - 1) * blockLen, i * blockLen, block); + } + return dk.sublist(0, dkLen); +} + +Uint8List _aesCtr(Uint8List key, Uint8List iv, Uint8List data) { + final cipher = pc.SICStreamCipher(pc.AESEngine()) + ..init(true, pc.ParametersWithIV(pc.KeyParameter(key), iv)); + final out = Uint8List(data.length); + cipher.processBytes(data, 0, data.length, out, 0); + return out; +} + +// args: {passphrase: String, sessions: List} → encrypted String +String _encryptMegolmKeys(Map args) { + final pass = args['passphrase'] as String; + final sessions = args['sessions'] as List; + + final rng = Random.secure(); + final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256))); + // IV: 8 random bytes as nonce, last 8 bytes 0x00 (counter starts at 0) + final iv = Uint8List(16); + for (var i = 0; i < 8; i++) { iv[i] = rng.nextInt(256); } + + const iterations = 100000; + final dk = _runPbkdf2({ + 'pass': Uint8List.fromList(utf8.encode(pass)), + 'salt': salt, + 'iter': iterations, + }); + final aesKey = dk.sublist(0, 32); + final hmacKey = dk.sublist(32, 64); + + final plaintext = Uint8List.fromList(utf8.encode(jsonEncode(sessions))); + final encrypted = _aesCtr(aesKey, iv, plaintext); + + final iterBe = Uint8List(4)..buffer.asByteData().setUint32(0, iterations); + + // header bytes: 0x01 | salt(16) | iv(16) | iterBe(4) | encrypted(n) + final header = Uint8List(1 + 16 + 16 + 4 + encrypted.length); + var off = 0; + header[off++] = 0x01; + header.setRange(off, off += 16, salt); + header.setRange(off, off += 16, iv); + header.setRange(off, off += 4, iterBe); + header.setRange(off, off + encrypted.length, encrypted); + + final mac = Uint8List.fromList( + mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(header).bytes, + ); + + final full = Uint8List(header.length + 32); + full.setRange(0, header.length, header); + full.setRange(header.length, full.length, mac); + + return '$_kMegolmHeader\n${base64.encode(full)}\n$_kMegolmFooter'; +} + +// args: {passphrase: String, data: List} → List sessions +List _decryptMegolmKeys(Map args) { + final pass = args['passphrase'] as String; + final raw = Uint8List.fromList(args['data'] as List); + + if (raw.length < 1 + 16 + 16 + 4 + 32 || raw[0] != 0x01) { + throw Exception('Ungültiges Dateiformat (Version ${raw.isEmpty ? "?" : raw[0]}).'); + } + + final salt = raw.sublist(1, 17); + final iv = raw.sublist(17, 33); + final iterations = raw.buffer.asByteData().getUint32(33); + final encrypted = raw.sublist(37, raw.length - 32); + final storedMac = raw.sublist(raw.length - 32); + + if (iterations < 1 || iterations > 10000000) { + throw Exception('Iterationsanzahl außerhalb des erlaubten Bereichs.'); + } + + final dk = _runPbkdf2({ + 'pass': Uint8List.fromList(utf8.encode(pass)), + 'salt': Uint8List.fromList(salt), + 'iter': iterations, + }); + final aesKey = dk.sublist(0, 32); + final hmacKey = dk.sublist(32, 64); + + // Verify MAC over all bytes except the appended MAC itself + final toMac = raw.sublist(0, raw.length - 32); + final expectedMac = mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(toMac).bytes; + var macOk = true; + for (var i = 0; i < 32; i++) { + if (expectedMac[i] != storedMac[i]) { macOk = false; break; } + } + if (!macOk) throw Exception('Falsches Passwort oder beschädigte Datei.'); + + final decrypted = _aesCtr(aesKey, Uint8List.fromList(iv), Uint8List.fromList(encrypted)); + return jsonDecode(utf8.decode(decrypted)) as List; +} + diff --git a/lib/widgets/settings/encryption/recovery_key_dialogs.dart b/lib/widgets/settings/encryption/recovery_key_dialogs.dart new file mode 100644 index 0000000..45fdc72 --- /dev/null +++ b/lib/widgets/settings/encryption/recovery_key_dialogs.dart @@ -0,0 +1,484 @@ +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(); + + @override + void initState() { + super.initState(); + _startBootstrap(); + } + + Future _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 _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'), + ), + ), + ], + ); + } +} + diff --git a/lib/widgets/settings/settings_encryption.dart b/lib/widgets/settings/settings_encryption.dart deleted file mode 100644 index 5a6d7bf..0000000 --- a/lib/widgets/settings/settings_encryption.dart +++ /dev/null @@ -1,1470 +0,0 @@ -part of '../settings_modal.dart'; - -// ── Megolm key export / import – top-level so compute() can use them ───────── - -const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----'; -const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----'; - -// PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int} -Uint8List _runPbkdf2(Map args) { - final pass = args['pass'] as Uint8List; - final salt = args['salt'] as Uint8List; - final iterations = args['iter'] as int; - const blockLen = 64; // SHA-512 digest size - const dkLen = 64; - - final hmac = mcrypto.Hmac(mcrypto.sha512, pass); - final blockCount = (dkLen / blockLen).ceil(); - final dk = Uint8List(blockCount * blockLen); - - for (var i = 1; i <= blockCount; i++) { - final saltI = Uint8List(salt.length + 4); - saltI.setRange(0, salt.length, salt); - ByteData.sublistView(saltI, salt.length).setUint32(0, i); - - var u = Uint8List.fromList(hmac.convert(saltI).bytes); - final block = Uint8List.fromList(u); - - for (var j = 1; j < iterations; j++) { - u = Uint8List.fromList(hmac.convert(u).bytes); - for (var k = 0; k < blockLen; k++) { block[k] ^= u[k]; } - } - dk.setRange((i - 1) * blockLen, i * blockLen, block); - } - return dk.sublist(0, dkLen); -} - -Uint8List _aesCtr(Uint8List key, Uint8List iv, Uint8List data) { - final cipher = pc.SICStreamCipher(pc.AESEngine()) - ..init(true, pc.ParametersWithIV(pc.KeyParameter(key), iv)); - final out = Uint8List(data.length); - cipher.processBytes(data, 0, data.length, out, 0); - return out; -} - -// args: {passphrase: String, sessions: List} → encrypted String -String _encryptMegolmKeys(Map args) { - final pass = args['passphrase'] as String; - final sessions = args['sessions'] as List; - - final rng = Random.secure(); - final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256))); - // IV: 8 random bytes as nonce, last 8 bytes 0x00 (counter starts at 0) - final iv = Uint8List(16); - for (var i = 0; i < 8; i++) { iv[i] = rng.nextInt(256); } - - const iterations = 100000; - final dk = _runPbkdf2({ - 'pass': Uint8List.fromList(utf8.encode(pass)), - 'salt': salt, - 'iter': iterations, - }); - final aesKey = dk.sublist(0, 32); - final hmacKey = dk.sublist(32, 64); - - final plaintext = Uint8List.fromList(utf8.encode(jsonEncode(sessions))); - final encrypted = _aesCtr(aesKey, iv, plaintext); - - final iterBe = Uint8List(4)..buffer.asByteData().setUint32(0, iterations); - - // header bytes: 0x01 | salt(16) | iv(16) | iterBe(4) | encrypted(n) - final header = Uint8List(1 + 16 + 16 + 4 + encrypted.length); - var off = 0; - header[off++] = 0x01; - header.setRange(off, off += 16, salt); - header.setRange(off, off += 16, iv); - header.setRange(off, off += 4, iterBe); - header.setRange(off, off + encrypted.length, encrypted); - - final mac = Uint8List.fromList( - mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(header).bytes, - ); - - final full = Uint8List(header.length + 32); - full.setRange(0, header.length, header); - full.setRange(header.length, full.length, mac); - - return '$_kMegolmHeader\n${base64.encode(full)}\n$_kMegolmFooter'; -} - -// args: {passphrase: String, data: List} → List sessions -List _decryptMegolmKeys(Map args) { - final pass = args['passphrase'] as String; - final raw = Uint8List.fromList(args['data'] as List); - - if (raw.length < 1 + 16 + 16 + 4 + 32 || raw[0] != 0x01) { - throw Exception('Ungültiges Dateiformat (Version ${raw.isEmpty ? "?" : raw[0]}).'); - } - - final salt = raw.sublist(1, 17); - final iv = raw.sublist(17, 33); - final iterations = raw.buffer.asByteData().getUint32(33); - final encrypted = raw.sublist(37, raw.length - 32); - final storedMac = raw.sublist(raw.length - 32); - - if (iterations < 1 || iterations > 10000000) { - throw Exception('Iterationsanzahl außerhalb des erlaubten Bereichs.'); - } - - final dk = _runPbkdf2({ - 'pass': Uint8List.fromList(utf8.encode(pass)), - 'salt': Uint8List.fromList(salt), - 'iter': iterations, - }); - final aesKey = dk.sublist(0, 32); - final hmacKey = dk.sublist(32, 64); - - // Verify MAC over all bytes except the appended MAC itself - final toMac = raw.sublist(0, raw.length - 32); - final expectedMac = mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(toMac).bytes; - var macOk = true; - for (var i = 0; i < 32; i++) { - if (expectedMac[i] != storedMac[i]) { macOk = false; break; } - } - if (!macOk) throw Exception('Falsches Passwort oder beschädigte Datei.'); - - final decrypted = _aesCtr(aesKey, Uint8List.fromList(iv), Uint8List.fromList(encrypted)); - return jsonDecode(utf8.decode(decrypted)) as List; -} - -// ───────────────────────────────────────────────────────────────────────────── - -enum _EncState { idle, loading, needsKey, unlocking, done, error } - -enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error } - -class _EncryptionSection extends ConsumerStatefulWidget { - final PyramidTheme pt; - final WidgetRef ref; - const _EncryptionSection({required this.pt, required this.ref}); - - @override - ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState(); -} - -class _EncryptionSectionState extends ConsumerState<_EncryptionSection> { - _EncState _state = _EncState.idle; - Bootstrap? _bootstrap; - final _keyCtrl = TextEditingController(); - String? _error; - - // Status loaded from encryption object - bool? _crossSigningEnabled; - bool? _crossSigningCached; - bool? _keyBackupEnabled; - bool _statusLoaded = false; - - // Key file export / import - bool _exportLoading = false; - bool _importLoading = false; - String? _keyFileMsg; - bool _keyFileMsgIsError = false; - bool _diagUploading = false; - String? _diagMsg; - bool _diagMsgIsError = false; - - @override - void initState() { - super.initState(); - _loadStatus(); - } - - @override - void dispose() { - _keyCtrl.dispose(); - super.dispose(); - } - - Future _loadStatus() async { - try { - final client = await ref.read(matrixClientProvider.future); - final enc = client.encryption; - if (enc == null) return; - final cached = await enc.crossSigning.isCached().catchError((_) => false); - if (!mounted) return; - setState(() { - _crossSigningEnabled = enc.crossSigning.enabled; - _crossSigningCached = cached; - _keyBackupEnabled = enc.keyManager.enabled; - _statusLoaded = true; - }); - } catch (_) {} - } - - Future _start() async { - setState(() { _state = _EncState.loading; _error = null; }); - try { - final client = await ref.read(matrixClientProvider.future); - if (client.encryption == null) { - setState(() { _state = _EncState.error; _error = 'Verschlüsselung nicht verfügbar.'; }); - return; - } - final enc = client.encryption!; - final cached = await enc.keyManager.isCached().catchError((_) => false); - if (cached) { - await enc.keyManager.loadAllKeys().catchError((_) {}); - if (mounted) setState(() { _state = _EncState.done; _error = null; }); - await _loadStatus(); - return; - } - - await client.roomsLoading; - await client.accountDataLoading; - await client.userDeviceKeysLoading; - - final completer = Completer(); - _bootstrap = enc.bootstrap(onUpdate: (bs) { - if (!mounted) return; - switch (bs.state) { - case BootstrapState.askWipeSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(false)); - case BootstrapState.askBadSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); - case BootstrapState.askUseExistingSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(true)); - case BootstrapState.openExistingSsss: - if (!completer.isCompleted) completer.complete(true); - case BootstrapState.askNewSsss: - if (!completer.isCompleted) completer.complete(false); - case BootstrapState.error: - if (!completer.isCompleted) completer.complete(false); - if (mounted) setState(() { _state = _EncState.error; _error = 'Bootstrap-Fehler.'; }); - default: - break; - } - }); - - final needsKey = await completer.future - .timeout(const Duration(seconds: 30), onTimeout: () => false); - if (!mounted) return; - setState(() => _state = needsKey ? _EncState.needsKey : _EncState.done); - await _loadStatus(); - } catch (e) { - if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); - } - } - - Future _unlock() async { - final bs = _bootstrap; - if (bs == null || bs.newSsssKey == null) return; - final key = _keyCtrl.text.trim(); - if (key.isEmpty) { setState(() => _error = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; } - setState(() { _state = _EncState.unlocking; _error = null; }); - try { - await bs.newSsssKey!.unlock(keyOrPassphrase: key); - await bs.openExistingSsss(); - final client = await ref.read(matrixClientProvider.future); - if (bs.encryption.crossSigning.enabled) { - await client.encryption!.crossSigning.selfSign(recoveryKey: key); - } - await client.encryption!.keyManager.loadAllKeys(); - if (mounted) setState(() => _state = _EncState.done); - await _loadStatus(); - } on InvalidPassphraseException catch (_) { - if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Falscher Schlüssel.'; }); - } on FormatException catch (_) { - if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Ungültiges Format.'; }); - } catch (e) { - if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); - } - } - - Future _resetSecurity() async { - final confirm = await showDialog( - context: context, - builder: (ctx) { - final pt = widget.pt; - return AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), - side: BorderSide(color: pt.border)), - title: Text('Sicherheit zurücksetzen?', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), - content: Text( - 'Dies löscht deine aktuellen Cross-Signing-Schlüssel und den Online-Schlüssel-Backup. Nicht gesicherte Sitzungen können Nachrichten möglicherweise nicht mehr entschlüsseln.', - style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Zurücksetzen'), - ), - ], - ); - }, - ); - if (confirm != true || !mounted) return; - setState(() { _state = _EncState.loading; _error = null; }); - 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; - final completer = Completer(); - enc.bootstrap(onUpdate: (bs) { - if (!mounted) return; - switch (bs.state) { - case BootstrapState.askWipeSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(true)); - case BootstrapState.askBadSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); - case BootstrapState.askUseExistingSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(false)); - case BootstrapState.askNewSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss()); - case BootstrapState.askWipeCrossSigning: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeCrossSigning(true)); - case BootstrapState.askSetupCrossSigning: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupCrossSigning( - setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true)); - case BootstrapState.askWipeOnlineKeyBackup: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(true)); - case BootstrapState.askSetupOnlineKeyBackup: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true)); - case BootstrapState.done: - if (!completer.isCompleted) completer.complete(); - case BootstrapState.error: - if (!completer.isCompleted) completer.completeError('Bootstrap-Fehler'); - default: - break; - } - }); - await completer.future.timeout(const Duration(seconds: 60)); - if (mounted) setState(() => _state = _EncState.done); - await _loadStatus(); - } catch (e) { - if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); - } - } - - Future _showPassphraseDialog({required bool forExport}) { - return showDialog( - context: context, - builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport), - ); - } - - Future _exportKeys() async { - final passphrase = await _showPassphraseDialog(forExport: true); - if (passphrase == null || !mounted) return; - - setState(() { _exportLoading = true; _keyFileMsg = null; }); - try { - final client = await ref.read(matrixClientProvider.future); - final db = client.database; - - final stored = await db.getAllInboundGroupSessions(); - final sessions = >[]; - - for (final s in stored) { - try { - final content = jsonDecode(s.content) as Map; - final sessionKey = content['session_key'] as String?; - if (sessionKey == null || sessionKey.isEmpty) continue; - - final claimedKeys = s.senderClaimedKeys.isNotEmpty - ? (jsonDecode(s.senderClaimedKeys) as Map) - .map((k, v) => MapEntry(k, v.toString())) - : {}; - - sessions.add({ - 'algorithm': 'm.megolm.v1.aes-sha2', - 'forwarding_curve25519_key_chain': [], - 'room_id': s.roomId, - 'sender_key': s.senderKey, - 'sender_claimed_keys': claimedKeys, - 'session_id': s.sessionId, - 'session_key': sessionKey, - }); - } catch (_) { continue; } - } - - if (sessions.isEmpty) throw Exception('Keine exportierbaren Sitzungsschlüssel gefunden.'); - - final encrypted = await compute(_encryptMegolmKeys, { - 'passphrase': passphrase, - 'sessions': sessions, - }); - - final dir = await getApplicationDocumentsDirectory(); - final now = DateTime.now(); - final stamp = '${now.year}' - '${now.month.toString().padLeft(2, '0')}' - '${now.day.toString().padLeft(2, '0')}'; - final file = File('${dir.path}/pyramid_keys_$stamp.txt'); - await file.writeAsString(encrypted); - - if (mounted) { - setState(() { - _exportLoading = false; - _keyFileMsgIsError = false; - _keyFileMsg = '${sessions.length} Schlüssel exportiert → ${file.path}'; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _exportLoading = false; - _keyFileMsgIsError = true; - _keyFileMsg = 'Export fehlgeschlagen: ${e.toString().split('\n').first}'; - }); - } - } - } - - Future _uploadDiagnostics() async { - setState(() { _diagUploading = true; _diagMsg = null; }); - try { - final notifier = ref.read(e2eeDiagnosticsProvider.notifier); - final msg = await notifier.upload( - serverUrl: 'https://dashboard.steggi-matrix.work', - // Eng begrenzter Diagnose-Token (haengt nur an den Diagnose-Log an, - // KEINE Admin-Rechte). Bewusst NICHT der Matrix-Server-Admin-Token – - // der darf niemals im Client landen. Passendes Gegenstueck in - // /home/steggi/matrix/server.py (DIAG_TOKEN) auf dem Pi. - token: 'pyr-diag-6AyELgODRv-4rF4dcau3kSa5YbgZqUcn', - ); - if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; }); - } catch (e) { - if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); }); - } - } - - Future _importKeys() async { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['txt', 'key', 'keys'], - allowMultiple: false, - ); - if (result == null || result.files.isEmpty || result.files.first.path == null) return; - if (!mounted) return; - - final passphrase = await _showPassphraseDialog(forExport: false); - if (passphrase == null || !mounted) return; - - setState(() { _importLoading = true; _keyFileMsg = null; }); - try { - final fileContent = await File(result.files.first.path!).readAsString(); - final trimmed = fileContent.trim(); - - if (!trimmed.startsWith(_kMegolmHeader) || !trimmed.endsWith(_kMegolmFooter)) { - throw Exception('Ungültiges Dateiformat – kein Megolm-Schlüsseldatei-Header.'); - } - - final lines = trimmed.split('\n'); - // Strip \r so Windows line endings (\r\n) don't corrupt the base64 string. - final b64 = lines.skip(1).take(lines.length - 2).join('').replaceAll(RegExp(r'\s'), ''); - final padded = b64 + '=' * ((4 - b64.length % 4) % 4); - final dataBytes = base64.decode(padded); - - final sessions = await compute(_decryptMegolmKeys, { - 'passphrase': passphrase, - 'data': dataBytes.toList(), - }); - - final client = await ref.read(matrixClientProvider.future); - final km = client.encryption?.keyManager; - if (km == null) throw Exception('Verschlüsselung nicht initialisiert.'); - - var imported = 0; - for (final raw in sessions) { - try { - final session = raw as Map; - final roomId = session['room_id'] as String?; - final sessionId = session['session_id'] as String?; - final senderKey = session['sender_key'] as String?; - if (roomId == null || sessionId == null || senderKey == null) continue; - - final claimedKeys = (session['sender_claimed_keys'] as Map?) - ?.map((k, v) => MapEntry(k.toString(), v.toString())) ?? - {}; - - await km.setInboundGroupSession( - roomId, sessionId, senderKey, - Map.from(session), - forwarded: true, - senderClaimedKeys: claimedKeys, - ); - imported++; - } catch (_) { continue; } - } - - if (mounted) { - setState(() { - _importLoading = false; - _keyFileMsgIsError = false; - _keyFileMsg = '$imported von ${sessions.length} Schlüssel importiert.'; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _importLoading = false; - _keyFileMsgIsError = true; - _keyFileMsg = 'Import fehlgeschlagen: ${e.toString().split('\n').first}'; - }); - } - } - } - - Future _changeRecoveryKey() async { - final changed = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => _ChangeRecoveryKeyDialog(pt: widget.pt), - ); - if (changed == true && mounted) { - await _loadStatus(); - } - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - title: 'Verschlüsselung', - subtitle: 'Ende-zu-Ende-Verschlüsselung & Sicherheit verwalten.', - pt: pt), - - // ── Status overview ─────────────────────────────────────────────── - if (_statusLoaded) ...[ - _SettingsGroup(title: 'Sicherheitsstatus', pt: pt, children: [ - _SettingsRow( - title: 'Cross-Signing', - desc: _crossSigningEnabled == true - ? (_crossSigningCached == true ? 'Eingerichtet & verifiziert' : 'Eingerichtet, Schlüssel nicht im Cache') - : 'Nicht eingerichtet', - pt: pt, - control: Icon( - _crossSigningEnabled == true && _crossSigningCached == true - ? Icons.verified_rounded - : (_crossSigningEnabled == true ? Icons.warning_amber_rounded : Icons.cancel_outlined), - size: 16, - color: _crossSigningEnabled == true && _crossSigningCached == true - ? PyramidColors.success - : (_crossSigningEnabled == true ? pt.away : pt.danger), - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Online-Schlüsselbackup', - desc: _keyBackupEnabled == true ? 'Aktiv – Sitzungsschlüssel gesichert' : 'Nicht aktiviert', - pt: pt, - control: Icon( - _keyBackupEnabled == true ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, - size: 16, - color: _keyBackupEnabled == true ? PyramidColors.success : pt.fgDim, - ), - ), - ]), - const SizedBox(height: 20), - _FingerprintGroup(pt: pt), - const SizedBox(height: 20), - ], - - // ── Key recovery / unlock ───────────────────────────────────────── - _SettingsGroup(title: 'Wiederherstellung', pt: pt, children: [ - _SettingsRow( - title: 'Schlüssel wiederherstellen', - desc: 'Nachrichten mit dem Wiederherstellungsschlüssel entschlüsseln', - pt: pt, - showArrow: true, - onTap: _state == _EncState.loading ? null : _start, - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Wiederherstellungsschlüssel ändern', - desc: 'Neuen Schlüssel generieren, notieren und aktivieren', - pt: pt, - showArrow: true, - onTap: _state == _EncState.loading ? null : _changeRecoveryKey, - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Sicherheit neu einrichten', - desc: 'Cross-Signing & Backup zurücksetzen und neu aufsetzen', - pt: pt, - showArrow: true, - destructive: true, - onTap: _state == _EncState.loading ? null : _resetSecurity, - ), - ]), - const SizedBox(height: 20), - - // ── Key file export / import ─────────────────────────────────────── - _SettingsGroup(title: 'Schlüsseldatei', pt: pt, children: [ - _SettingsRow( - title: 'Schlüssel exportieren', - desc: 'Sitzungsschlüssel als verschlüsselte Datei sichern (kompatibel mit Element)', - pt: pt, - showArrow: !_exportLoading, - control: _exportLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ) - : null, - onTap: (_exportLoading || _importLoading) ? null : _exportKeys, - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Schlüssel importieren', - desc: 'Sitzungsschlüssel aus exportierter Datei laden', - pt: pt, - showArrow: !_importLoading, - control: _importLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ) - : null, - onTap: (_exportLoading || _importLoading) ? null : _importKeys, - ), - ]), - - if (_keyFileMsg != null) ...[ - const SizedBox(height: 12), - _StatusCard( - pt: pt, - icon: _keyFileMsgIsError - ? Icons.error_outline_rounded - : Icons.check_circle_outline_rounded, - color: _keyFileMsgIsError ? pt.danger : PyramidColors.success, - text: _keyFileMsg!, - ), - ], - const SizedBox(height: 20), - - // ── E2EE Diagnostics ────────────────────────────────────────────── - Consumer(builder: (context, ref, _) { - final count = ref.watch(e2eeDiagnosticsProvider).length; - return _SettingsGroup(title: 'Diagnose', pt: pt, children: [ - _SettingsRow( - title: 'Entschlüsselungsfehler hochladen', - desc: count == 0 - ? 'Keine Fehler gesammelt' - : '$count Fehler gesammelt – zum Server hochladen', - pt: pt, - showArrow: !_diagUploading && count > 0, - control: _diagUploading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ) - : count > 0 - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: pt.danger.withAlpha(30), - borderRadius: BorderRadius.circular(10), - ), - child: Text('$count', style: TextStyle(color: pt.danger, fontSize: 12)), - ) - : null, - onTap: (_diagUploading || count == 0) ? null : _uploadDiagnostics, - ), - ]); - }), - - if (_diagMsg != null) ...[ - const SizedBox(height: 12), - _StatusCard( - pt: pt, - icon: _diagMsgIsError - ? Icons.error_outline_rounded - : Icons.cloud_done_rounded, - color: _diagMsgIsError ? pt.danger : PyramidColors.success, - text: _diagMsg!, - ), - ], - const SizedBox(height: 20), - - // ── Inline state UI ─────────────────────────────────────────────── - if (_state == _EncState.loading) - const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive())), - - if (_state == _EncState.done) ...[ - _StatusCard( - pt: pt, - icon: Icons.check_circle_outline_rounded, - color: PyramidColors.success, - text: 'Alle Schlüssel importiert. Nachrichten werden entschlüsselt.'), - const SizedBox(height: 12), - ], - - if (_state == _EncState.needsKey || _state == _EncState.unlocking) ...[ - _StatusCard( - pt: pt, - icon: Icons.lock_outline_rounded, - color: pt.accent, - text: 'Wiederherstellungsschlüssel erforderlich.'), - const SizedBox(height: 16), - TextField( - controller: _keyCtrl, - readOnly: _state == _EncState.unlocking, - autofocus: true, - autocorrect: false, - style: TextStyle(color: pt.fg, fontSize: 13, fontFamily: 'monospace'), - decoration: InputDecoration( - hintText: 'EsXX XXXX XXXX XXXX …', - hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), - labelText: 'Wiederherstellungsschlüssel', - labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), - errorText: _error, - errorMaxLines: 2, - filled: true, - fillColor: pt.bg2, - prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - cursorColor: pt.accent, - onSubmitted: (_) => _unlock(), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: _PrimaryBtn( - label: _state == _EncState.unlocking ? 'Entschlüssele…' : 'Nachrichten entschlüsseln', - icon: Icons.lock_open_outlined, - pt: pt, - loading: _state == _EncState.unlocking, - onPressed: _unlock, - ), - ), - const SizedBox(height: 12), - ], - - if (_state == _EncState.error) ...[ - _StatusCard( - pt: pt, icon: Icons.error_outline_rounded, color: PyramidColors.danger, - text: _error ?? 'Unbekannter Fehler.'), - const SizedBox(height: 12), - OutlinedButton.icon( - style: OutlinedButton.styleFrom( - foregroundColor: pt.fg, - side: BorderSide(color: pt.border), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: _start, - icon: const Icon(Icons.refresh_rounded, size: 16), - label: const Text('Erneut versuchen'), - ), - ], - - if (_state == _EncState.idle && !_statusLoaded) - _StatusCard( - pt: pt, - icon: Icons.shield_outlined, - color: pt.fgMuted, - text: 'Lade Sicherheitsstatus…'), - ], - ), - ); - } -} - -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(); - - @override - void initState() { - super.initState(); - _startBootstrap(); - } - - Future _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 _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'), - ), - ), - ], - ); - } -} - -class _FingerprintGroup extends ConsumerWidget { - final PyramidTheme pt; - const _FingerprintGroup({required this.pt}); - - static String _fmt(String key) { - final clean = key.replaceAll(RegExp(r'\s'), ''); - final groups = []; - for (var i = 0; i < clean.length; i += 4) { - groups.add(clean.substring(i, (i + 4).clamp(0, clean.length))); - } - return groups.join(' '); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final clientAsync = ref.watch(matrixClientProvider); - final client = clientAsync.valueOrNull; - if (client == null) return const SizedBox.shrink(); - - final fingerprint = _fmt(client.fingerprintKey); - final deviceId = client.deviceID ?? ''; - - return _SettingsGroup(title: 'Dieses Gerät', pt: pt, children: [ - _SettingsRow( - title: 'Geräte-ID', - desc: deviceId, - pt: pt, - control: IconButton( - icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), - tooltip: 'Kopieren', - padding: EdgeInsets.zero, - constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), - onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)), - ), - ), - _Divider(pt: pt), - Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text('Ed25519-Fingerabdruck', - style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), - const Spacer(), - IconButton( - icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), - tooltip: 'Fingerabdruck kopieren', - padding: EdgeInsets.zero, - constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), - onPressed: () => Clipboard.setData(ClipboardData(text: client.fingerprintKey)), - ), - ], - ), - const SizedBox(height: 4), - SelectableText( - fingerprint, - style: TextStyle( - color: pt.fgMuted, fontSize: 11, - fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, - ), - ), - ], - ), - ), - ]); - } -} - -// Passphrase dialog (for key export/import) -class _PassphraseDialog extends StatefulWidget { - final PyramidTheme pt; - final bool forExport; - const _PassphraseDialog({required this.pt, required this.forExport}); - - @override - State<_PassphraseDialog> createState() => _PassphraseDialogState(); -} - -class _PassphraseDialogState extends State<_PassphraseDialog> { - final _ctrl1 = TextEditingController(); - final _ctrl2 = TextEditingController(); - bool _obscure = true; - String? _error; - - @override - void dispose() { - _ctrl1.dispose(); - _ctrl2.dispose(); - super.dispose(); - } - - void _submit() { - final pass = _ctrl1.text.trim(); - if (pass.isEmpty) { - setState(() => _error = 'Bitte ein Passwort eingeben.'); - return; - } - if (widget.forExport && pass != _ctrl2.text.trim()) { - setState(() => _error = 'Passwörter stimmen nicht überein.'); - return; - } - Navigator.pop(context, pass); - } - - InputDecoration _inputDeco(String label, PyramidTheme pt) => InputDecoration( - labelText: label, - labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), - errorText: _error, - filled: true, - fillColor: pt.bg3, - prefixIcon: Icon(Icons.lock_outline_rounded, color: pt.fgDim, size: 18), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), - isDense: true, - errorMaxLines: 2, - suffixIcon: IconButton( - icon: Icon( - _obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, - size: 18, - color: pt.fgDim, - ), - onPressed: () => setState(() => _obscure = !_obscure), - ), - ); - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), - side: BorderSide(color: pt.border), - ), - title: Text( - widget.forExport ? 'Schlüssel exportieren' : 'Schlüssel importieren', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), - ), - content: SizedBox( - width: 340, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.forExport - ? 'Wähle ein Passwort zum Verschlüsseln der Schlüsseldatei.' - : 'Gib das Passwort ein, mit dem die Datei verschlüsselt wurde.', - style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), - ), - const SizedBox(height: 16), - TextField( - controller: _ctrl1, - obscureText: _obscure, - autofocus: true, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: _inputDeco('Passwort', pt).copyWith( - // Only show error on confirm field if export, else here - errorText: widget.forExport ? null : _error, - ), - cursorColor: pt.accent, - onSubmitted: widget.forExport ? null : (_) => _submit(), - ), - if (widget.forExport) ...[ - const SizedBox(height: 12), - TextField( - controller: _ctrl2, - obscureText: _obscure, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: _inputDeco('Passwort bestätigen', pt), - cursorColor: pt.accent, - onSubmitted: (_) => _submit(), - ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.accent, - foregroundColor: pt.accentFg, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: _submit, - child: Text(widget.forExport ? 'Exportieren' : 'Importieren'), - ), - ], - ); - } -} diff --git a/lib/widgets/settings_modal.dart b/lib/widgets/settings_modal.dart index 5eb4ffa..d80906e 100644 --- a/lib/widgets/settings_modal.dart +++ b/lib/widgets/settings_modal.dart @@ -46,7 +46,10 @@ part 'settings/settings_keybinds.dart'; part 'settings/settings_privacy.dart'; part 'settings/settings_sessions.dart'; part 'settings/settings_verification.dart'; -part 'settings/settings_encryption.dart'; +part 'settings/encryption/megolm_crypto.dart'; +part 'settings/encryption/encryption_section.dart'; +part 'settings/encryption/recovery_key_dialogs.dart'; +part 'settings/encryption/fingerprint_passphrase.dart'; part 'settings/settings_account.dart'; part 'settings/settings_about.dart';