/// /// Cipher implementations for isolate workers /// Uses cryptography package for all crypto operations /// import 'dart:convert'; import 'dart:isolate'; import 'dart:math'; import 'dart:typed_data'; import 'package:cryptography/cryptography.dart'; import 'ccc_iso_operation.dart'; import 'ccc_iso_result.dart'; import 'cipher_constants.dart'; /// Main crypto worker function that runs in isolates @pragma('vm:entry-point') Future> cryptoWorkerFunction(Map data) async { final startTime = DateTime.now(); // Use isolate hash code as a stable worker identifier final isolateHash = Isolate.current.hashCode.abs() % 10000; final workerId = 'isolate_$isolateHash'; try { // Deserialize operation final operation = CryptoOperation.fromMap(data); // Execute cipher chain final resultData = await _executeCipherChain( operation.data, operation.cipherSequence, operation.params, operation.type, ); final processingTime = DateTime.now().difference(startTime); // Create success result final result = CryptoResult.success( operationId: operation.id, data: resultData, processingTime: processingTime, workerId: workerId, ); return result.toMap(); } catch (error, stackTrace) { final processingTime = DateTime.now().difference(startTime); // Create error result final result = CryptoResult.error( operationId: data['id'] as String? ?? 'unknown', error: error.toString(), stackTrace: stackTrace.toString(), processingTime: processingTime, workerId: workerId, ); return result.toMap(); } } /// Execute cipher chain based on operation type Future> _executeCipherChain(List inputData, List cipherSequence, Map params, OperationType operationType) async { List data = inputData; final associatedData = _buildAssociatedDataBytes(params); if (operationType == OperationType.encrypt) { // Apply length-hiding padding before encryption data = _applyPadding(data, params); // Forward execution for encryption for (var i = 0; i < cipherSequence.length; i++) { data = await _executeCipher( data, cipherSequence[i], params, true, associatedData: associatedData, layerIndex: i, ); } } else { // Reverse execution for decryption — peel outermost layer first for (var i = cipherSequence.length - 1; i >= 0; i--) { data = await _executeCipher( data, cipherSequence[i], params, false, associatedData: associatedData, layerIndex: i, ); } // Strip length-hiding padding after decryption data = _stripPadding(data); } return data; } /// Execute single cipher operation. /// /// [layerIndex] identifies this step's position in the cipher sequence. /// Combined with [cipherConstant], it allows deterministic per-layer key /// derivation when `phase1_root_key_b64` is present in [params]. Future> _executeCipher(List data, int cipherConstant, Map params, bool isEncrypt, {List? associatedData, int layerIndex = 0}) async { switch (cipherConstant) { // Key Derivation Functions case CipherConstants.ARGON2ID: return await _executeArgon2id(data, params); // AEAD Ciphers case CipherConstants.AES_GCM_256: return await _executeAesGcm256(data, params, isEncrypt, associatedData: associatedData, layerIndex: layerIndex); case CipherConstants.CHACHA20_POLY1305: return await _executeChacha20Poly1305(data, params, isEncrypt, associatedData: associatedData, layerIndex: layerIndex); case CipherConstants.XCHACHA20_POLY1305: return await _executeXChacha20Poly1305(data, params, isEncrypt, associatedData: associatedData, layerIndex: layerIndex); // MAC Algorithms case CipherConstants.HMAC_SHA512: return await _executeHmacSha512(data, params, isEncrypt, layerIndex: layerIndex); case CipherConstants.BLAKE2B: return await _executeBlake2b(data, params, isEncrypt); default: throw UnsupportedError('Cipher not implemented: ${CipherConstants.getCipherName(cipherConstant)}'); } } /// Argon2id key derivation (always applied, regardless of encrypt/decrypt) Future> _executeArgon2id(List data, Map params) async { final algorithm = Argon2id( memory: params['argon2_memory'] as int? ?? 64 * 1024, parallelism: params['argon2_parallelism'] as int? ?? 4, iterations: params['argon2_iterations'] as int? ?? 3, hashLength: params['argon2_hash_length'] as int? ?? 32, ); // Use first 16 bytes as salt, or generate if data is too short Uint8List salt; if (data.length >= 16) { salt = Uint8List.fromList(data.take(16).toList()); } else { salt = Uint8List.fromList(List.generate(16, (i) => i)); } final secretKey = await algorithm.deriveKeyFromPassword( password: String.fromCharCodes(data), nonce: salt.toList(), ); final keyBytes = await secretKey.extractBytes(); return keyBytes; } /// AES-256-GCM encryption/decryption. /// /// When `phase1_root_key_b64` is present in [params], derives a deterministic /// per-layer key from the channel root key (real E2E encryption). /// Output format: `[12B nonce][ciphertext][16B MAC]`. /// /// Legacy mode (no root key): generates a random key per encryption and /// embeds it in the output: `[32B key][12B nonce][ciphertext][16B MAC]`. Future> _executeAesGcm256( List data, Map params, bool isEncrypt, { List? associatedData, int layerIndex = 0, }) async { final algorithm = AesGcm.with256bits(); final useDerivedKey = params.containsKey('phase1_root_key_b64'); if (isEncrypt) { final SecretKey secretKey; if (useDerivedKey) { final keyBytes = await _deriveLayerKey(params, layerIndex, CipherConstants.AES_GCM_256, 32); secretKey = SecretKeyData(keyBytes); } else { secretKey = await algorithm.newSecretKey(); } final secretBox = await algorithm.encrypt( data, secretKey: secretKey, aad: associatedData ?? const [], ); if (useDerivedKey) { // Derived-key format: [nonce][ciphertext][mac] return [...secretBox.nonce, ...secretBox.cipherText, ...secretBox.mac.bytes]; } // Legacy format: [key][nonce][ciphertext][mac] final keyBytes = await secretKey.extractBytes(); return [...keyBytes, ...secretBox.nonce, ...secretBox.cipherText, ...secretBox.mac.bytes]; } else { if (useDerivedKey) { // Derived-key format: [12B nonce][ciphertext][16B mac] if (data.length < 12 + 16) { throw ArgumentError('Invalid AES-GCM derived-key data length'); } final keyBytes = await _deriveLayerKey(params, layerIndex, CipherConstants.AES_GCM_256, 32); final nonce = data.sublist(0, 12); final cipherText = data.sublist(12, data.length - 16); final macBytes = data.sublist(data.length - 16); return await algorithm.decrypt( SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)), secretKey: SecretKeyData(keyBytes), aad: associatedData ?? const [], ); } // Legacy format: [32B key][12B nonce][ciphertext][16B mac] if (data.length < 32 + 12 + 16) { throw ArgumentError('Invalid AES-GCM data length'); } final keyBytes = data.sublist(0, 32); final nonce = data.sublist(32, 44); final cipherText = data.sublist(44, data.length - 16); final macBytes = data.sublist(data.length - 16); return await algorithm.decrypt( SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)), secretKey: SecretKeyData(keyBytes), aad: associatedData ?? const [], ); } } /// ChaCha20-Poly1305 encryption/decryption. /// /// Derived-key output: `[12B nonce][ciphertext][16B MAC]`. /// Legacy output: `[32B key][12B nonce][ciphertext][16B MAC]`. Future> _executeChacha20Poly1305( List data, Map params, bool isEncrypt, { List? associatedData, int layerIndex = 0, }) async { final algorithm = Chacha20.poly1305Aead(); final useDerivedKey = params.containsKey('phase1_root_key_b64'); if (isEncrypt) { final SecretKey secretKey; if (useDerivedKey) { final keyBytes = await _deriveLayerKey(params, layerIndex, CipherConstants.CHACHA20_POLY1305, 32); secretKey = SecretKeyData(keyBytes); } else { secretKey = await algorithm.newSecretKey(); } final secretBox = await algorithm.encrypt( data, secretKey: secretKey, aad: associatedData ?? const [], ); if (useDerivedKey) { return [...secretBox.nonce, ...secretBox.cipherText, ...secretBox.mac.bytes]; } final keyBytes = await secretKey.extractBytes(); return [...keyBytes, ...secretBox.nonce, ...secretBox.cipherText, ...secretBox.mac.bytes]; } else { if (useDerivedKey) { if (data.length < 12 + 16) { throw ArgumentError('Invalid ChaCha20-Poly1305 derived-key data length'); } final keyBytes = await _deriveLayerKey(params, layerIndex, CipherConstants.CHACHA20_POLY1305, 32); final nonce = data.sublist(0, 12); final cipherText = data.sublist(12, data.length - 16); final macBytes = data.sublist(data.length - 16); return await algorithm.decrypt( SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)), secretKey: SecretKeyData(keyBytes), aad: associatedData ?? const [], ); } if (data.length < 32 + 12 + 16) { throw ArgumentError('Invalid ChaCha20-Poly1305 data length'); } final keyBytes = data.sublist(0, 32); final nonce = data.sublist(32, 44); final cipherText = data.sublist(44, data.length - 16); final macBytes = data.sublist(data.length - 16); return await algorithm.decrypt( SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)), secretKey: SecretKeyData(keyBytes), aad: associatedData ?? const [], ); } } /// XChaCha20-Poly1305 encryption/decryption. /// /// Derived-key output: `[24B nonce][ciphertext][16B MAC]`. /// Legacy output: `[32B key][24B nonce][ciphertext][16B MAC]`. Future> _executeXChacha20Poly1305( List data, Map params, bool isEncrypt, { List? associatedData, int layerIndex = 0, }) async { final algorithm = Xchacha20.poly1305Aead(); final useDerivedKey = params.containsKey('phase1_root_key_b64'); if (isEncrypt) { final SecretKey secretKey; if (useDerivedKey) { final keyBytes = await _deriveLayerKey(params, layerIndex, CipherConstants.XCHACHA20_POLY1305, 32); secretKey = SecretKeyData(keyBytes); } else { secretKey = await algorithm.newSecretKey(); } final secretBox = await algorithm.encrypt( data, secretKey: secretKey, aad: associatedData ?? const [], ); if (useDerivedKey) { return [...secretBox.nonce, ...secretBox.cipherText, ...secretBox.mac.bytes]; } final keyBytes = await secretKey.extractBytes(); return [...keyBytes, ...secretBox.nonce, ...secretBox.cipherText, ...secretBox.mac.bytes]; } else { if (useDerivedKey) { if (data.length < 24 + 16) { throw ArgumentError('Invalid XChaCha20-Poly1305 derived-key data length'); } final keyBytes = await _deriveLayerKey(params, layerIndex, CipherConstants.XCHACHA20_POLY1305, 32); final nonce = data.sublist(0, 24); final cipherText = data.sublist(24, data.length - 16); final macBytes = data.sublist(data.length - 16); return await algorithm.decrypt( SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)), secretKey: SecretKeyData(keyBytes), aad: associatedData ?? const [], ); } if (data.length < 32 + 24 + 16) { throw ArgumentError('Invalid XChaCha20-Poly1305 data length'); } final keyBytes = data.sublist(0, 32); final nonce = data.sublist(32, 56); final cipherText = data.sublist(56, data.length - 16); final macBytes = data.sublist(data.length - 16); return await algorithm.decrypt( SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)), secretKey: SecretKeyData(keyBytes), aad: associatedData ?? const [], ); } } /// Build canonical associated-data bytes from Phase 1 params. /// /// The worker expects `phase1_associated_data` in params and canonicalizes it /// to stable UTF-8 JSON so encrypt/decrypt use identical AEAD AAD bytes. /// /// Phase 1 exclusions (both removed so sender/receiver produce identical AD): /// - `direction`: sender uses 'out', receiver uses 'in' for the same ciphertext. /// - `message_sequence`: sender encrypts before the relay assigns server_seq, /// so the receiver's relay-assigned sequence would mismatch. Sequence binding /// will be re-enabled in Phase 2 when the ratchet provides agreed-upon counters. List? _buildAssociatedDataBytes(Map params) { final phase1 = params['phase1_associated_data']; if (phase1 == null) return null; final canonical = _canonicalizeJsonSafe(phase1); if (canonical == null) return null; if (canonical is Map) { final normalized = Map.from(canonical) ..remove('direction') ..remove('message_sequence'); return utf8.encode(jsonEncode(normalized)); } if (canonical is List) { return utf8.encode(jsonEncode(canonical)); } return utf8.encode(canonical.toString()); } dynamic _canonicalizeJsonSafe(dynamic value) { if (value is Map) { final keys = value.keys.map((item) => item.toString()).toList()..sort(); final normalized = {}; for (final key in keys) { normalized[key] = _canonicalizeJsonSafe(value[key]); } return normalized; } if (value is List) { return value.map(_canonicalizeJsonSafe).toList(growable: false); } return value; } // --------------------------------------------------------------------------- // Derived-key helpers // --------------------------------------------------------------------------- /// Derive a deterministic per-layer encryption key from the channel root key. /// /// Uses SHA-512 (always, regardless of channel KDF selection) to ensure /// sufficient output length for all cipher types: /// - AEAD ciphers (AES-256-GCM, ChaCha20, XChaCha20): 32 bytes /// - HMAC-SHA512: 64 bytes /// /// The derivation is deterministic: identical inputs on sender and receiver /// produce the same key, enabling real E2E encryption without embedding keys /// in the ciphertext. /// /// Formula: `SHA-512(rootKeyBytes || "|lk|{layerIndex}|c|{cipherConstant}")` /// truncated to [keyLength] bytes. Future> _deriveLayerKey( Map params, int layerIndex, int cipherConstant, int keyLength, ) async { final rootKeyB64 = params['phase1_root_key_b64'] as String; final rootKeyBytes = _decodeBase64UrlNoPad(rootKeyB64); // Derive: SHA-512(rootKey || domain-separation-label) final input = [ ...rootKeyBytes, ...utf8.encode('|lk|$layerIndex|c|$cipherConstant'), ]; final hash = await Sha512().hash(input); return hash.bytes.sublist(0, keyLength); } /// Decode a base64url string that may lack padding characters. List _decodeBase64UrlNoPad(String encoded) { final padded = encoded + '=' * ((4 - encoded.length % 4) % 4); return base64Url.decode(padded); } /// Constant-time MAC/hash comparison to prevent timing side-channels. void _verifyMacBytes(List expected, List computed) { if (expected.length != computed.length) { throw ArgumentError('MAC verification failed'); } int diff = 0; for (int i = 0; i < expected.length; i++) { diff |= expected[i] ^ computed[i]; } if (diff != 0) { throw ArgumentError('MAC verification failed'); } } /// HMAC-SHA512 authentication/verification. /// /// Derived-key output: `[data][64B MAC]`. /// Legacy output: `[64B key][data][64B MAC]`. Future> _executeHmacSha512(List data, Map params, bool isEncrypt, {int layerIndex = 0}) async { final algorithm = Hmac.sha512(); final useDerivedKey = params.containsKey('phase1_root_key_b64'); if (isEncrypt) { final SecretKey secretKey; final List? legacyKeyBytes; if (useDerivedKey) { final keyBytes = await _deriveLayerKey(params, layerIndex, CipherConstants.HMAC_SHA512, 64); secretKey = SecretKeyData(keyBytes); legacyKeyBytes = null; } else { final keyBytes = List.generate(64, (i) => DateTime.now().microsecond % 256); secretKey = SecretKeyData(keyBytes); legacyKeyBytes = keyBytes; } final mac = await algorithm.calculateMac(data, secretKey: secretKey); if (useDerivedKey) { // Derived-key format: [data][mac] return [...data, ...mac.bytes]; } // Legacy format: [key][data][mac] return [...legacyKeyBytes!, ...data, ...mac.bytes]; } else { if (useDerivedKey) { // Derived-key format: [data][64B mac] if (data.length < 64) { throw ArgumentError('Invalid HMAC-SHA512 derived-key data length'); } final macBytes = data.sublist(data.length - 64); final originalData = data.sublist(0, data.length - 64); final keyBytes = await _deriveLayerKey(params, layerIndex, CipherConstants.HMAC_SHA512, 64); final computedMac = await algorithm.calculateMac(originalData, secretKey: SecretKeyData(keyBytes)); _verifyMacBytes(macBytes, computedMac.bytes); return originalData; } // Legacy format: [64B key][data][64B mac] if (data.length < 64 + 64) { throw ArgumentError('Invalid HMAC-SHA512 data length'); } final keyBytes = data.sublist(0, 64); final macBytes = data.sublist(data.length - 64); final originalData = data.sublist(64, data.length - 64); final computedMac = await algorithm.calculateMac(originalData, secretKey: SecretKeyData(keyBytes)); _verifyMacBytes(macBytes, computedMac.bytes); return originalData; } } /// BLAKE2b hashing/verification (keyless integrity check). Future> _executeBlake2b(List data, Map params, bool isEncrypt) async { final algorithm = Blake2b(); if (isEncrypt) { final hash = await algorithm.hash(data); return [...data, ...hash.bytes]; } else { if (data.length < 64) { throw ArgumentError('Invalid BLAKE2b data length'); } final hashBytes = data.sublist(data.length - 64); final originalData = data.sublist(0, data.length - 64); final computedHash = await algorithm.hash(originalData); _verifyMacBytes(hashBytes, computedHash.bytes); return originalData; } } // --------------------------------------------------------------------------- // Length-Hiding Padding (Phase 3) // --------------------------------------------------------------------------- /// Default minimum random padding bytes. const int _paddingDefaultMinBytes = 32; /// Default block size for length-hiding alignment. const int _paddingDefaultBlockSize = 256; /// Apply length-hiding padding to plaintext before encryption. /// /// Format: `[plaintext][random_padding][4-byte padding_length_LE]` /// /// The padding ensures: /// 1. At least [minPaddingBytes] random bytes are appended (defeats exact /// length correlation even for known plaintexts). /// 2. The total padded length is rounded up to the next multiple of /// [blockSize] (groups all messages into fixed-size buckets so an /// eavesdropper cannot distinguish "hi" from "hey"). /// /// The last 4 bytes always store the total padding length as a little-endian /// uint32, allowing deterministic stripping on the receiver side. /// /// When both `ccc_min_padding_bytes` and `ccc_block_size` are absent from /// [params] (legacy channels), padding is still applied with conservative /// defaults (32B min, 256B block) so all new encryptions are length-hidden. List _applyPadding(List plaintext, Map params) { final minPadding = (params['ccc_min_padding_bytes'] as int?) ?? _paddingDefaultMinBytes; final blockSize = (params['ccc_block_size'] as int?) ?? _paddingDefaultBlockSize; // 4 bytes reserved for the padding-length trailer const trailerSize = 4; // Minimum total size: plaintext + minPadding + trailer final minTotal = plaintext.length + minPadding + trailerSize; // Round up to next block boundary (block size of 0 or 1 means no alignment) final effectiveBlock = blockSize > 1 ? blockSize : 1; final paddedTotal = ((minTotal + effectiveBlock - 1) ~/ effectiveBlock) * effectiveBlock; // Total padding = everything after plaintext final totalPadding = paddedTotal - plaintext.length; // Fill random padding bytes (all except last 4 which are the trailer) final rng = Random.secure(); final randomLen = totalPadding - trailerSize; final randomBytes = List.generate(randomLen, (_) => rng.nextInt(256)); // Encode totalPadding as 4-byte little-endian final trailer = [ totalPadding & 0xFF, (totalPadding >> 8) & 0xFF, (totalPadding >> 16) & 0xFF, (totalPadding >> 24) & 0xFF, ]; return [...plaintext, ...randomBytes, ...trailer]; } /// Strip length-hiding padding after decryption. /// /// Reads the 4-byte little-endian trailer at the end of [paddedData] to /// determine how many bytes of padding to remove. /// /// Throws [ArgumentError] if the trailer claims a padding length larger than /// the data itself (corruption or tamper indicator — the AEAD layer should /// already have caught tampering, but defense-in-depth). List _stripPadding(List paddedData) { const trailerSize = 4; if (paddedData.length < trailerSize) { // Data too short to contain a padding trailer — return as-is for // backward compatibility with pre-Phase-3 messages that have no padding. return paddedData; } final len = paddedData.length; final totalPadding = paddedData[len - 4] | (paddedData[len - 3] << 8) | (paddedData[len - 2] << 16) | (paddedData[len - 1] << 24); // Sanity: padding must be at least trailerSize and at most the full data if (totalPadding < trailerSize || totalPadding > len) { // Not a padded message (pre-Phase-3 legacy) — return unchanged. return paddedData; } return paddedData.sublist(0, len - totalPadding); }