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