Saba-dart/lib/utils/secure_crypto_helper.dart
2026-04-13 23:41:27 +03:30

491 lines
15 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart' as dart_crypto;
import 'package:cryptography/cryptography.dart';
import 'package:flutter/foundation.dart';
import 'package:pointycastle/export.dart' as pc;
class EccIdentityMaterial {
final String privateKey;
final String publicKey;
final String fingerprint;
const EccIdentityMaterial({
required this.privateKey,
required this.publicKey,
required this.fingerprint,
});
}
class SecureCryptoHelper {
final _aesGcm = AesGcm.with256bits();
final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
final _random = Random.secure();
final pc.ECDomainParameters _ecDomain = pc.ECDomainParameters('secp256r1');
static const _variantMap = {
"ك": "ک",
"ي": "ی",
"ى": "ی",
"ة": "ه",
"ە": "ه",
"٠": "0",
"١": "1",
"٢": "2",
"٣": "3",
"٤": "4",
"٥": "5",
"٦": "6",
"٧": "7",
"٨": "8",
"٩": "9",
"۰": "0",
"۱": "1",
"۲": "2",
"۳": "3",
"۴": "4",
"۵": "5",
"۶": "6",
"۷": "7",
"۸": "8",
"۹": "9",
};
String _visualSafe(String text) {
String res = text.trim();
// 1. Remove invisible/zero-width characters (matches Python's _LEGACY_INVISIBLE_CHARS)
res = res.replaceAll(RegExp(r'[\u200c\u200d\u200e\u200f\ufeff]'), '');
// 2. Normalize common decomposed (NFD) Persian characters to composite (NFC) legacy fallback
res = res.replaceAll('\u0627\u0653', '\u0622'); // Alif + Madda -> آ
res = res.replaceAll('\u0627\u0654', '\u0623'); // Alif + Hamza -> أ
res = res.replaceAll('\u0627\u0655', '\u0625'); // Alif + Lower Hamza -> إ
res = res.replaceAll('\u0648\u0654', '\u0624'); // Waw + Hamza -> ؤ
res = res.replaceAll('\u064a\u0654', '\u0626'); // Yeh + Hamza (Arabic) -> ئ
res =
res.replaceAll('\u06cc\u0654', '\u0626'); // Yeh + Hamza (Persian) -> ئ
res = res.replaceAll(
'\u0627\u0644\u0644\u0647', 'الله'); // Allah ligature (Standard)
// 3. Map Arabic variants and digits to Persian/Western standards (matches Python's dict)
_variantMap.forEach((key, value) {
res = res.replaceAll(key, value);
});
// 4. Aggressive cleanup of invisible characters (Matches Python's _LEGACY_INVISIBLE_CHARS)
res = res.replaceAll(RegExp(r'[\u200c\u200d\u200e\u200f\ufeff]'), '');
// NOTE: We DO NOT remove Harakats (diacritics) here because Python's NFC normalization
// preserves them. We only map letter/digit variants.
return res;
}
String b64uEncode(List<int> bytes) {
return base64UrlEncode(bytes).replaceAll('=', '');
}
List<int> b64uDecode(String value) {
// 1. Strip transport prefixes if present
String input = value.trim();
if (input.startsWith('b1:')) {
input = input.substring(3);
}
// 2. Log illegal characters for diagnostics
final illegalChars = input.replaceAll(RegExp(r'[A-Za-z0-9+/=\s\-_]'), '');
if (illegalChars.isNotEmpty) {
debugPrint('[CRYPTO] Found illegal chars in Base64: "$illegalChars"');
}
// 3. Aggressive cleaning - ALLOW | ; ! as they might be mangled separators/chars
String clean = input.replaceAll(RegExp(r'[^A-Za-z0-9+/=\s\-_|;!]'), '');
// 4. Map standard mangles (Parity with Python's _repair_base64)
clean = clean.replaceAll(' ', '+');
clean =
clean.replaceAll('|', '/').replaceAll(';', '/').replaceAll('!', '/');
// 4. Parity with Python: translate URL-safe to Standard before decoding
clean = clean.replaceAll('-', '+').replaceAll('_', '/');
// 5. Handle padding: discard all '=' to recalculate from scratch based on data bits
clean = clean.split('=')[0];
// 6. Critical: Final cleanup from non-alphabet symbols after mapping/split
clean = clean.replaceAll(RegExp(r'[^A-Za-z0-9+/]'), '');
// 7. Base64 length validation logic
if (clean.length % 4 == 1) {
debugPrint(
'[CRYPTO] Base64 Critical Failure: len ${clean.length} % 4 == 1. Prefix: ${clean.substring(0, min(10, clean.length))}');
return [];
}
final missingPadding = (4 - (clean.length % 4)) % 4;
final padded = clean + ('=' * missingPadding);
try {
return base64.decode(padded);
} catch (e) {
debugPrint('[CRYPTO] Base64 Final Decode Error: $e');
return [];
}
}
String hexEncode(List<int> bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
List<int> hexDecode(String value) {
String input = value.trim();
if (input.startsWith('h1:')) {
input = input.substring(3);
}
final clean = input.replaceAll(RegExp(r'[^0-9A-Fa-f]'), '');
final evenLength = clean.length - (clean.length % 2);
final normalized = clean.substring(0, evenLength);
return List<int>.generate(
normalized.length ~/ 2,
(i) => int.parse(normalized.substring(i * 2, i * 2 + 2), radix: 16),
);
}
List<int> decodeTransportPayload(String value) {
if (value.startsWith('h1:')) {
return hexDecode(value.substring(3));
}
if (value.startsWith('b1:')) {
return b64uDecode(value.substring(3));
}
return b64uDecode(value);
}
Future<String> encryptSymmetric(String message, String password) async {
final normKeyText = _visualSafe(password);
final digest = dart_crypto.sha256.convert(utf8.encode(normKeyText));
final keyBytes = Uint8List.fromList(digest.bytes);
final nonce = _aesGcm.newNonce();
final secretBox = await _aesGcm.encrypt(
utf8.encode(message),
secretKey: SecretKey(keyBytes),
nonce: nonce,
);
final combined = Uint8List.fromList([
...secretBox.nonce,
...secretBox.cipherText,
...secretBox.mac.bytes,
]);
return 'b1:${b64uEncode(combined)}';
}
Future<String?> decryptSymmetric(String payload, String password) async {
try {
final raw = decodeTransportPayload(payload);
final keyVariants = [
{'label': 'visual_safe', 'text': _visualSafe(password)},
{'label': 'raw', 'text': password.trim()},
];
if (raw.length < 28) {
debugPrint('[CRYPTO] Symmetric decryption failed: payload too short.');
return null;
}
final nonce = raw.sublist(0, 12);
final ciphertextWithTag = raw.sublist(12);
final ciphertext =
ciphertextWithTag.sublist(0, ciphertextWithTag.length - 16);
final mac = ciphertextWithTag.sublist(ciphertextWithTag.length - 16);
for (final variant in keyVariants) {
final label = variant['label']!;
final keyText = variant['text']!;
final digest = dart_crypto.sha256.convert(utf8.encode(keyText));
final keyBytes = Uint8List.fromList(digest.bytes);
debugPrint('[CRYPTO] Trying symmetric decryption variant "$label".');
try {
final secretBox = SecretBox(
ciphertext,
nonce: nonce,
mac: Mac(mac),
);
final decryptedBytes = await _aesGcm.decrypt(
secretBox,
secretKey: SecretKey(keyBytes),
);
debugPrint(
'[CRYPTO] Symmetric decryption succeeded via variant "$label".');
try {
return utf8.decode(decryptedBytes);
} catch (e) {
debugPrint('[CRYPTO] UTF-8 decode failed after decryption.');
return '[UTF-8 Decode Error]';
}
} catch (e) {
debugPrint('[CRYPTO] Symmetric decryption variant "$label" failed.');
}
}
debugPrint('[CRYPTO] All symmetric decryption variants failed.');
return null;
} catch (e) {
debugPrint('[CRYPTO] Symmetric decryption error: $e');
return null;
}
}
String fingerprintFromPublicBytes(List<int> publicBytes) {
final digest =
dart_crypto.sha256.convert(publicBytes).toString().toUpperCase();
final parts = <String>[];
for (var i = 0; i < digest.length; i += 4) {
parts.add(digest.substring(i, i + 4));
}
return parts.join(' ');
}
List<int> _normalizeP256Coordinate(List<int> value) {
if (value.length == 32) return List<int>.from(value);
if (value.length == 33 && value.first == 0) {
return value.sublist(1);
}
if (value.length < 32) {
return List<int>.filled(32 - value.length, 0) + value;
}
return value.sublist(value.length - 32);
}
pc.SecureRandom _newSecureRandom() {
final seed = Uint8List.fromList(
List<int>.generate(32, (_) => _random.nextInt(256)),
);
return pc.FortunaRandom()..seed(pc.KeyParameter(seed));
}
BigInt _bytesToBigInt(List<int> bytes) {
var result = BigInt.zero;
for (final byte in bytes) {
result = (result << 8) | BigInt.from(byte);
}
return result;
}
List<int> _bigIntToBytes(BigInt value) {
if (value == BigInt.zero) {
return [0];
}
final result = <int>[];
var current = value;
while (current > BigInt.zero) {
result.insert(0, (current & BigInt.from(0xff)).toInt());
current = current >> 8;
}
return result;
}
String publicKeyTransport(String value) {
final publicBytes = decodePublicKey(value);
return 'b1:${b64uEncode(publicBytes)}';
}
Future<EccIdentityMaterial> generateIdentity() async {
final params = pc.ParametersWithRandom<pc.ECKeyGeneratorParameters>(
pc.ECKeyGeneratorParameters(_ecDomain),
_newSecureRandom(),
);
final generator = pc.KeyGenerator('EC')..init(params);
final keyPair = generator.generateKeyPair();
final privateKey = keyPair.privateKey as pc.ECPrivateKey;
final publicKey = keyPair.publicKey as pc.ECPublicKey;
final privateBytes = Uint8List.fromList(
_normalizeP256Coordinate(_bigIntToBytes(privateKey.d!)),
);
final publicBytes = Uint8List.fromList([
..._normalizeP256Coordinate(
_bigIntToBytes(publicKey.Q!.x!.toBigInteger()!),
),
..._normalizeP256Coordinate(
_bigIntToBytes(publicKey.Q!.y!.toBigInteger()!),
),
]);
return EccIdentityMaterial(
privateKey: b64uEncode(privateBytes),
publicKey: b64uEncode(publicBytes),
fingerprint: fingerprintFromPublicBytes(publicBytes),
);
}
List<int> decodePublicKey(String value) {
try {
final String input = value.startsWith('h1:') || value.startsWith('b1:')
? value.substring(3)
: value;
final raw = b64uDecode(input);
if (raw.isNotEmpty) {
if (raw.length == 65 && raw.first == 4) {
return raw.sublist(1);
}
if (raw.length == 64) {
return raw;
}
}
throw StateError(
'P-256 public key length must be 64 bytes (got ${raw.length}).');
} catch (e) {
debugPrint('Public key decode error for value "$value": $e');
rethrow;
}
}
Future<List<int>> deriveSharedKey({
required String privateKey,
required String localPublicKey,
required String publicKey,
}) async {
final privateBytes = b64uDecode(privateKey);
final publicBytes = decodePublicKey(publicKey);
if (privateBytes.length != 32) {
throw StateError('P-256 private key must contain 32 bytes.');
}
final peerX = publicBytes.sublist(0, 32);
final peerY = publicBytes.sublist(32, 64);
final localPublicBytes = decodePublicKey(localPublicKey);
if (localPublicBytes.length != 64) {
throw StateError('Local P-256 public key must contain 64 bytes.');
}
final agreement = pc.ECDHBasicAgreement()
..init(
pc.ECPrivateKey(
_bytesToBigInt(privateBytes),
_ecDomain,
),
);
final sharedPoint = pc.ECPublicKey(
_ecDomain.curve.createPoint(
_bytesToBigInt(peerX),
_bytesToBigInt(peerY),
),
_ecDomain,
);
final sharedValue = agreement.calculateAgreement(sharedPoint);
final sharedBytes = Uint8List.fromList(
_normalizeP256Coordinate(_bigIntToBytes(sharedValue)),
);
final derived = await _hkdf.deriveKey(
secretKey: SecretKey(sharedBytes),
nonce: utf8.encode('SABA:ECDH:P256:HKDF:v1'),
info: utf8.encode('SABA:ECDH:P256:AES-256-GCM'),
);
final finalKey = await derived.extractBytes();
return finalKey;
}
Future<String> encryptWithSharedKey(
String message, List<int> sharedKey) async {
final nonce = _aesGcm.newNonce();
final secretBox = await _aesGcm.encrypt(
utf8.encode(message),
secretKey: SecretKey(sharedKey),
nonce: nonce,
);
final combined = [
...secretBox.nonce,
...secretBox.cipherText,
...secretBox.mac.bytes,
];
return b64uEncode(combined);
}
Future<String?> decryptWithSharedKey(
String payload, List<int> sharedKey) async {
try {
final raw = b64uDecode(payload);
if (raw.length < 28) return null;
final nonce = raw.sublist(0, 12);
final ciphertextWithTag = raw.sublist(12);
final ciphertext =
ciphertextWithTag.sublist(0, ciphertextWithTag.length - 16);
final mac = ciphertextWithTag.sublist(ciphertextWithTag.length - 16);
try {
final secretBox = SecretBox(
ciphertext,
nonce: nonce,
mac: Mac(mac),
);
final plain =
await _aesGcm.decrypt(secretBox, secretKey: SecretKey(sharedKey));
return utf8.decode(plain);
} catch (e) {
debugPrint('[CRYPTO] Asymmetric decryption failed: $e');
return null;
}
} catch (e) {
debugPrint('Asymmetric payload decryption error: $e');
return null;
}
}
List<int> randomBytes(int length) {
return List<int>.generate(length, (_) => _random.nextInt(256));
}
Future<String> encryptStorageText(String value, List<int> keyBytes) async {
final nonce = _aesGcm.newNonce();
final secretBox = await _aesGcm.encrypt(
utf8.encode(value),
secretKey: SecretKey(keyBytes),
nonce: nonce,
);
return 'enc1:${b64uEncode([
...secretBox.nonce,
...secretBox.cipherText,
...secretBox.mac.bytes,
])}';
}
Future<String?> decryptStorageText(String? value, List<int> keyBytes) async {
if (value == null || value.isEmpty) return value;
if (!value.startsWith('enc1:')) return value;
try {
final raw = b64uDecode(value.substring(5));
if (raw.length < 28) {
return null;
}
final nonce = raw.sublist(0, 12);
final ciphertextWithTag = raw.sublist(12);
final ciphertext =
ciphertextWithTag.sublist(0, ciphertextWithTag.length - 16);
final mac = ciphertextWithTag.sublist(ciphertextWithTag.length - 16);
final secretBox = SecretBox(
ciphertext,
nonce: nonce,
mac: Mac(mac),
);
final plain = await _aesGcm.decrypt(
secretBox,
secretKey: SecretKey(keyBytes),
);
return utf8.decode(plain);
} catch (e) {
debugPrint('Storage payload decryption error: $e');
return null;
}
}
}