From 03032dcd66f9aae50bcd20a6094ec04b83667dfc Mon Sep 17 00:00:00 2001 From: grunch Date: Sun, 30 Aug 2026 21:33:05 -0300 Subject: [PATCH 1/2] perf: run media sanitizing and file crypto off the main isolate Image sanitizing uses package:image (pure Dart): decode + re-encode of a phone photo takes seconds, and it ran on the UI isolate for every image sent. Whole-file ChaCha20-Poly1305 (pointycastle, up to 25 MB) ran there too for every media send, download and history auto-download, as did the O(n*m) macro-pattern scan over document bytes. - MediaValidationService.validateAndSanitizeImage / ...Light keep their signatures but execute through Isolate.run. - EncryptionService gains encryptToBlobAsync / decryptFromBlobAsync (Isolate.run over the existing sync implementations); the encrypted image/file upload services use them for both directions. - FileValidationService's macro scan runs through Isolate.run. Byte buffers and simple exception objects transfer across the isolate boundary; error propagation is pinned by test. --- .../encrypted_file_upload_service.dart | 9 +- .../encrypted_image_upload_service.dart | 9 +- lib/services/encryption_service.dart | 33 ++++++++ lib/services/file_validation_service.dart | 5 +- lib/services/media_validation_service.dart | 13 ++- test/services/media_crypto_isolate_test.dart | 83 +++++++++++++++++++ 6 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 test/services/media_crypto_isolate_test.dart diff --git a/lib/services/encrypted_file_upload_service.dart b/lib/services/encrypted_file_upload_service.dart index 4e11f548d..c11e62cf0 100644 --- a/lib/services/encrypted_file_upload_service.dart +++ b/lib/services/encrypted_file_upload_service.dart @@ -80,17 +80,12 @@ class EncryptedFileUploadService { ); // 2. Encrypt with ChaCha20-Poly1305 - final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + final encryptedBlob = await EncryptionService.encryptToBlobAsync( key: sharedKey, plaintext: validationResult.validatedData, ); - - final encryptedBlob = encryptionResult.toBlob(); logger.i( '🔐 File encrypted successfully: ${encryptedBlob.length} bytes ' - '(nonce: ${encryptionResult.nonce.length}B, ' - 'data: ${encryptionResult.encryptedData.length}B, ' - 'tag: ${encryptionResult.authTag.length}B)' ); // 3. Upload encrypted blob to Blossom @@ -136,7 +131,7 @@ class EncryptedFileUploadService { logger.i('📥 Downloaded encrypted blob: ${encryptedBlob.length} bytes'); // 2. Decrypt with ChaCha20-Poly1305 - final decryptedFile = EncryptionService.decryptFromBlob( + final decryptedFile = await EncryptionService.decryptFromBlobAsync( key: sharedKey, blob: encryptedBlob, ); diff --git a/lib/services/encrypted_image_upload_service.dart b/lib/services/encrypted_image_upload_service.dart index 62081912e..f05f7daae 100644 --- a/lib/services/encrypted_image_upload_service.dart +++ b/lib/services/encrypted_image_upload_service.dart @@ -91,17 +91,12 @@ class EncryptedImageUploadService { ); // 3. Encrypt with ChaCha20-Poly1305 - final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + final encryptedBlob = await EncryptionService.encryptToBlobAsync( key: sharedKey, plaintext: validationResult.validatedData, ); - - final encryptedBlob = encryptionResult.toBlob(); logger.i( '🔐 Image encrypted successfully: ${encryptedBlob.length} bytes ' - '(nonce: ${encryptionResult.nonce.length}B, ' - 'data: ${encryptionResult.encryptedData.length}B, ' - 'tag: ${encryptionResult.authTag.length}B)' ); // 4. Upload encrypted blob to Blossom @@ -151,7 +146,7 @@ class EncryptedImageUploadService { logger.i('📥 Downloaded encrypted blob: ${encryptedBlob.length} bytes'); // 2. Decrypt with ChaCha20-Poly1305 - final decryptedImage = EncryptionService.decryptFromBlob( + final decryptedImage = await EncryptionService.decryptFromBlobAsync( key: sharedKey, blob: encryptedBlob, ); diff --git a/lib/services/encryption_service.dart b/lib/services/encryption_service.dart index d5462af85..8636b19c5 100644 --- a/lib/services/encryption_service.dart +++ b/lib/services/encryption_service.dart @@ -1,3 +1,4 @@ +import 'dart:isolate'; import 'dart:typed_data'; import 'dart:math'; import 'package:pointycastle/export.dart'; @@ -185,6 +186,38 @@ class EncryptionService { } } + /// Isolate-backed variants: ChaCha20-Poly1305 here is pure Dart + /// (pointycastle) over whole files (up to 25 MB), which froze the UI while + /// sending or opening media. Inputs and outputs are plain byte buffers, so + /// they transfer cheaply. + static Future encryptToBlobAsync({ + required Uint8List key, + required Uint8List plaintext, + Uint8List? additionalData, + }) { + return Isolate.run( + () => encryptToBlob( + key: key, + plaintext: plaintext, + additionalData: additionalData, + ), + ); + } + + static Future decryptFromBlobAsync({ + required Uint8List key, + required Uint8List blob, + Uint8List? additionalData, + }) { + return Isolate.run( + () => decryptFromBlob( + key: key, + blob: blob, + additionalData: additionalData, + ), + ); + } + /// Convenience method to encrypt and return a blob static Uint8List encryptToBlob({ required Uint8List key, diff --git a/lib/services/file_validation_service.dart b/lib/services/file_validation_service.dart index 6b1184f5a..085da7cfe 100644 --- a/lib/services/file_validation_service.dart +++ b/lib/services/file_validation_service.dart @@ -1,3 +1,4 @@ +import 'dart:isolate'; import 'dart:io'; import 'dart:typed_data'; import 'package:mime/mime.dart'; @@ -226,7 +227,7 @@ class FileValidationService { } // Basic macro detection for DOC files using byte pattern search - if (_containsMacroPatterns(fileData)) { + if (await Isolate.run(() => _containsMacroPatterns(fileData))) { throw FileValidationException('Document contains macros which are not allowed for security reasons'); } } @@ -242,7 +243,7 @@ class FileValidationService { } // Basic macro detection - look for vbaProject.bin in the ZIP structure - if (_containsMacroPatterns(fileData)) { + if (await Isolate.run(() => _containsMacroPatterns(fileData))) { throw FileValidationException('Document contains macros which are not allowed for security reasons'); } } diff --git a/lib/services/media_validation_service.dart b/lib/services/media_validation_service.dart index f5fc2be68..9427a845d 100644 --- a/lib/services/media_validation_service.dart +++ b/lib/services/media_validation_service.dart @@ -1,3 +1,4 @@ +import 'dart:isolate'; import 'dart:typed_data'; import 'package:image/image.dart' as img; import 'package:mime/mime.dart'; @@ -59,8 +60,10 @@ class MediaValidationService { /// 3. Re-encodes to eliminate malicious metadata static Future validateAndSanitizeImage( Uint8List imageData, - ) async { - return _sanitizeImageHeavy(imageData); + ) { + // Pure-Dart decode + re-encode takes seconds for a phone photo; run it + // off the main isolate (bytes and the result transfer cheaply). + return Isolate.run(() => _sanitizeImageHeavy(imageData)); } /// Light image sanitization for better performance @@ -70,6 +73,12 @@ class MediaValidationService { /// 4. Quick re-encode with minimal quality loss static Future validateAndSanitizeImageLight( Uint8List imageData, + ) { + return Isolate.run(() => _sanitizeImageLightImpl(imageData)); + } + + static Future _sanitizeImageLightImpl( + Uint8List imageData, ) async { logger.i('📸 Light image sanitization started: ${imageData.length} bytes'); diff --git a/test/services/media_crypto_isolate_test.dart b/test/services/media_crypto_isolate_test.dart new file mode 100644 index 000000000..aea0612b1 --- /dev/null +++ b/test/services/media_crypto_isolate_test.dart @@ -0,0 +1,83 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:mostro_mobile/services/encryption_service.dart'; +import 'package:mostro_mobile/services/media_validation_service.dart'; + +/// Image sanitizing (pure-Dart decode + re-encode: 1-5 s for a phone photo) +/// and whole-file ChaCha20-Poly1305 ran on the main isolate, freezing the UI +/// while sending or opening media. Both now run through Isolate.run; these +/// tests pin the async variants' behaviour, including error propagation +/// across the isolate boundary. +void main() { + final key = Uint8List.fromList(List.generate(32, (i) => i)); + + Uint8List tinyPng() { + final image = img.Image(width: 2, height: 2); + img.fill(image, color: img.ColorRgb8(200, 50, 50)); + return Uint8List.fromList(img.encodePng(image)); + } + + group('EncryptionService isolate variants', () { + test('async blob roundtrip matches the sync implementation', () async { + final plaintext = Uint8List.fromList(List.generate(1024, (i) => i % 251)); + + final blob = await EncryptionService.encryptToBlobAsync( + key: key, + plaintext: plaintext, + ); + final decrypted = await EncryptionService.decryptFromBlobAsync( + key: key, + blob: blob, + ); + + expect(decrypted, plaintext); + // Cross-compatibility: sync decrypt reads the async-produced blob. + expect(EncryptionService.decryptFromBlob(key: key, blob: blob), + plaintext); + }); + + test('a tampered blob fails across the isolate boundary', () async { + final blob = await EncryptionService.encryptToBlobAsync( + key: key, + plaintext: Uint8List.fromList([1, 2, 3]), + ); + blob[blob.length - 1] ^= 0xFF; + + await expectLater( + EncryptionService.decryptFromBlobAsync(key: key, blob: blob), + throwsA(isA()), + ); + }); + }); + + group('MediaValidationService off the main isolate', () { + test('light sanitization still validates and strips a PNG', () async { + final result = + await MediaValidationService.validateAndSanitizeImageLight(tinyPng()); + + expect(result.mimeType, 'image/png'); + expect(result.width, 2); + expect(result.height, 2); + expect(img.decodePng(result.validatedData), isNotNull); + }); + + test('heavy sanitization still validates a PNG', () async { + final result = + await MediaValidationService.validateAndSanitizeImage(tinyPng()); + + expect(result.mimeType, 'image/png'); + expect(img.decodePng(result.validatedData), isNotNull); + }); + + test('garbage input propagates the validation error', () async { + await expectLater( + MediaValidationService.validateAndSanitizeImageLight( + Uint8List.fromList(List.filled(64, 7)), + ), + throwsA(isA()), + ); + }); + }); +} From 63b4eba160e87a74a95b53bfb597a7cf6441908b Mon Sep 17 00:00:00 2001 From: grunch Date: Sun, 30 Aug 2026 21:40:22 -0300 Subject: [PATCH 2/2] fix: keep the nonce metadata by moving encryption through an EncryptionResult-returning isolate variant --- lib/services/encrypted_file_upload_service.dart | 7 ++++++- lib/services/encrypted_image_upload_service.dart | 7 ++++++- lib/services/encryption_service.dart | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/services/encrypted_file_upload_service.dart b/lib/services/encrypted_file_upload_service.dart index c11e62cf0..5115f128a 100644 --- a/lib/services/encrypted_file_upload_service.dart +++ b/lib/services/encrypted_file_upload_service.dart @@ -80,12 +80,17 @@ class EncryptedFileUploadService { ); // 2. Encrypt with ChaCha20-Poly1305 - final encryptedBlob = await EncryptionService.encryptToBlobAsync( + final encryptionResult = await EncryptionService.encryptChaCha20Poly1305Async( key: sharedKey, plaintext: validationResult.validatedData, ); + + final encryptedBlob = encryptionResult.toBlob(); logger.i( '🔐 File encrypted successfully: ${encryptedBlob.length} bytes ' + '(nonce: ${encryptionResult.nonce.length}B, ' + 'data: ${encryptionResult.encryptedData.length}B, ' + 'tag: ${encryptionResult.authTag.length}B)' ); // 3. Upload encrypted blob to Blossom diff --git a/lib/services/encrypted_image_upload_service.dart b/lib/services/encrypted_image_upload_service.dart index f05f7daae..081e9d7ea 100644 --- a/lib/services/encrypted_image_upload_service.dart +++ b/lib/services/encrypted_image_upload_service.dart @@ -91,12 +91,17 @@ class EncryptedImageUploadService { ); // 3. Encrypt with ChaCha20-Poly1305 - final encryptedBlob = await EncryptionService.encryptToBlobAsync( + final encryptionResult = await EncryptionService.encryptChaCha20Poly1305Async( key: sharedKey, plaintext: validationResult.validatedData, ); + + final encryptedBlob = encryptionResult.toBlob(); logger.i( '🔐 Image encrypted successfully: ${encryptedBlob.length} bytes ' + '(nonce: ${encryptionResult.nonce.length}B, ' + 'data: ${encryptionResult.encryptedData.length}B, ' + 'tag: ${encryptionResult.authTag.length}B)' ); // 4. Upload encrypted blob to Blossom diff --git a/lib/services/encryption_service.dart b/lib/services/encryption_service.dart index 8636b19c5..9e5bfd766 100644 --- a/lib/services/encryption_service.dart +++ b/lib/services/encryption_service.dart @@ -190,6 +190,22 @@ class EncryptionService { /// (pointycastle) over whole files (up to 25 MB), which froze the UI while /// sending or opening media. Inputs and outputs are plain byte buffers, so /// they transfer cheaply. + /// Isolate-backed encrypt that preserves access to the nonce/tag parts + /// (the upload results carry the nonce as protocol metadata). + static Future encryptChaCha20Poly1305Async({ + required Uint8List key, + required Uint8List plaintext, + Uint8List? additionalData, + }) { + return Isolate.run( + () => encryptChaCha20Poly1305( + key: key, + plaintext: plaintext, + additionalData: additionalData, + ), + ); + } + static Future encryptToBlobAsync({ required Uint8List key, required Uint8List plaintext,