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 getApplicationSupportPath() async => path; @override Future getTemporaryPath() async => path; @override Future getApplicationDocumentsPath() async => path; } class _Messages extends MessagesModule { _Messages() : super(Api()); final wire = []; @override Future sendMessage( int accountId, int chatId, String text, { void Function()? beforeSend, int? clientId, bool notify = true, int? scheduledTime, int? replyToMessageId, int? replySourceChatId, List> 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 editMessage( int chatId, String messageId, { required String text, List> 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 = {}; 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 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().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()), ); 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); }, ); }