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
This commit is contained in:
Bernd Steckmeister
2026-07-06 12:31:38 +02:00
parent cead62ccb2
commit f64398d3c6
10 changed files with 787 additions and 49 deletions
+484
View File
@@ -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<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("'", "''");
}