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