test: Live-E2E-Verifikation SQLCipher mit echtem Login (Test-Account)

Neuer gegateter Test (PYRAMID_LIVE_TEST=1, Windows): echter Login als
pyramidtest1, Byte-Pruefung der Platte (kein SQLite-Header, Token +
Nachricht nicht im Klartext, Positiv-Kontrolle in Klartext-DB),
Neustart aus verschluesselter DB, Logout/Re-Login, Migration einer
Klartext-DB mit echter eingeloggter Session. Zugangsdaten bleiben
ausserhalb des Repos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-06 22:11:48 +02:00
parent dba4b64df8
commit 9f797279eb
4 changed files with 490 additions and 1 deletions
+423
View File
@@ -0,0 +1,423 @@
@TestOn('windows')
library;
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart' as sqlite_open;
import 'package:pyramid/core/app_database.dart';
/// LIVE-End-to-End-Test der SQLCipher-Verschlüsselung mit einem ECHTEN
/// Matrix-Login gegen steggi-matrix.work (Test-Account, NICHT Bernd/Uta).
///
/// Bewusst doppelt gegated, damit er nie in einem normalen `flutter test`
/// mitläuft (Netzwerk + echter Homeserver):
/// 1. Umgebungsvariable `PYRAMID_LIVE_TEST=1` muss gesetzt sein.
/// 2. Die Zugangsdaten-Datei (`PYRAMID_TEST_ACCOUNTS` bzw.
/// `%USERPROFILE%\.pyramid-autopilot\test-accounts.txt`) muss existieren
/// sie liegt AUSSERHALB des Repos, hier stehen keine Secrets.
///
/// Was der Test wirklich beweist (auf Windows, mit der SQLCipher-DLL aus dem
/// App-Build):
/// * Eine frisch angelegte DB ist auf der Platte verschlüsselt: Header ist
/// nicht "SQLite format 3", und weder der echte Access-Token noch der
/// Klartext einer echten gesendeten Nachricht sind in den Datei-Bytes
/// auffindbar (inkl. WAL-Sidecar).
/// * Die Byte-Suche selbst funktioniert (Positiv-Kontrolle: in einer
/// Klartext-DB werden Token UND Nachricht gefunden).
/// * "Neustart": Die Session (Access-Token) und die Nachricht kommen
/// nachweislich aus der verschlüsselten DB (Raw-Query mit Schlüssel,
/// Client-Init ohne erneute Passwort-Eingabe).
/// * Logout → erneuter Login → die alte Nachricht ist wieder lesbar.
/// * Utas Update-Fall: eine Klartext-DB MIT ECHTER eingeloggter Session wird
/// migriert; Session und Nachricht überleben, danach ist nichts mehr im
/// Klartext auf der Platte.
///
/// NICHT abgedeckt (ehrlich): flutter_secure_storage (der Test injiziert den
/// Schlüssel direkt der Keystore-Pfad ist durch die echten App-Läufe auf
/// Windows und dem Android-Emulator belegt) und E2EE-verschlüsselte Räume
/// (kein vodozemac im Host-Test; der Testraum ist unverschlüsselt, was für
/// die Klartext-Suche sogar der härtere Fall ist).
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;
}
class _TestAccounts {
_TestAccounts({
required this.homeserver,
required this.user1,
required this.pass1,
required this.user2,
required this.pass2,
});
final Uri homeserver;
final String user1;
final String pass1;
final String user2;
final String pass2;
static _TestAccounts? load() {
final override = Platform.environment['PYRAMID_TEST_ACCOUNTS'];
final profile = Platform.environment['USERPROFILE'] ?? '';
final path = (override != null && override.isNotEmpty)
? override
: '$profile\\.pyramid-autopilot\\test-accounts.txt';
final file = File(path);
if (!file.existsSync()) return null;
final map = <String, String>{};
for (final line in file.readAsLinesSync()) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
final idx = trimmed.indexOf('=');
if (idx <= 0) continue;
map[trimmed.substring(0, idx)] = trimmed.substring(idx + 1);
}
final hs = map['homeserver'];
if (hs == null ||
map['user1'] == null ||
map['pass1'] == null ||
map['user2'] == null ||
map['pass2'] == null) {
return null;
}
return _TestAccounts(
homeserver: Uri.parse(hs),
user1: map['user1']!,
pass1: map['pass1']!,
user2: map['user2']!,
pass2: map['pass2']!,
);
}
}
/// Zufälliger 32-Byte-Schlüssel wie in AppDatabase._getOrCreateKey nur
/// direkt injiziert statt über den Keystore (siehe Kopfkommentar).
String _randomHexKey() {
final rnd = Random.secure();
return List.generate(
32,
(_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
String _randomMarker(String prefix) {
final rnd = Random.secure();
final suffix =
List.generate(8, (_) => rnd.nextInt(16).toRadixString(16)).join();
return 'PYRAMID_${prefix}_PROBE_$suffix';
}
/// Naive ASCII-Substring-Suche über rohe Datei-Bytes (DB + WAL). Bewusst kein
/// String-Decode der Gesamtdatei: Binärdaten sind kein gültiges UTF-8.
bool _bytesContain(Uint8List haystack, String needle) {
final n = ascii.encode(needle);
if (n.isEmpty || haystack.length < n.length) return false;
outer:
for (var i = 0; i <= haystack.length - n.length; i++) {
for (var j = 0; j < n.length; j++) {
if (haystack[i + j] != n[j]) continue outer;
}
return true;
}
return false;
}
/// Durchsucht Hauptdatei + WAL/SHM-Sidecars nach einem Klartext-String.
Future<bool> _filesContain(String dbPath, String needle) async {
for (final path in [dbPath, '$dbPath-wal', '$dbPath-shm']) {
final file = File(path);
if (!await file.exists()) continue;
if (_bytesContain(await file.readAsBytes(), needle)) return true;
}
return false;
}
/// Öffnet die verschlüsselte DB mit Schlüssel und sucht den String in allen
/// Tabelleninhalten beweist, dass die Daten IN der verschlüsselten Datei
/// stecken (und nicht etwa vom Server nachgeladen werden müssten).
Future<bool> _encryptedDbContains(
DatabaseFactory factory,
String dbPath,
String hexKey,
String needle,
) async {
final db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
try {
final tables = await db.rawQuery(
"SELECT name FROM sqlite_master "
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
);
for (final row in tables) {
final name = row['name'] as String;
final rows = await db.rawQuery('SELECT * FROM "$name"');
for (final r in rows) {
for (final v in r.values) {
if (v == null) continue;
if (v is Uint8List) {
if (_bytesContain(v, needle)) return true;
} else if (v.toString().contains(needle)) {
return true;
}
}
}
}
return false;
} finally {
await db.close();
}
}
Future<Client> _newClient(
Database db, {
required String name,
bool waitForRooms = false,
}) async {
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
final client = Client(name, database: sdkDb);
// waitForRooms: Die Raumliste wird beim Init ASYNCHRON aus der DB geladen
// wer direkt danach client.rooms prüfen will, muss darauf warten.
await client.init(
waitForFirstSync: false,
waitUntilLoadCompletedLoaded: waitForRooms,
);
return client;
}
Future<void> _login(Client client, _TestAccounts acc,
{required String user, required String pass, required String device}) async {
await client.checkHomeserver(acc.homeserver);
await client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: user),
password: pass,
initialDeviceDisplayName: device,
refreshToken: true,
);
}
/// Holt (oder erzeugt) den unverschlüsselten Autopilot-Testraum.
Future<Room> _ensureTestRoom(Client client) async {
const roomName = 'Pyramid Autopilot Testraum';
await client.oneShotSync().timeout(const Duration(seconds: 60));
for (final room in client.rooms) {
if (room.name == roomName && room.membership == Membership.join) {
return room;
}
}
final roomId = await client.createRoom(
name: roomName,
preset: CreateRoomPreset.privateChat,
);
await client.waitForRoomInSync(roomId, join: true).timeout(
const Duration(seconds: 60),
);
final room = client.getRoomById(roomId);
expect(room, isNotNull, reason: 'Testraum nach createRoom nicht im Sync');
return room!;
}
/// Sucht die Marker-Nachricht in der Timeline (lädt bei Bedarf Historie nach).
Future<bool> _timelineContains(Room room, String marker) async {
final timeline = await room.getTimeline();
try {
for (var i = 0; i < 10; i++) {
if (timeline.events.any(
(e) => e.type == EventTypes.Message && e.body.contains(marker),
)) {
return true;
}
if (!timeline.canRequestHistory) break;
await timeline.requestHistory(historyCount: 100);
}
return timeline.events.any(
(e) => e.type == EventTypes.Message && e.body.contains(marker),
);
} finally {
timeline.cancelSubscriptions();
}
}
void main() {
final dllPath = _findCipherDll();
final accounts = _TestAccounts.load();
final liveEnabled = Platform.environment['PYRAMID_LIVE_TEST'] == '1';
final skip = !liveEnabled
? 'Live-Test nur mit PYRAMID_LIVE_TEST=1 (echter Homeserver!)'
: dllPath == null
? 'SQLCipher-DLL fehlt (erst "flutter build windows")'
: accounts == null
? 'Test-Account-Datei fehlt (~/.pyramid-autopilot/test-accounts.txt)'
: null;
final factory = dllPath == null
? null
: createDatabaseFactoryFfi(
ffiInit: () {
sqlite_open.open.overrideFor(
sqlite_open.OperatingSystem.windows,
() => DynamicLibrary.open(dllPath),
);
},
noIsolate: true,
);
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('pyramid_live_test');
});
tearDown(() async {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
});
test(
'Frische Installation: Login → Platte verschlüsselt → Neustart → '
'Logout → Re-Login → alte Nachricht lesbar',
() async {
final acc = accounts!;
final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
final hexKey = _randomHexKey();
final marker = _randomMarker('ENC');
// ── Phase 1: frisch verschlüsselt anlegen, echter Login, Nachricht ──
var db = await AppDatabase.openEncrypted(factory!, dbPath, hexKey);
await db.execute('PRAGMA journal_mode=WAL');
var client = await _newClient(db, name: 'PyramidLiveTest');
await _login(client, acc,
user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test');
expect(client.isLogged(), isTrue);
final accessToken = client.accessToken!;
final room = await _ensureTestRoom(client);
final eventId = await room.sendTextEvent(marker);
expect(eventId, isNotNull, reason: 'Senden der Probe-Nachricht schlug fehl');
await client.oneShotSync().timeout(const Duration(seconds: 60));
await client.dispose(closeDatabase: true);
// ── Phase 2: Byte-Prüfung auf der Platte ──
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse,
reason: 'DB-Header ist "SQLite format 3" NICHT verschlüsselt!');
expect(await _filesContain(dbPath, accessToken), isFalse,
reason: 'Access-Token im Klartext auf der Platte gefunden!');
expect(await _filesContain(dbPath, marker), isFalse,
reason: 'Nachrichtentext im Klartext auf der Platte gefunden!');
// Beweis, dass die Daten wirklich IN der Datei stecken (nur eben
// verschlüsselt): Raw-Query mit Schlüssel findet beides.
expect(
await _encryptedDbContains(factory, dbPath, hexKey, accessToken),
isTrue,
reason: 'Access-Token fehlt in der DB falsche Datei geprüft?',
);
expect(
await _encryptedDbContains(factory, dbPath, hexKey, marker),
isTrue,
reason: 'Nachricht fehlt in der DB falsche Datei geprüft?',
);
// ── Phase 3: „Neustart" Session kommt aus der verschlüsselten DB ──
db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
client = await _newClient(db, name: 'PyramidLiveTest');
expect(client.isLogged(), isTrue,
reason: 'Session hat den Neustart nicht überlebt');
expect(client.accessToken, accessToken,
reason: 'Access-Token nach Neustart nicht aus der DB restauriert');
expect(client.userID?.toLowerCase(), acc.user1.toLowerCase());
// ── Phase 4: Logout → Re-Login → alte Nachricht wieder lesbar ──
await client.logout();
expect(client.isLogged(), isFalse);
await _login(client, acc,
user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test 2');
final roomAfter = await _ensureTestRoom(client);
expect(await _timelineContains(roomAfter, marker), isTrue,
reason: 'Alte Nachricht nach Logout+Re-Login nicht mehr lesbar');
// Aufräumen: Session serverseitig beenden, DB wird dabei geleert.
await client.logout();
await client.dispose(closeDatabase: true);
},
skip: skip,
timeout: const Timeout(Duration(minutes: 8)),
);
test(
'Utas Update-Fall: Klartext-DB mit ECHTER Session → Migration → '
'Session + Nachricht überleben, Platte danach dicht',
() async {
final acc = accounts!;
final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
final hexKey = _randomHexKey();
final marker = _randomMarker('MIG');
// ── Phase 1: Klartext-DB wie vor der SQLCipher-Einführung ──
var db = await factory!.openDatabase(dbPath);
await db.execute('PRAGMA journal_mode=WAL');
var client = await _newClient(db, name: 'PyramidLiveTest');
await _login(client, acc,
user: acc.user1, pass: acc.pass1, device: 'Autopilot Mig-Test');
final accessToken = client.accessToken!;
final room = await _ensureTestRoom(client);
final eventId = await room.sendTextEvent(marker);
expect(eventId, isNotNull);
await client.oneShotSync().timeout(const Duration(seconds: 60));
await client.dispose(closeDatabase: true);
// Positiv-Kontrolle: In der Klartext-DB findet die Byte-Suche BEIDES
// damit ist bewiesen, dass die Suche Leaks erkennen würde.
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue);
expect(await _filesContain(dbPath, accessToken), isTrue,
reason: 'Kontrolle kaputt: Token nicht mal im Klartext gefunden');
expect(await _filesContain(dbPath, marker), isTrue,
reason: 'Kontrolle kaputt: Nachricht nicht mal im Klartext gefunden');
// ── Phase 2: Migration (exakt der App-Codepfad) ──
final ok = await AppDatabase.migratePlaintextToEncrypted(
factory: factory,
dbPath: dbPath,
hexKey: hexKey,
);
expect(ok, isTrue, reason: 'Migration fehlgeschlagen');
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse);
expect(File('$dbPath.premigration').existsSync(), isTrue);
expect(await _filesContain(dbPath, accessToken), isFalse,
reason: 'Token nach Migration weiterhin im Klartext!');
expect(await _filesContain(dbPath, marker), isFalse,
reason: 'Nachricht nach Migration weiterhin im Klartext!');
// ── Phase 3: Session + Daten intakt? ──
expect(
await _encryptedDbContains(factory, dbPath, hexKey, marker),
isTrue,
reason: 'Nachricht hat die Migration nicht überlebt',
);
db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
client = await _newClient(db, name: 'PyramidLiveTest', waitForRooms: true);
expect(client.isLogged(), isTrue,
reason: 'Session hat die Migration nicht überlebt');
expect(client.accessToken, accessToken);
final roomAfter = client.getRoomById(room.id);
expect(roomAfter, isNotNull,
reason: 'Testraum nach Migration nicht mehr in der DB');
// Aufräumen (Session serverseitig beenden).
await client.logout();
await client.dispose(closeDatabase: true);
},
skip: skip,
timeout: const Timeout(Duration(minutes: 8)),
);
}