Files
pyramid/lib/core/app_database.dart
T
Bernd Steckmeister f64398d3c6 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
2026-07-06 12:31:38 +02:00

485 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> _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<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("'", "''");
}