Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/services/encrypted_file_upload_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
);
Expand Down
4 changes: 2 additions & 2 deletions lib/services/encrypted_image_upload_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
);
Expand Down
49 changes: 49 additions & 0 deletions lib/services/encryption_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:isolate';
import 'dart:typed_data';
import 'dart:math';
import 'package:pointycastle/export.dart';
Expand Down Expand Up @@ -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<EncryptionResult> encryptChaCha20Poly1305Async({
required Uint8List key,
required Uint8List plaintext,
Uint8List? additionalData,
}) {
return Isolate.run(
() => encryptChaCha20Poly1305(
key: key,
plaintext: plaintext,
additionalData: additionalData,
),
);
}

static Future<Uint8List> encryptToBlobAsync({
required Uint8List key,
required Uint8List plaintext,
Uint8List? additionalData,
}) {
return Isolate.run(
() => encryptToBlob(
key: key,
plaintext: plaintext,
additionalData: additionalData,
),
);
}

static Future<Uint8List> 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,
Expand Down
5 changes: 3 additions & 2 deletions lib/services/file_validation_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:isolate';
import 'dart:io';
import 'dart:typed_data';
import 'package:mime/mime.dart';
Expand Down Expand Up @@ -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');
}
}
Expand All @@ -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');
}
}
Expand Down
13 changes: 11 additions & 2 deletions lib/services/media_validation_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:isolate';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:mime/mime.dart';
Expand Down Expand Up @@ -59,8 +60,10 @@ class MediaValidationService {
/// 3. Re-encodes to eliminate malicious metadata
static Future<MediaValidationResult> 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
Expand All @@ -70,6 +73,12 @@ class MediaValidationService {
/// 4. Quick re-encode with minimal quality loss
static Future<MediaValidationResult> validateAndSanitizeImageLight(
Uint8List imageData,
) {
return Isolate.run(() => _sanitizeImageLightImpl(imageData));
}

static Future<MediaValidationResult> _sanitizeImageLightImpl(
Uint8List imageData,
) async {
logger.i('📸 Light image sanitization started: ${imageData.length} bytes');

Expand Down
83 changes: 83 additions & 0 deletions test/services/media_crypto_isolate_test.dart
Original file line number Diff line number Diff line change
@@ -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<Exception>()),
);
});
});

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<Exception>()),
);
});
});
}
Loading