lum_ccc_rust/flutter_src/ccc/ccc_iso.dart

633 lines
22 KiB
Dart

///
/// 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<Map<String, dynamic>> cryptoWorkerFunction(Map<String, dynamic> 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<List<int>> _executeCipherChain(List<int> inputData, List<int> cipherSequence,
Map<String, dynamic> params, OperationType operationType) async {
List<int> 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<List<int>> _executeCipher(List<int> data, int cipherConstant,
Map<String, dynamic> params, bool isEncrypt,
{List<int>? 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<List<int>> _executeArgon2id(List<int> data, Map<String, dynamic> 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<List<int>> _executeAesGcm256(
List<int> data,
Map<String, dynamic> params,
bool isEncrypt, {
List<int>? 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 <int>[],
);
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 <int>[],
);
}
// 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 <int>[],
);
}
}
/// ChaCha20-Poly1305 encryption/decryption.
///
/// Derived-key output: `[12B nonce][ciphertext][16B MAC]`.
/// Legacy output: `[32B key][12B nonce][ciphertext][16B MAC]`.
Future<List<int>> _executeChacha20Poly1305(
List<int> data,
Map<String, dynamic> params,
bool isEncrypt, {
List<int>? 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 <int>[],
);
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 <int>[],
);
}
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 <int>[],
);
}
}
/// XChaCha20-Poly1305 encryption/decryption.
///
/// Derived-key output: `[24B nonce][ciphertext][16B MAC]`.
/// Legacy output: `[32B key][24B nonce][ciphertext][16B MAC]`.
Future<List<int>> _executeXChacha20Poly1305(
List<int> data,
Map<String, dynamic> params,
bool isEncrypt, {
List<int>? 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 <int>[],
);
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 <int>[],
);
}
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 <int>[],
);
}
}
/// 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<int>? _buildAssociatedDataBytes(Map<String, dynamic> 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<String, dynamic>) {
final normalized = Map<String, dynamic>.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 = <String, dynamic>{};
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<List<int>> _deriveLayerKey(
Map<String, dynamic> 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<int> _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<int> expected, List<int> 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<List<int>> _executeHmacSha512(List<int> data, Map<String, dynamic> 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<int>? 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<List<int>> _executeBlake2b(List<int> data, Map<String, dynamic> 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<int> _applyPadding(List<int> plaintext, Map<String, dynamic> 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<int>.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<int> _stripPadding(List<int> 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);
}