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: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/app_dirs.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 appSupportDir(); 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("'", "''"); }