Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 2 additions & 7 deletions lib/services/encrypted_file_upload_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,12 @@ class EncryptedFileUploadService {
);

// 2. Encrypt with ChaCha20-Poly1305
final encryptionResult = EncryptionService.encryptChaCha20Poly1305(
final encryptedBlob = await EncryptionService.encryptToBlobAsync(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the file nonce after switching encryption APIs

This replacement removes the only declaration of encryptionResult, but the result construction below still evaluates _bytesToHex(encryptionResult.nonce). Any build that includes this service therefore fails with an undefined identifier, preventing encrypted file uploads from compiling; derive the nonce from the blob prefix or return it alongside the blob from the async API.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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,
);
Expand Down
9 changes: 2 additions & 7 deletions lib/services/encrypted_image_upload_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,12 @@ class EncryptedImageUploadService {
);

// 3. Encrypt with ChaCha20-Poly1305
final encryptionResult = EncryptionService.encryptChaCha20Poly1305(
final encryptedBlob = await EncryptionService.encryptToBlobAsync(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the image nonce after switching encryption APIs

This replacement likewise eliminates encryptionResult, while EncryptedImageUploadResult is still initialized with _bytesToHex(encryptionResult.nonce) below. Consequently, targets containing this service cannot compile and encrypted-image uploads are unavailable; extract the nonce from encryptedBlob or expose it from the asynchronous encryption operation.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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,
);
Expand Down
33 changes: 33 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,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<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