b5762ea08d
Beide Logout-Bestätigungsdialoge prüfen jetzt vorab per neuem isKeyBackupMissing(client) (bootstrap_dialog.dart), ob Cross-Signing/ Key-Backup eingerichtet ist. Fehlt es, zeigt der Dialog eine Klartext-Warnung: alte verschlüsselte Nachrichten wären nach dem Logout auf keinem Gerät mehr lesbar. Ohne fehlendes Backup bleibt der bisherige Text unverändert.
544 lines
17 KiB
Dart
544 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:matrix/encryption.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:pyramid/core/theme.dart';
|
|
|
|
/// Ob Cross-Signing/Key-Backup fehlt oder nicht entsperrt ist — wenn ja, würde
|
|
/// ein Logout den Zugriff auf ältere verschlüsselte Nachrichten kosten.
|
|
Future<bool> isKeyBackupMissing(Client client) async {
|
|
final enc = client.encryption;
|
|
if (enc == null) return true;
|
|
if (!enc.crossSigning.enabled) return true;
|
|
return !(await enc.keyManager.isCached().catchError((_) => false));
|
|
}
|
|
|
|
Future<void> showBootstrapDialog(
|
|
BuildContext context,
|
|
Client client, {
|
|
bool force = false,
|
|
}) async {
|
|
if (client.encryption == null) return;
|
|
if (!force) {
|
|
final enc = client.encryption!;
|
|
final cached = await enc.keyManager.isCached().catchError((_) => false);
|
|
if (cached && enc.crossSigning.enabled) return;
|
|
}
|
|
if (!context.mounted) return;
|
|
await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => _BootstrapDialog(client: client),
|
|
);
|
|
}
|
|
|
|
class _BootstrapDialog extends StatefulWidget {
|
|
final Client client;
|
|
const _BootstrapDialog({required this.client});
|
|
|
|
@override
|
|
State<_BootstrapDialog> createState() => _BootstrapDialogState();
|
|
}
|
|
|
|
class _BootstrapDialogState extends State<_BootstrapDialog> {
|
|
Bootstrap? _bootstrap;
|
|
final _keyCtrl = TextEditingController();
|
|
bool _keyLoading = false;
|
|
String? _keyError;
|
|
bool _wipe = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_start();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_keyCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _start() async {
|
|
final client = widget.client;
|
|
await client.roomsLoading;
|
|
await client.accountDataLoading;
|
|
await client.userDeviceKeysLoading;
|
|
while (client.prevBatch == null) {
|
|
await client.onSyncStatus.stream.first;
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_bootstrap = client.encryption!.bootstrap(
|
|
onUpdate: (_) {
|
|
if (mounted) setState(() {});
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
void _autoAdvance(Bootstrap bs) {
|
|
switch (bs.state) {
|
|
case BootstrapState.askWipeSsss:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.wipeSsss(_wipe));
|
|
case BootstrapState.askBadSsss:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.ignoreBadSecrets(true));
|
|
case BootstrapState.askUseExistingSsss:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.useExistingSsss(!_wipe));
|
|
case BootstrapState.askUnlockSsss:
|
|
// Handled by UI if oldSsssKeys exist, otherwise skip
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.unlockedSsss());
|
|
case BootstrapState.askNewSsss:
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss());
|
|
case BootstrapState.askWipeCrossSigning:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.wipeCrossSigning(_wipe));
|
|
case BootstrapState.askSetupCrossSigning:
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => bs.askSetupCrossSigning(
|
|
setupMasterKey: true,
|
|
setupSelfSigningKey: true,
|
|
setupUserSigningKey: true,
|
|
),
|
|
);
|
|
case BootstrapState.askWipeOnlineKeyBackup:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(_wipe));
|
|
case BootstrapState.askSetupOnlineKeyBackup:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true));
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _unlock() async {
|
|
final bs = _bootstrap;
|
|
if (bs == null || bs.newSsssKey == null) return;
|
|
final key = _keyCtrl.text.trim();
|
|
if (key.isEmpty) {
|
|
setState(() => _keyError = 'Bitte Wiederherstellungsschlüssel eingeben.');
|
|
return;
|
|
}
|
|
setState(() {
|
|
_keyLoading = true;
|
|
_keyError = null;
|
|
});
|
|
try {
|
|
await bs.newSsssKey!.unlock(keyOrPassphrase: key);
|
|
Logs().d('[Bootstrap] SSSS key unlocked');
|
|
await bs.openExistingSsss();
|
|
Logs().d('[Bootstrap] SSSS opened, secrets cached');
|
|
if (bs.encryption.crossSigning.enabled) {
|
|
await widget.client.encryption!.crossSigning.selfSign(recoveryKey: key);
|
|
Logs().d('[Bootstrap] Cross-signing self-signed');
|
|
}
|
|
} on InvalidPassphraseException catch (_) {
|
|
setState(
|
|
() => _keyError = 'Falscher Schlüssel. Bitte nochmal prüfen.',
|
|
);
|
|
} on FormatException catch (_) {
|
|
setState(() => _keyError = 'Ungültiges Format. Prüfe deinen Schlüssel.');
|
|
} catch (e) {
|
|
setState(() => _keyError = e.toString().split('\n').first);
|
|
} finally {
|
|
if (mounted) setState(() => _keyLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _close({required bool success}) async {
|
|
if (success) {
|
|
// Download all megolm session keys from backup — bypasses
|
|
// _requestedSessionIds deduplication so previously failed decryptions
|
|
// get a second chance.
|
|
widget.client.encryption?.keyManager
|
|
.loadAllKeys()
|
|
.catchError((e) => Logs().e('[Bootstrap] loadAllKeys failed', e));
|
|
}
|
|
if (mounted) Navigator.of(context).pop(success);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = PyramidTheme.of(context);
|
|
final bs = _bootstrap;
|
|
|
|
// Waiting for bootstrap to initialise
|
|
if (bs == null || bs.state == BootstrapState.loading) {
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Verschlüsselung einrichten',
|
|
onClose: () => _close(success: false),
|
|
child: const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: CircularProgressIndicator.adaptive(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// User must enter recovery key
|
|
if (bs.state == BootstrapState.openExistingSsss) {
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Nachrichten entschlüsseln',
|
|
onClose: () => _close(success: false),
|
|
child: _KeyInputBody(
|
|
pt: pt,
|
|
ctrl: _keyCtrl,
|
|
loading: _keyLoading,
|
|
error: _keyError,
|
|
onUnlock: _unlock,
|
|
onWipe: () {
|
|
setState(() {
|
|
_wipe = true;
|
|
_start();
|
|
});
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
// Success — all done
|
|
if (bs.state == BootstrapState.done) {
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Einrichtung abgeschlossen',
|
|
onClose: null,
|
|
child: _DoneBody(
|
|
pt: pt,
|
|
onClose: () => _close(success: true),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Error
|
|
if (bs.state == BootstrapState.error) {
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Fehler',
|
|
onClose: () => _close(success: false),
|
|
child: _ErrorBody(
|
|
pt: pt,
|
|
onClose: () => _close(success: false),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Auto-advance all other (non-interactive) states
|
|
_autoAdvance(bs);
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Verschlüsselung einrichten…',
|
|
onClose: () => _close(success: false),
|
|
child: const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: CircularProgressIndicator.adaptive(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Sub-widgets
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class _Shell extends StatelessWidget {
|
|
final PyramidTheme pt;
|
|
final String title;
|
|
final VoidCallback? onClose;
|
|
final Widget child;
|
|
|
|
const _Shell({
|
|
required this.pt,
|
|
required this.title,
|
|
required this.onClose,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
backgroundColor: pt.bg1,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(pt.rBase + 4),
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
maxWidth: 460,
|
|
minWidth: 320,
|
|
minHeight: 120,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.shield_outlined, size: 18, color: pt.accent),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: TextStyle(
|
|
color: pt.fg,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
if (onClose != null)
|
|
IconButton(
|
|
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
|
|
onPressed: onClose,
|
|
tooltip: 'Überspringen',
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(color: pt.border, height: 1),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _KeyInputBody extends StatelessWidget {
|
|
final PyramidTheme pt;
|
|
final TextEditingController ctrl;
|
|
final bool loading;
|
|
final String? error;
|
|
final VoidCallback onUnlock;
|
|
final VoidCallback onWipe;
|
|
|
|
const _KeyInputBody({
|
|
required this.pt,
|
|
required this.ctrl,
|
|
required this.loading,
|
|
required this.error,
|
|
required this.onUnlock,
|
|
required this.onWipe,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Gib deinen Wiederherstellungsschlüssel ein, um deine verschlüsselten Nachrichten lesbar zu machen.',
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: ctrl,
|
|
readOnly: loading,
|
|
autofocus: true,
|
|
autocorrect: false,
|
|
style: TextStyle(
|
|
color: pt.fg,
|
|
fontSize: 13,
|
|
fontFamily: 'monospace',
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: 'EsXX XXXX XXXX XXXX XXXX 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: (_) => onUnlock(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: pt.accent,
|
|
foregroundColor: pt.accentFg,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
onPressed: loading ? null : onUnlock,
|
|
icon: loading
|
|
? SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
color: pt.accentFg,
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: const Icon(Icons.lock_open_outlined, size: 18),
|
|
label: Text(
|
|
loading ? 'Entschlüssele…' : 'Nachrichten entschlüsseln',
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Divider + lost key option
|
|
Row(
|
|
children: [
|
|
Expanded(child: Divider(color: pt.border)),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Text('oder', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
|
),
|
|
Expanded(child: Divider(color: pt.border)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton.icon(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: PyramidColors.danger,
|
|
side: BorderSide(color: PyramidColors.danger.withAlpha(100)),
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
onPressed: loading ? null : onWipe,
|
|
icon: const Icon(Icons.delete_outlined, size: 16),
|
|
label: const Text('Schlüssel verloren — neu einrichten', style: TextStyle(fontSize: 13)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DoneBody extends StatelessWidget {
|
|
final PyramidTheme pt;
|
|
final VoidCallback onClose;
|
|
|
|
const _DoneBody({required this.pt, required this.onClose});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.check_circle_outline_rounded,
|
|
color: PyramidColors.success,
|
|
size: 56,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Alle Schlüssel wurden erfolgreich importiert.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Deine verschlüsselten Nachrichten werden jetzt im Hintergrund entschlüsselt.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
|
),
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: pt.accent,
|
|
foregroundColor: pt.accentFg,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
elevation: 0,
|
|
),
|
|
onPressed: onClose,
|
|
child: const Text('Fertig'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorBody extends StatelessWidget {
|
|
final PyramidTheme pt;
|
|
final VoidCallback onClose;
|
|
|
|
const _ErrorBody({required this.pt, required this.onClose});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Etwas ist schiefgelaufen. Versuche es erneut.',
|
|
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: onClose,
|
|
child: const Text('Schließen'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|