5d772389d6
Reine Verschiebung per Dart part/part-of (keine Umbenennung, keine Logikänderung) - settings_modal.dart schrumpft von 5541 auf 270 Zeilen. Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze (Baseline unverändert: 1 bekannter Hinweis). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1466 lines
55 KiB
Dart
1466 lines
55 KiB
Dart
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<String, dynamic> 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<dynamic>} → encrypted String
|
||
String _encryptMegolmKeys(Map<String, dynamic> args) {
|
||
final pass = args['passphrase'] as String;
|
||
final sessions = args['sessions'] as List<dynamic>;
|
||
|
||
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<int>} → List<dynamic> sessions
|
||
List<dynamic> _decryptMegolmKeys(Map<String, dynamic> args) {
|
||
final pass = args['passphrase'] as String;
|
||
final raw = Uint8List.fromList(args['data'] as List<int>);
|
||
|
||
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<dynamic>;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
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<void> _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<void> _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<bool>();
|
||
_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<void> _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<void> _resetSecurity() async {
|
||
final confirm = await showDialog<bool>(
|
||
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<void>();
|
||
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<String?> _showPassphraseDialog({required bool forExport}) {
|
||
return showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport),
|
||
);
|
||
}
|
||
|
||
Future<void> _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 = <Map<String, dynamic>>[];
|
||
|
||
for (final s in stored) {
|
||
try {
|
||
final content = jsonDecode(s.content) as Map<String, dynamic>;
|
||
final sessionKey = content['session_key'] as String?;
|
||
if (sessionKey == null || sessionKey.isEmpty) continue;
|
||
|
||
final claimedKeys = s.senderClaimedKeys.isNotEmpty
|
||
? (jsonDecode(s.senderClaimedKeys) as Map<String, dynamic>)
|
||
.map((k, v) => MapEntry(k, v.toString()))
|
||
: <String, String>{};
|
||
|
||
sessions.add({
|
||
'algorithm': 'm.megolm.v1.aes-sha2',
|
||
'forwarding_curve25519_key_chain': <dynamic>[],
|
||
'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<void> _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',
|
||
token: 'J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI',
|
||
);
|
||
if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; });
|
||
} catch (e) {
|
||
if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); });
|
||
}
|
||
}
|
||
|
||
Future<void> _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<String, dynamic>;
|
||
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())) ??
|
||
<String, String>{};
|
||
|
||
await km.setInboundGroupSession(
|
||
roomId, sessionId, senderKey,
|
||
Map<String, dynamic>.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<void> _changeRecoveryKey() async {
|
||
final changed = await showDialog<bool>(
|
||
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<void>();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_startBootstrap();
|
||
}
|
||
|
||
Future<void> _startBootstrap() async {
|
||
try {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
final enc = client.encryption;
|
||
if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.');
|
||
await client.roomsLoading;
|
||
await client.accountDataLoading;
|
||
await client.userDeviceKeysLoading;
|
||
enc.bootstrap(onUpdate: (bs) {
|
||
if (!mounted) return;
|
||
_onBootstrapUpdate(bs);
|
||
});
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_step = _RecoveryStep.error;
|
||
_error = e.toString().split('\n').first;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// After newSsss() calls checkCrossSigning(), the bootstrap immediately
|
||
// transitions to askWipeCrossSigning (or askSetupCrossSigning). All post-
|
||
// newSsss states must therefore auto-advance unconditionally — guarding on
|
||
// _step would leave them stuck.
|
||
void _onBootstrapUpdate(Bootstrap bs) {
|
||
switch (bs.state) {
|
||
case BootstrapState.askWipeSsss:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true);
|
||
});
|
||
case BootstrapState.askBadSsss:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true);
|
||
});
|
||
case BootstrapState.askUseExistingSsss:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false);
|
||
});
|
||
case BootstrapState.askUnlockSsss:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss();
|
||
});
|
||
case BootstrapState.askNewSsss:
|
||
// Only generate once
|
||
if (_step == _RecoveryStep.loading) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||
if (bs.state != BootstrapState.askNewSsss) return;
|
||
try {
|
||
await bs.newSsss();
|
||
// newSsss() internally calls checkCrossSigning() which already
|
||
// transitions to askWipeCrossSigning; capture key after it returns.
|
||
final key = bs.newSsssKey?.recoveryKey;
|
||
if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; });
|
||
} catch (e) {
|
||
if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; });
|
||
}
|
||
});
|
||
}
|
||
case BootstrapState.askWipeCrossSigning:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false);
|
||
});
|
||
case BootstrapState.askSetupCrossSigning:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askSetupCrossSigning) {
|
||
bs.askSetupCrossSigning(
|
||
setupMasterKey: true,
|
||
setupSelfSigningKey: true,
|
||
setupUserSigningKey: true,
|
||
);
|
||
}
|
||
});
|
||
case BootstrapState.askWipeOnlineKeyBackup:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true);
|
||
});
|
||
case BootstrapState.askSetupOnlineKeyBackup:
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true);
|
||
});
|
||
case BootstrapState.done:
|
||
if (!_doneCompleter.isCompleted) _doneCompleter.complete();
|
||
// Only auto-advance to done if user already clicked through (applying).
|
||
// If still on showKey/confirmKey, the user must finish reading first.
|
||
if (mounted && _step == _RecoveryStep.applying) {
|
||
setState(() => _step = _RecoveryStep.done);
|
||
}
|
||
case BootstrapState.error:
|
||
if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler');
|
||
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; });
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen.
|
||
void _onKeySaved() {
|
||
setState(() => _step = _RecoveryStep.confirmKey);
|
||
}
|
||
|
||
// Called when user submits the correct key confirmation.
|
||
// Bootstrap may still be running (cross-signing + key backup); wait for done.
|
||
Future<void> _onKeyConfirmed() async {
|
||
setState(() => _step = _RecoveryStep.applying);
|
||
try {
|
||
await _doneCompleter.future.timeout(const Duration(seconds: 90));
|
||
if (mounted) setState(() => _step = _RecoveryStep.done);
|
||
} catch (e) {
|
||
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; });
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey;
|
||
|
||
return Dialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border),
|
||
),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 440, minWidth: 320),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.key_rounded, size: 18, color: pt.accent),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'Wiederherstellungsschlüssel',
|
||
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
if (canClose)
|
||
IconButton(
|
||
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
|
||
onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Divider(color: pt.border, height: 1),
|
||
Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: _buildBody(pt),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBody(PyramidTheme pt) {
|
||
return switch (_step) {
|
||
_RecoveryStep.loading => const Center(
|
||
child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()),
|
||
),
|
||
_RecoveryStep.showKey => _ShowKeyBody(
|
||
pt: pt,
|
||
generatedKey: _generatedKey ?? '',
|
||
copied: _copied,
|
||
onCopy: () {
|
||
Clipboard.setData(ClipboardData(text: _generatedKey ?? ''));
|
||
setState(() => _copied = true);
|
||
},
|
||
onKeySaved: _onKeySaved,
|
||
),
|
||
_RecoveryStep.confirmKey => _ConfirmKeyBody(
|
||
pt: pt,
|
||
expectedKey: _generatedKey ?? '',
|
||
onConfirmed: _onKeyConfirmed,
|
||
),
|
||
_RecoveryStep.applying => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator.adaptive(),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||
),
|
||
],
|
||
),
|
||
_RecoveryStep.done => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'Wiederherstellungsschlüssel wurde erfolgreich geändert.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4),
|
||
),
|
||
const SizedBox(height: 20),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
onPressed: () => Navigator.of(context).pop(true),
|
||
child: const Text('Fertig'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
_RecoveryStep.error => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
_error ?? 'Ein unbekannter Fehler ist aufgetreten.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
),
|
||
const SizedBox(height: 20),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: pt.fg, side: BorderSide(color: pt.border),
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
onPressed: () => Navigator.of(context).pop(false),
|
||
child: const Text('Schließen'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
};
|
||
}
|
||
}
|
||
|
||
class _ShowKeyBody extends StatelessWidget {
|
||
final PyramidTheme pt;
|
||
final String generatedKey;
|
||
final bool copied;
|
||
final VoidCallback onCopy;
|
||
final VoidCallback onKeySaved;
|
||
|
||
const _ShowKeyBody({
|
||
required this.pt,
|
||
required this.generatedKey,
|
||
required this.copied,
|
||
required this.onCopy,
|
||
required this.onKeySaved,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Dein neuer Wiederherstellungsschlüssel',
|
||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: pt.bg3,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
child: SelectableText(
|
||
generatedKey,
|
||
style: TextStyle(
|
||
color: pt.accent, fontSize: 13,
|
||
fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
IconButton(
|
||
onPressed: onCopy,
|
||
icon: Icon(
|
||
copied ? Icons.check_rounded : Icons.copy_rounded,
|
||
size: 18,
|
||
color: copied ? PyramidColors.success : pt.fgDim,
|
||
),
|
||
tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: Colors.orange.withAlpha(28),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: Colors.orange.withAlpha(80)),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.',
|
||
style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
onPressed: onKeySaved,
|
||
child: const Text('Schlüssel gespeichert – weiter'),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ConfirmKeyBody extends StatefulWidget {
|
||
final PyramidTheme pt;
|
||
final String expectedKey;
|
||
final VoidCallback onConfirmed;
|
||
|
||
const _ConfirmKeyBody({
|
||
required this.pt,
|
||
required this.expectedKey,
|
||
required this.onConfirmed,
|
||
});
|
||
|
||
@override
|
||
State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState();
|
||
}
|
||
|
||
class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> {
|
||
final _controller = TextEditingController();
|
||
bool _mismatch = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _submit() {
|
||
final entered = _controller.text.trim();
|
||
if (entered == widget.expectedKey) {
|
||
widget.onConfirmed();
|
||
} else {
|
||
setState(() => _mismatch = true);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Schlüssel bestätigen',
|
||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
controller: _controller,
|
||
autofocus: true,
|
||
style: TextStyle(
|
||
color: pt.fg, fontSize: 13,
|
||
fontFamily: 'monospace', letterSpacing: 0.4,
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: 'EsTX XXXX XXXX …',
|
||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
||
filled: true,
|
||
fillColor: pt.bg3,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: pt.border),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.border),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5),
|
||
),
|
||
errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null,
|
||
),
|
||
onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); },
|
||
onSubmitted: (_) => _submit(),
|
||
),
|
||
const SizedBox(height: 20),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
onPressed: _submit,
|
||
child: const Text('Aktivieren'),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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 = <String>[];
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
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'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|