From f64398d3c6cf845160a8568c844b4ac4768c94c1 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Mon, 6 Jul 2026 12:31:38 +0200 Subject: [PATCH] feat: SQLCipher-Verschluesselung der pyramid.sqlite (M1) via neue Storage-Fassade app_database.dart - AppDatabase.open()/openForPush() kapseln Factory, Schluessel (Keystore), Klartext->SQLCipher-Migration (Backup/Verify/atomarer Tausch) und PRAGMAs - Android/Windows/Linux verschluesselt, iOS/macOS bewusst Klartext - 5 neue Tests mit echter SQLCipher-DLL; Windows-Praxistest mit echter DB erfolgreich (23 Tabellen verifiziert, E2EE intakt); Android UNGETESTET --- CHANGES.md | 14 + PROGRESS.md | 74 ++++++ ROADMAP.md | 11 + docs/SQLCIPHER_MIGRATION.md | 15 +- lib/core/app_database.dart | 484 ++++++++++++++++++++++++++++++++++ lib/core/background_push.dart | 23 +- lib/core/matrix_client.dart | 34 +-- pubspec.lock | 2 +- pubspec.yaml | 3 + test/app_database_test.dart | 176 +++++++++++++ 10 files changed, 787 insertions(+), 49 deletions(-) create mode 100644 lib/core/app_database.dart create mode 100644 test/app_database_test.dart diff --git a/CHANGES.md b/CHANGES.md index 22add88..7db591d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1319,3 +1319,17 @@ 2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md 2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md 2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md +2026-07-06 12:16 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart +2026-07-06 12:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart +2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart +2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart +2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart +2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml +2026-07-06 12:22 [Write] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart +2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart +2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart +2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart +2026-07-06 12:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md +2026-07-06 12:30 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md +2026-07-06 12:31 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md index 2436a39..82f8ec4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,6 +13,80 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format: --- +## 2026-07-06 – SQLCipher-Migration UMGESETZT + auf Windows mit echter DB praxisgetestet (M1, Bernds Prio 1) + +**Erledigt:** Der M1-Punkt „Sichere Speicherung von Access-Token & Krypto-DB" +ist nach `docs/SQLCIPHER_MIGRATION.md` umgesetzt (PC-Session laut +Entscheidung 1 in `ANTWORTEN_BERND.md`): + +- **Neue Storage-Fassade `lib/core/app_database.dart`** (erster Baustein im + Sinne des M2-Modularitäts-Leitsatzes): kapselt Factory-Wahl, SQLCipher- + Schlüssel (32 Zufallsbytes, hex im Keystore via flutter_secure_storage, + Eintrag `db_cipher_key`, mit Read-Back-Verifikation gegen stilles + Write-Versagen), Klartext→SQLCipher-Migration und die vorher duplizierten + PRAGMAs. `matrix_client.dart` und `background_push.dart` rufen nur noch + `AppDatabase.open()` / `AppDatabase.openForPush()`. +- **Migration exakt nach Plan:** Marker gegen Push-Isolate-Race → + WAL-Checkpoint → Byte-Backup `.premigration` (nach 7 Tagen auto-gelöscht) → + `sqlcipher_export` in NEUE Datei → Verifikation (integrity_check + + Zeilenzahlen ALLER Tabellen) → atomarer rename-Tausch. Jeder Fehlpfad lässt + die Klartext-DB unangetastet (App läuft dann unverschlüsselt weiter, + Fehler im auth_log). Absturz zwischen Löschen und Umbenennen wird beim + nächsten Start aus dem Byte-Backup repariert. +- **Wichtige Plan-Korrektur:** Die Plan-Annahme „sqlcipher_flutter_libs hat + keine Desktop-Binaries" ist seit 0.6.x überholt – das Plugin baut SQLCipher + auf Windows als `sqlite3.dll` MIT (im Build-Output verifiziert, nur dieses + eine sqlite-Plugin ist im Build). Deshalb verschlüsseln Windows/Linux + ebenfalls (kein Test-Override nötig); iOS/macOS bleiben bewusst Klartext + (Pod-Kollision mit firebase_messaging, kein Release-Ziel). Laufzeit-Wächter + `PRAGMA cipher_version` verhindert, dass je stillschweigend die falsche + Bibliothek benutzt wird; verschlüsselte DB ohne Cipher-Lib/Schlüssel ist + ein harter Fehler mit Klartext-Meldung – NIE stille Neuanlage. +- **Tests `test/app_database_test.dart`** (5 neue, gesamt 29, alle grün): + fahren die ECHTE Migration mit der SQLCipher-DLL aus dem Build-Output – + cipher_version-Wächter, Header-Erkennung, Migration mit Zeilenzahl- + Verifikation, falscher Schlüssel scheitert sofort, erzwungener Fehlschlag + lässt das Klartext-Original intakt. +- **Praxistest Windows (`flutter run -d windows`), echte Daten:** Bernds + gewachsene 14-MB-DB (inkl. 4 MB WAL) wurde beim Start migriert + (auth_log: „23 Tabellen verifiziert"), Session blieb eingeloggt, E2EE + nachweislich intakt (App entschlüsselte live to_device-Events von + @uta, forwardete Room-Keys). Zweiter Start öffnet die verschlüsselte DB + ohne erneute Migration, sauberer Quit per `q`. DB-Header jetzt zufällig + (verschlüsselt), `.premigration` = Klartext-Original. Zusätzliche manuelle + Sicherung liegt als `pyramid.sqlite*.claude-vormigration-20260706` im + App-Verzeichnis (kann nach ein paar Tagen weg). +- `sqlite3` als direkte Dependency ergänzt (war schon transitiv im Lockfile – + kein neues externes Paket), für `open.overrideFor`/`openCipherOnAndroid`. +- `flutter analyze` sauber (nur der bekannte, bewusst zurückgestellte + onUpdate-Hinweis). + +**UNGETESTET (Android – deshalb ROADMAP-Punkt NICHT abgehakt):** Migration +auf echtem Android-Gerät, Push-Isolate liest verschlüsselte DB +(`databaseFactoryFfiNoIsolate` + `openCipherOnAndroid`), flutter_secure_storage +aus dem Background-Isolate (bekanntes Risiko, Code steigt dann kontrolliert +mit Platzhalter-Notification aus). Testplan Punkt 4 in +`docs/SQLCIPHER_MIGRATION.md`; erst danach Release + Uta (vorher ihr +Key-Backup verifizieren). + +**Offen/Nächster Schritt:** M2-Call-Pilot (Entscheidung 2: Call-Fassade nach +`docs/M2_MODULE_SCHNITT.md`), danach Härtetest nach `docs/PC_TESTPLAN.md`. + +**Stolperfallen:** (1) Plan-Annahmen über Paket-Fähigkeiten VOR der Umsetzung +gegen den Pub-Cache/Build-Output prüfen – die „keine Desktop-Binaries"-Annahme +hätte unnötig einen Test-Override-Sonderpfad erzeugt. (2) `sqfliteFfiInit()` +war auf Windows faktisch wirkungslos fürs DB-Isolate (Override wirkt nur im +aufrufenden Isolate; das Worker-Isolate lädt die exe-nachbarschaftliche +`sqlite3.dll` – und die IST hier SQLCipher). Für eigene Overrides deshalb +`createDatabaseFactoryFfi(ffiInit: ...)` benutzen, das läuft IM DB-Isolate. +(3) Beim Schlüssel-Anlegen im Keystore IMMER read-back-verifizieren, bevor +damit verschlüsselt wird – ein still verlorener Schlüssel wäre Totalverlust. +(4) `flutter_test` findet keine `sqlite3.dll` neben dem Tester-Binary – die +DLL aus `build/windows/x64/runner/*/` explizit laden; Tests überspringen sich +selbst, wenn kein Build-Output da ist (Pi-tauglich). + +--- + ## 2026-07-06 – Fable-5-Review (aa): autopilot.sh fest auf nordmann-Konto (uncommittete Änderung geprüft, E-Mail-Leak vor Publikation gefixt, committet) **Erledigt:** Session-Start nach Arbeitszyklus zeigte echten neuen Anlass (also diff --git a/ROADMAP.md b/ROADMAP.md index 6efd9ed..bdfcf3a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,6 +42,17 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert. + Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3 Fallstricke der Hintergrund-Entschlüsselung gegenprüfen. - [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.** + **Stand 2026-07-06: UMGESETZT + auf Windows praxisgetestet** – neue + Storage-Fassade `lib/core/app_database.dart` (SQLCipher auf + Android/Windows/Linux, Schlüssel im Keystore, Klartext-Migration mit + Backup/Verifikation/atomarem Tausch, Push-Isolate-Marker). Bernds echte + Windows-DB wurde erfolgreich migriert (PROGRESS.md 2026-07-06), Tests in + `test/app_database_test.dart` fahren die Migration mit echtem SQLCipher. + **Bleibt offen, weil Android UNGETESTET ist** (Migration auf Gerät, + Push-Isolate liest verschlüsselte DB, secure_storage im + Background-Isolate) – Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`; + erst nach grünem Gerätetest Release + Utas Gerät (vorher ihr Key-Backup + prüfen!), dann abhaken. `sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends benutzt – `matrix_client.dart` und `background_push.dart` öffnen die `pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über diff --git a/docs/SQLCIPHER_MIGRATION.md b/docs/SQLCIPHER_MIGRATION.md index 13b413c..5549c7e 100644 --- a/docs/SQLCIPHER_MIGRATION.md +++ b/docs/SQLCIPHER_MIGRATION.md @@ -1,8 +1,17 @@ # SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin) -Stand: 2026-07-03, geschrieben auf dem Pi (Plan gegen den echten Code verifiziert, -Umsetzung braucht PC + Android-Testgerät). Gehört zum offenen M1-Punkt -„Sichere Speicherung von Access-Token & Krypto-DB". +> **STATUS 2026-07-06: UMGESETZT (`lib/core/app_database.dart`) und auf +> Windows mit Bernds echter 14-MB-DB praxisgetestet** (Migration gelaufen, +> 23 Tabellen verifiziert, Login/E2EE danach intakt, Neustart öffnet die +> verschlüsselte DB, `flutter test` deckt Migration/Fehlpfade mit echtem +> SQLCipher ab). **Android bleibt UNGETESTET** (Punkt 4 des Testplans unten: +> Migration auf Gerät, Push-Isolate + secure_storage) – erst danach Release +> und Uta. **Abweichung vom Plan unten:** Die Annahme „keine Desktop-Binaries" +> ist seit sqlcipher_flutter_libs 0.6.x überholt – das Plugin baut SQLCipher +> auf Windows/Linux als `sqlite3.dll`/`libsqlite3.so` mit (im Build-Output +> verifiziert). Deshalb ist Phase 1 NICHT Android-only: Windows/Linux +> verschlüsseln ebenfalls, ein Test-Override ist unnötig. iOS/macOS bleiben +> Klartext (SQLCipher-Pod kollidiert mit firebase_messaging, kein Release-Ziel). ## Für Bernd in einfach diff --git a/lib/core/app_database.dart b/lib/core/app_database.dart new file mode 100644 index 0000000..92c7639 --- /dev/null +++ b/lib/core/app_database.dart @@ -0,0 +1,484 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart' as sqflite_native; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart'; +import 'package:sqlite3/open.dart'; +import 'package:pyramid/core/auth_log.dart'; + +/// Läuft im DB-Isolate der FFI-Factory (muss deshalb top-level sein): +/// Auf Android liefert das sqlcipher_flutter_libs-Plugin die Bibliothek als +/// `libsqlcipher.so` – dem sqlite3-Paket muss man das dort sagen. Auf +/// Windows/Linux bündelt dasselbe Plugin SQLCipher bereits UNTER DEM +/// STANDARDNAMEN (`sqlite3.dll` neben der exe, verifiziert im Build-Output), +/// der Default-Loader findet sie also ohne Override. +void _sqlcipherFfiInit() { + open.overrideFor(OperatingSystem.android, openCipherOnAndroid); +} + +/// Gemeinsamer Öffner für die `pyramid.sqlite` – der eine Storage-Baustein, +/// hinter dem Factory-Wahl, Verschlüsselung und Migration stecken (Plan: +/// `docs/SQLCIPHER_MIGRATION.md`, Modularitäts-Leitsatz aus CLAUDE.md). +/// +/// Kapselt an EINER Stelle, was vorher in `matrix_client.dart` und +/// `background_push.dart` dupliziert war: +/// * Factory-Wahl pro Plattform (FFI + SQLCipher wo verfügbar), +/// * Schlüsselbeschaffung: 32 zufällige Bytes, hex-kodiert im +/// Plattform-Keystore (`flutter_secure_storage`, Eintrag `db_cipher_key`), +/// * einmalige Migration bestehender Klartext-DBs nach SQLCipher, +/// * die PRAGMAs (WAL, busy_timeout). +/// +/// Oberste Regel (CLAUDE.md „Kein Datenverlust"): KEIN Codepfad darf eine +/// bestehende DB beschädigen oder still eine leere neue anlegen. Im Zweifel +/// läuft die App unverschlüsselt weiter und der Fehler steht im auth_log. +class AppDatabase { + AppDatabase._(); + + static const _cipherKeyStorageKey = 'db_cipher_key'; + + /// Klartext-Backup `*.premigration` nach so vielen Tagen automatisch + /// aufräumen (Kompromiss aus dem Migrationsplan: Rollback-Fenster vs. + /// dauerhaft herumliegende Klartext-Kopie). + static const _premigrationMaxAge = Duration(days: 7); + + /// Plattformen, auf denen sqlcipher_flutter_libs die Cipher-Bibliothek + /// mitliefert. iOS/macOS bleiben bewusst beim nativen sqflite-Plugin + /// (Klartext): der SQLCipher-Pod kollidiert dort mit Paketen, die das + /// System-sqlite3 linken (z. B. firebase_messaging, siehe Paket-README), + /// und iOS ist kein Release-Ziel von Pyramid. + static bool get _isCipherPlatform => + !kIsWeb && (Platform.isAndroid || Platform.isWindows || Platform.isLinux); + + static Future _dbPath() async { + final appDir = await getApplicationSupportDirectory(); + return p.join(appDir.path, 'pyramid.sqlite'); + } + + // ── Öffnen: Haupt-App ──────────────────────────────────────────────────── + + /// Öffnet die Datenbank für die Haupt-App. Nur dieser Pfad migriert eine + /// bestehende Klartext-DB (das Push-Isolate migriert NIE selbst). + static Future open() async { + final dbPath = await _dbPath(); + + if (!_isCipherPlatform) { + // iOS/macOS: unverändert das native sqflite-Plugin, Klartext. + final db = await sqflite_native.databaseFactory.openDatabase(dbPath); + await _applyPragmas(db); + return db; + } + + if (Platform.isAndroid) { + // Lade-Workaround für Android 6 (Paket-README) – im Main-Isolate + // ausführen, BEVOR irgendein Isolate sqlite benutzt. + try { + await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions(); + } catch (_) {} + } + + final factory = createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit); + + await _recoverFromInterruptedSwap(dbPath); + await _clearStaleMarker(dbPath); + + final dbFile = File(dbPath); + final exists = await dbFile.exists(); + final isPlaintext = exists && await isPlaintextSqlite(dbFile); + + if (!await _cipherAvailable(factory)) { + // Sicherheitsnetz: sollte auf Cipher-Plattformen nie passieren + // (Bibliothek wird als Plugin mitgebaut). Eine VERSCHLÜSSELTE DB ohne + // Cipher-Lib ist ein harter Fehler – niemals still neu anlegen. + if (exists && !isPlaintext) { + throw StateError( + 'pyramid.sqlite ist verschlüsselt, aber die SQLCipher-Bibliothek ' + 'fehlt in diesem Build. Kein Zugriff möglich – Daten bleiben ' + 'unangetastet. Bitte App-Build prüfen (sqlcipher_flutter_libs).', + ); + } + await AuthLog.write( + '[DB] SQLCipher nicht verfügbar – Datenbank bleibt unverschlüsselt.', + ); + final db = await factory.openDatabase(dbPath); + await _applyPragmas(db); + return db; + } + + if (exists && !isPlaintext) { + // Bereits verschlüsselt: der Schlüssel MUSS im Keystore liegen. + final key = await _readStoredKey(); + if (key == null || key.isEmpty) { + throw StateError( + 'pyramid.sqlite ist verschlüsselt, aber im Schlüsselspeicher fehlt ' + '"$_cipherKeyStorageKey". Es wird KEINE neue Datenbank angelegt, ' + 'damit keine Daten verloren gehen. Schlüsselspeicher prüfen ' + '(flutter_secure_storage) oder Backup einspielen.', + ); + } + final db = await _openWithKey(factory, dbPath, key); + await _applyPragmas(db); + await _cleanupOldBackup(dbPath); + return db; + } + + final key = await _getOrCreateKey(); + + if (exists && isPlaintext) { + final migrated = await migratePlaintextToEncrypted( + factory: factory, + dbPath: dbPath, + hexKey: key, + ); + if (!migrated) { + // Migration fehlgeschlagen → Klartext-DB ist unversehrt, App läuft + // wie bisher weiter (Fehlerdetails stehen im auth_log). + final db = await factory.openDatabase(dbPath); + await _applyPragmas(db); + return db; + } + } + + // Frische Installation (legt verschlüsselt neu an) oder frisch migriert. + final db = await _openWithKey(factory, dbPath, key); + await _applyPragmas(db); + return db; + } + + // ── Öffnen: Android-Push-Isolate ───────────────────────────────────────── + + /// Öffnet die Datenbank für das Push-/Hintergrund-Isolate. Gibt `null` + /// zurück, wenn gerade kein sicherer Zugriff möglich ist (Migration läuft, + /// DB fehlt, Schlüssel fehlt) – die Notification zeigt dann einmalig den + /// nativen „Neue Nachricht"-Platzhalter, das ist der akzeptierte Preis. + /// Dieser Pfad migriert NIE und legt NIE eine neue Datei an. + static Future openForPush() async { + final dbPath = await _dbPath(); + + if (await File('$dbPath.migrating').exists()) { + debugPrint('[DB] Migration läuft – Push-Zugriff übersprungen.'); + return null; + } + + final dbFile = File(dbPath); + if (!await dbFile.exists()) return null; + + if (!_isCipherPlatform) { + final db = await sqflite_native.databaseFactory.openDatabase(dbPath); + await _applyPragmas(db); + return db; + } + + // noIsolate: databaseFactoryFfi würde ein Unter-Isolate spawnen – genau + // die Fallenklasse, an der im Firebase-Background-Isolate schon + // NativeImplementationsIsolate scheiterte (siehe background_push.dart). + final factory = + createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit, noIsolate: true); + + if (await isPlaintextSqlite(dbFile)) { + // Noch nicht migriert (macht beim nächsten Start die Haupt-App). + final db = await factory.openDatabase(dbPath); + await _applyPragmas(db); + return db; + } + + final key = await _readStoredKey(); + if (key == null || key.isEmpty) { + // Bekanntes Risiko aus dem Plan: secure_storage aus dem + // Background-Isolate. Niemals raten oder neu anlegen – nur aussteigen. + debugPrint('[DB] Kein Cipher-Schlüssel im Push-Isolate lesbar.'); + return null; + } + final db = await _openWithKey(factory, dbPath, key); + await _applyPragmas(db); + return db; + } + + // ── Migration Klartext → SQLCipher ─────────────────────────────────────── + + /// Wandelt eine bestehende Klartext-DB in eine SQLCipher-DB um – der heikle + /// Teil des Plans. Reihenfolge exakt wie in `docs/SQLCIPHER_MIGRATION.md`: + /// Marker → WAL-Checkpoint → Byte-Backup → Export in NEUE Datei → + /// Verifikation (integrity_check + Zeilenzahlen ALLER Tabellen) → atomarer + /// Tausch. Schlägt irgendetwas fehl, bleibt die Klartext-DB unangetastet + /// und die Funktion gibt `false` zurück. + @visibleForTesting + static Future migratePlaintextToEncrypted({ + required DatabaseFactory factory, + required String dbPath, + required String hexKey, + }) async { + final marker = File('$dbPath.migrating'); + final encPath = '$dbPath.enc'; + final backupPath = '$dbPath.premigration'; + try { + // Marker zuerst: hält das Push-Isolate fern (Race aus dem Plan). + await marker.writeAsString(DateTime.now().toIso8601String()); + + // 1. WAL eindampfen – sonst fehlen im Export Daten, die noch in + // pyramid.sqlite-wal liegen. + var db = await factory.openDatabase(dbPath); + try { + await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)'); + } finally { + await db.close(); + } + + // 2. Byte-Backup (Kopie, kein rename) – bleibt bis zum verifizierten + // Erfolg + Aufräumfrist liegen. + await File(dbPath).copy(backupPath); + + // 3. Export in NEUE Datei; das Original wird nie beschrieben. + final encFile = File(encPath); + if (await encFile.exists()) await encFile.delete(); + final Map sourceCounts; + db = await factory.openDatabase(dbPath); + try { + sourceCounts = await _tableRowCounts(db); + await db.rawQuery( + 'ATTACH DATABASE \'${_escapeSqlString(encPath)}\' AS enc ' + 'KEY "x\'$hexKey\'"', + ); + await db.rawQuery("SELECT sqlcipher_export('enc')"); + await db.rawQuery('DETACH DATABASE enc'); + } finally { + await db.close(); + } + + // 4. Verifikation der NEUEN Datei, bevor irgendetwas getauscht wird. + final encDb = await _openWithKey(factory, encPath, hexKey); + try { + final integrity = await encDb.rawQuery('PRAGMA integrity_check'); + final integrityOk = integrity.length == 1 && + integrity.first.values.first.toString() == 'ok'; + if (!integrityOk) { + throw StateError('integrity_check fehlgeschlagen: $integrity'); + } + final encCounts = await _tableRowCounts(encDb); + for (final entry in sourceCounts.entries) { + if (encCounts[entry.key] != entry.value) { + throw StateError( + 'Zeilenzahl weicht ab in "${entry.key}": ' + '${entry.value} (Original) != ${encCounts[entry.key]} (Export)', + ); + } + } + } finally { + await encDb.close(); + } + + // 5. Tausch. rename auf derselben Partition ist atomar; für das + // Zeitfenster zwischen delete und rename existiert das Byte-Backup + // (Wiederanlauf repariert das, siehe _recoverFromInterruptedSwap). + for (final suffix in ['-wal', '-shm']) { + final sidecar = File('$dbPath$suffix'); + if (await sidecar.exists()) await sidecar.delete(); + } + await File(dbPath).delete(); + await File(encPath).rename(dbPath); + + await AuthLog.write( + '[DB] Klartext-DB erfolgreich nach SQLCipher migriert ' + '(${sourceCounts.length} Tabellen verifiziert).', + ); + return true; + } catch (e) { + // Aufräumen: halbfertigen Export löschen, Original bleibt maßgeblich. + try { + final encFile = File(encPath); + if (await encFile.exists()) await encFile.delete(); + } catch (_) {} + await AuthLog.write( + '[DB] SQLCipher-Migration fehlgeschlagen – Klartext-DB bleibt ' + 'unangetastet: $e', + ); + return false; + } finally { + try { + if (await marker.exists()) await marker.delete(); + } catch (_) {} + } + } + + /// Absturz exakt zwischen „Original gelöscht" und „Export umbenannt": + /// dann fehlt `pyramid.sqlite`, aber das Byte-Backup existiert. Backup + /// zurückkopieren (die Migration läuft danach einfach erneut). Ein evtl. + /// liegengebliebener Export ist nicht als vollständig beweisbar → löschen. + static Future _recoverFromInterruptedSwap(String dbPath) async { + final backup = File('$dbPath.premigration'); + if (await File(dbPath).exists() || !await backup.exists()) return; + try { + final enc = File('$dbPath.enc'); + if (await enc.exists()) await enc.delete(); + await backup.copy(dbPath); + await AuthLog.write( + '[DB] Unterbrochene Migration erkannt – Klartext-Backup ' + 'wiederhergestellt, Migration läuft erneut.', + ); + } catch (e) { + await AuthLog.write('[DB] Wiederherstellung aus .premigration fehlgeschlagen: $e'); + } + } + + /// Ein Marker ohne laufende Migration ist ein Absturz-Überbleibsel – beim + /// Start der Haupt-App (es gibt nur eine) gefahrlos entfernbar. + static Future _clearStaleMarker(String dbPath) async { + try { + final marker = File('$dbPath.migrating'); + if (await marker.exists()) { + await marker.delete(); + await AuthLog.write('[DB] Verwaisten Migrations-Marker entfernt.'); + } + } catch (_) {} + } + + static Future _cleanupOldBackup(String dbPath) async { + try { + final backup = File('$dbPath.premigration'); + if (!await backup.exists()) return; + final age = DateTime.now().difference((await backup.stat()).modified); + if (age > _premigrationMaxAge) { + await backup.delete(); + await AuthLog.write( + '[DB] Klartext-Backup .premigration nach ' + '${_premigrationMaxAge.inDays} Tagen gelöscht.', + ); + } + } catch (_) {} + } + + // ── Bausteine ──────────────────────────────────────────────────────────── + + /// Klartext-SQLite-Dateien beginnen mit dem 16-Byte-Header + /// `SQLite format 3\0`; SQLCipher-Dateien haben keinen lesbaren Header. + /// Leere/winzige Dateien zählen als Klartext (die Migration exportiert sie + /// dann schlicht leer). + @visibleForTesting + static Future isPlaintextSqlite(File file) async { + final raf = await file.open(); + try { + final header = await raf.read(16); + if (header.length < 16) return true; + // 16-Byte-Magic: "SQLite format 3" gefolgt von einem NUL-Byte. + const magic = 'SQLite format 3'; + for (var i = 0; i < magic.length; i++) { + if (header[i] != magic.codeUnitAt(i)) return false; + } + return header[15] == 0x00; + } finally { + await raf.close(); + } + } + + /// Öffnet eine Datei mit Schlüssel. `PRAGMA key` MUSS das allererste + /// Statement der Verbindung sein → onConfigure. Der sqlite_master-Zugriff + /// danach lässt einen falschen/fehlenden Schlüssel sofort hier scheitern + /// statt irgendwann später mitten im Betrieb. + static Future _openWithKey( + DatabaseFactory factory, + String path, + String hexKey, + ) async { + final db = await factory.openDatabase( + path, + options: OpenDatabaseOptions( + onConfigure: (db) async { + await db.rawQuery('PRAGMA key = "x\'$hexKey\'"'); + }, + ), + ); + try { + await db.rawQuery('SELECT count(*) FROM sqlite_master'); + } catch (e) { + await db.close(); + rethrow; + } + return db; + } + + @visibleForTesting + static Future openEncrypted( + DatabaseFactory factory, + String path, + String hexKey, + ) => + _openWithKey(factory, path, hexKey); + + /// `PRAGMA cipher_version` liefert bei normalem sqlite3 KEINE Zeile – der + /// vom Paket-README empfohlene Wächter dagegen, versehentlich unverschlüsselt + /// weiterzulaufen, weil die falsche Bibliothek geladen wurde. + static Future _cipherAvailable(DatabaseFactory factory) async { + final db = await factory.openDatabase(inMemoryDatabasePath); + try { + final rows = await db.rawQuery('PRAGMA cipher_version'); + if (rows.isEmpty) return false; + final value = rows.first.values.first; + return value != null && value.toString().isNotEmpty; + } finally { + await db.close(); + } + } + + /// Wie bisher in matrix_client.dart/background_push.dart: WAL gegen + /// "database is locked" zwischen Haupt-App und Push-Isolate. + static Future _applyPragmas(Database db) async { + try { + await db.execute('PRAGMA journal_mode=WAL'); + await db.execute('PRAGMA busy_timeout = 5000'); + } catch (e) { + if (kDebugMode) debugPrint('Could not set PRAGMAs: $e'); + } + } + + static Future _readStoredKey() => + const FlutterSecureStorage().read(key: _cipherKeyStorageKey); + + /// Erzeugt beim allerersten Mal 32 kryptografisch zufällige Bytes und legt + /// sie hex-kodiert im Keystore ab. Der Read-Back danach ist Pflicht: würde + /// das Schreiben still scheitern und wir trotzdem damit verschlüsseln, + /// wäre die DB beim nächsten Start unlesbar (Totalverlust). + static Future _getOrCreateKey() async { + const storage = FlutterSecureStorage(); + final existing = await storage.read(key: _cipherKeyStorageKey); + if (existing != null && existing.isNotEmpty) return existing; + + final rnd = Random.secure(); + final key = List.generate( + 32, + (_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + await storage.write(key: _cipherKeyStorageKey, value: key); + final readBack = await storage.read(key: _cipherKeyStorageKey); + if (readBack != key) { + throw StateError( + 'Schlüsselspeicher-Verifikation fehlgeschlagen: ' + '"$_cipherKeyStorageKey" konnte nicht zuverlässig abgelegt werden. ' + 'Es wird NICHT verschlüsselt (sonst wäre die DB später unlesbar).', + ); + } + return key; + } + + static Future> _tableRowCounts(Database db) async { + final tables = await db.rawQuery( + "SELECT name FROM sqlite_master " + "WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ); + final result = {}; + for (final row in tables) { + final name = row['name'] as String; + final count = await db.rawQuery('SELECT count(*) AS c FROM "$name"'); + result[name] = (count.first['c'] as num).toInt(); + } + return result; + } + + /// SQL-String-Literal: einfache Anführungszeichen verdoppeln (der Pfad + /// kommt aus dem App-Verzeichnis, aber sicher ist sicher). + static String _escapeSqlString(String value) => value.replaceAll("'", "''"); +} diff --git a/lib/core/background_push.dart b/lib/core/background_push.dart index 6db03a5..c7d88a4 100644 --- a/lib/core/background_push.dart +++ b/lib/core/background_push.dart @@ -7,11 +7,9 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; import 'package:matrix/matrix.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; +import 'package:pyramid/core/app_database.dart'; import 'package:pyramid/core/soft_logout_guard.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:sqflite/sqflite.dart' as sqflite_native; /// Port name used to locate the main isolate from background isolates. /// Registered by notification_service.dart when the app starts. @@ -182,18 +180,13 @@ Future _buildClient() async { await vod.init(); } catch (_) {} - final appDir = await getApplicationSupportDirectory(); - final dbPath = p.join(appDir.path, 'pyramid.sqlite'); - - final db = await sqflite_native.databaseFactory - .openDatabase(dbPath) - .timeout(const Duration(seconds: 5)); - - // Prevent "database is locked" if the main process still has the DB open. - try { - await db.execute('PRAGMA busy_timeout = 5000'); - await db.execute('PRAGMA journal_mode = WAL'); - } catch (_) {} + // Gemeinsame Storage-Fassade: erkennt Klartext vs. SQLCipher am + // Datei-Header, migriert aber NIE selbst (docs/SQLCIPHER_MIGRATION.md). + // null = gerade kein sicherer Zugriff (Migration läuft / DB oder + // Schlüssel fehlt) → Notification behält den nativen Platzhalter. + final db = + await AppDatabase.openForPush().timeout(const Duration(seconds: 10)); + if (db == null) return null; final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); diff --git a/lib/core/matrix_client.dart b/lib/core/matrix_client.dart index 2b5fcab..cc7e8c0 100644 --- a/lib/core/matrix_client.dart +++ b/lib/core/matrix_client.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,40 +6,15 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; import 'package:matrix/encryption/utils/key_verification.dart'; import 'package:matrix/matrix.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:sqflite/sqflite.dart' as sqflite_native; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:pyramid/core/app_database.dart'; import 'package:pyramid/core/auth_log.dart'; import 'package:pyramid/core/soft_logout_guard.dart'; import 'package:pyramid/core/voip_manager.dart'; final matrixClientProvider = FutureProvider((ref) async { - final DatabaseFactory factory; - - if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { - // Use the native sqflite plugin — Android/iOS have system SQLite built-in, - // no .so loading required. - factory = sqflite_native.databaseFactory; - } else if (!kIsWeb) { - sqfliteFfiInit(); - factory = databaseFactoryFfi; - } else { - throw UnsupportedError('Web not supported'); - } - - final appDir = await getApplicationSupportDirectory(); - final dbPath = p.join(appDir.path, 'pyramid.sqlite'); - - final db = await factory.openDatabase(dbPath); - - // Optimization for Windows/FFI to prevent "database is locked" errors - try { - await db.execute('PRAGMA journal_mode=WAL'); - await db.execute('PRAGMA busy_timeout = 5000'); - } catch (e) { - if (kDebugMode) debugPrint('Could not set PRAGMAs: $e'); - } + // Factory-Wahl, Verschlüsselung (SQLCipher), Migration und PRAGMAs stecken + // komplett in der Storage-Fassade — siehe docs/SQLCIPHER_MIGRATION.md. + final db = await AppDatabase.open(); final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); diff --git a/pubspec.lock b/pubspec.lock index e37919d..ce1269e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1235,7 +1235,7 @@ packages: source: hosted version: "0.6.8" sqlite3: - dependency: transitive + dependency: "direct main" description: name: sqlite3 sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" diff --git a/pubspec.yaml b/pubspec.yaml index 4e8e3a0..df84e4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,9 @@ dependencies: sqflite: ^2.4.2 sqflite_common_ffi: ^2.3.7+1 sqlcipher_flutter_libs: ^0.6.8 + # Direkt (statt nur transitiv) für open.overrideFor in app_database.dart — + # so lädt Android libsqlcipher.so statt des System-sqlite3. + sqlite3: ^2.4.0 # LiveKit video/audio/screen livekit_client: ^2.4.0 diff --git a/test/app_database_test.dart b/test/app_database_test.dart new file mode 100644 index 0000000..5b288c2 --- /dev/null +++ b/test/app_database_test.dart @@ -0,0 +1,176 @@ +@TestOn('windows') +library; + +import 'dart:ffi'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite3/open.dart'; +import 'package:pyramid/core/app_database.dart'; + +/// Diese Tests fahren die ECHTE Klartext→SQLCipher-Migration – mit derselben +/// SQLCipher-Bibliothek, die auch die App benutzt: der vom +/// sqlcipher_flutter_libs-Plugin gebauten `sqlite3.dll` aus dem Windows-Build. +/// Existiert kein Build-Output (z. B. auf dem Pi), werden sie übersprungen – +/// dann zählt der Lauf als UNGETESTET im Sinne von CLAUDE.md. +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; +} + +const _hexKey = + 'a3b1c2d3e4f50617a3b1c2d3e4f50617a3b1c2d3e4f50617a3b1c2d3e4f50617'; + +void main() { + final dllPath = _findCipherDll(); + final skip = dllPath == null + ? 'SQLCipher-DLL fehlt (erst "flutter build windows" bzw. ' + '"flutter run -d windows" ausführen)' + : null; + + // noIsolate: der Override muss im selben Isolate wirken wie die DB-Zugriffe. + final factory = dllPath == null + ? null + : createDatabaseFactoryFfi( + ffiInit: () { + open.overrideFor( + OperatingSystem.windows, + () => DynamicLibrary.open(dllPath), + ); + }, + noIsolate: true, + ); + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('pyramid_db_test'); + }); + + tearDown(() async { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + }); + + /// Legt eine Klartext-DB mit zwei Tabellen und bekannten Zeilen an. + Future createPlaintextDb() async { + final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite'; + final db = await factory!.openDatabase(dbPath); + await db.execute('CREATE TABLE box_client (k TEXT PRIMARY KEY, v TEXT)'); + await db.execute( + 'CREATE TABLE box_inbound_group_session (k TEXT PRIMARY KEY, v TEXT)', + ); + for (var i = 0; i < 50; i++) { + await db.insert('box_client', {'k': 'key$i', 'v': 'wert$i'}); + } + for (var i = 0; i < 7; i++) { + await db.insert( + 'box_inbound_group_session', + {'k': 'session$i', 'v': 'geheim$i'}, + ); + } + await db.close(); + return dbPath; + } + + test('Geladene Bibliothek ist wirklich SQLCipher (cipher_version)', + () async { + final db = await factory!.openDatabase(inMemoryDatabasePath); + final rows = await db.rawQuery('PRAGMA cipher_version'); + await db.close(); + expect(rows, isNotEmpty, + reason: 'PRAGMA cipher_version leer → normales sqlite3 geladen'); + expect(rows.first.values.first.toString(), isNotEmpty); + }, skip: skip); + + test('Header-Erkennung: Klartext ja, verschlüsselt nein, Mini-Datei ja', + () async { + final dbPath = await createPlaintextDb(); + expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue); + + final tiny = File('${tempDir.path}${Platform.pathSeparator}tiny.sqlite'); + await tiny.writeAsString('kurz'); + expect(await AppDatabase.isPlaintextSqlite(tiny), isTrue); + + final encPath = '${tempDir.path}${Platform.pathSeparator}enc.sqlite'; + final enc = await AppDatabase.openEncrypted(factory!, encPath, _hexKey); + await enc.execute('CREATE TABLE t (x INTEGER)'); + await enc.close(); + expect(await AppDatabase.isPlaintextSqlite(File(encPath)), isFalse); + }, skip: skip); + + test('Migration: verschlüsselt, alle Zeilen erhalten, Backup liegt da', + () async { + final dbPath = await createPlaintextDb(); + + final ok = await AppDatabase.migratePlaintextToEncrypted( + factory: factory!, + dbPath: dbPath, + hexKey: _hexKey, + ); + expect(ok, isTrue); + + // Datei ist jetzt verschlüsselt, Aufräum-Artefakte sind weg. + expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse); + expect(File('$dbPath.enc').existsSync(), isFalse); + expect(File('$dbPath.migrating').existsSync(), isFalse); + + // Byte-Backup existiert und ist die alte Klartext-DB. + final backup = File('$dbPath.premigration'); + expect(backup.existsSync(), isTrue); + expect(await AppDatabase.isPlaintextSqlite(backup), isTrue); + + // Inhalt vollständig? Mit Schlüssel öffnen und nachzählen. + final db = await AppDatabase.openEncrypted(factory, dbPath, _hexKey); + final clients = await db.rawQuery('SELECT count(*) AS c FROM box_client'); + final sessions = await db + .rawQuery('SELECT count(*) AS c FROM box_inbound_group_session'); + final probe = await db.rawQuery( + "SELECT v FROM box_inbound_group_session WHERE k = 'session3'"); + await db.close(); + expect(clients.first['c'], 50); + expect(sessions.first['c'], 7); + expect(probe.first['v'], 'geheim3'); + }, skip: skip); + + test('Falscher Schlüssel scheitert sofort beim Öffnen', () async { + final dbPath = await createPlaintextDb(); + await AppDatabase.migratePlaintextToEncrypted( + factory: factory!, + dbPath: dbPath, + hexKey: _hexKey, + ); + final wrongKey = 'ff${_hexKey.substring(2)}'; + await expectLater( + AppDatabase.openEncrypted(factory, dbPath, wrongKey), + throwsA(anything), + ); + }, skip: skip); + + test('Fehlschlag lässt die Klartext-DB unangetastet weiterlaufen', () async { + final dbPath = await createPlaintextDb(); + // Fehler erzwingen: an der Stelle des Exports liegt ein VERZEICHNIS – + // ATTACH kann die Datei nicht anlegen. + await Directory('$dbPath.enc').create(); + + final ok = await AppDatabase.migratePlaintextToEncrypted( + factory: factory!, + dbPath: dbPath, + hexKey: _hexKey, + ); + expect(ok, isFalse); + + // Original unversehrt, Marker weg, App könnte normal weiterlaufen. + expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue); + expect(File('$dbPath.migrating').existsSync(), isFalse); + final db = await factory.openDatabase(dbPath); + final clients = await db.rawQuery('SELECT count(*) AS c FROM box_client'); + await db.close(); + expect(clients.first['c'], 50); + }, skip: skip); +}