From ac951ea80a7aebf0b90b025e66cf8dae5926314b Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 26 Jul 2026 16:23:05 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=20=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE=D0=BC/?= =?UTF-8?q?=D1=84=D0=BE=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/file_uploader.dart | 11 +- lib/backend/modules/messages.dart | 11 +- lib/core/crypto/chat_crypto_service.dart | 197 ++++++++ lib/core/crypto/encrypted_photo.dart | 103 ++++ lib/core/crypto/message_decryption_cache.dart | 111 +++++ lib/core/storage/chat_encryption_store.dart | 38 ++ lib/core/utils/file_download.dart | 15 + .../screens/chats/chat/view/chat_header.dart | 10 + .../screens/chats/chat_encryption_screen.dart | 198 ++++++++ .../screens/chats/chat_list_screen.dart | 78 ++- lib/frontend/screens/chats/chat_screen.dart | 234 ++++++++- .../screens/profile/cloud_storage_screen.dart | 4 +- .../attachment/bubbles/file_bubble.dart | 90 +++- .../chat_info/shared_content_tabs.dart | 2 +- lib/frontend/widgets/decrypted_text.dart | 59 +++ .../widgets/encryption_lock_badge.dart | 31 ++ lib/frontend/widgets/message_bubble.dart | 95 +++- lib/frontend/widgets/photo_viewer.dart | 12 +- lib/l10n/app_en.arb | 8 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 5 + lib/l10n/app_localizations_ru.dart | 5 + lib/l10n/app_ru.arb | 1 + lib/main.dart | 2 + native/komet_crypto/.gitignore | 33 ++ native/komet_crypto/analysis_options.yaml | 4 + native/komet_crypto/android/.gitignore | 9 + native/komet_crypto/android/build.gradle | 56 +++ native/komet_crypto/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + native/komet_crypto/cargokit/.gitignore | 4 + native/komet_crypto/cargokit/LICENSE | 42 ++ native/komet_crypto/cargokit/README | 11 + native/komet_crypto/cargokit/build_pod.sh | 58 +++ .../cargokit/build_tool/README.md | 5 + .../cargokit/build_tool/analysis_options.yaml | 34 ++ .../cargokit/build_tool/bin/build_tool.dart | 8 + .../cargokit/build_tool/lib/build_tool.dart | 8 + .../lib/src/android_environment.dart | 195 ++++++++ .../lib/src/artifacts_provider.dart | 266 ++++++++++ .../build_tool/lib/src/build_cmake.dart | 40 ++ .../build_tool/lib/src/build_gradle.dart | 49 ++ .../build_tool/lib/src/build_pod.dart | 89 ++++ .../build_tool/lib/src/build_tool.dart | 276 ++++++++++ .../cargokit/build_tool/lib/src/builder.dart | 209 ++++++++ .../cargokit/build_tool/lib/src/cargo.dart | 48 ++ .../build_tool/lib/src/crate_hash.dart | 124 +++++ .../build_tool/lib/src/environment.dart | 68 +++ .../cargokit/build_tool/lib/src/logging.dart | 52 ++ .../cargokit/build_tool/lib/src/options.dart | 309 ++++++++++++ .../lib/src/precompile_binaries.dart | 205 ++++++++ .../cargokit/build_tool/lib/src/rustup.dart | 149 ++++++ .../cargokit/build_tool/lib/src/target.dart | 147 ++++++ .../cargokit/build_tool/lib/src/util.dart | 172 +++++++ .../build_tool/lib/src/verify_binaries.dart | 84 ++++ .../cargokit/build_tool/pubspec.lock | 453 +++++++++++++++++ .../cargokit/build_tool/pubspec.yaml | 33 ++ .../cargokit/cmake/cargokit.cmake | 99 ++++ .../cargokit/cmake/resolve_symlinks.ps1 | 34 ++ .../cargokit/gradle/plugin.gradle | 184 +++++++ .../komet_crypto/cargokit/run_build_tool.cmd | 91 ++++ .../komet_crypto/cargokit/run_build_tool.sh | 99 ++++ native/komet_crypto/flutter_rust_bridge.yaml | 3 + native/komet_crypto/ios/Classes/dummy_file.c | 1 + native/komet_crypto/ios/komet_crypto.podspec | 46 ++ native/komet_crypto/lib/komet_crypto.dart | 4 + .../komet_crypto/lib/src/rust/api/crypto.dart | 40 ++ .../lib/src/rust/frb_generated.dart | 427 ++++++++++++++++ .../lib/src/rust/frb_generated.io.dart | 96 ++++ .../lib/src/rust/frb_generated.web.dart | 96 ++++ native/komet_crypto/linux/CMakeLists.txt | 19 + .../komet_crypto/macos/Classes/dummy_file.c | 1 + .../komet_crypto/macos/komet_crypto.podspec | 45 ++ native/komet_crypto/pubspec.yaml | 32 ++ native/komet_crypto/rust/.gitignore | 2 + native/komet_crypto/rust/Cargo.toml | 30 ++ native/komet_crypto/rust/examples/demo.rs | 35 ++ native/komet_crypto/rust/src/alphabet.rs | 135 +++++ native/komet_crypto/rust/src/api/crypto.rs | 57 +++ native/komet_crypto/rust/src/api/mod.rs | 1 + native/komet_crypto/rust/src/cipher.rs | 245 +++++++++ native/komet_crypto/rust/src/error.rs | 32 ++ native/komet_crypto/rust/src/frb_generated.rs | 471 ++++++++++++++++++ native/komet_crypto/rust/src/image.rs | 218 ++++++++ native/komet_crypto/rust/src/lib.rs | 7 + native/komet_crypto/windows/.gitignore | 17 + native/komet_crypto/windows/CMakeLists.txt | 20 + pubspec.lock | 9 +- pubspec.yaml | 10 + test/chat_crypto_roundtrip_test.dart | 136 +++++ 90 files changed, 7248 insertions(+), 64 deletions(-) create mode 100644 lib/core/crypto/chat_crypto_service.dart create mode 100644 lib/core/crypto/encrypted_photo.dart create mode 100644 lib/core/crypto/message_decryption_cache.dart create mode 100644 lib/core/storage/chat_encryption_store.dart create mode 100644 lib/frontend/screens/chats/chat_encryption_screen.dart create mode 100644 lib/frontend/widgets/decrypted_text.dart create mode 100644 lib/frontend/widgets/encryption_lock_badge.dart create mode 100644 native/komet_crypto/.gitignore create mode 100644 native/komet_crypto/analysis_options.yaml create mode 100644 native/komet_crypto/android/.gitignore create mode 100644 native/komet_crypto/android/build.gradle create mode 100644 native/komet_crypto/android/settings.gradle create mode 100644 native/komet_crypto/android/src/main/AndroidManifest.xml create mode 100644 native/komet_crypto/cargokit/.gitignore create mode 100644 native/komet_crypto/cargokit/LICENSE create mode 100644 native/komet_crypto/cargokit/README create mode 100755 native/komet_crypto/cargokit/build_pod.sh create mode 100644 native/komet_crypto/cargokit/build_tool/README.md create mode 100644 native/komet_crypto/cargokit/build_tool/analysis_options.yaml create mode 100644 native/komet_crypto/cargokit/build_tool/bin/build_tool.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/build_tool.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/android_environment.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/artifacts_provider.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/build_cmake.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/build_gradle.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/build_pod.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/build_tool.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/builder.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/cargo.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/crate_hash.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/environment.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/logging.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/options.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/precompile_binaries.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/rustup.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/target.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/util.dart create mode 100644 native/komet_crypto/cargokit/build_tool/lib/src/verify_binaries.dart create mode 100644 native/komet_crypto/cargokit/build_tool/pubspec.lock create mode 100644 native/komet_crypto/cargokit/build_tool/pubspec.yaml create mode 100644 native/komet_crypto/cargokit/cmake/cargokit.cmake create mode 100644 native/komet_crypto/cargokit/cmake/resolve_symlinks.ps1 create mode 100644 native/komet_crypto/cargokit/gradle/plugin.gradle create mode 100755 native/komet_crypto/cargokit/run_build_tool.cmd create mode 100755 native/komet_crypto/cargokit/run_build_tool.sh create mode 100644 native/komet_crypto/flutter_rust_bridge.yaml create mode 100644 native/komet_crypto/ios/Classes/dummy_file.c create mode 100644 native/komet_crypto/ios/komet_crypto.podspec create mode 100644 native/komet_crypto/lib/komet_crypto.dart create mode 100644 native/komet_crypto/lib/src/rust/api/crypto.dart create mode 100644 native/komet_crypto/lib/src/rust/frb_generated.dart create mode 100644 native/komet_crypto/lib/src/rust/frb_generated.io.dart create mode 100644 native/komet_crypto/lib/src/rust/frb_generated.web.dart create mode 100644 native/komet_crypto/linux/CMakeLists.txt create mode 100644 native/komet_crypto/macos/Classes/dummy_file.c create mode 100644 native/komet_crypto/macos/komet_crypto.podspec create mode 100644 native/komet_crypto/pubspec.yaml create mode 100644 native/komet_crypto/rust/.gitignore create mode 100644 native/komet_crypto/rust/Cargo.toml create mode 100644 native/komet_crypto/rust/examples/demo.rs create mode 100644 native/komet_crypto/rust/src/alphabet.rs create mode 100644 native/komet_crypto/rust/src/api/crypto.rs create mode 100644 native/komet_crypto/rust/src/api/mod.rs create mode 100644 native/komet_crypto/rust/src/cipher.rs create mode 100644 native/komet_crypto/rust/src/error.rs create mode 100644 native/komet_crypto/rust/src/frb_generated.rs create mode 100644 native/komet_crypto/rust/src/image.rs create mode 100644 native/komet_crypto/rust/src/lib.rs create mode 100644 native/komet_crypto/windows/.gitignore create mode 100644 native/komet_crypto/windows/CMakeLists.txt create mode 100644 test/chat_crypto_roundtrip_test.dart diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 65733cf..8929d8a 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -26,12 +26,18 @@ class UploadDone extends UploadEvent { final String? url; final String filename; final int size; + + /// Server-assigned message id. Without it the optimistic message keeps its + /// local temp id and download URLs cannot be resolved until a restart. + final String? messageId; + const UploadDone({ required this.fileId, required this.filename, required this.size, this.token, this.url, + this.messageId, }); } @@ -139,14 +145,14 @@ class FileUploader { return; } - final ok = await messages.sendFileMessage( + final messageId = await messages.sendFileMessage( chatId, info.fileId, token: info.token, scheduledTime: scheduledTime, ); if (cancelled) return; - if (!ok) { + if (messageId == null) { ctrl.add(const UploadError('send_failed')); return; } @@ -158,6 +164,7 @@ class FileUploader { url: info.url, filename: filename, size: totalSize, + messageId: messageId, ), ); } catch (e) { diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e381a5d..881dc5a 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1314,7 +1314,10 @@ class MessagesModule { ); } - Future sendFileMessage( + /// Returns the server-assigned message id, or null on failure. The id is + /// required to later resolve a download URL — a message still carrying its + /// local temp id resolves to messageId 0 and the server rejects it. + Future sendFileMessage( int chatId, int fileId, { String? token, @@ -1343,12 +1346,12 @@ class MessagesModule { } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; - return _sendWithNotReadyRetry( + return _sendWithNotReadyRetry( payload: payload, maxAttempts: maxAttempts, retryDelay: retryDelay, - onResult: (response) => response.isOk, - onExhausted: false, + onResult: (response) => _sentMessageMap(response)?['id']?.toString(), + onExhausted: null, ); } diff --git a/lib/core/crypto/chat_crypto_service.dart b/lib/core/crypto/chat_crypto_service.dart new file mode 100644 index 0000000..5b745ac --- /dev/null +++ b/lib/core/crypto/chat_crypto_service.dart @@ -0,0 +1,197 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:komet_crypto/komet_crypto.dart' as kc; + +import '../storage/chat_encryption_store.dart'; +import '../utils/logger.dart'; + +const int kMaxEncryptedMessageLength = 1000; + +enum CryptoFailure { noKey, wrongKey, notEncrypted, malformed, unavailable } + +class CryptoResult { + final String? text; + final CryptoFailure? failure; + + const CryptoResult.ok(String this.text) : failure = null; + const CryptoResult.failed(CryptoFailure this.failure) : text = null; + + bool get isOk => text != null; +} + +class ChatCryptoService { + ChatCryptoService._() { + ChatEncryptionStore.instance.revision.addListener(clearKeys); + } + + static final ChatCryptoService instance = ChatCryptoService._(); + + final Map _keys = {}; + final Map> _pending = {}; + Future? _init; + bool _unavailable = false; + + String _cacheKey(int accountId, int chatId) => '$accountId/$chatId'; + + void clearKeys() { + _keys.clear(); + _pending.clear(); + } + + Future _ensureInitialized() async { + if (_unavailable) return false; + try { + await (_init ??= kc.RustLib.init()); + return true; + } catch (e) { + _init = null; + _unavailable = true; + logger.w('komet_crypto init failed: $e'); + return false; + } + } + + Future _keyFor(int accountId, int chatId) { + final cacheKey = _cacheKey(accountId, chatId); + final cached = _keys[cacheKey]; + if (cached != null) return Future.value(cached); + return _pending[cacheKey] ??= _deriveKey(accountId, chatId, cacheKey); + } + + Future _deriveKey( + int accountId, + int chatId, + String cacheKey, + ) async { + try { + if (!await _ensureInitialized()) return null; + final password = await ChatEncryptionStore.instance.readKey( + accountId, + chatId, + ); + if (password == null || password.isEmpty) return null; + final key = await kc.deriveKey(password: password); + _keys[cacheKey] = key; + return key; + } catch (e) { + logger.w('derive key for chat $chatId: $e'); + return null; + } finally { + _pending.remove(cacheKey); + } + } + + bool isEnabled(int accountId, int chatId) => + ChatEncryptionStore.instance.isEnabled(accountId, chatId); + + Future warmKey(int accountId, int chatId) => _keyFor(accountId, chatId); + + Future encrypt( + int accountId, + int chatId, + String plaintext, + ) async { + final key = await _keyFor(accountId, chatId); + if (key == null) { + return CryptoResult.failed( + _unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey, + ); + } + try { + return CryptoResult.ok( + await kc.encryptMessage(plaintext: plaintext, key: key), + ); + } catch (e) { + logger.w('encrypt for chat $chatId: $e'); + return const CryptoResult.failed(CryptoFailure.unavailable); + } + } + + Future decrypt(int accountId, int chatId, String text) async { + final key = await _keyFor(accountId, chatId); + if (key == null) { + return CryptoResult.failed( + _unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey, + ); + } + try { + return CryptoResult.ok(await kc.decryptMessage(text: text, key: key)); + } catch (e) { + return CryptoResult.failed(_failureFromCode(e.toString())); + } + } + + Future encryptImageFile( + int accountId, + int chatId, + String sourcePath, + String destPath, + ) => _imageOp( + accountId, + chatId, + () => kc.encryptImageFile( + sourcePath: sourcePath, + destPath: destPath, + key: _keys[_cacheKey(accountId, chatId)]!, + ), + ); + + Future decryptImageFile( + int accountId, + int chatId, + String sourcePath, + String destPath, + ) => _imageOp( + accountId, + chatId, + () => kc.decryptImageFile( + sourcePath: sourcePath, + destPath: destPath, + key: _keys[_cacheKey(accountId, chatId)]!, + ), + ); + + Future _imageOp( + int accountId, + int chatId, + Future Function() run, + ) async { + final key = await _keyFor(accountId, chatId); + if (key == null) { + return _unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey; + } + try { + await run(); + return null; + } catch (e) { + logger.w('image crypto for chat $chatId: $e'); + return _failureFromCode(e.toString()); + } + } + + Future looksEncryptedImage(String path) async { + if (!await _ensureInitialized()) return false; + try { + return await kc.looksEncryptedImageFile(path: path); + } catch (_) { + return false; + } + } + + Future looksEncrypted(String text) async { + if (!await _ensureInitialized()) return false; + try { + return await kc.looksEncrypted(text: text); + } catch (_) { + return false; + } + } + + CryptoFailure _failureFromCode(String message) { + if (message.contains('wrong_key')) return CryptoFailure.wrongKey; + if (message.contains('not_encrypted')) return CryptoFailure.notEncrypted; + if (message.contains('malformed')) return CryptoFailure.malformed; + return CryptoFailure.unavailable; + } +} diff --git a/lib/core/crypto/encrypted_photo.dart b/lib/core/crypto/encrypted_photo.dart new file mode 100644 index 0000000..655d25a --- /dev/null +++ b/lib/core/crypto/encrypted_photo.dart @@ -0,0 +1,103 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:path_provider/path_provider.dart'; + +import '../utils/logger.dart'; +import '../utils/media_cache.dart'; +import 'chat_crypto_service.dart'; + +const String kEncryptedPhotoExtension = '.png'; + +class EncryptedPhotoResult { + final File? file; + final CryptoFailure? failure; + + const EncryptedPhotoResult.ok(File this.file) : failure = null; + const EncryptedPhotoResult.failed(CryptoFailure this.failure) : file = null; + + bool get isOk => file != null; +} + +/// Re-encodes an arbitrary image into lossless PNG. Encryption needs a format +/// that survives byte-for-byte; a re-encoded JPEG would not. +Future reencodeAsPng(File source, String destPath) async { + try { + final bytes = await source.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final data = await frame.image.toByteData(format: ui.ImageByteFormat.png); + frame.image.dispose(); + codec.dispose(); + if (data == null) return null; + final dest = File(destPath); + await dest.writeAsBytes(data.buffer.asUint8List(), flush: true); + return dest; + } catch (e) { + logger.w('png re-encode failed: $e'); + return null; + } +} + +Future _scratchDir() async { + final dir = Directory('${(await getTemporaryDirectory()).path}/komet_enc'); + if (!await dir.exists()) await dir.create(recursive: true); + return dir; +} + +/// Picked image → PNG → encrypted noise PNG, ready to upload as a file. +Future prepareEncryptedPhoto({ + required int accountId, + required int chatId, + required File source, + required String stamp, +}) async { + final dir = await _scratchDir(); + final pngPath = '${dir.path}/plain_$stamp.png'; + final encPath = '${dir.path}/enc_$stamp.png'; + + final png = await reencodeAsPng(source, pngPath); + if (png == null) { + return const EncryptedPhotoResult.failed(CryptoFailure.malformed); + } + + final failure = await ChatCryptoService.instance.encryptImageFile( + accountId, + chatId, + png.path, + encPath, + ); + await _quietDelete(png); + if (failure != null) return EncryptedPhotoResult.failed(failure); + return EncryptedPhotoResult.ok(File(encPath)); +} + +/// Downloaded noise PNG → original photo, cached for the viewer. +Future openEncryptedPhoto({ + required int accountId, + required int chatId, + required File encrypted, + required String cacheName, +}) async { + final target = await MediaCache.fileFor('decrypted_$cacheName'); + if (await target.exists() && await target.length() > 0) { + return EncryptedPhotoResult.ok(target); + } + final failure = await ChatCryptoService.instance.decryptImageFile( + accountId, + chatId, + encrypted.path, + target.path, + ); + if (failure != null) { + await _quietDelete(target); + return EncryptedPhotoResult.failed(failure); + } + return EncryptedPhotoResult.ok(target); +} + +Future _quietDelete(File file) async { + try { + if (await file.exists()) await file.delete(); + } catch (_) {} +} diff --git a/lib/core/crypto/message_decryption_cache.dart b/lib/core/crypto/message_decryption_cache.dart new file mode 100644 index 0000000..40ef89b --- /dev/null +++ b/lib/core/crypto/message_decryption_cache.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +import '../storage/chat_encryption_store.dart'; +import 'chat_crypto_service.dart'; + +enum MessageDecryptionState { decrypted, wrongKey } + +@immutable +class MessageDecryption { + final String? plaintext; + final MessageDecryptionState state; + + const MessageDecryption.decrypted(String this.plaintext) + : state = MessageDecryptionState.decrypted; + + const MessageDecryption.wrongKey() + : plaintext = null, + state = MessageDecryptionState.wrongKey; + + bool get isDecrypted => state == MessageDecryptionState.decrypted; +} + +class MessageDecryptionCache { + MessageDecryptionCache._() { + ChatEncryptionStore.instance.revision.addListener(clear); + } + + static final MessageDecryptionCache instance = MessageDecryptionCache._(); + + static const int _maxEntries = 1000; + + final LinkedHashMap> _entries = + LinkedHashMap(); + final Set _inFlight = {}; + + ValueListenable listenableFor(String messageId) => + _entryFor(messageId); + + ValueNotifier _entryFor(String messageId) => + _entries[messageId] ??= ValueNotifier(null); + + void _evictStale(String keep) { + while (_entries.length > _maxEntries) { + final oldest = _entries.keys.first; + if (oldest == keep) break; + _entries.remove(oldest); + } + } + + void seed(String messageId, String plaintext) { + _entryFor(messageId).value = MessageDecryption.decrypted(plaintext); + } + + void adopt(String fromMessageId, String toMessageId) { + final value = _entries[fromMessageId]?.value; + if (value != null) _entryFor(toMessageId).value = value; + } + + void request({ + required int accountId, + required int chatId, + required String messageId, + required String cipherText, + }) { + if (cipherText.isEmpty) return; + if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return; + if (_entryFor(messageId).value != null) return; + if (!_inFlight.add(messageId)) return; + _evictStale(messageId); + unawaited(_resolve(accountId, chatId, messageId, cipherText)); + } + + Future _resolve( + int accountId, + int chatId, + String messageId, + String cipherText, + ) async { + try { + final crypto = ChatCryptoService.instance; + final result = await crypto.decrypt(accountId, chatId, cipherText); + if (result.isOk) { + _entryFor(messageId).value = MessageDecryption.decrypted(result.text!); + return; + } + switch (result.failure) { + case CryptoFailure.wrongKey: + _entryFor(messageId).value = const MessageDecryption.wrongKey(); + case CryptoFailure.noKey: + if (await crypto.looksEncrypted(cipherText)) { + _entryFor(messageId).value = const MessageDecryption.wrongKey(); + } + case CryptoFailure.notEncrypted: + case CryptoFailure.malformed: + case CryptoFailure.unavailable: + case null: + break; + } + } finally { + _inFlight.remove(messageId); + } + } + + void clear() { + _entries.clear(); + _inFlight.clear(); + } +} diff --git a/lib/core/storage/chat_encryption_store.dart b/lib/core/storage/chat_encryption_store.dart new file mode 100644 index 0000000..3d751de --- /dev/null +++ b/lib/core/storage/chat_encryption_store.dart @@ -0,0 +1,38 @@ +import 'per_chat_json_store.dart'; +import 'token_storage.dart'; + +class ChatEncryptionStore extends PerChatJsonStore { + ChatEncryptionStore._() + : super( + prefsKey: 'chat_encryption', + fromJson: (raw) => raw == true ? true : null, + toJson: (value) => value, + ); + + static final ChatEncryptionStore instance = ChatEncryptionStore._(); + + static const String _keyPrefix = 'chat_encryption_key'; + + bool isEnabled(int accountId, int chatId) => read(accountId, chatId) == true; + + Future setEnabled(int accountId, int chatId, bool enabled) => + write(accountId, chatId, enabled ? true : null); + + Future readKey(int accountId, int chatId) async { + if (accountId == 0) return null; + return TokenStorage.readSecure(_secureKey(accountId, chatId)); + } + + Future writeKey(int accountId, int chatId, String key) async { + if (accountId == 0) return; + await TokenStorage.writeSecure(_secureKey(accountId, chatId), key); + } + + Future deleteKey(int accountId, int chatId) async { + if (accountId == 0) return; + await TokenStorage.deleteSecure(_secureKey(accountId, chatId)); + } + + String _secureKey(int accountId, int chatId) => + '${_keyPrefix}_${accountId}_$chatId'; +} diff --git a/lib/core/utils/file_download.dart b/lib/core/utils/file_download.dart index a9fe696..8edb88d 100644 --- a/lib/core/utils/file_download.dart +++ b/lib/core/utils/file_download.dart @@ -15,17 +15,29 @@ class FileDownloadResult { /// [cacheName] — стабильное имя в кэше (например, `_имя.ext`). /// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше, /// чтобы не дёргать сервер за временной ссылкой повторно. +/// [onReady] вызывается как только файл лежит на диске — до открытия во +/// внешнем приложении, которое может не возвращать управление, пока его не +/// закроют. Без этого индикатор загрузки висел бы всё это время. Future openCachedFile( String cacheName, Future Function() resolveUrl, { void Function(double progress)? onProgress, + void Function()? onReady, }) async { + var readyFired = false; + void ready() { + if (readyFired) return; + readyFired = true; + onReady?.call(); + } + try { var file = await MediaCache.existing(cacheName); if (file == null) { final url = await resolveUrl(); if (url == null || url.isEmpty) { + ready(); return const FileDownloadResult(ok: false, error: 'нет ссылки'); } file = await MediaCache.getOrDownload( @@ -34,10 +46,12 @@ Future openCachedFile( onProgress: onProgress, ); if (file == null) { + ready(); return const FileDownloadResult(ok: false, error: 'ошибка загрузки'); } } + ready(); final opened = await OpenFilex.open(file.path); return FileDownloadResult( ok: opened.type == ResultType.done, @@ -45,6 +59,7 @@ Future openCachedFile( error: opened.type == ResultType.done ? null : opened.message, ); } catch (e) { + ready(); return FileDownloadResult(ok: false, error: e.toString()); } } diff --git a/lib/frontend/screens/chats/chat/view/chat_header.dart b/lib/frontend/screens/chats/chat/view/chat_header.dart index 7e23e24..9703dc5 100644 --- a/lib/frontend/screens/chats/chat/view/chat_header.dart +++ b/lib/frontend/screens/chats/chat/view/chat_header.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/app_frost.dart'; +import 'package:komet/frontend/widgets/encryption_lock_badge.dart'; import 'package:komet/frontend/widgets/glossy_pill.dart'; import 'package:komet/frontend/widgets/online_dot.dart'; @@ -18,6 +19,7 @@ class ChatHeaderRow extends StatelessWidget { final String imageUrl; final String chatType; final bool isOfficial; + final bool encrypted; final int myId; final ValueListenable headerStatus; final ValueListenable scheduledCount; @@ -42,6 +44,7 @@ class ChatHeaderRow extends StatelessWidget { required this.imageUrl, required this.chatType, required this.isOfficial, + this.encrypted = false, required this.myId, required this.headerStatus, required this.scheduledCount, @@ -373,6 +376,7 @@ class ChatHeaderRow extends StatelessWidget { final otherId = chatId ^ myId; final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0; return Stack( + clipBehavior: Clip.none, children: [ avatar, if (showDot) @@ -385,6 +389,12 @@ class ChatHeaderRow extends StatelessWidget { size: dotSize, ), ), + if (encrypted) + Positioned( + left: -2, + bottom: -2, + child: EncryptionLockBadge(size: dotSize + 4), + ), ], ); } diff --git a/lib/frontend/screens/chats/chat_encryption_screen.dart b/lib/frontend/screens/chats/chat_encryption_screen.dart new file mode 100644 index 0000000..76e4c97 --- /dev/null +++ b/lib/frontend/screens/chats/chat_encryption_screen.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/storage/chat_encryption_store.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/glossy_pill.dart'; +import '../../widgets/primary_loading_button.dart'; +import '../../widgets/settings_card.dart'; +import '../../widgets/small_spinner.dart'; + +class ChatEncryptionScreen extends StatefulWidget { + final int accountId; + final int chatId; + + const ChatEncryptionScreen({ + super.key, + required this.accountId, + required this.chatId, + }); + + @override + State createState() => _ChatEncryptionScreenState(); +} + +class _ChatEncryptionScreenState extends State { + final _keyController = TextEditingController(); + final ValueNotifier _saving = ValueNotifier(false); + + bool _loading = true; + bool _enabled = false; + bool _keyVisible = false; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _keyController.dispose(); + _saving.dispose(); + super.dispose(); + } + + Future _load() async { + final store = ChatEncryptionStore.instance; + await store.load(); + final key = await store.readKey(widget.accountId, widget.chatId); + if (!mounted) return; + setState(() { + _enabled = store.isEnabled(widget.accountId, widget.chatId); + _keyController.text = key ?? ''; + _loading = false; + }); + } + + Future _save() async { + if (widget.accountId == 0) { + showCustomNotification(context, 'Профиль ещё не загружен'); + return; + } + final key = _keyController.text.trim(); + if (_enabled && key.isEmpty) { + showCustomNotification(context, 'Введите ключ шифрования'); + return; + } + _saving.value = true; + final store = ChatEncryptionStore.instance; + if (key.isEmpty) { + await store.deleteKey(widget.accountId, widget.chatId); + } else { + await store.writeKey(widget.accountId, widget.chatId, key); + } + await store.setEnabled(widget.accountId, widget.chatId, _enabled); + if (!mounted) return; + _saving.value = false; + showCustomNotification( + context, + _enabled ? 'Шифрование включено' : 'Шифрование отключено', + ); + Navigator.pop(context, true); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface, weight: 400), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Шифрование сообщений', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + body: _loading + ? Center(child: SmallSpinner(size: 36, color: cs.primary)) + : SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsCard( + children: [ + SettingsToggleTile( + icon: _enabled ? Symbols.lock : Symbols.lock_open, + label: 'Шифровать сообщения', + subtitle: + 'Текст сообщений в этом чате будет зашифрован ' + 'ключом ниже', + value: _enabled, + onChanged: (v) => setState(() => _enabled = v), + ), + ], + ), + const SizedBox(height: 16), + GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + padding: const EdgeInsets.all(20), + depth: 6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ключ', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _keyController, + obscureText: !_keyVisible, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: 'Введите ключ', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + suffixIcon: IconButton( + icon: Icon( + _keyVisible + ? Symbols.visibility_off + : Symbols.visibility, + color: cs.onSurfaceVariant, + ), + onPressed: () => + setState(() => _keyVisible = !_keyVisible), + ), + ), + ), + const SizedBox(height: 12), + Text( + 'Ключ хранится только на этом устройстве. ' + 'Собеседник должен ввести такой же ключ, иначе он ' + 'не прочитает сообщения.', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.35, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: PrimaryLoadingButton( + loading: _saving, + onPressed: _save, + child: const Text('Сохранить'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1645c8c..c92a349 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -12,6 +12,9 @@ import 'create_channel_flow.dart'; import 'create_group_flow.dart'; import '../contacts/add_contact_sheet.dart'; import '../../widgets/adaptive_shell.dart'; +import '../../../core/crypto/message_decryption_cache.dart'; +import '../../widgets/decrypted_text.dart'; +import '../../widgets/encryption_lock_badge.dart'; import '../../widgets/online_dot.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; @@ -52,6 +55,7 @@ import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/draft_store.dart'; import '../../../core/storage/archived_chats_store.dart'; +import '../../../core/storage/chat_encryption_store.dart'; import '../../../core/storage/token_storage.dart'; import '../../../core/storage/chat_activity_store.dart'; import '../../../main.dart' @@ -579,6 +583,7 @@ class _ChatListScreenState extends State }); chats.chatsChanged.addListener(_onChatsChanged); ArchivedChatsStore.instance.revision.addListener(_onArchivedChanged); + ChatEncryptionStore.instance.revision.addListener(_onEncryptionChanged); DraftStore.instance.revision.addListener(_onDraftsChanged); AppStories.current.addListener(_onStoriesEnabledChanged); storiesModule.storiesChanged.addListener(_onStoriesDataChanged); @@ -624,6 +629,10 @@ class _ChatListScreenState extends State if (mounted) _requestReload(); } + void _onEncryptionChanged() { + if (mounted) setState(() {}); + } + void _onStoriesEnabledChanged() { if (!mounted) return; if (!AppStories.current.value) { @@ -1225,6 +1234,7 @@ class _ChatListScreenState extends State _settleTimer?.cancel(); chats.chatsChanged.removeListener(_onChatsChanged); ArchivedChatsStore.instance.revision.removeListener(_onArchivedChanged); + ChatEncryptionStore.instance.revision.removeListener(_onEncryptionChanged); DraftStore.instance.revision.removeListener(_onDraftsChanged); AppStories.current.removeListener(_onStoriesEnabledChanged); storiesModule.storiesChanged.removeListener(_onStoriesDataChanged); @@ -1645,6 +1655,10 @@ class _ChatListScreenState extends State messageRanges: isPlaceholder ? const [] : chat.lastMsgFormatRanges, + previewMessageId: isPlaceholder ? null : chat.lastMsgId, + previewCipherText: isPlaceholder + ? null + : chat.lastMsgTextOneLine, ), ); } else { @@ -1654,6 +1668,7 @@ class _ChatListScreenState extends State : null; String fullMsg = ""; + String senderPrefix = ""; List messageRanges = const []; if (isPlaceholder) { fullMsg = 'зайдите в чат для подгрузки'; @@ -1661,6 +1676,7 @@ class _ChatListScreenState extends State var prefixLen = 0; if (sender?.isNotEmpty == true && chat.id != 0) { final prefix = "$sender: "; + senderPrefix = prefix; fullMsg += prefix; prefixLen = prefix.length; } @@ -1702,6 +1718,11 @@ class _ChatListScreenState extends State ownStatus: _ownStatusFor(chat, isPlaceholder), ownRead: chat.lastMsgReadByOthers, messageRanges: messageRanges, + previewMessageId: isPlaceholder ? null : chat.lastMsgId, + previewPrefix: senderPrefix, + previewCipherText: isPlaceholder + ? null + : chat.lastMsgText, ), ); } @@ -2550,19 +2571,55 @@ class _ChatListScreenState extends State String? ownStatus, bool ownRead = false, List messageRanges = const [], + int? previewMessageId, + String previewPrefix = '', + String? previewCipherText, }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); + final isEncrypted = ChatEncryptionStore.instance.isEnabled( + _profile?.id ?? 0, + int.tryParse(id) ?? 0, + ); final Widget? statusIcon = (ownStatus != null && draft == null) ? _ownStatusIcon(cs, ownStatus, ownRead) : null; - final Widget messageLine = _buildPreviewLine( - cs, - message, - messageRanges, - draft, - messageItalic, - ); + final canDecryptPreview = + isEncrypted && + draft == null && + previewMessageId != null && + (previewCipherText?.isNotEmpty ?? false); + final Widget messageLine = canDecryptPreview + ? DecryptedContent( + accountId: _profile?.id ?? 0, + chatId: int.tryParse(id) ?? 0, + messageId: previewMessageId.toString(), + cipherText: previewCipherText!, + builder: (decryption) => switch (decryption?.state) { + null => _buildPreviewLine( + cs, + message, + messageRanges, + draft, + messageItalic, + ), + MessageDecryptionState.wrongKey => _buildPreviewLine( + cs, + '$previewPrefix' 'неверный ключ', + const [], + draft, + true, + ), + MessageDecryptionState.decrypted => _buildPreviewLine( + cs, + '$previewPrefix${decryption!.plaintext}', + const [], + draft, + messageItalic, + ), + }, + ) + : _buildPreviewLine(cs, message, messageRanges, draft, messageItalic); final Widget avatarCircle = CircleAvatar( radius: 24, @@ -2645,8 +2702,15 @@ class _ChatListScreenState extends State crossAxisAlignment: CrossAxisAlignment.center, children: [ Stack( + clipBehavior: Clip.none, children: [ avatarCircle, + if (isEncrypted) + const Positioned( + left: -2, + bottom: -2, + child: EncryptionLockBadge(size: 18), + ), if (isSelected) Positioned( right: -2, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index f18fd2d..c70ff5a 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -40,6 +40,10 @@ import '../../../core/protocol/packet.dart'; import '../../../core/push/push_service.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/chat_activity_store.dart'; +import '../../../core/crypto/chat_crypto_service.dart'; +import '../../../core/crypto/encrypted_photo.dart'; +import '../../../core/crypto/message_decryption_cache.dart'; +import '../../../core/storage/chat_encryption_store.dart'; import '../../../core/storage/chat_wallpaper_store.dart'; import '../../../core/storage/draft_store.dart'; import '../../../core/storage/archived_chats_store.dart'; @@ -103,6 +107,7 @@ import '../../widgets/chat_wallpaper_view.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/liquid_glass.dart'; import 'scheduled_messages_screen.dart'; +import 'chat_encryption_screen.dart'; import 'chat_wallpaper_preview_screen.dart'; class _DateSeparatorItem { @@ -263,6 +268,7 @@ class _ChatScreenState extends State final GlobalKey _listKey = GlobalKey(); final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; + bool _encryptionEnabled = false; final ValueNotifier _showAttachmentPanel = ValueNotifier(false); late final StickerPanelController _stickers; final ValueNotifier _uploadStatus = ValueNotifier( @@ -750,6 +756,7 @@ class _ChatScreenState extends State _restoreDraft(); unawaited(_loadPeerKind()); unawaited(_loadWallpaper()); + unawaited(_loadEncryption()); unawaited(_refreshBadge()); try { @@ -1870,6 +1877,9 @@ class _ChatScreenState extends State _applyEffectiveWallpaper, ); } + if (_encryptionListening) { + ChatEncryptionStore.instance.revision.removeListener(_applyEncryption); + } _headerStatusNotifier.dispose(); _otherReadTime.dispose(); _chatController.dispose(); @@ -2960,6 +2970,7 @@ class _ChatScreenState extends State imageUrl: widget.imageUrl, chatType: widget.chatType, isOfficial: chat?.isOfficial ?? false, + encrypted: _encryptionEnabled, myId: _myId, headerStatus: _headerStatusNotifier, scheduledCount: _scheduledCount, @@ -3049,6 +3060,11 @@ class _ChatScreenState extends State label: 'Очистить историю', onTap: _clearHistory, ), + ChatMenuItem( + icon: _encryptionEnabled ? Symbols.lock : Symbols.lock_open, + label: 'Шифрование сообщений', + onTap: _openEncryptionSettings, + ), ChatMenuItem( icon: Symbols.delete, label: 'Удалить чат', @@ -3113,6 +3129,43 @@ class _ChatScreenState extends State ); } + bool _encryptionListening = false; + + Future _loadEncryption() async { + await ChatEncryptionStore.instance.load(); + if (!mounted) return; + if (!_encryptionListening) { + _encryptionListening = true; + ChatEncryptionStore.instance.revision.addListener(_applyEncryption); + } + _applyEncryption(); + } + + void _applyEncryption() { + if (!mounted) return; + final enabled = ChatEncryptionStore.instance.isEnabled( + _myId, + widget.chatId, + ); + if (enabled != _encryptionEnabled) { + setState(() => _encryptionEnabled = enabled); + } + if (enabled && _myId != 0) { + unawaited(ChatCryptoService.instance.warmKey(_myId, widget.chatId)); + } + } + + Future _openEncryptionSettings() async { + if (_myId == 0) return; + await pushSwipeable( + context, + (context) => + ChatEncryptionScreen(accountId: _myId, chatId: widget.chatId), + ); + if (!mounted) return; + _applyEncryption(); + } + bool _wallpaperListening = false; Future _loadWallpaper() async { @@ -3482,6 +3535,36 @@ class _ChatScreenState extends State return result; } + Future _encryptOutgoing(String text) async { + if (!_encryptionEnabled || _myId == 0) return text; + final result = await ChatCryptoService.instance.encrypt( + _myId, + widget.chatId, + text, + ); + if (result.isOk) { + if (result.text!.length > kMaxEncryptedMessageLength) { + if (mounted) { + showCustomNotification( + context, + 'Слишком длинное сообщение. Разделите на несколько', + ); + } + return null; + } + return result.text; + } + if (mounted) { + showCustomNotification( + context, + result.failure == CryptoFailure.noKey + ? 'Не задан ключ шифрования' + : 'Не удалось зашифровать сообщение', + ); + } + return null; + } + Future _sendMessage() async { final content = _messageController.buildContent(); final rawText = content.text; @@ -3505,6 +3588,10 @@ class _ChatScreenState extends State } } + final wireText = await _encryptOutgoing(text); + if (wireText == null || !mounted) return; + final encrypted = wireText != text; + final tempId = _nextTempId(); final now = DateTime.now().millisecondsSinceEpoch; final online = api.state == SessionState.online; @@ -3531,7 +3618,9 @@ class _ChatScreenState extends State _replyTo.value = null; _replySourceChatId = null; - final elements = _trimmedElements(content.elements, rawText, text); + final elements = encrypted + ? const >[] + : _trimmedElements(content.elements, rawText, text); final Map? composedPayload = (replyPayload == null && elements.isEmpty) ? null @@ -3542,11 +3631,12 @@ class _ChatScreenState extends State accountId: _myId, chatId: widget.chatId, senderId: _myId, - text: text, + text: wireText, time: now, status: online ? 'sending' : 'pending', payload: composedPayload, ); + if (encrypted) MessageDecryptionCache.instance.seed(tempId, text); _hasText.value = false; _lastSentId = tempId; @@ -3565,7 +3655,7 @@ class _ChatScreenState extends State widget.chatId, messageId: tempId, time: now, - text: text, + text: wireText, status: composed.status ?? 'sending', elements: elements, ), @@ -3587,14 +3677,14 @@ class _ChatScreenState extends State _myId, widget.chatId, widget.commentPostId!, - text, + wireText, replyToMessageId: replyId, elements: elements, ) : await messagesModule.sendMessage( _myId, widget.chatId, - text, + wireText, replyToMessageId: replyId, replySourceChatId: replySourceChatId, elements: elements, @@ -3607,11 +3697,14 @@ class _ChatScreenState extends State accountId: _myId, chatId: widget.chatId, senderId: _myId, - text: text, + text: wireText, time: now, status: 'sent', payload: composedPayload, ); + if (encrypted) { + MessageDecryptionCache.instance.adopt(tempId, sent.id); + } _messages[index] = sent; _bumpMessages(); if (!_commentsMode) { @@ -3622,7 +3715,7 @@ class _ChatScreenState extends State widget.chatId, messageId: sent.id, time: now, - text: text, + text: wireText, status: 'sent', elements: elements, ), @@ -5591,13 +5684,14 @@ class _ChatScreenState extends State String tempId, String status, { FileAttachment? attachment, + String? realId, }) { if (!mounted) return; final idx = _messages.indexWhere((m) => m.id == tempId); if (idx == -1) return; final old = _messages[idx]; _messages[idx] = CachedMessage( - id: tempId, + id: realId != null && realId.isNotEmpty ? realId : tempId, accountId: old.accountId, chatId: old.chatId, senderId: old.senderId, @@ -5621,12 +5715,16 @@ class _ChatScreenState extends State ); _showAttachmentPanel.value = false; try { - final ok = await messagesModule.sendFileMessage( + final realId = await messagesModule.sendFileMessage( widget.chatId, entry.fileId, token: entry.token, ); - _updateFileMessageStatus(tempId, ok ? 'sent' : 'error'); + _updateFileMessageStatus( + tempId, + realId != null ? 'sent' : 'error', + realId: realId, + ); } catch (_) { _updateFileMessageStatus(tempId, 'error'); } @@ -5635,13 +5733,14 @@ class _ChatScreenState extends State Future _sendFileById(int fileId) async { final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId)); try { - final ok = await messagesModule.sendFileMessage(widget.chatId, fileId); + final realId = await messagesModule.sendFileMessage(widget.chatId, fileId); + final ok = realId != null; if (!mounted) return ok; if (ok) { FileHistoryCache.add( FileHistoryEntry(fileId: fileId, sentAt: DateTime.now()), ); - _updateFileMessageStatus(tempId, 'sent'); + _updateFileMessageStatus(tempId, 'sent', realId: realId); _showAttachmentPanel.value = false; } else { _updateFileMessageStatus(tempId, 'error'); @@ -5675,11 +5774,17 @@ class _ChatScreenState extends State ? _sendPhotos : (picked, caption) => _sendScheduledPhotos(picked, caption, scheduledTime), - onPickFile: scheduledTime == null - ? _pickAndUploadFile - : () => _pickAndUploadFile(scheduledTime: scheduledTime), - onShareLocation: _shareLocation, - onCreatePoll: _createPoll, + onPickFile: _encryptionEnabled + ? () => _refuseUnencrypted('Файлы') + : (scheduledTime == null + ? _pickAndUploadFile + : () => _pickAndUploadFile(scheduledTime: scheduledTime)), + onShareLocation: _encryptionEnabled + ? () => _refuseUnencrypted('Геолокацию') + : _shareLocation, + onCreatePoll: _encryptionEnabled + ? () => _refuseUnencrypted('Опросы') + : _createPoll, ); if (!mounted || !hadKeyboard) return; _messageFocusNode.requestFocus(); @@ -5689,6 +5794,7 @@ class _ChatScreenState extends State Future _sendPhotos(List picked, String caption) async { if (_myId == 0) return; + if (_encryptionEnabled) return _sendEncryptedPhotos(picked, caption); final videos = picked.where((ph) => ph.item.isVideo).toList(); final photos = picked.where((ph) => !ph.item.isVideo).toList(); if (photos.isEmpty && videos.isEmpty) return; @@ -6208,11 +6314,88 @@ class _ChatScreenState extends State _photoUploadProgress.remove(tempId)?.dispose(); } + void _refuseUnencrypted(String what) { + if (!mounted) return; + _showAttachmentPanel.value = false; + showCustomNotification(context, '$what пока нельзя зашифровать'); + } + + Future _sendEncryptedPhotos( + List picked, + String caption, + ) async { + final photos = picked.where((ph) => !ph.item.isVideo).toList(); + if (photos.length != picked.length && mounted) { + showCustomNotification(context, 'Видео пока нельзя зашифровать'); + } + if (photos.isEmpty) return; + + for (final photo in photos) { + final source = + photo.editedFile ?? + photo.item.localFile ?? + await photo.item.originFile(); + if (source == null || !mounted) continue; + + _showAttachmentPanel.value = false; + _uploadStatus.value = const UploadStatus(active: true); + final stamp = DateTime.now().microsecondsSinceEpoch.toString(); + final prepared = await prepareEncryptedPhoto( + accountId: _myId, + chatId: widget.chatId, + source: source, + stamp: stamp, + ); + if (!mounted) return; + if (!prepared.isOk) { + _uploadStatus.value = const UploadStatus(); + showCustomNotification( + context, + prepared.failure == CryptoFailure.noKey + ? 'Не задан ключ шифрования' + : 'Не удалось зашифровать фото', + ); + return; + } + + final encrypted = prepared.file!; + await _uploadAsFile( + source: encrypted, + filename: 'photo_$stamp$kEncryptedPhotoExtension', + size: await encrypted.length(), + ); + if (!mounted) return; + } + + if (caption.isNotEmpty) { + final wire = await _encryptOutgoing(caption); + if (wire != null && mounted) { + await messagesModule.sendMessage(_myId, widget.chatId, wire); + } + } + } + Future _pickAndUploadFile({int? scheduledTime}) async { final result = await FilePicker.platform.pickFiles(); if (result == null || result.files.isEmpty) return; - final file = result.files.first; - if (file.path == null) return; + final picked = result.files.first; + if (picked.path == null) return; + await _uploadAsFile( + source: File(picked.path!), + filename: picked.name, + size: picked.size, + scheduledTime: scheduledTime, + ); + } + + Future _uploadAsFile({ + required File source, + required String filename, + required int size, + int? scheduledTime, + }) async { + final file = (name: filename, size: size); + final done = Completer(); _showAttachmentPanel.value = false; _uploadStatus.value = UploadStatus(active: true, total: file.size); @@ -6237,7 +6420,7 @@ class _ChatScreenState extends State _uploadSub = fileUploader .upload( chatId: widget.chatId, - file: File(file.path!), + file: source, filename: file.name, totalSize: file.size, scheduledTime: scheduledTime, @@ -6269,7 +6452,12 @@ class _ChatScreenState extends State speedBps: notifSpeedBps, ); } - case UploadDone(:final fileId, :final token, :final url): + case UploadDone( + :final fileId, + :final token, + :final url, + :final messageId, + ): stopNotif(); FileHistoryCache.add( FileHistoryEntry( @@ -6292,6 +6480,7 @@ class _ChatScreenState extends State _updateFileMessageStatus( tempId!, 'sent', + realId: messageId, attachment: FileAttachment( fileId: fileId, fileToken: token, @@ -6326,6 +6515,7 @@ class _ChatScreenState extends State } _uploadStatus.value = const UploadStatus(); _uploadSub = null; + if (!done.isCompleted) done.complete(); }, onError: (Object e) { if (!mounted) return; @@ -6334,8 +6524,10 @@ class _ChatScreenState extends State if (tempId != null) _updateFileMessageStatus(tempId, 'error'); _uploadStatus.value = const UploadStatus(); _uploadSub = null; + if (!done.isCompleted) done.complete(); }, ); + return done.future; } } diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index 1364b79..c921cea 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -288,8 +288,8 @@ class _CloudStorageScreenState extends State backgroundColor: Colors.transparent, builder: (_) => _SendByIdSheet( onSend: (id) async { - final ok = await messagesModule.sendFileMessage(chatId, id); - if (!ok) return false; + final sentId = await messagesModule.sendFileMessage(chatId, id); + if (sentId == null) return false; final newest = await CloudStorageModule.fetchLatestFile( messagesModule, accountId, diff --git a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart index 2f87916..279244f 100644 --- a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -8,8 +10,11 @@ import '../../../../core/utils/file_download.dart'; import '../../../../core/utils/media_cache.dart'; import '../../../../core/utils/format.dart'; import '../../../../core/utils/haptics.dart'; +import '../../../../core/crypto/chat_crypto_service.dart'; +import '../../../../core/crypto/encrypted_photo.dart'; import '../../../../models/attachment.dart'; import '../../custom_notification.dart'; +import '../../photo_viewer.dart'; import 'bubble_context.dart'; class FileBubble extends StatelessWidget { @@ -167,7 +172,86 @@ class FileBubble extends StatelessWidget { ], ), ); - return fill ? inner : IntrinsicWidth(child: inner); + final body = fill ? inner : IntrinsicWidth(child: inner); + if (!_isViewableImage(name)) return body; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openInViewer(ctx.context, name, cacheName), + child: body, + ); + } + + static bool _isViewableImage(String name) => + name.toLowerCase().endsWith('.png'); + + Future _openInViewer( + BuildContext context, + String name, + String cacheName, + ) async { + final fileId = file.fileId; + if (fileId == null) return; + Haptics.tap(); + + final wasCached = (await MediaCache.existing(cacheName)) != null; + if (!wasCached) MediaDownloadProgress.set(cacheName, 0); + File? local; + try { + final url = await messagesModule.getFileUrl( + messageId: ctx.message.id, + chatId: ctx.message.chatId, + fileId: fileId, + ); + if (url != null && url.isNotEmpty) { + local = await MediaCache.getOrDownload( + cacheName, + url, + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + ); + } + } finally { + if (!wasCached) MediaDownloadProgress.set(cacheName, null); + } + + if (!context.mounted) return; + if (local == null) { + showCustomNotification(context, 'Не удалось загрузить файл'); + return; + } + + final shown = await _decryptIfNeeded(local, cacheName); + if (!context.mounted) return; + if (shown == null) { + showCustomNotification(context, 'Неверный ключ'); + return; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PhotoViewerScreen( + photos: [PhotoAttachment(localPath: shown.path)], + chatId: ctx.message.chatId, + message: ctx.message, + isFile: true, + ), + ), + ); + } + + Future _decryptIfNeeded(File local, String cacheName) async { + final accountId = ctx.message.accountId; + final chatId = ctx.message.chatId; + if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return local; + if (!await ChatCryptoService.instance.looksEncryptedImage(local.path)) { + return local; + } + final result = await openEncryptedPhoto( + accountId: accountId, + chatId: chatId, + encrypted: local, + cacheName: cacheName, + ); + return result.file; } Future _downloadFile( @@ -194,8 +278,10 @@ class FileBubble extends StatelessWidget { fileId: fileId, ), onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + onReady: () { + if (!cached) MediaDownloadProgress.set(cacheName, null); + }, ); - if (!cached) MediaDownloadProgress.set(cacheName, null); if (!context.mounted) return; if (!result.ok) { showCustomNotification( diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart index 60e69a4..686c3ef 100644 --- a/lib/frontend/widgets/chat_info/shared_content_tabs.dart +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -922,8 +922,8 @@ class _FileRow extends StatelessWidget { fileId: fileId, ), onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + onReady: () => MediaDownloadProgress.set(cacheName, null), ); - MediaDownloadProgress.set(cacheName, null); if (!context.mounted) return; if (!result.ok) { diff --git a/lib/frontend/widgets/decrypted_text.dart b/lib/frontend/widgets/decrypted_text.dart new file mode 100644 index 0000000..343eb4f --- /dev/null +++ b/lib/frontend/widgets/decrypted_text.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../core/crypto/message_decryption_cache.dart'; + +class DecryptedContent extends StatefulWidget { + final int accountId; + final int chatId; + final String messageId; + final String cipherText; + final Widget Function(MessageDecryption? decryption) builder; + + const DecryptedContent({ + super.key, + required this.accountId, + required this.chatId, + required this.messageId, + required this.cipherText, + required this.builder, + }); + + @override + State createState() => _DecryptedContentState(); +} + +class _DecryptedContentState extends State { + @override + void initState() { + super.initState(); + _request(); + } + + @override + void didUpdateWidget(DecryptedContent old) { + super.didUpdateWidget(old); + if (old.messageId != widget.messageId || + old.cipherText != widget.cipherText) { + _request(); + } + } + + void _request() { + MessageDecryptionCache.instance.request( + accountId: widget.accountId, + chatId: widget.chatId, + messageId: widget.messageId, + cipherText: widget.cipherText, + ); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: MessageDecryptionCache.instance.listenableFor( + widget.messageId, + ), + builder: (context, decryption, _) => widget.builder(decryption), + ); + } +} diff --git a/lib/frontend/widgets/encryption_lock_badge.dart b/lib/frontend/widgets/encryption_lock_badge.dart new file mode 100644 index 0000000..c8faf5b --- /dev/null +++ b/lib/frontend/widgets/encryption_lock_badge.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class EncryptionLockBadge extends StatelessWidget { + final double size; + final Color? borderColor; + + const EncryptionLockBadge({super.key, this.size = 16, this.borderColor}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: borderColor ?? cs.surface, width: 1.5), + ), + alignment: Alignment.center, + child: Icon( + Symbols.lock, + size: size * 0.62, + weight: 700, + fill: 1, + color: cs.onPrimary, + ), + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 79bacb5..a45e508 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -11,6 +11,8 @@ import '../../backend/modules/messages.dart'; import '../screens/webapp/web_app_screen.dart'; import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; +import '../../core/crypto/message_decryption_cache.dart'; +import 'decrypted_text.dart'; import '../../core/utils/bubble_radius.dart'; import '../../core/utils/link_opener.dart'; import '../../core/utils/text_format.dart'; @@ -1237,7 +1239,18 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildTextContent(BubbleContext ctx) { + Widget _buildTextContent(BubbleContext ctx) => DecryptedContent( + accountId: message.accountId, + chatId: message.chatId, + messageId: message.id, + cipherText: message.text ?? '', + builder: (decryption) => _buildTextContentBody(ctx, decryption), + ); + + Widget _buildTextContentBody( + BubbleContext ctx, + MessageDecryption? decryption, + ) { final attachments = message.attachments; final isForwardedContact = attachments != null && @@ -1267,20 +1280,43 @@ class MessageBubble extends StatelessWidget { : null, ); final ranges = message.formatRanges; - final baseTextWidget = isForwarded - ? _buildForwardedInlineText(ctx, forwarded) - : (FormattedMessageText.isFormatted(message.text, ranges) - ? FormattedMessageText( - text: message.text!, - ranges: ranges, - style: textStyle, - ) - : Text(message.text ?? '', style: textStyle)); + final decryptedText = decryption?.plaintext; + final Widget baseTextWidget; + if (decryption?.state == MessageDecryptionState.wrongKey) { + baseTextWidget = Text( + 'неверный ключ', + style: textStyle.copyWith( + color: ctx.cs.error, + fontStyle: FontStyle.italic, + ), + ); + } else if (decryptedText != null) { + baseTextWidget = Text(decryptedText, style: textStyle); + } else if (isForwarded) { + baseTextWidget = _buildForwardedInlineText(ctx, forwarded); + } else if (FormattedMessageText.isFormatted(message.text, ranges)) { + baseTextWidget = FormattedMessageText( + text: message.text!, + ranges: ranges, + style: textStyle, + ); + } else { + baseTextWidget = Text(message.text ?? '', style: textStyle); + } final textWidget = _wrapSelectable(baseTextWidget); - final metaWidget = Text( - message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText, - style: TextStyle(color: ctx.dim, fontSize: 10), + final metaWidget = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (decryption?.isDecrypted ?? false) ...[ + Icon(Symbols.lock, size: 11, weight: 700, fill: 1, color: ctx.dim), + const SizedBox(width: 3), + ], + Text( + message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText, + style: TextStyle(color: ctx.dim, fontSize: 10), + ), + ], ); if (hasReactions) { @@ -1353,7 +1389,8 @@ class MessageBubble extends StatelessWidget { final name = reply.senderId == myId ? 'Вы' : (ContactCache.get(reply.senderId) ?? 'Сообщение'); - final preview = reply.previewText(); + final rawPreview = reply.previewText(); + final quotedId = reply.messageId; final quote = Container( padding: const EdgeInsets.fromLTRB(8, 3, 8, 3), @@ -1376,14 +1413,28 @@ class MessageBubble extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - if (preview.isNotEmpty) - Text( - preview, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: textColor.withValues(alpha: 0.85), - fontSize: 13, + if (rawPreview.isNotEmpty) + DecryptedContent( + accountId: message.accountId, + chatId: message.chatId, + messageId: quotedId ?? '', + cipherText: quotedId == null ? '' : rawPreview, + builder: (decryption) => Text( + decryption?.state == MessageDecryptionState.wrongKey + ? 'неверный ключ' + : (decryption?.plaintext ?? rawPreview), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: decryption?.state == MessageDecryptionState.wrongKey + ? cs.error + : textColor.withValues(alpha: 0.85), + fontSize: 13, + fontStyle: + decryption?.state == MessageDecryptionState.wrongKey + ? FontStyle.italic + : null, + ), ), ), ], diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index 5163bd6..15a9ad8 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -80,6 +80,10 @@ class PhotoViewerScreen extends StatefulWidget { final CachedMessage? message; final PhotoViewerActions? actions; + /// The opened item is a file attachment rendered as an image, so the counter + /// reads "FILE of N" — it has no position within the chat's photo feed. + final bool isFile; + const PhotoViewerScreen({ super.key, required this.photos, @@ -87,6 +91,7 @@ class PhotoViewerScreen extends StatefulWidget { this.chatId, this.message, this.actions, + this.isFile = false, }); PhotoViewerScreen.single(String baseUrl, {super.key}) @@ -94,7 +99,8 @@ class PhotoViewerScreen extends StatefulWidget { initialIndex = 0, chatId = null, message = null, - actions = null; + actions = null, + isFile = false; @override State createState() => _PhotoViewerScreenState(); @@ -632,7 +638,9 @@ class _PhotoViewerScreenState extends State { children: [ if (_feedLoaded) Text( - l10n.photoViewerCounter(_total - _index, _total), + widget.isFile + ? l10n.photoViewerCounterFile(_total) + : l10n.photoViewerCounter(_total - _index, _total), style: const TextStyle( color: Colors.white, fontSize: 16, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index acc6067..03251f7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -545,6 +545,14 @@ } } }, + "photoViewerCounterFile": "FILE of {total}", + "@photoViewerCounterFile": { + "placeholders": { + "total": { + "type": "int" + } + } + }, "photoViewerSentToday": "{sender} • today at {time}", "@photoViewerSentToday": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2c09ad3..2e33b98 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2606,6 +2606,12 @@ abstract class AppLocalizations { /// **'Photo {index} of {total}'** String photoViewerCounter(int index, int total); + /// No description provided for @photoViewerCounterFile. + /// + /// In en, this message translates to: + /// **'FILE of {total}'** + String photoViewerCounterFile(int total); + /// No description provided for @photoViewerSentToday. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0b84ebb..53bf872 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1336,6 +1336,11 @@ class AppLocalizationsEn extends AppLocalizations { return 'Photo $index of $total'; } + @override + String photoViewerCounterFile(int total) { + return 'FILE of $total'; + } + @override String photoViewerSentToday(String sender, String time) { return '$sender • today at $time'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 389912f..035e282 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1344,6 +1344,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'Фото $index из $total'; } + @override + String photoViewerCounterFile(int total) { + return 'ФАЙЛ из $total'; + } + @override String photoViewerSentToday(String sender, String time) { return '$sender • сегодня в $time'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e555f81..6c520d5 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -441,6 +441,7 @@ "sharedGoToMessage": "Перейти к сообщению", "sharedDownload": "Скачать", "photoViewerCounter": "Фото {index} из {total}", + "photoViewerCounterFile": "ФАЙЛ из {total}", "photoViewerSentToday": "{sender} • сегодня в {time}", "photoViewerSentOn": "{sender} • {date} в {time}", "photoViewerSaveAs": "Сохранить как…", diff --git a/lib/main.dart b/lib/main.dart index 9c4b46e..aa69a9d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'core/cache/self_presence.dart'; import 'core/storage/app_instance.dart'; import 'core/storage/draft_store.dart'; import 'core/storage/archived_chats_store.dart'; +import 'core/storage/chat_encryption_store.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_show_extra_info.dart'; @@ -239,6 +240,7 @@ void main(List args) async { await FileHistoryCache.load(prefs); await DraftStore.instance.load(); await ArchivedChatsStore.instance.load(); + await ChatEncryptionStore.instance.load(); await KometSettings.load(); if (KometSettings.ghostMode.value) SelfPresence.markOffline(); await ContactCache.load(); diff --git a/native/komet_crypto/.gitignore b/native/komet_crypto/.gitignore new file mode 100644 index 0000000..b9d7f25 --- /dev/null +++ b/native/komet_crypto/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/native/komet_crypto/analysis_options.yaml b/native/komet_crypto/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/native/komet_crypto/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/native/komet_crypto/android/.gitignore b/native/komet_crypto/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/native/komet_crypto/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/native/komet_crypto/android/build.gradle b/native/komet_crypto/android/build.gradle new file mode 100644 index 0000000..80ee56f --- /dev/null +++ b/native/komet_crypto/android/build.gradle @@ -0,0 +1,56 @@ +// The Android Gradle Plugin builds the native code with the Android NDK. + +group 'com.flutter_rust_bridge.komet_crypto' +version '1.0' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + // The Android Gradle Plugin knows how to build native code with the NDK. + classpath 'com.android.tools.build:gradle:7.3.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'com.flutter_rust_bridge.komet_crypto' + } + + // Bumping the plugin compileSdkVersion requires all clients of this plugin + // to bump the version in their app. + compileSdkVersion 33 + + // Use the NDK version + // declared in /android/app/build.gradle file of the Flutter project. + // Replace it with a version number if this plugin requires a specfic NDK version. + // (e.g. ndkVersion "23.1.7779620") + ndkVersion android.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 19 + } +} + +apply from: "../cargokit/gradle/plugin.gradle" +cargokit { + manifestDir = "../rust" + libname = "komet_crypto" +} diff --git a/native/komet_crypto/android/settings.gradle b/native/komet_crypto/android/settings.gradle new file mode 100644 index 0000000..11d2600 --- /dev/null +++ b/native/komet_crypto/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'komet_crypto' diff --git a/native/komet_crypto/android/src/main/AndroidManifest.xml b/native/komet_crypto/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..023e90a --- /dev/null +++ b/native/komet_crypto/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/native/komet_crypto/cargokit/.gitignore b/native/komet_crypto/cargokit/.gitignore new file mode 100644 index 0000000..cf7bb86 --- /dev/null +++ b/native/komet_crypto/cargokit/.gitignore @@ -0,0 +1,4 @@ +target +.dart_tool +*.iml +!pubspec.lock diff --git a/native/komet_crypto/cargokit/LICENSE b/native/komet_crypto/cargokit/LICENSE new file mode 100644 index 0000000..d33a5fe --- /dev/null +++ b/native/komet_crypto/cargokit/LICENSE @@ -0,0 +1,42 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Copyright 2022 Matej Knopp + +================================================================================ + +MIT LICENSE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +APACHE LICENSE, VERSION 2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/native/komet_crypto/cargokit/README b/native/komet_crypto/cargokit/README new file mode 100644 index 0000000..398474d --- /dev/null +++ b/native/komet_crypto/cargokit/README @@ -0,0 +1,11 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Experimental repository to provide glue for seamlessly integrating cargo build +with flutter plugins and packages. + +See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/ +for a tutorial on how to use Cargokit. + +Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin. + diff --git a/native/komet_crypto/cargokit/build_pod.sh b/native/komet_crypto/cargokit/build_pod.sh new file mode 100755 index 0000000..ed0e0d9 --- /dev/null +++ b/native/komet_crypto/cargokit/build_pod.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -e + +BASEDIR=$(dirname "$0") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +BASEDIR=$(cd "$BASEDIR" ; pwd -P) + +# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project +NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"` + +export PATH=${NEW_PATH%?} # remove trailing : + +env + +# Platform name (macosx, iphoneos, iphonesimulator) +export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME + +# Arctive architectures (arm64, armv7, x86_64), space separated. +export CARGOKIT_DARWIN_ARCHS=$ARCHS + +# Current build configuration (Debug, Release) +export CARGOKIT_CONFIGURATION=$CONFIGURATION + +# Path to directory containing Cargo.toml. +export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1 + +# Temporary directory for build artifacts. +export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR + +# Output directory for final artifacts. +export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME + +# Directory to store built tool artifacts. +export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool + +# Directory inside root project. Not necessarily the top level directory of root project. +export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT + +FLUTTER_EXPORT_BUILD_ENVIRONMENT=( + "$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS + "$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS +) + +for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}" +do + if [[ -f "$path" ]]; then + source "$path" + fi +done + +sh "$BASEDIR/run_build_tool.sh" build-pod "$@" + +# Make a symlink from built framework to phony file, which will be used as input to +# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate +# attribute on custom build phase) +ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony" +ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out" diff --git a/native/komet_crypto/cargokit/build_tool/README.md b/native/komet_crypto/cargokit/build_tool/README.md new file mode 100644 index 0000000..a878c27 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/README.md @@ -0,0 +1,5 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +A sample command-line application with an entrypoint in `bin/`, library code +in `lib/`, and example unit test in `test/`. diff --git a/native/komet_crypto/cargokit/build_tool/analysis_options.yaml b/native/komet_crypto/cargokit/build_tool/analysis_options.yaml new file mode 100644 index 0000000..0e16a8b --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/analysis_options.yaml @@ -0,0 +1,34 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - prefer_relative_imports + - directives_ordering + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/native/komet_crypto/cargokit/build_tool/bin/build_tool.dart b/native/komet_crypto/cargokit/build_tool/bin/build_tool.dart new file mode 100644 index 0000000..268eb52 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/bin/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:build_tool/build_tool.dart' as build_tool; + +void main(List arguments) { + build_tool.runMain(arguments); +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/build_tool.dart b/native/komet_crypto/cargokit/build_tool/lib/build_tool.dart new file mode 100644 index 0000000..7c1bb75 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'src/build_tool.dart' as build_tool; + +Future runMain(List args) async { + return build_tool.runMain(args); +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/android_environment.dart b/native/komet_crypto/cargokit/build_tool/lib/src/android_environment.dart new file mode 100644 index 0000000..15fc9ee --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/android_environment.dart @@ -0,0 +1,195 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; +import 'package:version/version.dart'; + +import 'target.dart'; +import 'util.dart'; + +class AndroidEnvironment { + AndroidEnvironment({ + required this.sdkPath, + required this.ndkVersion, + required this.minSdkVersion, + required this.targetTempDir, + required this.target, + }); + + static void clangLinkerWrapper(List args) { + final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG']; + if (clang == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var"); + } + final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET']; + if (target == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var"); + } + + runCommand(clang, [ + target, + ...args, + ]); + } + + /// Full path to Android SDK. + final String sdkPath; + + /// Full version of Android NDK. + final String ndkVersion; + + /// Minimum supported SDK version. + final int minSdkVersion; + + /// Target directory for build artifacts. + final String targetTempDir; + + /// Target being built. + final Target target; + + bool ndkIsInstalled() { + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final ndkPackageXml = File(path.join(ndkPath, 'package.xml')); + return ndkPackageXml.existsSync(); + } + + void installNdk({ + required String javaHome, + }) { + final sdkManagerExtension = Platform.isWindows ? '.bat' : ''; + final sdkManager = path.join( + sdkPath, + 'cmdline-tools', + 'latest', + 'bin', + 'sdkmanager$sdkManagerExtension', + ); + + log.info('Installing NDK $ndkVersion'); + runCommand(sdkManager, [ + '--install', + 'ndk;$ndkVersion', + ], environment: { + 'JAVA_HOME': javaHome, + }); + } + + Future> buildEnvironment() async { + final hostArch = Platform.isMacOS + ? "darwin-x86_64" + : (Platform.isLinux ? "linux-x86_64" : "windows-x86_64"); + + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final toolchainPath = path.join( + ndkPath, + 'toolchains', + 'llvm', + 'prebuilt', + hostArch, + 'bin', + ); + + final minSdkVersion = + math.max(target.androidMinSdkVersion!, this.minSdkVersion); + + final exe = Platform.isWindows ? '.exe' : ''; + + final arKey = 'AR_${target.rust}'; + final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe'] + .map((e) => path.join(toolchainPath, e)) + .firstWhereOrNull((element) => File(element).existsSync()); + if (arValue == null) { + throw Exception('Failed to find ar for $target in $toolchainPath'); + } + + final targetArg = '--target=${target.rust}$minSdkVersion'; + + final ccKey = 'CC_${target.rust}'; + final ccValue = path.join(toolchainPath, 'clang$exe'); + final cfFlagsKey = 'CFLAGS_${target.rust}'; + final cFlagsValue = targetArg; + + final cxxKey = 'CXX_${target.rust}'; + final cxxValue = path.join(toolchainPath, 'clang++$exe'); + final cxxFlagsKey = 'CXXFLAGS_${target.rust}'; + final cxxFlagsValue = targetArg; + + final linkerKey = + 'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase(); + + final ranlibKey = 'RANLIB_${target.rust}'; + final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe'); + + final ndkVersionParsed = Version.parse(ndkVersion); + final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS'; + final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed); + + final runRustTool = + Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh'; + + final packagePath = (await Isolate.resolvePackageUri( + Uri.parse('package:build_tool/buildtool.dart')))! + .toFilePath(); + final selfPath = path.canonicalize(path.join( + packagePath, + '..', + '..', + '..', + runRustTool, + )); + + // Make sure that run_build_tool is working properly even initially launched directly + // through dart run. + final toolTempDir = + Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir; + + return { + arKey: arValue, + ccKey: ccValue, + cfFlagsKey: cFlagsValue, + cxxKey: cxxValue, + cxxFlagsKey: cxxFlagsValue, + ranlibKey: ranlibValue, + rustFlagsKey: rustFlagsValue, + linkerKey: selfPath, + // Recognized by main() so we know when we're acting as a wrapper + '_CARGOKIT_NDK_LINK_TARGET': targetArg, + '_CARGOKIT_NDK_LINK_CLANG': ccValue, + 'CARGOKIT_TOOL_TEMP_DIR': toolTempDir, + }; + } + + // Workaround for libgcc missing in NDK23, inspired by cargo-ndk + String _libGccWorkaround(String buildDir, Version ndkVersion) { + final workaroundDir = path.join( + buildDir, + 'cargokit', + 'libgcc_workaround', + '${ndkVersion.major}', + ); + Directory(workaroundDir).createSync(recursive: true); + if (ndkVersion.major >= 23) { + File(path.join(workaroundDir, 'libgcc.a')) + .writeAsStringSync('INPUT(-lunwind)'); + } else { + // Other way around, untested, forward libgcc.a from libunwind once Rust + // gets updated for NDK23+. + File(path.join(workaroundDir, 'libunwind.a')) + .writeAsStringSync('INPUT(-lgcc)'); + } + + var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? ''; + if (rustFlags.isNotEmpty) { + rustFlags = '$rustFlags\x1f'; + } + rustFlags = '$rustFlags-L\x1f$workaroundDir'; + return rustFlags; + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/artifacts_provider.dart b/native/komet_crypto/cargokit/build_tool/lib/src/artifacts_provider.dart new file mode 100644 index 0000000..e608cec --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/artifacts_provider.dart @@ -0,0 +1,266 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'builder.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'rustup.dart'; +import 'target.dart'; + +class Artifact { + /// File system location of the artifact. + final String path; + + /// Actual file name that the artifact should have in destination folder. + final String finalFileName; + + AritifactType get type { + if (finalFileName.endsWith('.dll') || + finalFileName.endsWith('.dll.lib') || + finalFileName.endsWith('.pdb') || + finalFileName.endsWith('.so') || + finalFileName.endsWith('.dylib')) { + return AritifactType.dylib; + } else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) { + return AritifactType.staticlib; + } else { + throw Exception('Unknown artifact type for $finalFileName'); + } + } + + Artifact({ + required this.path, + required this.finalFileName, + }); +} + +final _log = Logger('artifacts_provider'); + +class ArtifactProvider { + ArtifactProvider({ + required this.environment, + required this.userOptions, + }); + + final BuildEnvironment environment; + final CargokitUserOptions userOptions; + + Future>> getArtifacts(List targets) async { + final result = await _getPrecompiledArtifacts(targets); + + final pendingTargets = List.of(targets); + pendingTargets.removeWhere((element) => result.containsKey(element)); + + if (pendingTargets.isEmpty) { + return result; + } + + final rustup = Rustup(); + for (final target in targets) { + final builder = RustBuilder(target: target, environment: environment); + builder.prepare(rustup); + _log.info('Building ${environment.crateInfo.packageName} for $target'); + final targetDir = await builder.build(); + // For local build accept both static and dynamic libraries. + final artifactNames = { + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.dylib, + remote: false, + ), + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.staticlib, + remote: false, + ) + }; + final artifacts = artifactNames + .map((artifactName) => Artifact( + path: path.join(targetDir, artifactName), + finalFileName: artifactName, + )) + .where((element) => File(element.path).existsSync()) + .toList(); + result[target] = artifacts; + } + return result; + } + + Future>> _getPrecompiledArtifacts( + List targets) async { + if (userOptions.usePrecompiledBinaries == false) { + _log.info('Precompiled binaries are disabled'); + return {}; + } + if (environment.crateOptions.precompiledBinaries == null) { + _log.fine('Precompiled binaries not enabled for this crate'); + return {}; + } + + final start = Stopwatch()..start(); + final crateHash = CrateHash.compute(environment.manifestDir, + tempStorage: environment.targetTempDir); + _log.fine( + 'Computed crate hash $crateHash in ${start.elapsedMilliseconds}ms'); + + final downloadedArtifactsDir = + path.join(environment.targetTempDir, 'precompiled', crateHash); + Directory(downloadedArtifactsDir).createSync(recursive: true); + + final res = >{}; + + for (final target in targets) { + final requiredArtifacts = getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + remote: true, + ); + final artifactsForTarget = []; + + for (final artifact in requiredArtifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final downloadedPath = path.join(downloadedArtifactsDir, fileName); + if (!File(downloadedPath).existsSync()) { + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + await _tryDownloadArtifacts( + crateHash: crateHash, + fileName: fileName, + signatureFileName: signatureFileName, + finalPath: downloadedPath, + ); + } + if (File(downloadedPath).existsSync()) { + artifactsForTarget.add(Artifact( + path: downloadedPath, + finalFileName: artifact, + )); + } else { + break; + } + } + + // Only provide complete set of artifacts. + if (artifactsForTarget.length == requiredArtifacts.length) { + _log.fine('Found precompiled artifacts for $target'); + res[target] = artifactsForTarget; + } + } + + return res; + } + + static Future _get(Uri url, {Map? headers}) async { + int attempt = 0; + const maxAttempts = 10; + while (true) { + try { + return await get(url, headers: headers); + } on SocketException catch (e) { + // Try to detect reset by peer error and retry. + if (attempt++ < maxAttempts && + (e.osError?.errorCode == 54 || e.osError?.errorCode == 10054)) { + _log.severe( + 'Failed to download $url: $e, attempt $attempt of $maxAttempts, will retry...'); + await Future.delayed(Duration(seconds: 1)); + continue; + } else { + rethrow; + } + } + } + } + + Future _tryDownloadArtifacts({ + required String crateHash, + required String fileName, + required String signatureFileName, + required String finalPath, + }) async { + final precompiledBinaries = environment.crateOptions.precompiledBinaries!; + final prefix = precompiledBinaries.uriPrefix; + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = Uri.parse('$prefix$crateHash/$signatureFileName'); + _log.fine('Downloading signature from $signatureUrl'); + final signature = await _get(signatureUrl); + if (signature.statusCode == 404) { + _log.warning( + 'Precompiled binaries not available for crate hash $crateHash ($fileName)'); + return; + } + if (signature.statusCode != 200) { + _log.severe( + 'Failed to download signature $signatureUrl: status ${signature.statusCode}'); + return; + } + _log.fine('Downloading binary from $url'); + final res = await _get(url); + if (res.statusCode != 200) { + _log.severe('Failed to download binary $url: status ${res.statusCode}'); + return; + } + if (verify( + precompiledBinaries.publicKey, res.bodyBytes, signature.bodyBytes)) { + File(finalPath).writeAsBytesSync(res.bodyBytes); + } else { + _log.shout('Signature verification failed! Ignoring binary.'); + } + } +} + +enum AritifactType { + staticlib, + dylib, +} + +AritifactType artifactTypeForTarget(Target target) { + if (target.darwinPlatform != null) { + return AritifactType.staticlib; + } else { + return AritifactType.dylib; + } +} + +List getArtifactNames({ + required Target target, + required String libraryName, + required bool remote, + AritifactType? aritifactType, +}) { + aritifactType ??= artifactTypeForTarget(target); + if (target.darwinArch != null) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.dylib']; + } + } else if (target.rust.contains('-windows-')) { + if (aritifactType == AritifactType.staticlib) { + return ['$libraryName.lib']; + } else { + return [ + '$libraryName.dll', + '$libraryName.dll.lib', + if (!remote) '$libraryName.pdb' + ]; + } + } else if (target.rust.contains('-linux-')) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.so']; + } + } else { + throw Exception("Unsupported target: ${target.rust}"); + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/build_cmake.dart b/native/komet_crypto/cargokit/build_tool/lib/src/build_cmake.dart new file mode 100644 index 0000000..6f3b2a4 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/build_cmake.dart @@ -0,0 +1,40 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +class BuildCMake { + final CargokitUserOptions userOptions; + + BuildCMake({required this.userOptions}); + + Future build() async { + final targetPlatform = Environment.targetPlatform; + final target = Target.forFlutterName(Environment.targetPlatform); + if (target == null) { + throw Exception("Unknown target platform: $targetPlatform"); + } + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts([target]); + + final libs = artifacts[target]!; + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path) + .copySync(path.join(Environment.outputDir, lib.finalFileName)); + } + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/build_gradle.dart b/native/komet_crypto/cargokit/build_tool/lib/src/build_gradle.dart new file mode 100644 index 0000000..7e61fcb --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/build_gradle.dart @@ -0,0 +1,49 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +final log = Logger('build_gradle'); + +class BuildGradle { + BuildGradle({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.targetPlatforms.map((arch) { + final target = Target.forFlutterName(arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: true); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + for (final target in targets) { + final libs = artifacts[target]!; + final outputDir = path.join(Environment.outputDir, target.android!); + Directory(outputDir).createSync(recursive: true); + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path).copySync(path.join(outputDir, lib.finalFileName)); + } + } + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/build_pod.dart b/native/komet_crypto/cargokit/build_tool/lib/src/build_pod.dart new file mode 100644 index 0000000..8a9c0db --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/build_pod.dart @@ -0,0 +1,89 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; +import 'util.dart'; + +class BuildPod { + BuildPod({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.darwinArchs.map((arch) { + final target = Target.forDarwin( + platformName: Environment.darwinPlatformName, darwinAarch: arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + void performLipo(String targetFile, Iterable sourceFiles) { + runCommand("lipo", [ + '-create', + ...sourceFiles, + '-output', + targetFile, + ]); + } + + final outputDir = Environment.outputDir; + + Directory(outputDir).createSync(recursive: true); + + final staticLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.staticlib) + .toList(); + final dynamicLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.dylib) + .toList(); + + final libName = environment.crateInfo.packageName; + + // If there is static lib, use it and link it with pod + if (staticLibs.isNotEmpty) { + final finalTargetFile = path.join(outputDir, "lib$libName.a"); + performLipo(finalTargetFile, staticLibs.map((e) => e.path)); + } else { + // Otherwise try to replace bundle dylib with our dylib + final bundlePaths = [ + '$libName.framework/Versions/A/$libName', + '$libName.framework/$libName', + ]; + + for (final bundlePath in bundlePaths) { + final targetFile = path.join(outputDir, bundlePath); + if (File(targetFile).existsSync()) { + performLipo(targetFile, dynamicLibs.map((e) => e.path)); + + // Replace absolute id with @rpath one so that it works properly + // when moved to Frameworks. + runCommand("install_name_tool", [ + '-id', + '@rpath/$bundlePath', + targetFile, + ]); + return; + } + } + throw Exception('Unable to find bundle for dynamic library'); + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/build_tool.dart b/native/komet_crypto/cargokit/build_tool/lib/src/build_tool.dart new file mode 100644 index 0000000..70dfe0e --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/build_tool.dart @@ -0,0 +1,276 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; + +import 'android_environment.dart'; +import 'build_cmake.dart'; +import 'build_gradle.dart'; +import 'build_pod.dart'; +import 'logging.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; +import 'util.dart'; +import 'verify_binaries.dart'; + +final log = Logger('build_tool'); + +abstract class BuildCommand extends Command { + Future runBuildCommand(CargokitUserOptions options); + + @override + Future run() async { + final options = CargokitUserOptions.load(); + + if (options.verboseLogging || + Platform.environment['CARGOKIT_VERBOSE'] == '1') { + enableVerboseLogging(); + } + + await runBuildCommand(options); + } +} + +class BuildPodCommand extends BuildCommand { + @override + final name = 'build-pod'; + + @override + final description = 'Build cocoa pod library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildPod(userOptions: options); + await build.build(); + } +} + +class BuildGradleCommand extends BuildCommand { + @override + final name = 'build-gradle'; + + @override + final description = 'Build android library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildGradle(userOptions: options); + await build.build(); + } +} + +class BuildCMakeCommand extends BuildCommand { + @override + final name = 'build-cmake'; + + @override + final description = 'Build CMake library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildCMake(userOptions: options); + await build.build(); + } +} + +class GenKeyCommand extends Command { + @override + final name = 'gen-key'; + + @override + final description = 'Generate key pair for signing precompiled binaries'; + + @override + void run() { + final kp = generateKey(); + final private = HEX.encode(kp.privateKey.bytes); + final public = HEX.encode(kp.publicKey.bytes); + print("Private Key: $private"); + print("Public Key: $public"); + } +} + +class PrecompileBinariesCommand extends Command { + PrecompileBinariesCommand() { + argParser + ..addOption( + 'repository', + mandatory: true, + help: 'Github repository slug in format owner/name', + ) + ..addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ) + ..addMultiOption('target', + help: 'Rust target triple of artifact to build.\n' + 'Can be specified multiple times or omitted in which case\n' + 'all targets for current platform will be built.') + ..addOption( + 'android-sdk-location', + help: 'Location of Android SDK (if available)', + ) + ..addOption( + 'android-ndk-version', + help: 'Android NDK version (if available)', + ) + ..addOption( + 'android-min-sdk-version', + help: 'Android minimum rquired version (if available)', + ) + ..addOption( + 'temp-dir', + help: 'Directory to store temporary build artifacts', + ) + ..addOption( + 'glibc-version', + help: 'GLIBC version to use for linux builds', + ) + ..addFlag( + "verbose", + abbr: "v", + defaultsTo: false, + help: "Enable verbose logging", + ); + } + + @override + final name = 'precompile-binaries'; + + @override + final description = 'Prebuild and upload binaries\n' + 'Private key must be passed through PRIVATE_KEY environment variable. ' + 'Use gen_key through generate priave key.\n' + 'Github token must be passed as GITHUB_TOKEN environment variable.\n'; + + @override + Future run() async { + final verbose = argResults!['verbose'] as bool; + if (verbose) { + enableVerboseLogging(); + } + + final privateKeyString = Platform.environment['PRIVATE_KEY']; + if (privateKeyString == null) { + throw ArgumentError('Missing PRIVATE_KEY environment variable'); + } + final githubToken = Platform.environment['GITHUB_TOKEN']; + if (githubToken == null) { + throw ArgumentError('Missing GITHUB_TOKEN environment variable'); + } + final privateKey = HEX.decode(privateKeyString); + if (privateKey.length != 64) { + throw ArgumentError('Private key must be 64 bytes long'); + } + final manifestDir = argResults!['manifest-dir'] as String; + if (!Directory(manifestDir).existsSync()) { + throw ArgumentError('Manifest directory does not exist: $manifestDir'); + } + String? androidMinSdkVersionString = + argResults!['android-min-sdk-version'] as String?; + int? androidMinSdkVersion; + if (androidMinSdkVersionString != null) { + androidMinSdkVersion = int.tryParse(androidMinSdkVersionString); + if (androidMinSdkVersion == null) { + throw ArgumentError( + 'Invalid android-min-sdk-version: $androidMinSdkVersionString'); + } + } + final targetStrigns = argResults!['target'] as List; + final targets = targetStrigns.map((target) { + final res = Target.forRustTriple(target); + if (res == null) { + throw ArgumentError('Invalid target: $target'); + } + return res; + }).toList(growable: false); + final precompileBinaries = PrecompileBinaries( + privateKey: PrivateKey(privateKey), + githubToken: githubToken, + manifestDir: manifestDir, + repositorySlug: RepositorySlug.full(argResults!['repository'] as String), + targets: targets, + androidSdkLocation: argResults!['android-sdk-location'] as String?, + androidNdkVersion: argResults!['android-ndk-version'] as String?, + androidMinSdkVersion: androidMinSdkVersion, + tempDir: argResults!['temp-dir'] as String?, + glibcVersion: argResults!['glibc-version'] as String?, + ); + + await precompileBinaries.run(); + } +} + +class VerifyBinariesCommand extends Command { + VerifyBinariesCommand() { + argParser.addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ); + } + + @override + final name = "verify-binaries"; + + @override + final description = 'Verifies published binaries\n' + 'Checks whether there is a binary published for each targets\n' + 'and checks the signature.'; + + @override + Future run() async { + final manifestDir = argResults!['manifest-dir'] as String; + final verifyBinaries = VerifyBinaries( + manifestDir: manifestDir, + ); + await verifyBinaries.run(); + } +} + +Future runMain(List args) async { + try { + // Init logging before options are loaded + initLogging(); + + if (Platform.environment['_CARGOKIT_NDK_LINK_TARGET'] != null) { + return AndroidEnvironment.clangLinkerWrapper(args); + } + + final runner = CommandRunner('build_tool', 'Cargokit built_tool') + ..addCommand(BuildPodCommand()) + ..addCommand(BuildGradleCommand()) + ..addCommand(BuildCMakeCommand()) + ..addCommand(GenKeyCommand()) + ..addCommand(PrecompileBinariesCommand()) + ..addCommand(VerifyBinariesCommand()); + + await runner.run(args); + } on ArgumentError catch (e) { + stderr.writeln(e.toString()); + exit(1); + } catch (e, s) { + log.severe(kDoubleSeparator); + log.severe('Cargokit BuildTool failed with error:'); + log.severe(kSeparator); + log.severe(e); + // This tells user to install Rust, there's no need to pollute the log with + // stack trace. + if (e is! RustupNotFoundException) { + log.severe(kSeparator); + log.severe(s); + log.severe(kSeparator); + log.severe('BuildTool arguments: $args'); + } + log.severe(kDoubleSeparator); + exit(1); + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/builder.dart b/native/komet_crypto/cargokit/build_tool/lib/src/builder.dart new file mode 100644 index 0000000..cd5269f --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/builder.dart @@ -0,0 +1,209 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'android_environment.dart'; +import 'cargo.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; +import 'util.dart'; + +final _log = Logger('builder'); + +enum BuildConfiguration { + debug, + release, + profile, +} + +extension on BuildConfiguration { + bool get isDebug => this == BuildConfiguration.debug; + String get rustName => switch (this) { + BuildConfiguration.debug => 'debug', + BuildConfiguration.release => 'release', + BuildConfiguration.profile => 'release', + }; +} + +class BuildException implements Exception { + final String message; + + BuildException(this.message); + + @override + String toString() { + return 'BuildException: $message'; + } +} + +class BuildEnvironment { + final BuildConfiguration configuration; + final CargokitCrateOptions crateOptions; + final String targetTempDir; + final String manifestDir; + final CrateInfo crateInfo; + + final bool isAndroid; + final String? androidSdkPath; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? javaHome; + + final String? glibcVersion; + + BuildEnvironment({ + required this.configuration, + required this.crateOptions, + required this.targetTempDir, + required this.manifestDir, + required this.crateInfo, + required this.isAndroid, + this.androidSdkPath, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.javaHome, + this.glibcVersion, + }); + + static BuildConfiguration parseBuildConfiguration(String value) { + // XCode configuration adds the flavor to configuration name. + final firstSegment = value.split('-').first; + final buildConfiguration = BuildConfiguration.values.firstWhereOrNull( + (e) => e.name == firstSegment, + ); + if (buildConfiguration == null) { + _log.warning('Unknown build configuraiton $value, will assume release'); + return BuildConfiguration.release; + } + return buildConfiguration; + } + + static BuildEnvironment fromEnvironment({ + required bool isAndroid, + }) { + final buildConfiguration = + parseBuildConfiguration(Environment.configuration); + final manifestDir = Environment.manifestDir; + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + final crateInfo = CrateInfo.load(manifestDir); + return BuildEnvironment( + configuration: buildConfiguration, + crateOptions: crateOptions, + targetTempDir: Environment.targetTempDir, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: isAndroid, + androidSdkPath: isAndroid ? Environment.sdkPath : null, + androidNdkVersion: isAndroid ? Environment.ndkVersion : null, + androidMinSdkVersion: + isAndroid ? int.parse(Environment.minSdkVersion) : null, + javaHome: isAndroid ? Environment.javaHome : null, + ); + } +} + +class RustBuilder { + final Target target; + final BuildEnvironment environment; + + RustBuilder({ + required this.target, + required this.environment, + }); + + void prepare( + Rustup rustup, + ) { + final toolchain = _toolchain; + if (rustup.installedTargets(toolchain) == null) { + rustup.installToolchain(toolchain); + } + if (toolchain == 'nightly') { + rustup.installRustSrcForNightly(); + } + if (!rustup.installedTargets(toolchain)!.contains(target.rust)) { + rustup.installTarget(target.rust, toolchain: toolchain); + } + if (environment.glibcVersion != null) { + rustup.installZigBuild(toolchain); + } + } + + CargoBuildOptions? get _buildOptions => + environment.crateOptions.cargo[environment.configuration]; + + String get _toolchain => _buildOptions?.toolchain.name ?? 'stable'; + + /// Returns the path of directory containing build artifacts. + Future build() async { + final extraArgs = _buildOptions?.flags ?? []; + final manifestPath = path.join(environment.manifestDir, 'Cargo.toml'); + runCommand( + 'rustup', + [ + 'run', + _toolchain, + 'cargo', + (target.android == null && environment.glibcVersion != null) + ? 'zigbuild' + : 'build', + ...extraArgs, + '--manifest-path', + manifestPath, + '-p', + environment.crateInfo.packageName, + if (!environment.configuration.isDebug) '--release', + '--target', + target.rust + + ((target.android == null && environment.glibcVersion != null) + ? '.${environment.glibcVersion!}' + : ""), + '--target-dir', + environment.targetTempDir, + ], + environment: await _buildEnvironment(), + ); + return path.join( + environment.targetTempDir, + target.rust, + environment.configuration.rustName, + ); + } + + Future> _buildEnvironment() async { + if (target.android == null) { + return {}; + } else { + final sdkPath = environment.androidSdkPath; + final ndkVersion = environment.androidNdkVersion; + final minSdkVersion = environment.androidMinSdkVersion; + if (sdkPath == null) { + throw BuildException('androidSdkPath is not set'); + } + if (ndkVersion == null) { + throw BuildException('androidNdkVersion is not set'); + } + if (minSdkVersion == null) { + throw BuildException('androidMinSdkVersion is not set'); + } + final env = AndroidEnvironment( + sdkPath: sdkPath, + ndkVersion: ndkVersion, + minSdkVersion: minSdkVersion, + targetTempDir: environment.targetTempDir, + target: target, + ); + if (!env.ndkIsInstalled() && environment.javaHome != null) { + env.installNdk(javaHome: environment.javaHome!); + } + return env.buildEnvironment(); + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/cargo.dart b/native/komet_crypto/cargokit/build_tool/lib/src/cargo.dart new file mode 100644 index 0000000..0d8958f --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/cargo.dart @@ -0,0 +1,48 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:toml/toml.dart'; + +class ManifestException { + ManifestException(this.message, {required this.fileName}); + + final String? fileName; + final String message; + + @override + String toString() { + if (fileName != null) { + return 'Failed to parse package manifest at $fileName: $message'; + } else { + return 'Failed to parse package manifest: $message'; + } + } +} + +class CrateInfo { + CrateInfo({required this.packageName}); + + final String packageName; + + static CrateInfo parseManifest(String manifest, {final String? fileName}) { + final toml = TomlDocument.parse(manifest); + final package = toml.toMap()['package']; + if (package == null) { + throw ManifestException('Missing package section', fileName: fileName); + } + final name = package['name']; + if (name == null) { + throw ManifestException('Missing package name', fileName: fileName); + } + return CrateInfo(packageName: name); + } + + static CrateInfo load(String manifestDir) { + final manifestFile = File(path.join(manifestDir, 'Cargo.toml')); + final manifest = manifestFile.readAsStringSync(); + return parseManifest(manifest, fileName: manifestFile.path); + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/crate_hash.dart b/native/komet_crypto/cargokit/build_tool/lib/src/crate_hash.dart new file mode 100644 index 0000000..0c4d88d --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/crate_hash.dart @@ -0,0 +1,124 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as path; + +class CrateHash { + /// Computes a hash uniquely identifying crate content. This takes into account + /// content all all .rs files inside the src directory, as well as Cargo.toml, + /// Cargo.lock, build.rs and cargokit.yaml. + /// + /// If [tempStorage] is provided, computed hash is stored in a file in that directory + /// and reused on subsequent calls if the crate content hasn't changed. + static String compute(String manifestDir, {String? tempStorage}) { + return CrateHash._( + manifestDir: manifestDir, + tempStorage: tempStorage, + )._compute(); + } + + CrateHash._({ + required this.manifestDir, + required this.tempStorage, + }); + + String _compute() { + final files = getFiles(); + final tempStorage = this.tempStorage; + if (tempStorage != null) { + final quickHash = _computeQuickHash(files); + final quickHashFolder = Directory(path.join(tempStorage, 'crate_hash')); + quickHashFolder.createSync(recursive: true); + final quickHashFile = File(path.join(quickHashFolder.path, quickHash)); + if (quickHashFile.existsSync()) { + return quickHashFile.readAsStringSync(); + } + final hash = _computeHash(files); + quickHashFile.writeAsStringSync(hash); + return hash; + } else { + return _computeHash(files); + } + } + + /// Computes a quick hash based on files stat (without reading contents). This + /// is used to cache the real hash, which is slower to compute since it involves + /// reading every single file. + String _computeQuickHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + final data = ByteData(8); + for (final file in files) { + input.add(utf8.encode(file.path)); + final stat = file.statSync(); + data.setUint64(0, stat.size); + input.add(data.buffer.asUint8List()); + data.setUint64(0, stat.modified.millisecondsSinceEpoch); + input.add(data.buffer.asUint8List()); + } + + input.close(); + return base64Url.encode(output.events.single.bytes); + } + + String _computeHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + void addTextFile(File file) { + // text Files are hashed by lines in case we're dealing with github checkout + // that auto-converts line endings. + final splitter = LineSplitter(); + if (file.existsSync()) { + final data = file.readAsStringSync(); + final lines = splitter.convert(data); + for (final line in lines) { + input.add(utf8.encode(line)); + } + } + } + + for (final file in files) { + addTextFile(file); + } + + input.close(); + final res = output.events.single; + + // Truncate to 128bits. + final hash = res.bytes.sublist(0, 16); + return hex.encode(hash); + } + + List getFiles() { + final src = Directory(path.join(manifestDir, 'src')); + final files = src + .listSync(recursive: true, followLinks: false) + .whereType() + .toList(); + files.sortBy((element) => element.path); + void addFile(String relative) { + final file = File(path.join(manifestDir, relative)); + if (file.existsSync()) { + files.add(file); + } + } + + addFile('Cargo.toml'); + addFile('Cargo.lock'); + addFile('build.rs'); + addFile('cargokit.yaml'); + return files; + } + + final String manifestDir; + final String? tempStorage; +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/environment.dart b/native/komet_crypto/cargokit/build_tool/lib/src/environment.dart new file mode 100644 index 0000000..996483a --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/environment.dart @@ -0,0 +1,68 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +extension on String { + String resolveSymlink() => File(this).resolveSymbolicLinksSync(); +} + +class Environment { + /// Current build configuration (debug or release). + static String get configuration => + _getEnv("CARGOKIT_CONFIGURATION").toLowerCase(); + + static bool get isDebug => configuration == 'debug'; + static bool get isRelease => configuration == 'release'; + + /// Temporary directory where Rust build artifacts are placed. + static String get targetTempDir => _getEnv("CARGOKIT_TARGET_TEMP_DIR"); + + /// Final output directory where the build artifacts are placed. + static String get outputDir => _getEnvPath('CARGOKIT_OUTPUT_DIR'); + + /// Path to the crate manifest (containing Cargo.toml). + static String get manifestDir => _getEnvPath('CARGOKIT_MANIFEST_DIR'); + + /// Directory inside root project. Not necessarily root folder. Symlinks are + /// not resolved on purpose. + static String get rootProjectDir => _getEnv('CARGOKIT_ROOT_PROJECT_DIR'); + + // Pod + + /// Platform name (macosx, iphoneos, iphonesimulator). + static String get darwinPlatformName => + _getEnv("CARGOKIT_DARWIN_PLATFORM_NAME"); + + /// List of architectures to build for (arm64, armv7, x86_64). + static List get darwinArchs => + _getEnv("CARGOKIT_DARWIN_ARCHS").split(' '); + + // Gradle + static String get minSdkVersion => _getEnv("CARGOKIT_MIN_SDK_VERSION"); + static String get ndkVersion => _getEnv("CARGOKIT_NDK_VERSION"); + static String get sdkPath => _getEnvPath("CARGOKIT_SDK_DIR"); + static String get javaHome => _getEnvPath("CARGOKIT_JAVA_HOME"); + static List get targetPlatforms => + _getEnv("CARGOKIT_TARGET_PLATFORMS").split(','); + + // CMAKE + static String get targetPlatform => _getEnv("CARGOKIT_TARGET_PLATFORM"); + + static String _getEnv(String key) { + final res = Platform.environment[key]; + if (res == null) { + throw Exception("Missing environment variable $key"); + } + return res; + } + + static String _getEnvPath(String key) { + final res = _getEnv(key); + if (Directory(res).existsSync()) { + return res.resolveSymlink(); + } else { + return res; + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/logging.dart b/native/komet_crypto/cargokit/build_tool/lib/src/logging.dart new file mode 100644 index 0000000..5edd4fd --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/logging.dart @@ -0,0 +1,52 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; + +const String kSeparator = "--"; +const String kDoubleSeparator = "=="; + +bool _lastMessageWasSeparator = false; + +void _log(LogRecord rec) { + final prefix = '${rec.level.name}: '; + final out = rec.level == Level.SEVERE ? stderr : stdout; + if (rec.message == kSeparator) { + if (!_lastMessageWasSeparator) { + out.write(prefix); + out.writeln('-' * 80); + _lastMessageWasSeparator = true; + } + return; + } else if (rec.message == kDoubleSeparator) { + out.write(prefix); + out.writeln('=' * 80); + _lastMessageWasSeparator = true; + return; + } + out.write(prefix); + out.writeln(rec.message); + _lastMessageWasSeparator = false; +} + +void initLogging() { + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((LogRecord rec) { + final lines = rec.message.split('\n'); + for (final line in lines) { + if (line.isNotEmpty || lines.length == 1 || line != lines.last) { + _log(LogRecord( + rec.level, + line, + rec.loggerName, + )); + } + } + }); +} + +void enableVerboseLogging() { + Logger.root.level = Level.ALL; +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/options.dart b/native/komet_crypto/cargokit/build_tool/lib/src/options.dart new file mode 100644 index 0000000..22aef1d --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/options.dart @@ -0,0 +1,309 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +import 'builder.dart'; +import 'environment.dart'; +import 'rustup.dart'; + +final _log = Logger('options'); + +/// A class for exceptions that have source span information attached. +class SourceSpanException implements Exception { + // This is a getter so that subclasses can override it. + /// A message describing the exception. + String get message => _message; + final String _message; + + // This is a getter so that subclasses can override it. + /// The span associated with this exception. + /// + /// This may be `null` if the source location can't be determined. + SourceSpan? get span => _span; + final SourceSpan? _span; + + SourceSpanException(this._message, this._span); + + /// Returns a string representation of `this`. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an ANSI terminal color escape that should be used to + /// highlight the span's text. If it's `true`, it indicates that the text + /// should be highlighted using the default color. If it's `false` or `null`, + /// it indicates that the text shouldn't be highlighted. + @override + String toString({Object? color}) { + if (span == null) return message; + return 'Error on ${span!.message(message, color: color)}'; + } +} + +enum Toolchain { + stable, + beta, + nightly, +} + +class CargoBuildOptions { + final Toolchain toolchain; + final List flags; + + CargoBuildOptions({ + required this.toolchain, + required this.flags, + }); + + static Toolchain _toolchainFromNode(YamlNode node) { + if (node case YamlScalar(value: String name)) { + final toolchain = + Toolchain.values.firstWhereOrNull((element) => element.name == name); + if (toolchain != null) { + return toolchain; + } + } + throw SourceSpanException( + 'Unknown toolchain. Must be one of ${Toolchain.values.map((e) => e.name)}.', + node.span); + } + + static CargoBuildOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + Toolchain toolchain = Toolchain.stable; + List flags = []; + for (final MapEntry(:key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: 'toolchain')) { + toolchain = _toolchainFromNode(value); + } else if (key case YamlScalar(value: 'extra_flags')) { + if (value case YamlList(nodes: List list)) { + if (list.every((element) { + if (element case YamlScalar(value: String _)) { + return true; + } + return false; + })) { + flags = list.map((e) => e.value as String).toList(); + continue; + } + } + throw SourceSpanException( + 'Extra flags must be a list of strings', value.span); + } else { + throw SourceSpanException( + 'Unknown cargo option type. Must be "toolchain" or "extra_flags".', + key.span); + } + } + return CargoBuildOptions(toolchain: toolchain, flags: flags); + } +} + +extension on YamlMap { + /// Map that extracts keys so that we can do map case check on them. + Map get valueMap => + nodes.map((key, value) => MapEntry(key.value, value)); +} + +class PrecompiledBinaries { + final String uriPrefix; + final PublicKey publicKey; + + PrecompiledBinaries({ + required this.uriPrefix, + required this.publicKey, + }); + + static PublicKey _publicKeyFromHex(String key, SourceSpan? span) { + final bytes = HEX.decode(key); + if (bytes.length != 32) { + throw SourceSpanException( + 'Invalid public key. Must be 32 bytes long.', span); + } + return PublicKey(bytes); + } + + static PrecompiledBinaries parse(YamlNode node) { + if (node case YamlMap(valueMap: Map map)) { + if (map + case { + 'url_prefix': YamlNode urlPrefixNode, + 'public_key': YamlNode publicKeyNode, + }) { + final urlPrefix = switch (urlPrefixNode) { + YamlScalar(value: String urlPrefix) => urlPrefix, + _ => throw SourceSpanException( + 'Invalid URL prefix value.', urlPrefixNode.span), + }; + final publicKey = switch (publicKeyNode) { + YamlScalar(value: String publicKey) => + _publicKeyFromHex(publicKey, publicKeyNode.span), + _ => throw SourceSpanException( + 'Invalid public key value.', publicKeyNode.span), + }; + return PrecompiledBinaries( + uriPrefix: urlPrefix, + publicKey: publicKey, + ); + } + } + throw SourceSpanException( + 'Invalid precompiled binaries value. ' + 'Expected Map with "url_prefix" and "public_key".', + node.span); + } +} + +/// Cargokit options specified for Rust crate. +class CargokitCrateOptions { + CargokitCrateOptions({ + this.cargo = const {}, + this.precompiledBinaries, + }); + + final Map cargo; + final PrecompiledBinaries? precompiledBinaries; + + static CargokitCrateOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + final options = {}; + PrecompiledBinaries? precompiledBinaries; + + for (final entry in node.nodes.entries) { + if (entry + case MapEntry( + key: YamlScalar(value: 'cargo'), + value: YamlNode node, + )) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + for (final MapEntry(:YamlNode key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: String name)) { + final configuration = BuildConfiguration.values + .firstWhereOrNull((element) => element.name == name); + if (configuration != null) { + options[configuration] = CargoBuildOptions.parse(value); + continue; + } + } + throw SourceSpanException( + 'Unknown build configuration. Must be one of ${BuildConfiguration.values.map((e) => e.name)}.', + key.span); + } + } else if (entry.key case YamlScalar(value: 'precompiled_binaries')) { + precompiledBinaries = PrecompiledBinaries.parse(entry.value); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "cargo" or "precompiled_binaries".', + entry.key.span); + } + } + return CargokitCrateOptions( + cargo: options, + precompiledBinaries: precompiledBinaries, + ); + } + + static CargokitCrateOptions load({ + required String manifestDir, + }) { + final uri = Uri.file(path.join(manifestDir, "cargokit.yaml")); + final file = File.fromUri(uri); + if (file.existsSync()) { + final contents = loadYamlNode(file.readAsStringSync(), sourceUrl: uri); + return parse(contents); + } else { + return CargokitCrateOptions(); + } + } +} + +class CargokitUserOptions { + // When Rustup is installed always build locally unless user opts into + // using precompiled binaries. + static bool defaultUsePrecompiledBinaries() { + return Rustup.executablePath() == null; + } + + CargokitUserOptions({ + required this.usePrecompiledBinaries, + required this.verboseLogging, + }); + + CargokitUserOptions._() + : usePrecompiledBinaries = defaultUsePrecompiledBinaries(), + verboseLogging = false; + + static CargokitUserOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + bool usePrecompiledBinaries = defaultUsePrecompiledBinaries(); + bool verboseLogging = false; + + for (final entry in node.nodes.entries) { + if (entry.key case YamlScalar(value: 'use_precompiled_binaries')) { + if (entry.value case YamlScalar(value: bool value)) { + usePrecompiledBinaries = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "use_precompiled_binaries". Must be a boolean.', + entry.value.span); + } else if (entry.key case YamlScalar(value: 'verbose_logging')) { + if (entry.value case YamlScalar(value: bool value)) { + verboseLogging = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "verbose_logging". Must be a boolean.', + entry.value.span); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "use_precompiled_binaries" or "verbose_logging".', + entry.key.span); + } + } + return CargokitUserOptions( + usePrecompiledBinaries: usePrecompiledBinaries, + verboseLogging: verboseLogging, + ); + } + + static CargokitUserOptions load() { + String fileName = "cargokit_options.yaml"; + var userProjectDir = Directory(Environment.rootProjectDir); + + while (userProjectDir.parent.path != userProjectDir.path) { + final configFile = File(path.join(userProjectDir.path, fileName)); + if (configFile.existsSync()) { + final contents = loadYamlNode( + configFile.readAsStringSync(), + sourceUrl: configFile.uri, + ); + final res = parse(contents); + if (res.verboseLogging) { + _log.info('Found user options file at ${configFile.path}'); + } + return res; + } + userProjectDir = userProjectDir.parent; + } + return CargokitUserOptions._(); + } + + final bool usePrecompiledBinaries; + final bool verboseLogging; +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/precompile_binaries.dart b/native/komet_crypto/cargokit/build_tool/lib/src/precompile_binaries.dart new file mode 100644 index 0000000..019859c --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/precompile_binaries.dart @@ -0,0 +1,205 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; + +final _log = Logger('precompile_binaries'); + +class PrecompileBinaries { + PrecompileBinaries({ + required this.privateKey, + required this.githubToken, + required this.repositorySlug, + required this.manifestDir, + required this.targets, + this.androidSdkLocation, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.tempDir, + this.glibcVersion, + }); + + final PrivateKey privateKey; + final String githubToken; + final RepositorySlug repositorySlug; + final String manifestDir; + final List targets; + final String? androidSdkLocation; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? tempDir; + final String? glibcVersion; + + static String fileName(Target target, String name) { + return '${target.rust}_$name'; + } + + static String signatureFileName(Target target, String name) { + return '${target.rust}_$name.sig'; + } + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final targets = List.of(this.targets); + if (targets.isEmpty) { + targets.addAll([ + ...Target.buildableTargets(), + if (androidSdkLocation != null) ...Target.androidTargets(), + ]); + } + + _log.info('Precompiling binaries for $targets'); + + final hash = CrateHash.compute(manifestDir); + _log.info('Computed crate hash: $hash'); + + final String tagName = 'precompiled_$hash'; + + final github = GitHub(auth: Authentication.withToken(githubToken)); + final repo = github.repositories; + final release = await _getOrCreateRelease( + repo: repo, + tagName: tagName, + packageName: crateInfo.packageName, + hash: hash, + ); + + final tempDir = this.tempDir != null + ? Directory(this.tempDir!) + : Directory.systemTemp.createTempSync('precompiled_'); + + tempDir.createSync(recursive: true); + + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + + final buildEnvironment = BuildEnvironment( + configuration: BuildConfiguration.release, + crateOptions: crateOptions, + targetTempDir: tempDir.path, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: androidSdkLocation != null, + androidSdkPath: androidSdkLocation, + androidNdkVersion: androidNdkVersion, + androidMinSdkVersion: androidMinSdkVersion, + glibcVersion: glibcVersion, + ); + + final rustup = Rustup(); + + for (final target in targets) { + final artifactNames = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + if (artifactNames.every((name) { + final fileName = PrecompileBinaries.fileName(target, name); + return (release.assets ?? []).any((e) => e.name == fileName); + })) { + _log.info("All artifacts for $target already exist - skipping"); + continue; + } + + _log.info('Building for $target'); + + final builder = + RustBuilder(target: target, environment: buildEnvironment); + builder.prepare(rustup); + final res = await builder.build(); + + final assets = []; + for (final name in artifactNames) { + final file = File(path.join(res, name)); + if (!file.existsSync()) { + throw Exception('Missing artifact: ${file.path}'); + } + + final data = file.readAsBytesSync(); + final create = CreateReleaseAsset( + name: PrecompileBinaries.fileName(target, name), + contentType: "application/octet-stream", + assetData: data, + ); + final signature = sign(privateKey, data); + final signatureCreate = CreateReleaseAsset( + name: signatureFileName(target, name), + contentType: "application/octet-stream", + assetData: signature, + ); + bool verified = verify(public(privateKey), data, signature); + if (!verified) { + throw Exception('Signature verification failed'); + } + assets.add(create); + assets.add(signatureCreate); + } + _log.info('Uploading assets: ${assets.map((e) => e.name)}'); + for (final asset in assets) { + // This seems to be failing on CI so do it one by one + int retryCount = 0; + while (true) { + try { + await repo.uploadReleaseAssets(release, [asset]); + break; + } on Exception catch (e) { + if (retryCount == 10) { + rethrow; + } + ++retryCount; + _log.shout( + 'Upload failed (attempt $retryCount, will retry): ${e.toString()}'); + await Future.delayed(Duration(seconds: 2)); + } + } + } + } + + _log.info('Cleaning up'); + tempDir.deleteSync(recursive: true); + } + + Future _getOrCreateRelease({ + required RepositoriesService repo, + required String tagName, + required String packageName, + required String hash, + }) async { + Release release; + try { + _log.info('Fetching release $tagName'); + release = await repo.getReleaseByTagName(repositorySlug, tagName); + } on ReleaseNotFound { + _log.info('Release not found - creating release $tagName'); + release = await repo.createRelease( + repositorySlug, + CreateRelease.from( + tagName: tagName, + name: 'Precompiled binaries ${hash.substring(0, 8)}', + targetCommitish: null, + isDraft: false, + isPrerelease: false, + body: 'Precompiled binaries for crate $packageName, ' + 'crate hash $hash.', + )); + } + return release; + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/rustup.dart b/native/komet_crypto/cargokit/build_tool/lib/src/rustup.dart new file mode 100644 index 0000000..e46722b --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/rustup.dart @@ -0,0 +1,149 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; + +import 'util.dart'; + +class _Toolchain { + _Toolchain( + this.name, + this.targets, + ); + + final String name; + final List targets; +} + +class Rustup { + List? installedTargets(String toolchain) { + final targets = _installedTargets(toolchain); + return targets != null ? List.unmodifiable(targets) : null; + } + + void installToolchain(String toolchain) { + log.info("Installing Rust toolchain: $toolchain"); + runCommand("rustup", ['toolchain', 'install', toolchain]); + _installedToolchains + .add(_Toolchain(toolchain, _getInstalledTargets(toolchain))); + } + + void installTarget( + String target, { + required String toolchain, + }) { + log.info("Installing Rust target: $target"); + runCommand("rustup", ['target', 'add', '--toolchain', toolchain, target]); + _installedTargets(toolchain)?.add(target); + } + + bool _didInstallZigBuild = false; + + void installZigBuild(String toolchain) { + if (_didInstallZigBuild) { + return; + } + + log.info("Installing Zig build"); + runCommand("rustup", [ + 'run', + toolchain, + 'cargo', + 'install', + '--locked', + 'cargo-zigbuild', + ]); + _didInstallZigBuild = true; + } + + final List<_Toolchain> _installedToolchains; + + Rustup() : _installedToolchains = _getInstalledToolchains(); + + List? _installedTargets(String toolchain) => _installedToolchains + .firstWhereOrNull( + (e) => e.name == toolchain || e.name.startsWith('$toolchain-')) + ?.targets; + + static List<_Toolchain> _getInstalledToolchains() { + String extractToolchainName(String line) { + // ignore (default) after toolchain name + final parts = line.split(' '); + return parts[0]; + } + + final res = runCommand("rustup", ['toolchain', 'list']); + + // To list all non-custom toolchains, we need to filter out lines that + // don't start with "stable", "beta", or "nightly". + Pattern nonCustom = RegExp(r"^(stable|beta|nightly)"); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty && e.startsWith(nonCustom)) + .map(extractToolchainName) + .toList(growable: true); + + return lines + .map( + (name) => _Toolchain( + name, + _getInstalledTargets(name), + ), + ) + .toList(growable: true); + } + + static List _getInstalledTargets(String toolchain) { + final res = runCommand("rustup", [ + 'target', + 'list', + '--toolchain', + toolchain, + '--installed', + ]); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty) + .toList(growable: true); + return lines; + } + + bool _didInstallRustSrcForNightly = false; + + void installRustSrcForNightly() { + if (_didInstallRustSrcForNightly) { + return; + } + // Useful for -Z build-std + runCommand( + "rustup", + ['component', 'add', 'rust-src', '--toolchain', 'nightly'], + ); + _didInstallRustSrcForNightly = true; + } + + static String? executablePath() { + final envPath = Platform.environment['PATH']; + final envPathSeparator = Platform.isWindows ? ';' : ':'; + final home = Platform.isWindows + ? Platform.environment['USERPROFILE'] + : Platform.environment['HOME']; + final paths = [ + if (home != null) path.join(home, '.cargo', 'bin'), + if (envPath != null) ...envPath.split(envPathSeparator), + ]; + for (final p in paths) { + final rustup = Platform.isWindows ? 'rustup.exe' : 'rustup'; + final rustupPath = path.join(p, rustup); + if (File(rustupPath).existsSync()) { + return rustupPath; + } + } + return null; + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/target.dart b/native/komet_crypto/cargokit/build_tool/lib/src/target.dart new file mode 100644 index 0000000..624504e --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/target.dart @@ -0,0 +1,147 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; + +import 'util.dart'; + +class Target { + Target({ + required this.rust, + this.flutter, + this.android, + this.androidMinSdkVersion, + this.darwinPlatform, + this.darwinArch, + }); + + static final all = [ + Target( + rust: 'armv7-linux-androideabi', + flutter: 'android-arm', + android: 'armeabi-v7a', + androidMinSdkVersion: 16, + ), + Target( + rust: 'aarch64-linux-android', + flutter: 'android-arm64', + android: 'arm64-v8a', + androidMinSdkVersion: 21, + ), + Target( + rust: 'i686-linux-android', + flutter: 'android-x86', + android: 'x86', + androidMinSdkVersion: 16, + ), + Target( + rust: 'x86_64-linux-android', + flutter: 'android-x64', + android: 'x86_64', + androidMinSdkVersion: 21, + ), + Target( + rust: 'x86_64-pc-windows-msvc', + flutter: 'windows-x64', + ), + Target( + rust: 'aarch64-pc-windows-msvc', + flutter: 'windows-arm64', + ), + Target( + rust: 'x86_64-unknown-linux-gnu', + flutter: 'linux-x64', + ), + Target( + rust: 'aarch64-unknown-linux-gnu', + flutter: 'linux-arm64', + ), + Target(rust: 'riscv64gc-unknown-linux-gnu', flutter: 'linux-riscv64'), + Target( + rust: 'x86_64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'x86_64', + ), + Target( + rust: 'aarch64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios', + darwinPlatform: 'iphoneos', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios-sim', + darwinPlatform: 'iphonesimulator', + darwinArch: 'arm64', + ), + Target( + rust: 'x86_64-apple-ios', + darwinPlatform: 'iphonesimulator', + darwinArch: 'x86_64', + ), + ]; + + static Target? forFlutterName(String flutterName) { + return all.firstWhereOrNull((element) => element.flutter == flutterName); + } + + static Target? forDarwin({ + required String platformName, + required String darwinAarch, + }) { + return all.firstWhereOrNull((element) => // + element.darwinPlatform == platformName && + element.darwinArch == darwinAarch); + } + + static Target? forRustTriple(String triple) { + return all.firstWhereOrNull((element) => element.rust == triple); + } + + static List androidTargets() { + return all + .where((element) => element.android != null) + .toList(growable: false); + } + + /// Returns buildable targets on current host platform ignoring Android targets. + static List buildableTargets() { + if (Platform.isLinux) { + // Right now we don't support cross-compiling on Linux. So we just return + // the host target. + final arch = (runCommand('arch', []).stdout as String).trim(); + if (arch == 'aarch64') { + return [Target.forRustTriple('aarch64-unknown-linux-gnu')!]; + } else if (arch == 'riscv64') { + return [Target.forRustTriple('riscv64gc-unknown-linux-gnu')!]; + } else { + return [Target.forRustTriple('x86_64-unknown-linux-gnu')!]; + } + } + return all.where((target) { + if (Platform.isWindows) { + return target.rust.contains('-windows-'); + } else if (Platform.isMacOS) { + return target.darwinPlatform != null; + } + return false; + }).toList(growable: false); + } + + @override + String toString() { + return rust; + } + + final String? flutter; + final String rust; + final String? android; + final int? androidMinSdkVersion; + final String? darwinPlatform; + final String? darwinArch; +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/util.dart b/native/komet_crypto/cargokit/build_tool/lib/src/util.dart new file mode 100644 index 0000000..8bb6a87 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/util.dart @@ -0,0 +1,172 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'logging.dart'; +import 'rustup.dart'; + +final log = Logger("process"); + +class CommandFailedException implements Exception { + final String executable; + final List arguments; + final ProcessResult result; + + CommandFailedException({ + required this.executable, + required this.arguments, + required this.result, + }); + + @override + String toString() { + final stdout = result.stdout.toString().trim(); + final stderr = result.stderr.toString().trim(); + return [ + "External Command: $executable ${arguments.map((e) => '"$e"').join(' ')}", + "Returned Exit Code: ${result.exitCode}", + kSeparator, + "STDOUT:", + if (stdout.isNotEmpty) stdout, + kSeparator, + "STDERR:", + if (stderr.isNotEmpty) stderr, + ].join('\n'); + } +} + +class TestRunCommandArgs { + final String executable; + final List arguments; + final String? workingDirectory; + final Map? environment; + final bool includeParentEnvironment; + final bool runInShell; + final Encoding? stdoutEncoding; + final Encoding? stderrEncoding; + + TestRunCommandArgs({ + required this.executable, + required this.arguments, + this.workingDirectory, + this.environment, + this.includeParentEnvironment = true, + this.runInShell = false, + this.stdoutEncoding, + this.stderrEncoding, + }); +} + +class TestRunCommandResult { + TestRunCommandResult({ + this.pid = 1, + this.exitCode = 0, + this.stdout = '', + this.stderr = '', + }); + + final int pid; + final int exitCode; + final String stdout; + final String stderr; +} + +TestRunCommandResult Function(TestRunCommandArgs args)? testRunCommandOverride; + +ProcessResult runCommand( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, +}) { + if (testRunCommandOverride != null) { + final result = testRunCommandOverride!(TestRunCommandArgs( + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + )); + return ProcessResult( + result.pid, + result.exitCode, + result.stdout, + result.stderr, + ); + } + log.finer('Running command $executable ${arguments.join(' ')}'); + final res = Process.runSync( + _resolveExecutable(executable), + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stderrEncoding: stderrEncoding, + stdoutEncoding: stdoutEncoding, + ); + if (res.exitCode != 0) { + throw CommandFailedException( + executable: executable, + arguments: arguments, + result: res, + ); + } else { + return res; + } +} + +class RustupNotFoundException implements Exception { + @override + String toString() { + return [ + ' ', + 'rustup not found in PATH.', + ' ', + 'Maybe you need to install Rust? It only takes a minute:', + ' ', + if (Platform.isWindows) 'https://www.rust-lang.org/tools/install', + if (hasHomebrewRustInPath()) ...[ + '\$ brew unlink rust # Unlink homebrew Rust from PATH', + ], + if (!Platform.isWindows) + "\$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh", + ' ', + ].join('\n'); + } + + static bool hasHomebrewRustInPath() { + if (!Platform.isMacOS) { + return false; + } + final envPath = Platform.environment['PATH'] ?? ''; + final paths = envPath.split(':'); + return paths.any((p) { + return p.contains('homebrew') && File(path.join(p, 'rustc')).existsSync(); + }); + } +} + +String _resolveExecutable(String executable) { + if (executable == 'rustup') { + final resolved = Rustup.executablePath(); + if (resolved != null) { + return resolved; + } + throw RustupNotFoundException(); + } else { + return executable; + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/verify_binaries.dart b/native/komet_crypto/cargokit/build_tool/lib/src/verify_binaries.dart new file mode 100644 index 0000000..2366b57 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/verify_binaries.dart @@ -0,0 +1,84 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; + +import 'artifacts_provider.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; + +class VerifyBinaries { + VerifyBinaries({ + required this.manifestDir, + }); + + final String manifestDir; + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final config = CargokitCrateOptions.load(manifestDir: manifestDir); + final precompiledBinaries = config.precompiledBinaries; + if (precompiledBinaries == null) { + stdout.writeln('Crate does not support precompiled binaries.'); + } else { + final crateHash = CrateHash.compute(manifestDir); + stdout.writeln('Crate hash: $crateHash'); + + for (final target in Target.all) { + final message = 'Checking ${target.rust}...'; + stdout.write(message.padRight(40)); + stdout.flush(); + + final artifacts = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + final prefix = precompiledBinaries.uriPrefix; + + bool ok = true; + + for (final artifact in artifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = + Uri.parse('$prefix$crateHash/$signatureFileName'); + + final signature = await get(signatureUrl); + if (signature.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + final asset = await get(url); + if (asset.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + + if (!verify(precompiledBinaries.publicKey, asset.bodyBytes, + signature.bodyBytes)) { + stdout.writeln('INVALID SIGNATURE'); + ok = false; + } + } + + if (ok) { + stdout.writeln('OK'); + } + } + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/pubspec.lock b/native/komet_crypto/cargokit/build_tool/pubspec.lock new file mode 100644 index 0000000..343bdd3 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/pubspec.lock @@ -0,0 +1,453 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + url: "https://pub.dev" + source: hosted + version: "64.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + args: + dependency: "direct main" + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: "direct main" + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + url: "https://pub.dev" + source: hosted + version: "1.6.3" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + ed25519_edwards: + dependency: "direct main" + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + github: + dependency: "direct main" + description: + name: github + sha256: "9966bc13bf612342e916b0a343e95e5f046c88f602a14476440e9b75d2295411" + url: "https://pub.dev" + source: hosted + version: "9.17.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + hex: + dependency: "direct main" + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + meta: + dependency: transitive + description: + name: meta + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: "direct main" + description: + name: path + sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: "direct main" + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "9b0dd8e36af4a5b1569029949d50a52cb2a2a2fdaa20cebb96e6603b9ae241f9" + url: "https://pub.dev" + source: hosted + version: "1.24.6" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + test_core: + dependency: transitive + description: + name: test_core + sha256: "4bef837e56375537055fdbbbf6dd458b1859881f4c7e6da936158f77d61ab265" + url: "https://pub.dev" + source: hosted + version: "0.5.6" + toml: + dependency: "direct main" + description: + name: toml + sha256: "157c5dca5160fced243f3ce984117f729c788bb5e475504f3dbcda881accee44" + url: "https://pub.dev" + source: hosted + version: "0.14.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + version: + dependency: "direct main" + description: + name: version + sha256: "2307e23a45b43f96469eeab946208ed63293e8afca9c28cd8b5241ff31c55f55" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d" + url: "https://pub.dev" + source: hosted + version: "11.9.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/native/komet_crypto/cargokit/build_tool/pubspec.yaml b/native/komet_crypto/cargokit/build_tool/pubspec.yaml new file mode 100644 index 0000000..18c61e3 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/pubspec.yaml @@ -0,0 +1,33 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +name: build_tool +description: Cargokit build_tool. Facilitates the build of Rust crate during Flutter application build. +publish_to: none +version: 1.0.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + +# Add regular dependencies here. +dependencies: + # these are pinned on purpose because the bundle_tool_runner doesn't have + # pubspec.lock. See run_build_tool.sh + logging: 1.2.0 + path: 1.8.0 + version: 3.0.0 + collection: 1.18.0 + ed25519_edwards: 0.3.1 + hex: 0.2.0 + yaml: 3.1.2 + source_span: 1.10.0 + github: 9.17.0 + args: 2.4.2 + crypto: 3.0.3 + convert: 3.1.1 + http: 1.1.0 + toml: 0.14.0 + +dev_dependencies: + lints: ^2.1.0 + test: ^1.24.0 diff --git a/native/komet_crypto/cargokit/cmake/cargokit.cmake b/native/komet_crypto/cargokit/cmake/cargokit.cmake new file mode 100644 index 0000000..ddd05df --- /dev/null +++ b/native/komet_crypto/cargokit/cmake/cargokit.cmake @@ -0,0 +1,99 @@ +SET(cargokit_cmake_root "${CMAKE_CURRENT_LIST_DIR}/..") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +get_filename_component(cargokit_cmake_root "${cargokit_cmake_root}" REALPATH) + +if(WIN32) + # REALPATH does not properly resolve symlinks on windows :-/ + execute_process(COMMAND powershell -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_LIST_DIR}/resolve_symlinks.ps1" "${cargokit_cmake_root}" OUTPUT_VARIABLE cargokit_cmake_root OUTPUT_STRIP_TRAILING_WHITESPACE) +endif() + +# Arguments +# - target: CMAKE target to which rust library is linked +# - manifest_dir: relative path from current folder to directory containing cargo manifest +# - lib_name: cargo package name +# - any_symbol_name: name of any exported symbol from the library. +# used on windows to force linking with library. +function(apply_cargokit target manifest_dir lib_name any_symbol_name) + + set(CARGOKIT_LIB_NAME "${lib_name}") + set(CARGOKIT_LIB_FULL_NAME "${CMAKE_SHARED_MODULE_PREFIX}${CARGOKIT_LIB_NAME}${CMAKE_SHARED_MODULE_SUFFIX}") + if (CMAKE_CONFIGURATION_TYPES) + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/$") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/$/${CARGOKIT_LIB_FULL_NAME}") + else() + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/${CARGOKIT_LIB_FULL_NAME}") + endif() + set(CARGOKIT_TEMP_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargokit_build") + + if (FLUTTER_TARGET_PLATFORM) + set(CARGOKIT_TARGET_PLATFORM "${FLUTTER_TARGET_PLATFORM}") + else() + set(CARGOKIT_TARGET_PLATFORM "windows-x64") + endif() + + set(CARGOKIT_ENV + "CARGOKIT_CMAKE=${CMAKE_COMMAND}" + "CARGOKIT_CONFIGURATION=$" + "CARGOKIT_MANIFEST_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${manifest_dir}" + "CARGOKIT_TARGET_TEMP_DIR=${CARGOKIT_TEMP_DIR}" + "CARGOKIT_OUTPUT_DIR=${CARGOKIT_OUTPUT_DIR}" + "CARGOKIT_TARGET_PLATFORM=${CARGOKIT_TARGET_PLATFORM}" + "CARGOKIT_TOOL_TEMP_DIR=${CARGOKIT_TEMP_DIR}/tool" + "CARGOKIT_ROOT_PROJECT_DIR=${CMAKE_SOURCE_DIR}" + ) + + if (WIN32) + set(SCRIPT_EXTENSION ".cmd") + set(IMPORT_LIB_EXTENSION ".lib") + else() + set(SCRIPT_EXTENSION ".sh") + set(IMPORT_LIB_EXTENSION "") + execute_process(COMMAND chmod +x "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}") + endif() + + # Using generators in custom command is only supported in CMake 3.20+ + if (CMAKE_CONFIGURATION_TYPES AND ${CMAKE_VERSION} VERSION_LESS "3.20.0") + foreach(CONFIG IN LISTS CMAKE_CONFIGURATION_TYPES) + add_custom_command( + OUTPUT + "${CMAKE_CURRENT_BINARY_DIR}/${CONFIG}/${CARGOKIT_LIB_FULL_NAME}" + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endforeach() + else() + add_custom_command( + OUTPUT + ${OUTPUT_LIB} + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endif() + + + set_source_files_properties("${CMAKE_CURRENT_BINARY_DIR}/_phony_" PROPERTIES SYMBOLIC TRUE) + + if (TARGET ${target}) + # If we have actual cmake target provided create target and make existing + # target depend on it + add_custom_target("${target}_cargokit" DEPENDS ${OUTPUT_LIB}) + add_dependencies("${target}" "${target}_cargokit") + target_link_libraries("${target}" PRIVATE "${OUTPUT_LIB}${IMPORT_LIB_EXTENSION}") + if(WIN32) + target_link_options(${target} PRIVATE "/INCLUDE:${any_symbol_name}") + endif() + else() + # Otherwise (FFI) just use ALL to force building always + add_custom_target("${target}_cargokit" ALL DEPENDS ${OUTPUT_LIB}) + endif() + + # Allow adding the output library to plugin bundled libraries + set("${target}_cargokit_lib" ${OUTPUT_LIB} PARENT_SCOPE) + +endfunction() diff --git a/native/komet_crypto/cargokit/cmake/resolve_symlinks.ps1 b/native/komet_crypto/cargokit/cmake/resolve_symlinks.ps1 new file mode 100644 index 0000000..2ac593a --- /dev/null +++ b/native/komet_crypto/cargokit/cmake/resolve_symlinks.ps1 @@ -0,0 +1,34 @@ +function Resolve-Symlinks { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [string] $Path + ) + + [string] $separator = '/' + [string[]] $parts = $Path.Split($separator) + + [string] $realPath = '' + foreach ($part in $parts) { + if ($realPath -and !$realPath.EndsWith($separator)) { + $realPath += $separator + } + + $realPath += $part.Replace('\', '/') + + # The slash is important when using Get-Item on Drive letters in pwsh. + if (-not($realPath.Contains($separator)) -and $realPath.EndsWith(':')) { + $realPath += '/' + } + + $item = Get-Item $realPath + if ($item.LinkTarget) { + $realPath = $item.LinkTarget.Replace('\', '/') + } + } + $realPath +} + +$path = Resolve-Symlinks -Path $args[0] +Write-Host $path diff --git a/native/komet_crypto/cargokit/gradle/plugin.gradle b/native/komet_crypto/cargokit/gradle/plugin.gradle new file mode 100644 index 0000000..68ff649 --- /dev/null +++ b/native/komet_crypto/cargokit/gradle/plugin.gradle @@ -0,0 +1,184 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import java.nio.file.Paths +import org.apache.tools.ant.taskdefs.condition.Os + +CargoKitPlugin.file = buildscript.sourceFile + +apply plugin: CargoKitPlugin + +class CargoKitExtension { + String manifestDir; // Relative path to folder containing Cargo.toml + String libname; // Library name within Cargo.toml. Must be a cdylib +} + +abstract class CargoKitBuildTask extends DefaultTask { + + @Input + String buildMode + + @Input + String buildDir + + @Input + String outputDir + + @Input + String ndkVersion + + @Input + String sdkDirectory + + @Input + int compileSdkVersion; + + @Input + int minSdkVersion; + + @Input + String pluginFile + + @Input + List targetPlatforms + + @TaskAction + def build() { + if (project.cargokit.manifestDir == null) { + throw new GradleException("Property 'manifestDir' must be set on cargokit extension"); + } + + if (project.cargokit.libname == null) { + throw new GradleException("Property 'libname' must be set on cargokit extension"); + } + + def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh" + def path = Paths.get(new File(pluginFile).parent, "..", executableName); + + def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir) + + def rootProjectDir = project.rootProject.projectDir + + if (!Os.isFamily(Os.FAMILY_WINDOWS)) { + project.exec { + commandLine 'chmod', '+x', path + } + } + + project.exec { + executable path + args "build-gradle" + environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir + environment "CARGOKIT_TOOL_TEMP_DIR", "${buildDir}/build_tool" + environment "CARGOKIT_MANIFEST_DIR", manifestDir + environment "CARGOKIT_CONFIGURATION", buildMode + environment "CARGOKIT_TARGET_TEMP_DIR", buildDir + environment "CARGOKIT_OUTPUT_DIR", outputDir + environment "CARGOKIT_NDK_VERSION", ndkVersion + environment "CARGOKIT_SDK_DIR", sdkDirectory + environment "CARGOKIT_COMPILE_SDK_VERSION", compileSdkVersion + environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion + environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",") + environment "CARGOKIT_JAVA_HOME", System.properties['java.home'] + } + } +} + +class CargoKitPlugin implements Plugin { + + static String file; + + private Plugin findFlutterPlugin(Project rootProject) { + _findFlutterPlugin(rootProject.childProjects) + } + + private Plugin _findFlutterPlugin(Map projects) { + for (project in projects) { + for (plugin in project.value.getPlugins()) { + if (plugin.class.name == "com.flutter.gradle.FlutterPlugin" || plugin.class.name == "FlutterPlugin") { + return plugin; + } + } + def plugin = _findFlutterPlugin(project.value.childProjects); + if (plugin != null) { + return plugin; + } + } + return null; + } + + @Override + void apply(Project project) { + def plugin = findFlutterPlugin(project.rootProject); + + project.extensions.create("cargokit", CargoKitExtension) + + if (plugin == null) { + print("Flutter plugin not found, CargoKit plugin will not be applied.") + return; + } + + def cargoBuildDir = "${project.buildDir}/build" + + // Determine if the project is an application or library + def isApplication = plugin.project.plugins.hasPlugin('com.android.application') + def variants = isApplication ? plugin.project.android.applicationVariants : plugin.project.android.libraryVariants + + variants.all { variant -> + + final buildType = variant.buildType.name + + def cargoOutputDir = "${project.buildDir}/jniLibs/${buildType}"; + def jniLibs = project.android.sourceSets.maybeCreate(buildType).jniLibs; + jniLibs.srcDir(new File(cargoOutputDir)) + + def List platforms + try { + platforms = com.flutter.gradle.FlutterPluginUtils.getTargetPlatforms(project).collect() + } catch (Exception ignored) { + platforms = plugin.getTargetPlatforms().collect() + } + + // Same thing addFlutterDependencies does in flutter.gradle + if (buildType == "debug") { + platforms.add("android-x86") + platforms.add("android-x64") + } + + // The task name depends on plugin properties, which are not available + // at this point + project.getGradle().afterProject { + def taskName = "cargokitCargoBuild${project.cargokit.libname.capitalize()}${buildType.capitalize()}"; + + if (project.tasks.findByName(taskName)) { + return + } + + if (plugin.project.android.ndkVersion == null) { + throw new GradleException("Please set 'android.ndkVersion' in 'app/build.gradle'.") + } + + def task = project.tasks.create(taskName, CargoKitBuildTask.class) { + buildMode = variant.buildType.name + buildDir = cargoBuildDir + outputDir = cargoOutputDir + ndkVersion = plugin.project.android.ndkVersion + sdkDirectory = plugin.project.android.sdkDirectory + minSdkVersion = plugin.project.android.defaultConfig.minSdkVersion.apiLevel as int + compileSdkVersion = plugin.project.android.compileSdkVersion.substring(8) as int + targetPlatforms = platforms + pluginFile = CargoKitPlugin.file + } + def onTask = { newTask -> + if (newTask.name == "merge${buildType.capitalize()}NativeLibs") { + newTask.dependsOn task + // Fix gradle 7.4.2 not picking up JNI library changes + newTask.outputs.upToDateWhen { false } + } + } + project.tasks.each onTask + project.tasks.whenTaskAdded onTask + } + } + } +} diff --git a/native/komet_crypto/cargokit/run_build_tool.cmd b/native/komet_crypto/cargokit/run_build_tool.cmd new file mode 100755 index 0000000..c45d0aa --- /dev/null +++ b/native/komet_crypto/cargokit/run_build_tool.cmd @@ -0,0 +1,91 @@ +@echo off +setlocal + +setlocal ENABLEDELAYEDEXPANSION + +SET BASEDIR=%~dp0 + +if not exist "%CARGOKIT_TOOL_TEMP_DIR%" ( + mkdir "%CARGOKIT_TOOL_TEMP_DIR%" +) +cd /D "%CARGOKIT_TOOL_TEMP_DIR%" + +SET BUILD_TOOL_PKG_DIR=%BASEDIR%build_tool +SET DART=%FLUTTER_ROOT%\bin\cache\dart-sdk\bin\dart + +set BUILD_TOOL_PKG_DIR_POSIX=%BUILD_TOOL_PKG_DIR:\=/% + +( + echo name: build_tool_runner + echo version: 1.0.0 + echo publish_to: none + echo. + echo environment: + echo sdk: '^>=3.0.0 ^<4.0.0' + echo. + echo dependencies: + echo build_tool: + echo path: %BUILD_TOOL_PKG_DIR_POSIX% +) >pubspec.yaml + +if not exist bin ( + mkdir bin +) + +( + echo import 'package:build_tool/build_tool.dart' as build_tool; + echo void main^(List^ args^) ^{ + echo build_tool.runMain^(args^); + echo ^} +) >bin\build_tool_runner.dart + +SET PRECOMPILED=bin\build_tool_runner.dill + +REM To detect changes in package we compare output of DIR /s (recursive) +set PREV_PACKAGE_INFO=.dart_tool\package_info.prev +set CUR_PACKAGE_INFO=.dart_tool\package_info.cur + +DIR "%BUILD_TOOL_PKG_DIR%" /s > "%CUR_PACKAGE_INFO%_orig" + +REM Last line in dir output is free space on harddrive. That is bound to +REM change between invocation so we need to remove it +( + Set "Line=" + For /F "UseBackQ Delims=" %%A In ("%CUR_PACKAGE_INFO%_orig") Do ( + SetLocal EnableDelayedExpansion + If Defined Line Echo !Line! + EndLocal + Set "Line=%%A") +) >"%CUR_PACKAGE_INFO%" +DEL "%CUR_PACKAGE_INFO%_orig" + +REM Compare current directory listing with previous +FC /B "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" > nul 2>&1 + +If %ERRORLEVEL% neq 0 ( + REM Changed - copy current to previous and remove precompiled kernel + if exist "%PREV_PACKAGE_INFO%" ( + DEL "%PREV_PACKAGE_INFO%" + ) + MOVE /Y "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" + if exist "%PRECOMPILED%" ( + DEL "%PRECOMPILED%" + ) +) + +REM There is no CUR_PACKAGE_INFO it was renamed in previous step to %PREV_PACKAGE_INFO% +REM which means we need to do pub get and precompile +if not exist "%PRECOMPILED%" ( + echo Running pub get in "%cd%" + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart +) + +"%DART%" "%PRECOMPILED%" %* + +REM 253 means invalid snapshot version. +If %ERRORLEVEL% equ 253 ( + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart + "%DART%" "%PRECOMPILED%" %* +) diff --git a/native/komet_crypto/cargokit/run_build_tool.sh b/native/komet_crypto/cargokit/run_build_tool.sh new file mode 100755 index 0000000..24b0ed8 --- /dev/null +++ b/native/komet_crypto/cargokit/run_build_tool.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +set -e + +BASEDIR=$(dirname "$0") + +mkdir -p "$CARGOKIT_TOOL_TEMP_DIR" + +cd "$CARGOKIT_TOOL_TEMP_DIR" + +# Write a very simple bin package in temp folder that depends on build_tool package +# from Cargokit. This is done to ensure that we don't pollute Cargokit folder +# with .dart_tool contents. + +BUILD_TOOL_PKG_DIR="$BASEDIR/build_tool" + +if [[ -z $FLUTTER_ROOT ]]; then # not defined + DART=dart +else + DART="$FLUTTER_ROOT/bin/cache/dart-sdk/bin/dart" +fi + +cat << EOF > "pubspec.yaml" +name: build_tool_runner +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + build_tool: + path: "$BUILD_TOOL_PKG_DIR" +EOF + +mkdir -p "bin" + +cat << EOF > "bin/build_tool_runner.dart" +import 'package:build_tool/build_tool.dart' as build_tool; +void main(List args) { + build_tool.runMain(args); +} +EOF + +# Create alias for `shasum` if it does not exist and `sha1sum` exists +if ! [ -x "$(command -v shasum)" ] && [ -x "$(command -v sha1sum)" ]; then + shopt -s expand_aliases + alias shasum="sha1sum" +fi + +# Dart run will not cache any package that has a path dependency, which +# is the case for our build_tool_runner. So instead we precompile the package +# ourselves. +# To invalidate the cached kernel we use the hash of ls -LR of the build_tool +# package directory. This should be good enough, as the build_tool package +# itself is not meant to have any path dependencies. + +if [[ "$OSTYPE" == "darwin"* ]]; then + PACKAGE_HASH=$(ls -lTR "$BUILD_TOOL_PKG_DIR" | shasum) +else + PACKAGE_HASH=$(ls -lR --full-time "$BUILD_TOOL_PKG_DIR" | shasum) +fi + +PACKAGE_HASH_FILE=".package_hash" + +if [ -f "$PACKAGE_HASH_FILE" ]; then + EXISTING_HASH=$(cat "$PACKAGE_HASH_FILE") + if [ "$PACKAGE_HASH" != "$EXISTING_HASH" ]; then + rm "$PACKAGE_HASH_FILE" + fi +fi + +# Run pub get if needed. +if [ ! -f "$PACKAGE_HASH_FILE" ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + echo "$PACKAGE_HASH" > "$PACKAGE_HASH_FILE" +fi + +# Rebuild the tool if it was deleted by Android Studio +if [ ! -f "bin/build_tool_runner.dill" ]; then + "$DART" compile kernel bin/build_tool_runner.dart +fi + +set +e + +"$DART" bin/build_tool_runner.dill "$@" + +exit_code=$? + +# 253 means invalid snapshot version. +if [ $exit_code == 253 ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + "$DART" bin/build_tool_runner.dill "$@" + exit_code=$? +fi + +exit $exit_code diff --git a/native/komet_crypto/flutter_rust_bridge.yaml b/native/komet_crypto/flutter_rust_bridge.yaml new file mode 100644 index 0000000..e15ed91 --- /dev/null +++ b/native/komet_crypto/flutter_rust_bridge.yaml @@ -0,0 +1,3 @@ +rust_input: crate::api +rust_root: rust/ +dart_output: lib/src/rust \ No newline at end of file diff --git a/native/komet_crypto/ios/Classes/dummy_file.c b/native/komet_crypto/ios/Classes/dummy_file.c new file mode 100644 index 0000000..e06dab9 --- /dev/null +++ b/native/komet_crypto/ios/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/native/komet_crypto/ios/komet_crypto.podspec b/native/komet_crypto/ios/komet_crypto.podspec new file mode 100644 index 0000000..84e4a5c --- /dev/null +++ b/native/komet_crypto/ios/komet_crypto.podspec @@ -0,0 +1,46 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint komet_crypto.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'komet_crypto' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.module_name = 'komet_crypto' + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '11.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust komet_crypto', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${PODS_CONFIGURATION_BUILD_DIR}/komet_crypto/libkomet_crypto.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${PODS_CONFIGURATION_BUILD_DIR}/komet_crypto/libkomet_crypto.a', + } +end \ No newline at end of file diff --git a/native/komet_crypto/lib/komet_crypto.dart b/native/komet_crypto/lib/komet_crypto.dart new file mode 100644 index 0000000..1b81183 --- /dev/null +++ b/native/komet_crypto/lib/komet_crypto.dart @@ -0,0 +1,4 @@ +library; + +export 'src/rust/api/crypto.dart'; +export 'src/rust/frb_generated.dart' show RustLib; diff --git a/native/komet_crypto/lib/src/rust/api/crypto.dart b/native/komet_crypto/lib/src/rust/api/crypto.dart new file mode 100644 index 0000000..149cbd6 --- /dev/null +++ b/native/komet_crypto/lib/src/rust/api/crypto.dart @@ -0,0 +1,40 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +// These functions are ignored because they are not marked as `pub`: `transform_file` + +Future deriveKey({required String password}) => + RustLib.instance.api.crateApiCryptoDeriveKey(password: password); + +Future encryptMessage( + {required String plaintext, required List key}) => + RustLib.instance.api + .crateApiCryptoEncryptMessage(plaintext: plaintext, key: key); + +Future decryptMessage({required String text, required List key}) => + RustLib.instance.api.crateApiCryptoDecryptMessage(text: text, key: key); + +Future looksEncrypted({required String text}) => + RustLib.instance.api.crateApiCryptoLooksEncrypted(text: text); + +Future encryptImageFile( + {required String sourcePath, + required String destPath, + required List key}) => + RustLib.instance.api.crateApiCryptoEncryptImageFile( + sourcePath: sourcePath, destPath: destPath, key: key); + +Future decryptImageFile( + {required String sourcePath, + required String destPath, + required List key}) => + RustLib.instance.api.crateApiCryptoDecryptImageFile( + sourcePath: sourcePath, destPath: destPath, key: key); + +Future looksEncryptedImageFile({required String path}) => + RustLib.instance.api.crateApiCryptoLooksEncryptedImageFile(path: path); diff --git a/native/komet_crypto/lib/src/rust/frb_generated.dart b/native/komet_crypto/lib/src/rust/frb_generated.dart new file mode 100644 index 0000000..e80d936 --- /dev/null +++ b/native/komet_crypto/lib/src/rust/frb_generated.dart @@ -0,0 +1,427 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/crypto.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + @internal + static final instance = RustLib._(); + + RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({ + required RustLibApi api, + }) { + instance.initMockImpl( + api: api, + ); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async {} + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.12.0'; + + @override + int get rustContentHash => -2021377439; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'komet_crypto', + ioDirectory: 'rust/target/release/', + webPrefix: 'pkg/', + wasmBindgenName: 'wasm_bindgen', + ); +} + +abstract class RustLibApi extends BaseApi { + Future crateApiCryptoDecryptImageFile( + {required String sourcePath, + required String destPath, + required List key}); + + Future crateApiCryptoDecryptMessage( + {required String text, required List key}); + + Future crateApiCryptoDeriveKey({required String password}); + + Future crateApiCryptoEncryptImageFile( + {required String sourcePath, + required String destPath, + required List key}); + + Future crateApiCryptoEncryptMessage( + {required String plaintext, required List key}); + + Future crateApiCryptoLooksEncrypted({required String text}); + + Future crateApiCryptoLooksEncryptedImageFile({required String path}); +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + Future crateApiCryptoDecryptImageFile( + {required String sourcePath, + required String destPath, + required List key}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(sourcePath, serializer); + sse_encode_String(destPath, serializer); + sse_encode_list_prim_u_8_loose(key, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 1, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoDecryptImageFileConstMeta, + argValues: [sourcePath, destPath, key], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoDecryptImageFileConstMeta => + const TaskConstMeta( + debugName: "decrypt_image_file", + argNames: ["sourcePath", "destPath", "key"], + ); + + @override + Future crateApiCryptoDecryptMessage( + {required String text, required List key}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(text, serializer); + sse_encode_list_prim_u_8_loose(key, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 2, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoDecryptMessageConstMeta, + argValues: [text, key], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoDecryptMessageConstMeta => + const TaskConstMeta( + debugName: "decrypt_message", + argNames: ["text", "key"], + ); + + @override + Future crateApiCryptoDeriveKey({required String password}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(password, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 3, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_prim_u_8_strict, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoDeriveKeyConstMeta, + argValues: [password], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoDeriveKeyConstMeta => const TaskConstMeta( + debugName: "derive_key", + argNames: ["password"], + ); + + @override + Future crateApiCryptoEncryptImageFile( + {required String sourcePath, + required String destPath, + required List key}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(sourcePath, serializer); + sse_encode_String(destPath, serializer); + sse_encode_list_prim_u_8_loose(key, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 4, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoEncryptImageFileConstMeta, + argValues: [sourcePath, destPath, key], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoEncryptImageFileConstMeta => + const TaskConstMeta( + debugName: "encrypt_image_file", + argNames: ["sourcePath", "destPath", "key"], + ); + + @override + Future crateApiCryptoEncryptMessage( + {required String plaintext, required List key}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(plaintext, serializer); + sse_encode_list_prim_u_8_loose(key, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 5, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoEncryptMessageConstMeta, + argValues: [plaintext, key], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoEncryptMessageConstMeta => + const TaskConstMeta( + debugName: "encrypt_message", + argNames: ["plaintext", "key"], + ); + + @override + Future crateApiCryptoLooksEncrypted({required String text}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(text, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 6, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: null, + ), + constMeta: kCrateApiCryptoLooksEncryptedConstMeta, + argValues: [text], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoLooksEncryptedConstMeta => + const TaskConstMeta( + debugName: "looks_encrypted", + argNames: ["text"], + ); + + @override + Future crateApiCryptoLooksEncryptedImageFile({required String path}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(path, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 7, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: null, + ), + constMeta: kCrateApiCryptoLooksEncryptedImageFileConstMeta, + argValues: [path], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoLooksEncryptedImageFileConstMeta => + const TaskConstMeta( + debugName: "looks_encrypted_image_file", + argNames: ["path"], + ); + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as List; + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_list_prim_u_8_loose( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer + .putUint8List(self is Uint8List ? self : Uint8List.fromList(self)); + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } +} diff --git a/native/komet_crypto/lib/src/rust/frb_generated.io.dart b/native/komet_crypto/lib/src/rust/frb_generated.io.dart new file mode 100644 index 0000000..e7d16ae --- /dev/null +++ b/native/komet_crypto/lib/src/rust/frb_generated.io.dart @@ -0,0 +1,96 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/crypto.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; +} diff --git a/native/komet_crypto/lib/src/rust/frb_generated.web.dart b/native/komet_crypto/lib/src/rust/frb_generated.web.dart new file mode 100644 index 0000000..080aa95 --- /dev/null +++ b/native/komet_crypto/lib/src/rust/frb_generated.web.dart @@ -0,0 +1,96 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'api/crypto.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(ExternalLibrary lib); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject {} diff --git a/native/komet_crypto/linux/CMakeLists.txt b/native/komet_crypto/linux/CMakeLists.txt new file mode 100644 index 0000000..b3f456d --- /dev/null +++ b/native/komet_crypto/linux/CMakeLists.txt @@ -0,0 +1,19 @@ +# The Flutter tooling requires that developers have CMake 3.10 or later +# installed. You should not increase this version, as doing so will cause +# the plugin to fail to compile for some customers of the plugin. +cmake_minimum_required(VERSION 3.10) + +# Project-level configuration. +set(PROJECT_NAME "komet_crypto") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../rust komet_crypto "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(komet_crypto_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/native/komet_crypto/macos/Classes/dummy_file.c b/native/komet_crypto/macos/Classes/dummy_file.c new file mode 100644 index 0000000..e06dab9 --- /dev/null +++ b/native/komet_crypto/macos/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/native/komet_crypto/macos/komet_crypto.podspec b/native/komet_crypto/macos/komet_crypto.podspec new file mode 100644 index 0000000..874d58e --- /dev/null +++ b/native/komet_crypto/macos/komet_crypto.podspec @@ -0,0 +1,45 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint komet_crypto.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'komet_crypto' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.module_name = 'komet_crypto' + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust komet_crypto', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${PODS_CONFIGURATION_BUILD_DIR}/komet_crypto/libkomet_crypto.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${PODS_CONFIGURATION_BUILD_DIR}/komet_crypto/libkomet_crypto.a', + } +end \ No newline at end of file diff --git a/native/komet_crypto/pubspec.yaml b/native/komet_crypto/pubspec.yaml new file mode 100644 index 0000000..49f73bd --- /dev/null +++ b/native/komet_crypto/pubspec.yaml @@ -0,0 +1,32 @@ +name: komet_crypto +description: "Message encryption core for Komet (Argon2id + ChaCha20-Poly1305 + Cyrillic base32)." +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.3.0 <4.0.0" + flutter: ">=3.3.0" + +dependencies: + flutter: + sdk: flutter + flutter_rust_bridge: 2.12.0 + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + ffiPlugin: true + ios: + ffiPlugin: true + linux: + ffiPlugin: true + macos: + ffiPlugin: true + windows: + ffiPlugin: true diff --git a/native/komet_crypto/rust/.gitignore b/native/komet_crypto/rust/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/native/komet_crypto/rust/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/native/komet_crypto/rust/Cargo.toml b/native/komet_crypto/rust/Cargo.toml new file mode 100644 index 0000000..9bbf845 --- /dev/null +++ b/native/komet_crypto/rust/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "komet_crypto" +version = "0.1.0" +edition = "2021" +description = "Message encryption core for Komet (Argon2id + ChaCha20-Poly1305 + Cyrillic base32)" +license = "MIT" + +[lib] +crate-type = ["cdylib", "staticlib", "rlib"] + +[dependencies] +argon2 = "0.5" +chacha20poly1305 = "0.10" +data-encoding = "2" +flutter_rust_bridge = "=2.12.0" +png = "0.17" +rand_core = { version = "0.6", features = ["getrandom"] } +sha2 = "0.10" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] } + +[workspace] + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "unwind" diff --git a/native/komet_crypto/rust/examples/demo.rs b/native/komet_crypto/rust/examples/demo.rs new file mode 100644 index 0000000..47d148f --- /dev/null +++ b/native/komet_crypto/rust/examples/demo.rs @@ -0,0 +1,35 @@ +use std::time::Instant; + +use komet_crypto::cipher; + +fn main() { + let password = "мой ключ 2026"; + let started = Instant::now(); + let key = cipher::derive_key(password).expect("derive"); + println!("derive_key: {:?}", started.elapsed()); + + for text in [ + "привет", + "встречаемся в 19:00 у метро", + "Hello! Это смешанный текст с эмодзи 🔐", + ] { + let encrypted = cipher::encrypt(text, &key).expect("encrypt"); + let decrypted = cipher::decrypt(&encrypted, &key).expect("decrypt"); + println!( + "\n{} символов -> {} символов", + text.chars().count(), + encrypted.chars().count() + ); + println!(" {text}"); + println!(" {encrypted}"); + assert_eq!(decrypted, text); + } + + let wrong = cipher::derive_key("не тот ключ").expect("derive"); + let sample = cipher::encrypt("секрет", &key).expect("encrypt"); + println!("\nчужой ключ: {:?}", cipher::decrypt(&sample, &wrong)); + println!( + "обычный текст: {:?}", + cipher::decrypt("привет как дела", &key) + ); +} diff --git a/native/komet_crypto/rust/src/alphabet.rs b/native/komet_crypto/rust/src/alphabet.rs new file mode 100644 index 0000000..4548653 --- /dev/null +++ b/native/komet_crypto/rust/src/alphabet.rs @@ -0,0 +1,135 @@ +use data_encoding::BASE32_NOPAD; + +use crate::error::CryptoError; + +pub const RU_LOWERCASE_WITHOUT_YO: [char; 32] = [ + 'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', + 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ъ', 'ы', 'ь', 'э', 'ю', 'я', +]; + +const MIN_WORD_LEN: usize = 4; +const WORD_LEN_SPREAD: usize = 5; + +fn base32_symbol(index: usize) -> u8 { + if index < 26 { + b'A' + index as u8 + } else { + b'2' + (index - 26) as u8 + } +} + +fn base32_index(symbol: u8) -> Option { + match symbol { + b'A'..=b'Z' => Some((symbol - b'A') as usize), + b'2'..=b'7' => Some((symbol - b'2') as usize + 26), + _ => None, + } +} + +fn letter_index(letter: char) -> Option { + RU_LOWERCASE_WITHOUT_YO.iter().position(|&l| l == letter) +} + +pub fn encode(bytes: &[u8]) -> String { + let base32 = BASE32_NOPAD.encode(bytes); + let mut out = String::with_capacity(base32.len() * 3); + let mut run = 0usize; + let mut word_len = MIN_WORD_LEN; + for symbol in base32.bytes() { + let Some(index) = base32_index(symbol) else { + continue; + }; + if run == word_len { + out.push(' '); + run = 0; + word_len = MIN_WORD_LEN + index % WORD_LEN_SPREAD; + } + out.push(RU_LOWERCASE_WITHOUT_YO[index]); + run += 1; + } + out +} + +pub fn decode(text: &str) -> Result, CryptoError> { + let mut symbols = Vec::with_capacity(text.len()); + for raw in text.chars() { + if raw.is_whitespace() { + continue; + } + let letter = raw.to_lowercase().next().unwrap_or(raw); + let index = letter_index(letter).ok_or(CryptoError::NotEncrypted)?; + symbols.push(base32_symbol(index)); + } + if symbols.is_empty() { + return Err(CryptoError::NotEncrypted); + } + BASE32_NOPAD + .decode(&symbols) + .map_err(|_| CryptoError::Malformed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alphabet_has_no_duplicates_and_no_yo() { + let mut sorted = RU_LOWERCASE_WITHOUT_YO.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), 32); + assert!(!RU_LOWERCASE_WITHOUT_YO.contains(&'ё')); + } + + #[test] + fn roundtrip_preserves_bytes() { + for len in 1..64usize { + let bytes: Vec = (0..len).map(|i| (i * 37 + 11) as u8).collect(); + let encoded = encode(&bytes); + assert_eq!(decode(&encoded).unwrap(), bytes, "len {len}"); + } + } + + #[test] + fn empty_input_encodes_to_empty_string() { + assert_eq!(encode(&[]), ""); + assert_eq!(decode(""), Err(CryptoError::NotEncrypted)); + } + + #[test] + fn output_is_lowercase_cyrillic_and_spaces() { + let encoded = encode(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + for ch in encoded.chars() { + assert!(ch == ' ' || RU_LOWERCASE_WITHOUT_YO.contains(&ch), "{ch}"); + } + assert!(encoded.contains(' ')); + assert!(!encoded.contains(" ")); + } + + #[test] + fn spaces_are_decorative_only() { + let bytes = b"komet encryption core"; + let encoded = encode(bytes); + let stripped: String = encoded.chars().filter(|c| *c != ' ').collect(); + let padded = format!(" {} ", encoded.replace(' ', " ")); + let newlined = encoded.replace(' ', "\n"); + assert_eq!(decode(&stripped).unwrap(), bytes); + assert_eq!(decode(&padded).unwrap(), bytes); + assert_eq!(decode(&newlined).unwrap(), bytes); + } + + #[test] + fn decode_is_case_insensitive() { + let bytes = b"autocapitalized"; + let encoded = encode(bytes); + assert_eq!(decode(&encoded.to_uppercase()).unwrap(), bytes); + } + + #[test] + fn foreign_characters_are_rejected() { + assert_eq!(decode("привет!"), Err(CryptoError::NotEncrypted)); + assert_eq!(decode("hello"), Err(CryptoError::NotEncrypted)); + assert_eq!(decode("ёжик"), Err(CryptoError::NotEncrypted)); + assert_eq!(decode(" "), Err(CryptoError::NotEncrypted)); + } +} diff --git a/native/komet_crypto/rust/src/api/crypto.rs b/native/komet_crypto/rust/src/api/crypto.rs new file mode 100644 index 0000000..cee0b78 --- /dev/null +++ b/native/komet_crypto/rust/src/api/crypto.rs @@ -0,0 +1,57 @@ +use std::fs; + +use crate::cipher; +use crate::error::CryptoError; +use crate::image; + +pub fn derive_key(password: String) -> Result, String> { + cipher::derive_key(&password).map_err(|e| e.code().to_string()) +} + +pub fn encrypt_message(plaintext: String, key: Vec) -> Result { + cipher::encrypt(&plaintext, &key).map_err(|e| e.code().to_string()) +} + +pub fn decrypt_message(text: String, key: Vec) -> Result { + cipher::decrypt(&text, &key).map_err(|e| e.code().to_string()) +} + +pub fn looks_encrypted(text: String) -> bool { + cipher::looks_encrypted(&text) +} + +pub fn encrypt_image_file( + source_path: String, + dest_path: String, + key: Vec, +) -> Result<(), String> { + transform_file(&source_path, &dest_path, |bytes| { + image::encrypt(bytes, &key) + }) +} + +pub fn decrypt_image_file( + source_path: String, + dest_path: String, + key: Vec, +) -> Result<(), String> { + transform_file(&source_path, &dest_path, |bytes| { + image::decrypt(bytes, &key) + }) +} + +pub fn looks_encrypted_image_file(path: String) -> bool { + fs::read(&path) + .map(|bytes| image::looks_encrypted(&bytes)) + .unwrap_or(false) +} + +fn transform_file( + source_path: &str, + dest_path: &str, + transform: impl FnOnce(&[u8]) -> Result, CryptoError>, +) -> Result<(), String> { + let bytes = fs::read(source_path).map_err(|e| format!("read: {e}"))?; + let out = transform(&bytes).map_err(|e| e.code().to_string())?; + fs::write(dest_path, out).map_err(|e| format!("write: {e}")) +} diff --git a/native/komet_crypto/rust/src/api/mod.rs b/native/komet_crypto/rust/src/api/mod.rs new file mode 100644 index 0000000..274f0ed --- /dev/null +++ b/native/komet_crypto/rust/src/api/mod.rs @@ -0,0 +1 @@ +pub mod crypto; diff --git a/native/komet_crypto/rust/src/cipher.rs b/native/komet_crypto/rust/src/cipher.rs new file mode 100644 index 0000000..d52989f --- /dev/null +++ b/native/komet_crypto/rust/src/cipher.rs @@ -0,0 +1,245 @@ +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use sha2::{Digest, Sha256}; + +use crate::alphabet; +use crate::error::CryptoError; + +pub const KEY_LEN: usize = 32; + +pub(crate) const MAGIC: u8 = 0x4B; +const VERSION: u8 = 0x01; +const HEADER_LEN: usize = 2; +pub(crate) const NONCE_LEN: usize = 12; +pub(crate) const TAG_LEN: usize = 16; +const MIN_BLOB_LEN: usize = HEADER_LEN + NONCE_LEN + TAG_LEN; + +const SALT_CONTEXT: &[u8] = b"komet-enc-v1"; +const SALT_LEN: usize = 16; +const ARGON_MEMORY_KIB: u32 = 65536; +const ARGON_ITERATIONS: u32 = 3; +const ARGON_PARALLELISM: u32 = 1; + +pub fn derive_key(password: &str) -> Result, CryptoError> { + if password.is_empty() { + return Err(CryptoError::EmptyPassword); + } + let mut hasher = Sha256::new(); + hasher.update(SALT_CONTEXT); + hasher.update(password.as_bytes()); + let digest = hasher.finalize(); + + let params = Params::new( + ARGON_MEMORY_KIB, + ARGON_ITERATIONS, + ARGON_PARALLELISM, + Some(KEY_LEN), + ) + .map_err(|_| CryptoError::Internal)?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + + let mut key = vec![0u8; KEY_LEN]; + argon + .hash_password_into(password.as_bytes(), &digest[..SALT_LEN], &mut key) + .map_err(|_| CryptoError::Internal)?; + Ok(key) +} + +pub fn encrypt(plaintext: &str, key: &[u8]) -> Result { + let cipher = cipher_from(key)?; + let header = [MAGIC, VERSION]; + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + let sealed = cipher + .encrypt( + &nonce, + Payload { + msg: plaintext.as_bytes(), + aad: &header, + }, + ) + .map_err(|_| CryptoError::Internal)?; + + let mut blob = Vec::with_capacity(HEADER_LEN + NONCE_LEN + sealed.len()); + blob.extend_from_slice(&header); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&sealed); + Ok(alphabet::encode(&blob)) +} + +pub fn decrypt(text: &str, key: &[u8]) -> Result { + let blob = alphabet::decode(text)?; + if !has_envelope(&blob) { + return Err(CryptoError::NotEncrypted); + } + let cipher = cipher_from(key)?; + let nonce = Nonce::from_slice(&blob[HEADER_LEN..HEADER_LEN + NONCE_LEN]); + let plain = cipher + .decrypt( + nonce, + Payload { + msg: &blob[HEADER_LEN + NONCE_LEN..], + aad: &blob[..HEADER_LEN], + }, + ) + .map_err(|_| CryptoError::WrongKey)?; + String::from_utf8(plain).map_err(|_| CryptoError::Malformed) +} + +pub fn looks_encrypted(text: &str) -> bool { + alphabet::decode(text) + .map(|b| has_envelope(&b)) + .unwrap_or(false) +} + +fn has_envelope(blob: &[u8]) -> bool { + blob.len() >= MIN_BLOB_LEN && blob[0] == MAGIC && blob[1] == VERSION +} + +pub(crate) fn cipher_from(key: &[u8]) -> Result { + if key.len() != KEY_LEN { + return Err(CryptoError::BadKeyLength); + } + Ok(ChaCha20Poly1305::new(Key::from_slice(key))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; KEY_LEN] = [7u8; KEY_LEN]; + const OTHER_KEY: [u8; KEY_LEN] = [8u8; KEY_LEN]; + + #[test] + fn roundtrip_returns_original_text() { + for text in [ + "привет", + "Hello, World!", + "эмодзи 🔐 и переносы\nстрок", + "a", + "\u{0}\u{1}", + ] { + let encrypted = encrypt(text, &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &KEY).unwrap(), text); + } + } + + #[test] + fn empty_plaintext_roundtrips() { + let encrypted = encrypt("", &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &KEY).unwrap(), ""); + } + + #[test] + fn ciphertext_looks_like_russian_words() { + let encrypted = encrypt("привет", &KEY).unwrap(); + for ch in encrypted.chars() { + assert!( + ch == ' ' || alphabet::RU_LOWERCASE_WITHOUT_YO.contains(&ch), + "unexpected char {ch}" + ); + } + assert!(encrypted.split(' ').count() > 1); + } + + #[test] + fn same_plaintext_produces_different_ciphertext() { + let a = encrypt("одно и то же", &KEY).unwrap(); + let b = encrypt("одно и то же", &KEY).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn wrong_key_is_rejected() { + let encrypted = encrypt("секрет", &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &OTHER_KEY), Err(CryptoError::WrongKey)); + } + + fn swap_letter_at(text: &str, position: usize) -> String { + text.chars() + .enumerate() + .map(|(i, c)| { + if i != position { + c + } else if c == 'а' { + 'б' + } else { + 'а' + } + }) + .collect() + } + + #[test] + fn tampered_ciphertext_is_rejected() { + let encrypted = encrypt("секрет", &KEY).unwrap(); + let letters: Vec = encrypted + .char_indices() + .enumerate() + .filter(|(_, (_, c))| *c != ' ') + .map(|(i, _)| i) + .collect(); + for position in &letters { + assert!( + decrypt(&swap_letter_at(&encrypted, *position), &KEY).is_err(), + "tamper at {position} slipped through" + ); + } + let middle = letters[letters.len() / 2]; + assert_eq!( + decrypt(&swap_letter_at(&encrypted, middle), &KEY), + Err(CryptoError::WrongKey) + ); + } + + #[test] + fn whitespace_mangling_survives() { + let encrypted = encrypt("пробелы декоративные", &KEY).unwrap(); + let no_spaces: String = encrypted.chars().filter(|c| *c != ' ').collect(); + let doubled = encrypted.replace(' ', " "); + let trimmed = format!(" {encrypted}\n"); + for variant in [no_spaces, doubled, trimmed] { + assert_eq!(decrypt(&variant, &KEY).unwrap(), "пробелы декоративные"); + } + } + + #[test] + fn plain_text_is_not_mistaken_for_ciphertext() { + for text in ["привет как дела", "ёлка", "hello", "", "12345"] { + assert!(!looks_encrypted(text), "{text}"); + } + let encrypted = encrypt("настоящее", &KEY).unwrap(); + assert!(looks_encrypted(&encrypted)); + } + + #[test] + fn plain_text_decrypt_reports_not_encrypted() { + assert_eq!( + decrypt("привет как дела", &KEY), + Err(CryptoError::NotEncrypted) + ); + } + + #[test] + fn bad_key_length_is_reported() { + assert_eq!(encrypt("x", &[0u8; 8]), Err(CryptoError::BadKeyLength)); + } + + #[test] + fn overhead_is_48_letters() { + let encrypted = encrypt("", &KEY).unwrap(); + let letters = encrypted.chars().filter(|c| *c != ' ').count(); + assert_eq!(letters, 48); + } + + #[test] + fn key_derivation_is_deterministic_and_password_bound() { + let a = derive_key("общий ключ").unwrap(); + let b = derive_key("общий ключ").unwrap(); + let c = derive_key("другой ключ").unwrap(); + assert_eq!(a, b); + assert_ne!(a, c); + assert_eq!(a.len(), KEY_LEN); + assert_eq!(derive_key(""), Err(CryptoError::EmptyPassword)); + } +} diff --git a/native/komet_crypto/rust/src/error.rs b/native/komet_crypto/rust/src/error.rs new file mode 100644 index 0000000..3aa42a6 --- /dev/null +++ b/native/komet_crypto/rust/src/error.rs @@ -0,0 +1,32 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CryptoError { + EmptyPassword, + BadKeyLength, + NotEncrypted, + Malformed, + WrongKey, + Internal, +} + +impl CryptoError { + pub fn code(self) -> &'static str { + match self { + CryptoError::EmptyPassword => "empty_password", + CryptoError::BadKeyLength => "bad_key_length", + CryptoError::NotEncrypted => "not_encrypted", + CryptoError::Malformed => "malformed", + CryptoError::WrongKey => "wrong_key", + CryptoError::Internal => "internal", + } + } +} + +impl fmt::Display for CryptoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.code()) + } +} + +impl std::error::Error for CryptoError {} diff --git a/native/komet_crypto/rust/src/frb_generated.rs b/native/komet_crypto/rust/src/frb_generated.rs new file mode 100644 index 0000000..69db823 --- /dev/null +++ b/native/komet_crypto/rust/src/frb_generated.rs @@ -0,0 +1,471 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::uninlined_format_args, + clippy::needless_borrow +)] + +// Section: imports + +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -2021377439; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__api__crypto__decrypt_image_file_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "decrypt_image_file", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_source_path = ::sse_decode(&mut deserializer); + let api_dest_path = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::decrypt_image_file( + api_source_path, + api_dest_path, + api_key, + )?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__decrypt_message_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "decrypt_message", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_text = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::decrypt_message(api_text, api_key)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__derive_key_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "derive_key", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_password = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::derive_key(api_password)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__encrypt_image_file_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "encrypt_image_file", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_source_path = ::sse_decode(&mut deserializer); + let api_dest_path = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::encrypt_image_file( + api_source_path, + api_dest_path, + api_key, + )?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__encrypt_message_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "encrypt_message", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_plaintext = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::encrypt_message(api_plaintext, api_key)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__looks_encrypted_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "looks_encrypted", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_text = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::api::crypto::looks_encrypted(api_text))?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__looks_encrypted_image_file_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "looks_encrypted_image_file", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok( + crate::api::crypto::looks_encrypted_image_file(api_path), + )?; + Ok(output_ok) + })()) + } + }, + ) +} + +// Section: dart2rust + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__crypto__decrypt_image_file_impl(port, ptr, rust_vec_len, data_len), + 2 => wire__crate__api__crypto__decrypt_message_impl(port, ptr, rust_vec_len, data_len), + 3 => wire__crate__api__crypto__derive_key_impl(port, ptr, rust_vec_len, data_len), + 4 => wire__crate__api__crypto__encrypt_image_file_impl(port, ptr, rust_vec_len, data_len), + 5 => wire__crate__api__crypto__encrypt_message_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__api__crypto__looks_encrypted_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__api__crypto__looks_encrypted_image_file_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + _ => unreachable!(), + } +} + +// Section: rust2dart + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.12.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.12.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/native/komet_crypto/rust/src/image.rs b/native/komet_crypto/rust/src/image.rs new file mode 100644 index 0000000..541e673 --- /dev/null +++ b/native/komet_crypto/rust/src/image.rs @@ -0,0 +1,218 @@ +use chacha20poly1305::aead::{Aead, AeadCore, OsRng, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; +use rand_core::RngCore; + +use crate::cipher::{cipher_from, MAGIC, NONCE_LEN, TAG_LEN}; +use crate::error::CryptoError; + +const VERSION_IMAGE: u8 = 0x02; +const HEADER_LEN: usize = 6; +const CHANNELS: usize = 3; +const MAX_DIMENSION: u32 = 16384; + +fn header(payload_len: u32) -> [u8; HEADER_LEN] { + let n = payload_len.to_be_bytes(); + [MAGIC, VERSION_IMAGE, n[0], n[1], n[2], n[3]] +} + +pub fn encrypt(plain_png: &[u8], key: &[u8]) -> Result, CryptoError> { + let cipher = cipher_from(key)?; + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + let payload_len = NONCE_LEN + plain_png.len() + TAG_LEN; + if u32::try_from(payload_len).is_err() { + return Err(CryptoError::Malformed); + } + let head = header(payload_len as u32); + + let sealed = cipher + .encrypt( + &nonce, + Payload { + msg: plain_png, + aad: &head, + }, + ) + .map_err(|_| CryptoError::Internal)?; + + let mut blob = Vec::with_capacity(HEADER_LEN + payload_len); + blob.extend_from_slice(&head); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&sealed); + + to_noise_png(&blob) +} + +pub fn decrypt(noise_png: &[u8], key: &[u8]) -> Result, CryptoError> { + let blob = from_noise_png(noise_png)?; + let payload_len = envelope_len(&blob).ok_or(CryptoError::NotEncrypted)?; + let end = HEADER_LEN + payload_len; + if blob.len() < end { + return Err(CryptoError::Malformed); + } + + let cipher = cipher_from(key)?; + let nonce = Nonce::from_slice(&blob[HEADER_LEN..HEADER_LEN + NONCE_LEN]); + cipher + .decrypt( + nonce, + Payload { + msg: &blob[HEADER_LEN + NONCE_LEN..end], + aad: &blob[..HEADER_LEN], + }, + ) + .map_err(|_| CryptoError::WrongKey) +} + +pub fn looks_encrypted(noise_png: &[u8]) -> bool { + from_noise_png(noise_png) + .ok() + .and_then(|blob| envelope_len(&blob)) + .is_some() +} + +fn envelope_len(blob: &[u8]) -> Option { + if blob.len() < HEADER_LEN || blob[0] != MAGIC || blob[1] != VERSION_IMAGE { + return None; + } + let len = u32::from_be_bytes([blob[2], blob[3], blob[4], blob[5]]) as usize; + if len < NONCE_LEN + TAG_LEN || HEADER_LEN + len > blob.len() { + return None; + } + Some(len) +} + +fn to_noise_png(blob: &[u8]) -> Result, CryptoError> { + let pixels = blob.len().div_ceil(CHANNELS); + let width = (pixels as f64).sqrt().ceil().max(1.0) as u32; + if width > MAX_DIMENSION { + return Err(CryptoError::Malformed); + } + let height = (pixels as u32).div_ceil(width).max(1); + + let mut raw = vec![0u8; width as usize * height as usize * CHANNELS]; + raw[..blob.len()].copy_from_slice(blob); + OsRng.fill_bytes(&mut raw[blob.len()..]); + + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, width, height); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_compression(png::Compression::Fast); + let mut writer = encoder + .write_header() + .map_err(|_| CryptoError::Internal)?; + writer + .write_image_data(&raw) + .map_err(|_| CryptoError::Internal)?; + } + Ok(out) +} + +fn from_noise_png(noise_png: &[u8]) -> Result, CryptoError> { + let decoder = png::Decoder::new(noise_png); + let mut reader = decoder.read_info().map_err(|_| CryptoError::NotEncrypted)?; + let info = reader.info(); + if info.color_type != png::ColorType::Rgb || info.bit_depth != png::BitDepth::Eight { + return Err(CryptoError::NotEncrypted); + } + let mut raw = vec![0u8; reader.output_buffer_size()]; + let frame = reader + .next_frame(&mut raw) + .map_err(|_| CryptoError::Malformed)?; + raw.truncate(frame.buffer_size()); + Ok(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; 32] = [7u8; 32]; + const OTHER_KEY: [u8; 32] = [8u8; 32]; + + fn sample_png(width: u32, height: u32) -> Vec { + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, width, height); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + let raw: Vec = (0..width as usize * height as usize * 3) + .map(|i| (i * 7 % 251) as u8) + .collect(); + writer.write_image_data(&raw).unwrap(); + } + out + } + + #[test] + fn roundtrip_returns_original_bytes() { + for (w, h) in [(1, 1), (16, 9), (64, 64), (200, 137)] { + let original = sample_png(w, h); + let encrypted = encrypt(&original, &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &KEY).unwrap(), original, "{w}x{h}"); + } + } + + #[test] + fn output_is_a_valid_rgb_png() { + let encrypted = encrypt(&sample_png(32, 32), &KEY).unwrap(); + assert_eq!(&encrypted[1..4], b"PNG"); + let decoder = png::Decoder::new(encrypted.as_slice()); + let reader = decoder.read_info().unwrap(); + let info = reader.info(); + assert_eq!(info.color_type, png::ColorType::Rgb); + assert_eq!(info.bit_depth, png::BitDepth::Eight); + assert!(info.width >= 1 && info.height >= 1); + } + + #[test] + fn same_input_produces_different_output() { + let original = sample_png(16, 16); + let a = encrypt(&original, &KEY).unwrap(); + let b = encrypt(&original, &KEY).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn wrong_key_is_rejected() { + let encrypted = encrypt(&sample_png(16, 16), &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &OTHER_KEY), Err(CryptoError::WrongKey)); + } + + #[test] + fn tampered_pixels_are_rejected() { + let mut encrypted = encrypt(&sample_png(16, 16), &KEY).unwrap(); + let raw = from_noise_png(&encrypted).unwrap(); + let mut tampered = raw.clone(); + tampered[HEADER_LEN + NONCE_LEN + 4] ^= 0x01; + encrypted = to_noise_png(&tampered).unwrap(); + assert_eq!(decrypt(&encrypted, &KEY), Err(CryptoError::WrongKey)); + } + + #[test] + fn plain_png_is_not_mistaken_for_ciphertext() { + let plain = sample_png(24, 24); + assert!(!looks_encrypted(&plain)); + assert_eq!(decrypt(&plain, &KEY), Err(CryptoError::NotEncrypted)); + assert!(looks_encrypted(&encrypt(&plain, &KEY).unwrap())); + } + + #[test] + fn garbage_input_is_rejected() { + assert!(!looks_encrypted(b"not a png at all")); + assert_eq!( + decrypt(b"not a png at all", &KEY), + Err(CryptoError::NotEncrypted) + ); + } + + #[test] + fn bad_key_length_is_reported() { + assert_eq!( + encrypt(&sample_png(8, 8), &[0u8; 8]), + Err(CryptoError::BadKeyLength) + ); + } +} diff --git a/native/komet_crypto/rust/src/lib.rs b/native/komet_crypto/rust/src/lib.rs new file mode 100644 index 0000000..65b2403 --- /dev/null +++ b/native/komet_crypto/rust/src/lib.rs @@ -0,0 +1,7 @@ +pub mod alphabet; +pub mod api; +pub mod cipher; +pub mod error; +pub mod image; + +mod frb_generated; diff --git a/native/komet_crypto/windows/.gitignore b/native/komet_crypto/windows/.gitignore new file mode 100644 index 0000000..b3eb2be --- /dev/null +++ b/native/komet_crypto/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/native/komet_crypto/windows/CMakeLists.txt b/native/komet_crypto/windows/CMakeLists.txt new file mode 100644 index 0000000..d28c66d --- /dev/null +++ b/native/komet_crypto/windows/CMakeLists.txt @@ -0,0 +1,20 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "komet_crypto") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../rust komet_crypto "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(komet_crypto_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/pubspec.lock b/pubspec.lock index 68fa50c..0739da1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -508,7 +508,7 @@ packages: source: hosted version: "2.0.35" flutter_rust_bridge: - dependency: transitive + dependency: "direct dev" description: name: flutter_rust_bridge sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a @@ -764,6 +764,13 @@ packages: relative: true source: path version: "0.1.0" + komet_crypto: + dependency: "direct main" + description: + path: "native/komet_crypto" + relative: true + source: path + version: "0.1.0" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 30f0c07..fbbaf50 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,12 @@ dependencies: kolibri: path: third_party/kolibri/kolibri-dart + # Rust message-encryption core — Argon2id + ChaCha20-Poly1305, output encoded + # as lowercase Cyrillic base32. Separate from kolibri: that is vendored + # transport, this is Komet's own crypto. + komet_crypto: + path: native/komet_crypto + # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. crypto: ^3.0.7 @@ -94,6 +100,10 @@ dev_dependencies: flutter_test: sdk: flutter + # Needed by test/chat_crypto_roundtrip_test.dart to open the built + # komet_crypto shared library directly. + flutter_rust_bridge: 2.12.0 + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your diff --git a/test/chat_crypto_roundtrip_test.dart b/test/chat_crypto_roundtrip_test.dart new file mode 100644 index 0000000..03c7f3b --- /dev/null +++ b/test/chat_crypto_roundtrip_test.dart @@ -0,0 +1,136 @@ +import 'dart:io'; + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet_crypto/komet_crypto.dart' as kc; + +const _libPath = 'build/linux/x64/debug/bundle/lib/libkomet_crypto.so'; + +void main() { + if (!File(_libPath).existsSync()) { + // ignore: avoid_print + print('skipping: run `flutter build linux --debug` first'); + return; + } + + setUpAll(() async { + 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('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 _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('komet_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'))), + ); + }); +}