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
+14
View File
@@ -1319,3 +1319,17 @@
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md 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 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
+74
View File
@@ -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) ## 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 **Erledigt:** Session-Start nach Arbeitszyklus zeigte echten neuen Anlass (also
+11
View File
@@ -42,6 +42,17 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
+ Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3 + Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3
Fallstricke der Hintergrund-Entschlüsselung gegenprüfen. Fallstricke der Hintergrund-Entschlüsselung gegenprüfen.
- [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.** - [ ] **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 `sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends
benutzt `matrix_client.dart` und `background_push.dart` öffnen die benutzt `matrix_client.dart` und `background_push.dart` öffnen die
`pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über `pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über
+12 -3
View File
@@ -1,8 +1,17 @@
# SQLCipher-Migration der `pyramid.sqlite` Umsetzungsplan (PC-Termin) # SQLCipher-Migration der `pyramid.sqlite` Umsetzungsplan (PC-Termin)
Stand: 2026-07-03, geschrieben auf dem Pi (Plan gegen den echten Code verifiziert, > **STATUS 2026-07-06: UMGESETZT (`lib/core/app_database.dart`) und auf
Umsetzung braucht PC + Android-Testgerät). Gehört zum offenen M1-Punkt > Windows mit Bernds echter 14-MB-DB praxisgetestet** (Migration gelaufen,
„Sichere Speicherung von Access-Token & Krypto-DB". > 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 ## Für Bernd in einfach
+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("'", "''");
}
+8 -15
View File
@@ -7,11 +7,9 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p; import 'package:pyramid/core/app_database.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/soft_logout_guard.dart'; import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.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. /// Port name used to locate the main isolate from background isolates.
/// Registered by notification_service.dart when the app starts. /// Registered by notification_service.dart when the app starts.
@@ -182,18 +180,13 @@ Future<Client?> _buildClient() async {
await vod.init(); await vod.init();
} catch (_) {} } catch (_) {}
final appDir = await getApplicationSupportDirectory(); // Gemeinsame Storage-Fassade: erkennt Klartext vs. SQLCipher am
final dbPath = p.join(appDir.path, 'pyramid.sqlite'); // Datei-Header, migriert aber NIE selbst (docs/SQLCIPHER_MIGRATION.md).
// null = gerade kein sicherer Zugriff (Migration läuft / DB oder
final db = await sqflite_native.databaseFactory // Schlüssel fehlt) Notification behält den nativen Platzhalter.
.openDatabase(dbPath) final db =
.timeout(const Duration(seconds: 5)); await AppDatabase.openForPush().timeout(const Duration(seconds: 10));
if (db == null) return null;
// 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 (_) {}
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
+4 -30
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.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:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/encryption/utils/key_verification.dart'; import 'package:matrix/encryption/utils/key_verification.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p; import 'package:pyramid/core/app_database.dart';
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/auth_log.dart'; import 'package:pyramid/core/auth_log.dart';
import 'package:pyramid/core/soft_logout_guard.dart'; import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:pyramid/core/voip_manager.dart'; import 'package:pyramid/core/voip_manager.dart';
final matrixClientProvider = FutureProvider<Client>((ref) async { final matrixClientProvider = FutureProvider<Client>((ref) async {
final DatabaseFactory factory; // Factory-Wahl, Verschlüsselung (SQLCipher), Migration und PRAGMAs stecken
// komplett in der Storage-Fassade siehe docs/SQLCIPHER_MIGRATION.md.
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { final db = await AppDatabase.open();
// 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');
}
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
+1 -1
View File
@@ -1235,7 +1235,7 @@ packages:
source: hosted source: hosted
version: "0.6.8" version: "0.6.8"
sqlite3: sqlite3:
dependency: transitive dependency: "direct main"
description: description:
name: sqlite3 name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
+3
View File
@@ -19,6 +19,9 @@ dependencies:
sqflite: ^2.4.2 sqflite: ^2.4.2
sqflite_common_ffi: ^2.3.7+1 sqflite_common_ffi: ^2.3.7+1
sqlcipher_flutter_libs: ^0.6.8 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 video/audio/screen
livekit_client: ^2.4.0 livekit_client: ^2.4.0
+176
View File
@@ -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 KlartextSQLCipher-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<String> 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);
}