diff --git a/CHANGES.md b/CHANGES.md index a81baa1..9076ada 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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: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 diff --git a/PROGRESS.md b/PROGRESS.md index 5ab6ebc..ecd337d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 **Erledigt:** Der größte offene Teil von Testplan Punkt 4 diff --git a/ROADMAP.md b/ROADMAP.md index b0dc431..75eb502 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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** (API 36: Neuanlage verschlüsselt, Klartext-Migration beim ersten Start, 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 (FCM liest verschlüsselte DB, secure_storage im Background-Isolate) – Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`; diff --git a/test/live_encryption_e2e_test.dart b/test/live_encryption_e2e_test.dart new file mode 100644 index 0000000..5c36833 --- /dev/null +++ b/test/live_encryption_e2e_test.dart @@ -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 = {}; + 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)), + ); +}