Import IKAR messenger client and server
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user