9f797279eb
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>
424 lines
16 KiB
Dart
424 lines
16 KiB
Dart
@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)),
|
||
);
|
||
}
|