test: Live-E2E-Verifikation SQLCipher mit echtem Login (Test-Account)

Neuer gegateter Test (PYRAMID_LIVE_TEST=1, Windows): echter Login als
pyramidtest1, Byte-Pruefung der Platte (kein SQLite-Header, Token +
Nachricht nicht im Klartext, Positiv-Kontrolle in Klartext-DB),
Neustart aus verschluesselter DB, Logout/Re-Login, Migration einer
Klartext-DB mit echter eingeloggter Session. Zugangsdaten bleiben
ausserhalb des Repos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-06 22:11:48 +02:00
parent dba4b64df8
commit 9f797279eb
4 changed files with 490 additions and 1 deletions
+5
View File
@@ -1450,3 +1450,8 @@
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart 2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart 2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
2026-07-06 18:50 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\5be37250-1cea-473c-b62c-923f3d071abd\scratchpad\ui.ps1 2026-07-06 18:50 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\5be37250-1cea-473c-b62c-923f3d071abd\scratchpad\ui.ps1
2026-07-06 22:04 [Write] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
2026-07-06 22:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 22:11 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
+52
View File
@@ -13,6 +13,58 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
--- ---
## 2026-07-06 Nacht-Session (A): SQLCipher mit ECHTEM Login live verifiziert (Test-Account, 2 neue E2E-Tests grün)
**Erledigt:** Bernds Fokus (A) für die Nacht die Verschlüsselung nicht nur
behaupten, sondern mit einem echten Konto belegen. Bernd hat dafür
Test-Accounts bereitgestellt (`~/.pyramid-autopilot/test-accounts.txt`,
pyramidtest1/pyramidtest2 auf steggi-matrix.work NICHT Bernds/Utas Konten).
Neuer Live-Test `test/live_encryption_e2e_test.dart` (nur auf Windows, nur
mit `PYRAMID_LIVE_TEST=1`, Zugangsdaten bleiben AUSSERHALB des Repos):
- **Test 1 frische Installation (grün, echter Lauf gegen den Homeserver):**
DB verschlüsselt angelegt (Schlüssel-Injektion wie in den Bestands-Tests),
echter Passwort-Login als pyramidtest1, Testraum angelegt, Nachricht mit
Zufalls-Marker gesendet. Danach Byte-Prüfung der Platte: Header ist NICHT
„SQLite format 3", und weder der echte Access-Token noch der
Nachrichtentext sind in `pyramid.sqlite`/`-wal` im Klartext auffindbar.
Gegenprobe: Raw-Query MIT Schlüssel findet beides in der Datei → die Daten
stecken wirklich drin, nur verschlüsselt. „Neustart" (DB neu geöffnet,
neuer Client): Session inkl. identischem Access-Token kommt aus der
verschlüsselten DB, ohne Passwort. Dann Logout → erneuter Login → alte
Nachricht wieder lesbar (Timeline inkl. History-Nachladen).
- **Test 2 Utas Update-Fall mit ECHTER Session (grün):** Klartext-DB wie
vor der SQLCipher-Einführung, echter Login, Nachricht. POSITIV-KONTROLLE:
in der Klartext-DB findet die Byte-Suche Token UND Nachricht (beweist,
dass die Suche Leaks erkennen würde). Dann `migratePlaintextToEncrypted`
(exakt der App-Codepfad): Migration grün, `.premigration`-Backup da,
danach ist auf der Platte nichts mehr im Klartext, Session (gleicher
Access-Token) und Nachricht überleben die Migration. Das war bisher der
große UNGETESTET-Punkt „Migration mit echter eingeloggter Session".
- Beide Tests räumen serverseitig auf (Logout am Ende). Gesamtsuite: 32
Tests grün, die 2 Live-Tests skippen ohne Gate sauber. analyze sauber
(nur bekannter onUpdate-Hinweis).
**Ehrlich abgegrenzt (bleibt UNGETESTET):** flutter_secure_storage wird im
Host-Test umgangen (Schlüssel direkt injiziert; der Keystore-Pfad ist durch
die echten App-Läufe Windows + Android-Emulator belegt). E2EE-verschlüsselte
Räume kann der Host-Test nicht (kein vodozemac) der Testraum ist
unverschlüsselt, was für die Klartext-Suche der härtere Fall ist. Echtes
ARM-Gerät + FCM-Push-Isolate bleiben offen (ROADMAP-Punkt weiter nicht
abgehakt).
**Offen/Nächster Schritt:** Fokus (C) Streaming 60 FPS
(`docs/STREAMING_60FPS.md`), danach Fokus (B) Call-Logik mit beiden
Test-Accounts.
**Stolperfallen:** `Client.init(waitUntilLoadCompletedLoaded: false)` lädt
die Raumliste ASYNCHRON wer direkt nach dem Init `client.rooms`/
`getRoomById` prüft, sieht eine leere Liste und meldet fälschlich
Datenverlust. Für DB-Restaurations-Checks `waitUntilLoadCompletedLoaded:
true` verwenden.
---
## 2026-07-06 SQLCipher auf ANDROID getestet (Emulator API 36): Neuanlage, Migration und Neustart grün ## 2026-07-06 SQLCipher auf ANDROID getestet (Emulator API 36): Neuanlage, Migration und Neustart grün
**Erledigt:** Der größte offene Teil von Testplan Punkt 4 **Erledigt:** Der größte offene Teil von Testplan Punkt 4
+10 -1
View File
@@ -51,7 +51,16 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
**Nachtrag 2026-07-06 (vierte PC-Session): Android-EMULATOR-Test grün** **Nachtrag 2026-07-06 (vierte PC-Session): Android-EMULATOR-Test grün**
(API 36: Neuanlage verschlüsselt, Klartext-Migration beim ersten Start, (API 36: Neuanlage verschlüsselt, Klartext-Migration beim ersten Start,
Neustart mit Keystore-Schlüssel ohne Re-Migration Details im Neustart mit Keystore-Schlüssel ohne Re-Migration Details im
Status-Kopf von `docs/SQLCIPHER_MIGRATION.md`). **Bleibt offen:** Status-Kopf von `docs/SQLCIPHER_MIGRATION.md`).
**Nachtrag 2026-07-06 (Nacht-Session): LIVE-E2E-Test mit ECHTEM Login grün**
(`test/live_encryption_e2e_test.dart`, Test-Account pyramidtest1 auf
steggi-matrix.work, Gate `PYRAMID_LIVE_TEST=1`): Platte nach echtem
Login/Nachricht byte-geprüft (kein SQLite-Header, Access-Token und
Nachrichtentext NICHT im Klartext auffindbar, Positiv-Kontrolle in
Klartext-DB findet beides), Neustart restauriert Session aus der
verschlüsselten DB, Logout→Re-Login→alte Nachricht lesbar, und die
Klartext-Migration mit einer ECHTEN eingeloggten Session erhält
Session+Daten. **Bleibt offen:**
echtes ARM-Gerät mit eingeloggter Session + Push-Isolate-Test echtes ARM-Gerät mit eingeloggter Session + Push-Isolate-Test
(FCM liest verschlüsselte DB, secure_storage im Background-Isolate) (FCM liest verschlüsselte DB, secure_storage im Background-Isolate)
Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`; Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`;
+423
View File
@@ -0,0 +1,423 @@
@TestOn('windows')
library;
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart' as sqlite_open;
import 'package:pyramid/core/app_database.dart';
/// LIVE-End-to-End-Test der SQLCipher-Verschlüsselung mit einem ECHTEN
/// Matrix-Login gegen steggi-matrix.work (Test-Account, NICHT Bernd/Uta).
///
/// Bewusst doppelt gegated, damit er nie in einem normalen `flutter test`
/// mitläuft (Netzwerk + echter Homeserver):
/// 1. Umgebungsvariable `PYRAMID_LIVE_TEST=1` muss gesetzt sein.
/// 2. Die Zugangsdaten-Datei (`PYRAMID_TEST_ACCOUNTS` bzw.
/// `%USERPROFILE%\.pyramid-autopilot\test-accounts.txt`) muss existieren
/// sie liegt AUSSERHALB des Repos, hier stehen keine Secrets.
///
/// Was der Test wirklich beweist (auf Windows, mit der SQLCipher-DLL aus dem
/// App-Build):
/// * Eine frisch angelegte DB ist auf der Platte verschlüsselt: Header ist
/// nicht "SQLite format 3", und weder der echte Access-Token noch der
/// Klartext einer echten gesendeten Nachricht sind in den Datei-Bytes
/// auffindbar (inkl. WAL-Sidecar).
/// * Die Byte-Suche selbst funktioniert (Positiv-Kontrolle: in einer
/// Klartext-DB werden Token UND Nachricht gefunden).
/// * "Neustart": Die Session (Access-Token) und die Nachricht kommen
/// nachweislich aus der verschlüsselten DB (Raw-Query mit Schlüssel,
/// Client-Init ohne erneute Passwort-Eingabe).
/// * Logout erneuter Login die alte Nachricht ist wieder lesbar.
/// * Utas Update-Fall: eine Klartext-DB MIT ECHTER eingeloggter Session wird
/// migriert; Session und Nachricht überleben, danach ist nichts mehr im
/// Klartext auf der Platte.
///
/// NICHT abgedeckt (ehrlich): flutter_secure_storage (der Test injiziert den
/// Schlüssel direkt der Keystore-Pfad ist durch die echten App-Läufe auf
/// Windows und dem Android-Emulator belegt) und E2EE-verschlüsselte Räume
/// (kein vodozemac im Host-Test; der Testraum ist unverschlüsselt, was für
/// die Klartext-Suche sogar der härtere Fall ist).
String? _findCipherDll() {
for (final config in ['Debug', 'Profile', 'Release']) {
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
if (file.existsSync()) return file.absolute.path;
}
return null;
}
class _TestAccounts {
_TestAccounts({
required this.homeserver,
required this.user1,
required this.pass1,
required this.user2,
required this.pass2,
});
final Uri homeserver;
final String user1;
final String pass1;
final String user2;
final String pass2;
static _TestAccounts? load() {
final override = Platform.environment['PYRAMID_TEST_ACCOUNTS'];
final profile = Platform.environment['USERPROFILE'] ?? '';
final path = (override != null && override.isNotEmpty)
? override
: '$profile\\.pyramid-autopilot\\test-accounts.txt';
final file = File(path);
if (!file.existsSync()) return null;
final map = <String, String>{};
for (final line in file.readAsLinesSync()) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
final idx = trimmed.indexOf('=');
if (idx <= 0) continue;
map[trimmed.substring(0, idx)] = trimmed.substring(idx + 1);
}
final hs = map['homeserver'];
if (hs == null ||
map['user1'] == null ||
map['pass1'] == null ||
map['user2'] == null ||
map['pass2'] == null) {
return null;
}
return _TestAccounts(
homeserver: Uri.parse(hs),
user1: map['user1']!,
pass1: map['pass1']!,
user2: map['user2']!,
pass2: map['pass2']!,
);
}
}
/// Zufälliger 32-Byte-Schlüssel wie in AppDatabase._getOrCreateKey nur
/// direkt injiziert statt über den Keystore (siehe Kopfkommentar).
String _randomHexKey() {
final rnd = Random.secure();
return List.generate(
32,
(_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
String _randomMarker(String prefix) {
final rnd = Random.secure();
final suffix =
List.generate(8, (_) => rnd.nextInt(16).toRadixString(16)).join();
return 'PYRAMID_${prefix}_PROBE_$suffix';
}
/// Naive ASCII-Substring-Suche über rohe Datei-Bytes (DB + WAL). Bewusst kein
/// String-Decode der Gesamtdatei: Binärdaten sind kein gültiges UTF-8.
bool _bytesContain(Uint8List haystack, String needle) {
final n = ascii.encode(needle);
if (n.isEmpty || haystack.length < n.length) return false;
outer:
for (var i = 0; i <= haystack.length - n.length; i++) {
for (var j = 0; j < n.length; j++) {
if (haystack[i + j] != n[j]) continue outer;
}
return true;
}
return false;
}
/// Durchsucht Hauptdatei + WAL/SHM-Sidecars nach einem Klartext-String.
Future<bool> _filesContain(String dbPath, String needle) async {
for (final path in [dbPath, '$dbPath-wal', '$dbPath-shm']) {
final file = File(path);
if (!await file.exists()) continue;
if (_bytesContain(await file.readAsBytes(), needle)) return true;
}
return false;
}
/// Öffnet die verschlüsselte DB mit Schlüssel und sucht den String in allen
/// Tabelleninhalten beweist, dass die Daten IN der verschlüsselten Datei
/// stecken (und nicht etwa vom Server nachgeladen werden müssten).
Future<bool> _encryptedDbContains(
DatabaseFactory factory,
String dbPath,
String hexKey,
String needle,
) async {
final db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
try {
final tables = await db.rawQuery(
"SELECT name FROM sqlite_master "
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
);
for (final row in tables) {
final name = row['name'] as String;
final rows = await db.rawQuery('SELECT * FROM "$name"');
for (final r in rows) {
for (final v in r.values) {
if (v == null) continue;
if (v is Uint8List) {
if (_bytesContain(v, needle)) return true;
} else if (v.toString().contains(needle)) {
return true;
}
}
}
}
return false;
} finally {
await db.close();
}
}
Future<Client> _newClient(
Database db, {
required String name,
bool waitForRooms = false,
}) async {
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
final client = Client(name, database: sdkDb);
// waitForRooms: Die Raumliste wird beim Init ASYNCHRON aus der DB geladen
// wer direkt danach client.rooms prüfen will, muss darauf warten.
await client.init(
waitForFirstSync: false,
waitUntilLoadCompletedLoaded: waitForRooms,
);
return client;
}
Future<void> _login(Client client, _TestAccounts acc,
{required String user, required String pass, required String device}) async {
await client.checkHomeserver(acc.homeserver);
await client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: user),
password: pass,
initialDeviceDisplayName: device,
refreshToken: true,
);
}
/// Holt (oder erzeugt) den unverschlüsselten Autopilot-Testraum.
Future<Room> _ensureTestRoom(Client client) async {
const roomName = 'Pyramid Autopilot Testraum';
await client.oneShotSync().timeout(const Duration(seconds: 60));
for (final room in client.rooms) {
if (room.name == roomName && room.membership == Membership.join) {
return room;
}
}
final roomId = await client.createRoom(
name: roomName,
preset: CreateRoomPreset.privateChat,
);
await client.waitForRoomInSync(roomId, join: true).timeout(
const Duration(seconds: 60),
);
final room = client.getRoomById(roomId);
expect(room, isNotNull, reason: 'Testraum nach createRoom nicht im Sync');
return room!;
}
/// Sucht die Marker-Nachricht in der Timeline (lädt bei Bedarf Historie nach).
Future<bool> _timelineContains(Room room, String marker) async {
final timeline = await room.getTimeline();
try {
for (var i = 0; i < 10; i++) {
if (timeline.events.any(
(e) => e.type == EventTypes.Message && e.body.contains(marker),
)) {
return true;
}
if (!timeline.canRequestHistory) break;
await timeline.requestHistory(historyCount: 100);
}
return timeline.events.any(
(e) => e.type == EventTypes.Message && e.body.contains(marker),
);
} finally {
timeline.cancelSubscriptions();
}
}
void main() {
final dllPath = _findCipherDll();
final accounts = _TestAccounts.load();
final liveEnabled = Platform.environment['PYRAMID_LIVE_TEST'] == '1';
final skip = !liveEnabled
? 'Live-Test nur mit PYRAMID_LIVE_TEST=1 (echter Homeserver!)'
: dllPath == null
? 'SQLCipher-DLL fehlt (erst "flutter build windows")'
: accounts == null
? 'Test-Account-Datei fehlt (~/.pyramid-autopilot/test-accounts.txt)'
: null;
final factory = dllPath == null
? null
: createDatabaseFactoryFfi(
ffiInit: () {
sqlite_open.open.overrideFor(
sqlite_open.OperatingSystem.windows,
() => DynamicLibrary.open(dllPath),
);
},
noIsolate: true,
);
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('pyramid_live_test');
});
tearDown(() async {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
});
test(
'Frische Installation: Login → Platte verschlüsselt → Neustart → '
'Logout → Re-Login → alte Nachricht lesbar',
() async {
final acc = accounts!;
final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
final hexKey = _randomHexKey();
final marker = _randomMarker('ENC');
// Phase 1: frisch verschlüsselt anlegen, echter Login, Nachricht
var db = await AppDatabase.openEncrypted(factory!, dbPath, hexKey);
await db.execute('PRAGMA journal_mode=WAL');
var client = await _newClient(db, name: 'PyramidLiveTest');
await _login(client, acc,
user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test');
expect(client.isLogged(), isTrue);
final accessToken = client.accessToken!;
final room = await _ensureTestRoom(client);
final eventId = await room.sendTextEvent(marker);
expect(eventId, isNotNull, reason: 'Senden der Probe-Nachricht schlug fehl');
await client.oneShotSync().timeout(const Duration(seconds: 60));
await client.dispose(closeDatabase: true);
// Phase 2: Byte-Prüfung auf der Platte
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse,
reason: 'DB-Header ist "SQLite format 3" NICHT verschlüsselt!');
expect(await _filesContain(dbPath, accessToken), isFalse,
reason: 'Access-Token im Klartext auf der Platte gefunden!');
expect(await _filesContain(dbPath, marker), isFalse,
reason: 'Nachrichtentext im Klartext auf der Platte gefunden!');
// Beweis, dass die Daten wirklich IN der Datei stecken (nur eben
// verschlüsselt): Raw-Query mit Schlüssel findet beides.
expect(
await _encryptedDbContains(factory, dbPath, hexKey, accessToken),
isTrue,
reason: 'Access-Token fehlt in der DB falsche Datei geprüft?',
);
expect(
await _encryptedDbContains(factory, dbPath, hexKey, marker),
isTrue,
reason: 'Nachricht fehlt in der DB falsche Datei geprüft?',
);
// Phase 3: Neustart" Session kommt aus der verschlüsselten DB ──
db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
client = await _newClient(db, name: 'PyramidLiveTest');
expect(client.isLogged(), isTrue,
reason: 'Session hat den Neustart nicht überlebt');
expect(client.accessToken, accessToken,
reason: 'Access-Token nach Neustart nicht aus der DB restauriert');
expect(client.userID?.toLowerCase(), acc.user1.toLowerCase());
// Phase 4: Logout Re-Login alte Nachricht wieder lesbar
await client.logout();
expect(client.isLogged(), isFalse);
await _login(client, acc,
user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test 2');
final roomAfter = await _ensureTestRoom(client);
expect(await _timelineContains(roomAfter, marker), isTrue,
reason: 'Alte Nachricht nach Logout+Re-Login nicht mehr lesbar');
// Aufräumen: Session serverseitig beenden, DB wird dabei geleert.
await client.logout();
await client.dispose(closeDatabase: true);
},
skip: skip,
timeout: const Timeout(Duration(minutes: 8)),
);
test(
'Utas Update-Fall: Klartext-DB mit ECHTER Session → Migration → '
'Session + Nachricht überleben, Platte danach dicht',
() async {
final acc = accounts!;
final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
final hexKey = _randomHexKey();
final marker = _randomMarker('MIG');
// Phase 1: Klartext-DB wie vor der SQLCipher-Einführung
var db = await factory!.openDatabase(dbPath);
await db.execute('PRAGMA journal_mode=WAL');
var client = await _newClient(db, name: 'PyramidLiveTest');
await _login(client, acc,
user: acc.user1, pass: acc.pass1, device: 'Autopilot Mig-Test');
final accessToken = client.accessToken!;
final room = await _ensureTestRoom(client);
final eventId = await room.sendTextEvent(marker);
expect(eventId, isNotNull);
await client.oneShotSync().timeout(const Duration(seconds: 60));
await client.dispose(closeDatabase: true);
// Positiv-Kontrolle: In der Klartext-DB findet die Byte-Suche BEIDES
// damit ist bewiesen, dass die Suche Leaks erkennen würde.
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue);
expect(await _filesContain(dbPath, accessToken), isTrue,
reason: 'Kontrolle kaputt: Token nicht mal im Klartext gefunden');
expect(await _filesContain(dbPath, marker), isTrue,
reason: 'Kontrolle kaputt: Nachricht nicht mal im Klartext gefunden');
// Phase 2: Migration (exakt der App-Codepfad)
final ok = await AppDatabase.migratePlaintextToEncrypted(
factory: factory,
dbPath: dbPath,
hexKey: hexKey,
);
expect(ok, isTrue, reason: 'Migration fehlgeschlagen');
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse);
expect(File('$dbPath.premigration').existsSync(), isTrue);
expect(await _filesContain(dbPath, accessToken), isFalse,
reason: 'Token nach Migration weiterhin im Klartext!');
expect(await _filesContain(dbPath, marker), isFalse,
reason: 'Nachricht nach Migration weiterhin im Klartext!');
// Phase 3: Session + Daten intakt?
expect(
await _encryptedDbContains(factory, dbPath, hexKey, marker),
isTrue,
reason: 'Nachricht hat die Migration nicht überlebt',
);
db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
client = await _newClient(db, name: 'PyramidLiveTest', waitForRooms: true);
expect(client.isLogged(), isTrue,
reason: 'Session hat die Migration nicht überlebt');
expect(client.accessToken, accessToken);
final roomAfter = client.getRoomById(room.id);
expect(roomAfter, isNotNull,
reason: 'Testraum nach Migration nicht mehr in der DB');
// Aufräumen (Session serverseitig beenden).
await client.logout();
await client.dispose(closeDatabase: true);
},
skip: skip,
timeout: const Timeout(Duration(minutes: 8)),
);
}