diff --git a/lib/services/encrypted_file_upload_service.dart b/lib/services/encrypted_file_upload_service.dart index 4e11f548..5115f128 100644 --- a/lib/services/encrypted_file_upload_service.dart +++ b/lib/services/encrypted_file_upload_service.dart @@ -80,7 +80,7 @@ class EncryptedFileUploadService { ); // 2. Encrypt with ChaCha20-Poly1305 - final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + final encryptionResult = await EncryptionService.encryptChaCha20Poly1305Async( key: sharedKey, plaintext: validationResult.validatedData, ); @@ -136,7 +136,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 62081912..081e9d7e 100644 --- a/lib/services/encrypted_image_upload_service.dart +++ b/lib/services/encrypted_image_upload_service.dart @@ -91,7 +91,7 @@ class EncryptedImageUploadService { ); // 3. Encrypt with ChaCha20-Poly1305 - final encryptionResult = EncryptionService.encryptChaCha20Poly1305( + final encryptionResult = await EncryptionService.encryptChaCha20Poly1305Async( key: sharedKey, plaintext: validationResult.validatedData, ); @@ -151,7 +151,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 d5462af8..9e5bfd76 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,54 @@ 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. + /// 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, + 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 6b1184f5..085da7cf 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 f5fc2be6..9427a845 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 00000000..aea0612b --- /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()), + ); + }); + }); +}