Files
IKAR/test/e2ee_integration_test.dart
T
sevenhill 1ef2b7420e
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Port Komet UI improvements and implement IKAR ghost mode
Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
2026-10-07 22:07:05 +03:00

581 lines
18 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet_crypto/komet_crypto.dart';
import 'package:image/image.dart' as img;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:ikar/backend/api.dart';
import 'package:ikar/backend/modules/messages.dart';
import 'package:ikar/backend/modules/outgoing_text.dart';
import 'package:ikar/backend/modules/outbox_queue.dart';
import 'package:ikar/core/crypto/e2ee_service.dart';
import 'package:ikar/core/crypto/encrypted_photo.dart';
import 'package:ikar/core/crypto/message_decryption_cache.dart';
import 'package:ikar/core/storage/app_database.dart';
import 'package:ikar/core/utils/media_cache.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
class _Paths extends PathProviderPlatform {
_Paths(this.path);
final String path;
@override
Future<String?> getApplicationSupportPath() async => path;
@override
Future<String?> getTemporaryPath() async => path;
@override
Future<String?> getApplicationDocumentsPath() async => path;
}
class _Messages extends MessagesModule {
_Messages() : super(Api());
final wire = <CachedMessage>[];
@override
Future<String> sendMessage(
int accountId,
int chatId,
String text, {
void Function()? beforeSend,
int? clientId,
bool notify = true,
int? scheduledTime,
int? replyToMessageId,
int? replySourceChatId,
List<Map<String, dynamic>> elements = const [],
}) async {
beforeSend?.call();
final id = '${wire.length + 100}';
wire.add(
CachedMessage(
id: id,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: text,
time: wire.length + 100,
),
);
return id;
}
@override
Future<bool> editMessage(
int chatId,
String messageId, {
required String text,
List<Map<String, dynamic>> elements = const [],
bool sendAttachments = false,
}) async => true;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
final service = E2eeService.instance;
late Directory dir;
late _Messages transport;
final secrets = <String, String>{};
final previousPaths = PathProviderPlatform.instance;
CachedMessage incoming(CachedMessage wire, int recipient) => CachedMessage(
id: wire.id,
accountId: recipient,
chatId: wire.chatId,
senderId: wire.senderId,
text: wire.text,
time: wire.time,
);
Future<void> handshake() async {
expect(
await service.startOffer(accountId: 42, chatId: 7, peerId: 84),
isTrue,
);
final offer = incoming(transport.wire.last, 84);
await service.inspectHistory(84, 7, [offer]);
expect(service.phaseOf(84, 7), E2eePhase.pendingConsent);
expect(await service.acceptOffer(accountId: 84, chatId: 7), isTrue);
await service.inspect(incoming(transport.wire.last, 42));
expect(service.phaseOf(42, 7), E2eePhase.established);
}
setUp(() async {
KometCrypto.libraryPath =
Platform.environment['QLYRA_E2EE_LIBRARY'] ??
'${Directory.current.path}/build/native-tests/crypto-core/Debug/komet_crypto.dll';
expect(
KometCrypto.isAvailable,
isTrue,
reason: 'Build the C crypto library first',
);
SharedPreferences.setMockInitialValues({});
secrets.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
final args = call.arguments as Map;
switch (call.method) {
case 'read':
return secrets[args['key']];
case 'write':
secrets[args['key'] as String] = args['value'] as String;
return null;
case 'delete':
secrets.remove(args['key']);
return null;
case 'readAll':
return secrets;
}
return null;
});
dir = await Directory.systemTemp.createTemp('qlyra-e2ee-test-');
PathProviderPlatform.instance = _Paths(dir.path);
MediaCache.resetForTesting();
await AppDatabase.init();
for (final account in [42, 84]) {
service.forgetAccount(account);
await AppDatabase.saveProfile(ProfileData.stub(account));
await AppDatabase.saveChats([
{'id': 7, 'account_id': account, 'type': 'DIALOG', 'cached_at': 1},
]);
}
transport = _Messages();
service.attach(transport);
});
tearDown(() async {
service.forgetAccount(42);
service.forgetAccount(84);
await AppDatabase.close();
MediaCache.resetForTesting();
PathProviderPlatform.instance = previousPaths;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
await dir.delete(recursive: true);
});
test(
'copy and edit resolve the same sealed text without exposing ciphertext',
() async {
await handshake();
const plaintext = 'synthetic message for editing';
final wire = await service.encryptText(42, 7, plaintext);
final incoming = CachedMessage(
id: '501',
accountId: 84,
chatId: 7,
senderId: 42,
text: wire,
time: 501,
);
final decoded = await service.inspect(
incoming,
commit: (message) => AppDatabase.saveMessages([message.toDbRow()]),
);
final cache = MessageDecryptionCache.instance;
cache.clear();
expect(cache.readableText(decoded), isNull);
final results = await Future.wait([
cache.resolveText(decoded),
cache.resolveText(decoded),
]);
expect(results, [plaintext, plaintext]);
expect(cache.readableText(decoded), plaintext);
cache.clear();
final pending = cache.resolveText(decoded);
cache.clear();
expect(await pending, isNull);
expect(cache.readableText(decoded), isNull);
expect((await AppDatabase.loadMessage(84, 7, '501'))!['text'], wire);
},
);
test(
'history establishes a session; fingerprints match; restart preserves decryption',
() async {
await handshake();
expect(
await service.fingerprint(42, 7),
await service.fingerprint(84, 7),
);
final wire = await service.encryptText(42, 7, 'synthetic secret');
final received = CachedMessage(
id: '200',
accountId: 84,
chatId: 7,
senderId: 42,
text: wire,
time: 200,
);
final decoded = await service.inspect(
received,
commit: (m) => AppDatabase.saveMessages([m.toDbRow()]),
);
expect(decoded.e2ee, CachedMessage.e2eeText);
service.forgetAccount(84);
await service.ensureLoaded(84);
expect(
await service.openText(84, 7, decoded.sealedText!),
'synthetic secret',
);
final row = (await AppDatabase.loadMessage(84, 7, '200'))!;
expect(row['text'], wire);
expect(
row.values.whereType<String>().join(),
isNot(contains('synthetic secret')),
);
final history = await service.inspectHistory(84, 7, [received]);
expect(history.single.sealedText, decoded.sealedText);
},
);
test(
'outbox retry keeps ciphertext, cid and the sealed local copy',
() async {
await handshake();
final wire = (await service.encryptText(42, 7, 'queued secret'))!;
final sealed = await service.sealText(42, 7, 'queued secret');
CachedMessage saved = OutgoingText.prepare(
accountId: 42,
chatId: 7,
wireText: wire,
encrypted: true,
sealedText: sealed,
e2ee: CachedMessage.e2eeText,
);
var fail = true;
var clock = DateTime(2026);
final requests = <(String, int)>[];
OutboxQueue queue() => OutboxQueue(
activeAccount: () async => 42,
isOnline: () => true,
sessionEpoch: () => 1,
now: () => clock,
load: (_) async => saved.status == 'pending' ? [saved] : [],
save: (m) async {
saved = CachedMessage.fromDbRow(m.toDbRow());
},
updatePending: (m) async {
saved = CachedMessage.fromDbRow(m.toDbRow());
return true;
},
replace: (_, m) async {
saved = CachedMessage.fromDbRow(m.toDbRow());
},
send: (m, cid) async {
requests.add((m.text!, cid));
if (fail) throw TimeoutException('lost ack');
return '250';
},
onChanged: (_, _) {},
onError: (_, _) {},
);
final first = queue();
await first.flush();
first.dispose();
service.lock();
clock = clock.add(const Duration(minutes: 1));
fail = false;
final second = queue();
await second.flush();
second.dispose();
expect(requests[0], requests[1]);
expect(saved.status, 'sent');
expect(saved.e2ee, CachedMessage.e2eeText);
expect(await service.openText(42, 7, saved.sealedText!), 'queued secret');
final peer = await service.inspect(incoming(saved, 84));
expect(await service.openText(84, 7, peer.sealedText!), 'queued secret');
},
);
test(
'own scheduled message can be recovered from history after restart',
() async {
await handshake();
final wire = await service.encryptText(42, 7, 'scheduled secret');
service.forgetAccount(42);
final restored = (await service.inspectHistory(42, 7, [
CachedMessage(
id: '350',
accountId: 42,
chatId: 7,
senderId: 42,
text: wire,
time: 350,
),
])).single;
expect(
await service.openText(42, 7, restored.sealedText!),
'scheduled secret',
);
},
);
test(
'schema 23 migration preserves existing messages and adds crypto storage',
() async {
await AppDatabase.saveMessages([
CachedMessage(
id: 'legacy',
accountId: 42,
chatId: 7,
senderId: 42,
text: 'legacy synthetic',
time: 1,
).toDbRow(),
]);
await AppDatabase.close();
final db = await databaseFactoryFfi.openDatabase('${dir.path}/qlyra.db');
await db.execute('DROP TABLE e2ee_outgoing');
await db.execute('DROP TABLE e2ee_sessions');
await db.execute('ALTER TABLE messages DROP COLUMN text_sealed');
await db.execute('ALTER TABLE messages DROP COLUMN e2ee');
await db.setVersion(23);
await db.close();
expect(
(await AppDatabase.loadMessage(42, 7, 'legacy'))!['text'],
'legacy synthetic',
);
await handshake();
expect(await service.encryptText(42, 7, 'after migration'), isNotNull);
},
);
test(
'server plaintext cannot replace an authenticated cached message',
() async {
await handshake();
final wire = await service.encryptText(42, 7, 'authentic');
final source = CachedMessage(
id: '360',
accountId: 84,
chatId: 7,
senderId: 42,
text: wire,
time: 360,
);
await service.inspect(
source,
commit: (m) => AppDatabase.saveMessages([m.toDbRow()]),
);
final result = (await service.inspectHistory(84, 7, [
CachedMessage(
id: source.id,
accountId: source.accountId,
chatId: source.chatId,
senderId: source.senderId,
text: 'forged',
time: source.time,
),
])).single;
expect(result.text, wire);
expect(await service.openText(84, 7, result.sealedText!), 'authentic');
},
);
test(
'failed export file write leaves the source session available',
() async {
await handshake();
final result = await service.exportTransfer(
42,
'test password',
persist: (_) async {
throw const FileSystemException('synthetic write failure');
},
);
expect(result, isNull);
expect(service.isActive(42, 7), isTrue);
expect(await service.encryptText(42, 7, 'still available'), isNotNull);
},
);
test('photo uses a separate file key and an encrypted ticket', () async {
await handshake();
final png = Uint8List.fromList([137, 80, 78, 71]);
final key = KometCrypto.randomBytes(KometCryptoSizes.fileKey);
final nonce = KometCrypto.randomBytes(KometCryptoSizes.fileNonce);
final seal = KometCrypto.fileSealer(key, nonce);
final encrypted = File('${dir.path}/synthetic.kce');
await encrypted.writeAsBytes(seal.chunk(png, last: true));
seal.dispose();
final ticket = Uint8List(key.length + nonce.length + 8)
..setAll(0, key)
..setAll(key.length, nonce);
ticket[ticket.length - 1] = png.length;
final wire = await service.encryptBytes(42, 7, ContentType.file, ticket);
final received = await service.inspect(
CachedMessage(
id: '300',
accountId: 84,
chatId: 7,
senderId: 42,
text: wire,
time: 300,
),
);
expect(received.e2ee, CachedMessage.e2eeFile);
final opened = await openE2eePhoto(
encrypted: encrypted,
ticket: (await service.openBytes(84, 7, received.sealedText!))!,
);
expect(opened.isOk, isTrue);
expect(await opened.file!.readAsBytes(), png);
});
test(
'transfer rejects wrong password and restores a session after restart',
() async {
await handshake();
final before = await service.encryptText(42, 7, 'before transfer');
await service.inspect(
CachedMessage(
id: '390',
accountId: 84,
chatId: 7,
senderId: 42,
text: before,
time: 390,
),
);
final originalPublic = await service.identityPublic(42);
final transfer = await service.exportTransfer(
42,
'synthetic transfer password',
);
expect(transfer, isNotNull);
expect(service.isActive(42, 7), isFalse);
expect(await service.importTransfer(42, transfer!, 'wrong'), isNull);
final peerIdentity = await service.identityPublic(84);
expect(
await service.importTransfer(
84,
transfer,
'synthetic transfer password',
),
isNull,
);
expect(await service.identityPublic(84), peerIdentity);
expect(
await service.importTransfer(
42,
transfer,
'synthetic transfer password',
),
1,
);
service.forgetAccount(42);
await service.ensureLoaded(42);
expect(await service.identityPublic(42), originalPublic);
expect(service.isActive(42, 7), isTrue);
final movedWire = await service.encryptText(
42,
7,
'first after transfer',
);
final movedReceived = await service.inspect(
CachedMessage(
id: '400',
accountId: 84,
chatId: 7,
senderId: 42,
text: movedWire,
time: 400,
),
);
expect(
await service.openText(84, 7, movedReceived.sealedText!),
'first after transfer',
);
final peerWire = await service.encryptText(84, 7, 'peer after transfer');
final received = await service.inspect(
CachedMessage(
id: '401',
accountId: 42,
chatId: 7,
senderId: 84,
text: peerWire,
time: 401,
),
);
expect(
await service.openText(42, 7, received.sealedText!),
'peer after transfer',
);
final reply = await service.encryptText(42, 7, 'reply after transfer');
expect(reply, isNotNull);
},
);
test(
'a transferred unused initiator session cannot emit an unreadable ratchet step',
() async {
await handshake();
final transfer = (await service.exportTransfer(42, 'transfer test'))!;
expect(await service.importTransfer(42, transfer, 'transfer test'), 1);
await expectLater(
service.encryptText(42, 7, 'unsafe first send'),
throwsA(isA<E2eeAwaitingPeer>()),
);
await service.resetSession(accountId: 42, chatId: 7);
expect(
await service.startOffer(accountId: 42, chatId: 7, peerId: 84),
isTrue,
);
await service.inspect(incoming(transport.wire.last, 84));
expect(await service.acceptOffer(accountId: 84, chatId: 7), isTrue);
await service.inspect(incoming(transport.wire.last, 42));
expect(await service.encryptText(42, 7, 'new session'), isNotNull);
},
);
test(
'photo preparation uses fresh keys and rejects corrupt content before caching',
() async {
final source = File('${dir.path}/photo.png');
await source.writeAsBytes(img.encodePng(img.Image(width: 4, height: 4)));
final a = (await prepareE2eePhoto(source: source, stamp: 'one'))!;
final b = (await prepareE2eePhoto(source: source, stamp: 'two'))!;
expect(a.ticket, isNot(b.ticket));
final ciphertext = await a.file.readAsBytes();
final corrupt = Uint8List.fromList(ciphertext)..[0] ^= 1;
await a.file.writeAsBytes(corrupt);
expect(
(await openE2eePhoto(encrypted: a.file, ticket: a.ticket)).isOk,
isFalse,
);
expect(
await (await MediaCache.fileFor(
e2eeDecryptedCacheName(a.ticket),
)).exists(),
isFalse,
);
await a.file.writeAsBytes(ciphertext);
final opened = await openE2eePhoto(encrypted: a.file, ticket: a.ticket);
expect(opened.isOk, isTrue);
expect(img.decodePng(await opened.file!.readAsBytes())!.width, 4);
},
);
test(
'changed identity clears verification and repeated history does not reset a session',
() async {
await handshake();
await service.setVerified(accountId: 42, chatId: 7, verified: true);
final oldOffer = incoming(transport.wire.first, 84);
await AppDatabase.saveMessages([oldOffer.toDbRow()]);
await service.inspectHistory(84, 7, [oldOffer]);
expect(service.phaseOf(84, 7), E2eePhase.established);
await service.rotateIdentity(84);
expect(
await service.startOffer(accountId: 84, chatId: 7, peerId: 42),
isTrue,
);
await service.inspect(incoming(transport.wire.last, 42));
expect(service.phaseOf(42, 7), E2eePhase.keyChanged);
expect(service.info(42, 7)!.verified, isFalse);
expect(service.offerChangesPeer(42, 7), isTrue);
},
);
}