633 lines
22 KiB
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);
|
|
}
|