Files
pyramid/test/live_encryption_e2e_test.dart
T
Bernd Steckmeister 9f797279eb 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>
2026-07-06 22:11:48 +02:00

424 lines
16 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.
@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)),
);
}