dba4b64df8
DB, auth_log und Media-Cache laufen jetzt ueber appSupportDir(); nur auf Desktop und nur wenn PYRAMID_PROFILE_DIR gesetzt ist, landet alles in einem eigenen Profilverzeichnis - fuer Autopilot-Testlogins parallel zur echten Session. Ohne Variable exakt das bisherige Verhalten. secure_storage/ shared_preferences bewusst nicht umgeleitet (Begruendung im Dateikopf). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
485 lines
19 KiB
Dart
485 lines
19 KiB
Dart
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<String> _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<Database> 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<Database?> 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<bool> 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<String, int> 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<void> _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<void> _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<void> _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<bool> 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<Database> _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<Database> 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<bool> _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<void> _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<String?> _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<String> _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<Map<String, int>> _tableRowCounts(Database db) async {
|
||
final tables = await db.rawQuery(
|
||
"SELECT name FROM sqlite_master "
|
||
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||
);
|
||
final result = <String, int>{};
|
||
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("'", "''");
|
||
}
|