refactor: settings_encryption.dart (1470 Zeilen) in 4 thematische part-Dateien unter encryption/ geteilt (M2, zeichenidentisch verifiziert)

Vorbereitung fuer Schnitt-Doc Schritt 3 (verification-Modul), ohne den
Verschluesselungs-heiligen Code anzufassen: megolm_crypto (Export/Import-
Krypto-Helfer), encryption_section (Enums + _EncryptionSection),
recovery_key_dialogs (_ChangeRecoveryKeyDialog/_ShowKeyBody/_ConfirmKeyBody),
fingerprint_passphrase (_FingerprintGroup/_PassphraseDialog).
Rekonstruktion zeichenidentisch (1468 Code-Zeilen, ordinaler Vergleich),
analyze sauber, 29 Tests gruen, App-Start ok (auth_log 17:43:14 loggedIn).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-06 17:43:53 +02:00
parent c8cc1480a8
commit 6128953e28
6 changed files with 1480 additions and 1471 deletions
@@ -0,0 +1,659 @@
part of '../../settings_modal.dart';
// ─────────────────────────────────────────────────────────────────────────────
enum _EncState { idle, loading, needsKey, unlocking, done, error }
enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error }
class _EncryptionSection extends ConsumerStatefulWidget {
final PyramidTheme pt;
final WidgetRef ref;
const _EncryptionSection({required this.pt, required this.ref});
@override
ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState();
}
class _EncryptionSectionState extends ConsumerState<_EncryptionSection> {
_EncState _state = _EncState.idle;
Bootstrap? _bootstrap;
final _keyCtrl = TextEditingController();
String? _error;
// Status loaded from encryption object
bool? _crossSigningEnabled;
bool? _crossSigningCached;
bool? _keyBackupEnabled;
bool _statusLoaded = false;
// Key file export / import
bool _exportLoading = false;
bool _importLoading = false;
String? _keyFileMsg;
bool _keyFileMsgIsError = false;
bool _diagUploading = false;
String? _diagMsg;
bool _diagMsgIsError = false;
@override
void initState() {
super.initState();
_loadStatus();
}
@override
void dispose() {
_keyCtrl.dispose();
super.dispose();
}
Future<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',
// 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<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…'),
],
),
);
}
}
@@ -0,0 +1,204 @@
part of '../../settings_modal.dart';
class _FingerprintGroup extends ConsumerWidget {
final PyramidTheme pt;
const _FingerprintGroup({required this.pt});
static String _fmt(String key) {
final clean = key.replaceAll(RegExp(r'\s'), '');
final groups = <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,
),
),
],
),
),
]);
}
}
// 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'),
),
],
);
}
}
@@ -0,0 +1,129 @@
part of '../../settings_modal.dart';
// ── Megolm key export / import top-level so compute() can use them ─────────
const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----';
const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----';
// PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int}
Uint8List _runPbkdf2(Map<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>;
}
@@ -0,0 +1,484 @@
part of '../../settings_modal.dart';
class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _ChangeRecoveryKeyDialog({required this.pt});
@override
ConsumerState<_ChangeRecoveryKeyDialog> createState() =>
_ChangeRecoveryKeyDialogState();
}
class _ChangeRecoveryKeyDialogState
extends ConsumerState<_ChangeRecoveryKeyDialog> {
_RecoveryStep _step = _RecoveryStep.loading;
String? _generatedKey;
String? _error;
bool _copied = false;
// Completer signals when bootstrap reaches `done`
final _doneCompleter = Completer<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'),
),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -46,7 +46,10 @@ part 'settings/settings_keybinds.dart';
part 'settings/settings_privacy.dart';
part 'settings/settings_sessions.dart';
part 'settings/settings_verification.dart';
part 'settings/settings_encryption.dart';
part 'settings/encryption/megolm_crypto.dart';
part 'settings/encryption/encryption_section.dart';
part 'settings/encryption/recovery_key_dialogs.dart';
part 'settings/encryption/fingerprint_passphrase.dart';
part 'settings/settings_account.dart';
part 'settings/settings_about.dart';