Files
pyramid/lib/widgets/settings/encryption/encryption_section.dart
T
Bernd Steckmeister 6128953e28 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>
2026-07-06 17:43:53 +02:00

660 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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…'),
],
),
);
}
}