Import IKAR messenger client and server

This commit is contained in:
sevenhill
2026-10-07 22:06:07 +03:00
commit 69ea5091eb
1951 changed files with 424546 additions and 0 deletions
+543
View File
@@ -0,0 +1,543 @@
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/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(
'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);
},
);
}