@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 = {}; 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 _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 _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 _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 _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 _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 _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)), ); }