491 lines
15 KiB
Dart
491 lines
15 KiB
Dart
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;
|
||
}
|
||
}
|
||
}
|