Files
IKAR/test/chat_crypto_roundtrip_test.dart

370 lines
9.0 KiB
Dart

import 'dart:io';
import 'dart:async';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:qlyra_crypto/qlyra_crypto.dart' as kc;
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:komet_crypto/komet_crypto.dart';
import 'package:ikar/core/crypto/chat_crypto_service.dart';
import 'package:ikar/core/crypto/noise_png.dart';
final _libPath =
Platform.environment['QLYRA_CRYPTO_LIBRARY'] ??
'native/qlyra_crypto/rust/target/debug/${Platform.isWindows
? 'qlyra_crypto.dll'
: Platform.isMacOS
? 'libqlyra_crypto.dylib'
: 'libqlyra_crypto.so'}';
void main() {
setUpAll(() async {
expect(
File(_libPath).existsSync(),
isTrue,
reason:
'Build native crypto with cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml',
);
await kc.RustLib.init(externalLibrary: ExternalLibrary.open(_libPath));
});
test('round-trips through the native bridge', () async {
final key = await kc.deriveKey(password: 'общий ключ');
expect(key.length, 32);
const plaintext = 'встречаемся в 19:00 у метро';
final encrypted = await kc.encryptMessage(plaintext: plaintext, key: key);
expect(encrypted, isNot(contains(RegExp(r'[a-zA-Z0-9]'))));
expect(encrypted, contains(' '));
expect(await kc.decryptMessage(text: encrypted, key: key), plaintext);
});
test('derives the same key from the same password', () async {
final a = await kc.deriveKey(password: 'один ключ');
final b = await kc.deriveKey(password: 'один ключ');
expect(a, b);
});
test(
'C message and photo envelopes remain compatible with ИКАР Rust legacy',
() async {
KometCrypto.libraryPath =
Platform.environment['QLYRA_E2EE_LIBRARY'] ??
'${Directory.current.path}/build/native-tests/crypto-core/Debug/komet_crypto.dll';
expect(KometCrypto.isAvailable, isTrue);
final key = await kc.deriveKey(password: 'legacy qlyra key');
final old = await kc.encryptMessage(plaintext: 'old history', key: key);
expect(KometCrypto.decryptMessage(old, key), 'old history');
expect(
await ChatCryptoService.instance.decryptWithPassword(
old,
'legacy qlyra key',
),
'old history',
);
final current = KometCrypto.encryptMessage('new legacy message', key);
expect(
await kc.decryptMessage(text: current, key: key),
'new legacy message',
);
final temp = await Directory.systemTemp.createTemp('qlyra-legacy-photo-');
try {
final source = File('${temp.path}/source');
await source.writeAsBytes([1, 2, 3, 4]);
final cipher = File('${temp.path}/old.png');
await kc.encryptImageFile(
sourcePath: source.path,
destPath: cipher.path,
key: key,
);
expect(
KometCrypto.decryptImageBlob(
unwrapNoisePng(await cipher.readAsBytes())!,
key,
),
[1, 2, 3, 4],
);
final wrapped = wrapNoisePng(
KometCrypto.encryptImageBlob(await source.readAsBytes(), key),
)!;
await cipher.writeAsBytes(wrapped);
final restored = File('${temp.path}/restored');
await kc.decryptImageFile(
sourcePath: cipher.path,
destPath: restored.path,
key: key,
);
expect(await restored.readAsBytes(), [1, 2, 3, 4]);
} finally {
await temp.delete(recursive: true);
}
},
);
test('native ciphertext survives queued retry after restart', () async {
final key = await kc.deriveKey(password: 'synthetic test key');
const plaintext = 'synthetic private message';
final cipher = await kc.encryptMessage(plaintext: plaintext, key: key);
final original = OutgoingText.prepare(
accountId: 42,
chatId: 7,
wireText: cipher,
encrypted: true,
);
CachedMessage? stored;
var fail = true;
var clock = DateTime(2026);
final requests = <(String, int)>[];
OutboxQueue create() => OutboxQueue(
activeAccount: () async => 42,
isOnline: () => true,
sessionEpoch: () => 1,
now: () => clock,
load: (_) async => stored?.status == 'pending' ? [stored!] : [],
save: (message) async {
stored = CachedMessage.fromDbRow(message.toDbRow());
},
updatePending: (m) async {
stored = CachedMessage.fromDbRow(m.toDbRow());
return true;
},
replace: (_, sent) async {
stored = sent;
},
send: (message, cid) async {
requests.add((message.text!, cid));
if (fail) throw TimeoutException('Response lost');
return 'confirmed';
},
onChanged: (_, _) {},
onError: (_, _) {},
);
final first = create();
await first.enqueue(original);
await first.flush();
first.dispose();
expect(stored!.text, isNot(plaintext));
fail = false;
clock = clock.add(const Duration(minutes: 1));
final restarted = create();
addTearDown(restarted.dispose);
await restarted.flush();
expect(requests, hasLength(2));
expect(requests.first, requests.last);
expect(
await kc.decryptMessage(text: requests.last.$1, key: key),
plaintext,
);
expect(stored!.status, 'sent');
});
test('rejects a wrong key', () async {
final key = await kc.deriveKey(password: 'правильный');
final wrong = await kc.deriveKey(password: 'неправильный');
final encrypted = await kc.encryptMessage(plaintext: 'секрет', key: key);
expect(
() => kc.decryptMessage(text: encrypted, key: wrong),
throwsA(predicate((e) => e.toString().contains('wrong_key'))),
);
});
test('reports plain text as not encrypted', () async {
final key = await kc.deriveKey(password: 'ключ');
expect(await kc.looksEncrypted(text: 'привет как дела'), isFalse);
expect(
() => kc.decryptMessage(text: 'привет как дела', key: key),
throwsA(predicate((e) => e.toString().contains('not_encrypted'))),
);
});
group('images', _imageTests);
test('survives whitespace mangling', () async {
final key = await kc.deriveKey(password: 'ключ');
final encrypted = await kc.encryptMessage(
plaintext: 'пробелы декоративные',
key: key,
);
final mangled = ' ${encrypted.replaceAll(' ', ' ')}\n';
expect(
await kc.decryptMessage(text: mangled, key: key),
'пробелы декоративные',
);
});
}
const List<int> _tinyPng = [
137,
80,
78,
71,
13,
10,
26,
10,
0,
0,
0,
13,
73,
72,
68,
82,
0,
0,
0,
4,
0,
0,
0,
4,
8,
2,
0,
0,
0,
38,
147,
9,
41,
0,
0,
0,
63,
73,
68,
65,
84,
120,
156,
1,
52,
0,
203,
255,
0,
0,
40,
80,
120,
160,
200,
240,
24,
64,
104,
144,
184,
0,
17,
57,
97,
137,
177,
217,
1,
41,
81,
121,
161,
201,
0,
34,
74,
114,
154,
194,
234,
18,
58,
98,
138,
178,
218,
0,
51,
91,
131,
171,
211,
251,
35,
75,
115,
155,
195,
235,
36,
246,
23,
9,
123,
15,
58,
142,
0,
0,
0,
0,
73,
69,
78,
68,
174,
66,
96,
130,
];
void _imageTests() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('qlyra_img'));
tearDown(() => tmp.deleteSync(recursive: true));
File writePlain() =>
File('${tmp.path}/plain.png')..writeAsBytesSync(_tinyPng);
test('round-trips a photo through the native bridge', () async {
final key = await kc.deriveKey(password: 'фото-ключ');
final plain = writePlain();
final enc = '${tmp.path}/enc.png';
final out = '${tmp.path}/out.png';
await kc.encryptImageFile(sourcePath: plain.path, destPath: enc, key: key);
final encBytes = File(enc).readAsBytesSync();
expect(encBytes.sublist(1, 4), 'PNG'.codeUnits);
expect(encBytes, isNot(_tinyPng));
expect(await kc.looksEncryptedImageFile(path: enc), isTrue);
expect(await kc.looksEncryptedImageFile(path: plain.path), isFalse);
await kc.decryptImageFile(sourcePath: enc, destPath: out, key: key);
expect(File(out).readAsBytesSync(), _tinyPng);
});
test('rejects a photo decrypted with a wrong key', () async {
final key = await kc.deriveKey(password: 'правильный');
final wrong = await kc.deriveKey(password: 'неправильный');
final enc = '${tmp.path}/enc.png';
await kc.encryptImageFile(
sourcePath: writePlain().path,
destPath: enc,
key: key,
);
expect(
() => kc.decryptImageFile(
sourcePath: enc,
destPath: '${tmp.path}/out.png',
key: wrong,
),
throwsA(predicate((e) => e.toString().contains('wrong_key'))),
);
});
}