370 lines
9.0 KiB
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'))),
|
|
);
|
|
});
|
|
}
|