992 lines
33 KiB
Dart
992 lines
33 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
import 'package:async/async.dart';
|
|
import 'package:cryptography/cryptography.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'auth_service.dart';
|
|
import 'api_service.dart';
|
|
import 'secure_chat_service.dart';
|
|
|
|
class SimpleE2EEService {
|
|
static final SimpleE2EEService _instance = SimpleE2EEService._internal();
|
|
factory SimpleE2EEService() => _instance;
|
|
|
|
static const String _storageKey = 'e2ee_keys_v3';
|
|
static const String _cloudStorageKey = 'e2ee_keys_cloud_backup';
|
|
|
|
final FlutterSecureStorage _storage;
|
|
final AuthService _auth;
|
|
final ApiService _api;
|
|
SecureChatService? _chatService;
|
|
|
|
// ALGORITHMS
|
|
// Identity Keys: Ed25519 (Signing)
|
|
final _signingAlgo = Ed25519();
|
|
// PreKeys & Diffie-Hellman: X25519 (Key Agreement)
|
|
final _dhAlgo = X25519();
|
|
// Symmetric Encryption: AES-GCM
|
|
final _cipher = AesGcm.with256bits();
|
|
// KDF
|
|
final _sha256 = Sha256();
|
|
|
|
// STATE
|
|
SimpleKeyPair? _identityDhKeyPair; // X25519 for DH
|
|
SimpleKeyPair? _identitySigningKeyPair; // Ed25519 for Signing
|
|
SimpleKeyPair? _signedPreKey; // X25519
|
|
List<SimpleKeyPair>? _oneTimePreKeys; // X25519
|
|
|
|
String? _initializedForUserId;
|
|
Future<void>? _initFuture;
|
|
|
|
// Cache for X3DH shared secrets
|
|
final Map<String, SecretKey> _sessionCache = {};
|
|
|
|
SimpleE2EEService._internal()
|
|
: _storage = const FlutterSecureStorage(
|
|
webOptions: WebOptions(
|
|
dbName: 'sojorn_e2ee_keys',
|
|
publicKey: 'sojorn_e2ee_public',
|
|
),
|
|
),
|
|
_auth = AuthService.instance,
|
|
_api = ApiService.instance,
|
|
_chatService = null;
|
|
|
|
void setChatService(SecureChatService chatService) {
|
|
_chatService = chatService;
|
|
}
|
|
|
|
bool get isReady => _identityDhKeyPair != null && _identitySigningKeyPair != null;
|
|
|
|
String get _backupPin => _auth.currentUser?.id.substring(0, 32) ?? 'default_pin_fallback';
|
|
|
|
/// Initialize the service
|
|
Future<void> initialize() async {
|
|
final userId = _auth.currentUser?.id;
|
|
if (userId == null) return;
|
|
|
|
if (_initializedForUserId == userId && isReady) return;
|
|
|
|
if (_initFuture != null) return _initFuture;
|
|
return _initFuture = _doInitialize(userId);
|
|
}
|
|
|
|
// Key rotation is now handled via initiateKeyRecovery() when needed
|
|
// DO NOT add debug flags here - use resetAllKeys() method for intentional resets
|
|
|
|
Future<void> resetAllKeys() async {
|
|
|
|
// Clear all storage
|
|
await _storage.deleteAll();
|
|
|
|
// Clear local key variables
|
|
_identityDhKeyPair = null;
|
|
_identitySigningKeyPair = null;
|
|
_signedPreKey = null;
|
|
_oneTimePreKeys = null;
|
|
|
|
// Generate fresh identity
|
|
await generateNewIdentity();
|
|
|
|
}
|
|
|
|
// Force reset to fix 208-bit key bug
|
|
Future<void> forceResetBrokenKeys() async {
|
|
|
|
// Clear ALL storage completely
|
|
await _storage.deleteAll();
|
|
|
|
// Clear local key variables
|
|
_identityDhKeyPair = null;
|
|
_identitySigningKeyPair = null;
|
|
_signedPreKey = null;
|
|
_oneTimePreKeys = null;
|
|
_initializedForUserId = null;
|
|
_initFuture = null;
|
|
|
|
// Clear session cache
|
|
_sessionCache.clear();
|
|
|
|
|
|
// Generate fresh identity with proper key lengths
|
|
await generateNewIdentity();
|
|
|
|
// Verify the new keys are proper length
|
|
if (_identityDhKeyPair != null) {
|
|
final publicKey = await _identityDhKeyPair!.extractPublicKey();
|
|
}
|
|
|
|
if (_identitySigningKeyPair != null) {
|
|
final publicKey = await _identitySigningKeyPair!.extractPublicKey();
|
|
}
|
|
|
|
}
|
|
|
|
// Manual key upload for testing
|
|
Future<void> uploadKeysManually() async {
|
|
|
|
if (!isReady) {
|
|
throw Exception('Keys not ready - generate keys first');
|
|
}
|
|
|
|
// Generate a real signature for the signed prekey
|
|
final spk = await _signedPreKey!.extractPublicKey();
|
|
final signature = await _signingAlgo.sign(
|
|
spk.bytes,
|
|
keyPair: _identitySigningKeyPair!,
|
|
);
|
|
final spkSignature = signature.bytes;
|
|
|
|
// Verify signature is not all zeros
|
|
final allZeros = spkSignature.every((b) => b == 0);
|
|
if (allZeros) {
|
|
throw Exception('CRITICAL: Generated SPK signature is all zeros!');
|
|
}
|
|
|
|
await _publishKeys(spkSignature);
|
|
}
|
|
|
|
// Check if keys exist on backend
|
|
Future<bool> _checkKeysExistOnBackend() async {
|
|
try {
|
|
final userId = _auth.currentUser?.id;
|
|
if (userId == null) return false;
|
|
|
|
final response = await _api.callGoApi('/keys/$userId', method: 'GET');
|
|
|
|
// If we get a successful response with key data, keys exist
|
|
if (response.containsKey('identity_key')) {
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Upload existing keys to backend
|
|
Future<void> _uploadExistingKeys() async {
|
|
|
|
if (!isReady) {
|
|
throw Exception('Keys not ready for upload');
|
|
}
|
|
|
|
// Generate a proper signature for the existing signed prekey
|
|
final spk = await _signedPreKey!.extractPublicKey();
|
|
final signature = await _signingAlgo.sign(
|
|
spk.bytes,
|
|
keyPair: _identitySigningKeyPair!,
|
|
);
|
|
final spkSignature = signature.bytes;
|
|
|
|
await _publishKeys(spkSignature);
|
|
}
|
|
|
|
Future<void> _doInitialize(String userId) async {
|
|
_initializedForUserId = userId;
|
|
|
|
|
|
|
|
// 1. Try Local Storage
|
|
try {
|
|
final loaded = await _loadKeysFromLocal(userId);
|
|
if (loaded) {
|
|
// Test if keys are working by attempting a simple encrypt/decrypt
|
|
if (await _testKeyCompatibility()) {
|
|
// Check if keys exist on backend, upload if not
|
|
if (await _checkKeysExistOnBackend()) {
|
|
final backendValid = await _validateBackendKeyBundle(userId);
|
|
if (!backendValid) {
|
|
await _uploadExistingKeys();
|
|
return;
|
|
}
|
|
return;
|
|
} else {
|
|
await _uploadExistingKeys();
|
|
return;
|
|
}
|
|
} else {
|
|
await initiateKeyRecovery(userId);
|
|
return;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
}
|
|
|
|
// 2. Try Cloud Restore
|
|
final restored = await _restoreFromCloud(userId);
|
|
if (restored) {
|
|
// Test restored keys
|
|
if (await _testKeyCompatibility()) {
|
|
return;
|
|
} else {
|
|
await initiateKeyRecovery(userId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 3. Generate New
|
|
await generateNewIdentity();
|
|
}
|
|
|
|
// Test if current keys can encrypt/decrypt properly
|
|
Future<bool> _testKeyCompatibility() async {
|
|
try {
|
|
final testMessage = 'test_key_compatibility';
|
|
// Just test local encryption/decryption without API call
|
|
// This tests if our local keys are working properly
|
|
final testKey = await _dhAlgo.newKeyPair();
|
|
final testNonce = _cipher.newNonce();
|
|
final testPlaintext = utf8.encode(testMessage);
|
|
|
|
// Generate proper 32-byte (256-bit) key for AES-GCM
|
|
final testKeyBytes = List<int>.filled(32, 0);
|
|
for (var i = 0; i < 32; i++) {
|
|
testKeyBytes[i] = i % 256; // Simple deterministic pattern for testing
|
|
}
|
|
final testSecretKey = SecretKey(testKeyBytes);
|
|
|
|
// Verify key length
|
|
if (testKeyBytes.length != 32) {
|
|
return false;
|
|
}
|
|
|
|
final encrypted = await _cipher.encrypt(
|
|
testPlaintext,
|
|
secretKey: testSecretKey,
|
|
nonce: testNonce
|
|
);
|
|
|
|
final decrypted = await _cipher.decrypt(
|
|
encrypted,
|
|
secretKey: testSecretKey
|
|
);
|
|
|
|
final result = utf8.decode(decrypted) == testMessage;
|
|
return result;
|
|
} catch (e) {
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<bool> _validateBackendKeyBundle(String userId) async {
|
|
try {
|
|
final bundle = await _api.getKeyBundle(userId);
|
|
|
|
String? ikField = bundle['identity_key_public'];
|
|
if (ikField == null && bundle['identity_key'] is Map) {
|
|
ikField = bundle['identity_key']['public_key'];
|
|
} else if (ikField == null) {
|
|
ikField = bundle['identity_key'];
|
|
}
|
|
|
|
String? spkField = bundle['signed_prekey_public'];
|
|
String? spkSignature = bundle['signed_prekey_signature'];
|
|
if (spkField == null && bundle['signed_prekey'] is Map) {
|
|
spkField = bundle['signed_prekey']['public_key'];
|
|
spkSignature = bundle['signed_prekey']['signature'];
|
|
} else if (spkField == null) {
|
|
spkField = bundle['signed_prekey'];
|
|
}
|
|
|
|
if (ikField == null || ikField.isEmpty) return false;
|
|
if (spkField == null || spkField.isEmpty) return false;
|
|
if (spkSignature == null || spkSignature.isEmpty) return false;
|
|
|
|
final ikParts = ikField.split(':');
|
|
if (ikParts.length != 2) return false;
|
|
|
|
final skBytes = base64Decode(ikParts[0]);
|
|
final spkBytes = base64Decode(spkField);
|
|
final sigBytes = base64Decode(spkSignature);
|
|
|
|
final theirSk = SimplePublicKey(skBytes, type: KeyPairType.ed25519);
|
|
final verified = await _signingAlgo.verify(
|
|
spkBytes,
|
|
signature: Signature(sigBytes, publicKey: theirSk),
|
|
);
|
|
|
|
return verified;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Smart key recovery that preserves messages when possible
|
|
Future<void> initiateKeyRecovery(String userId) async {
|
|
|
|
// Try to preserve existing messages by backing up encrypted content
|
|
final messageBackup = await _backupEncryptedMessages();
|
|
|
|
// Generate new keys
|
|
await generateNewIdentity();
|
|
|
|
// Restore message backup with new keys if possible
|
|
if (messageBackup > 0) {
|
|
// Note: Messages encrypted with old keys will show as "encrypted with old keys"
|
|
// but new messages will work perfectly
|
|
}
|
|
|
|
}
|
|
|
|
// Backup encrypted messages to preserve them during key recovery
|
|
Future<int> _backupEncryptedMessages() async {
|
|
try {
|
|
// This would integrate with local message store to count/preserve messages
|
|
// For now, just log that we're attempting preservation
|
|
return 0; // Return count of backed up messages
|
|
} catch (e) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
Future<void> generateNewIdentity() async {
|
|
final userId = _auth.currentUser?.id;
|
|
if (userId == null) return;
|
|
|
|
|
|
// 1. Identity Key Pair (DH)
|
|
_identityDhKeyPair = await _dhAlgo.newKeyPair();
|
|
|
|
// 2. Identity Signing Pair (Ed25519)
|
|
_identitySigningKeyPair = await _signingAlgo.newKeyPair();
|
|
|
|
// 3. Signed PreKey (X25519)
|
|
_signedPreKey = await _dhAlgo.newKeyPair();
|
|
final spkPublic = await _signedPreKey!.extractPublicKey();
|
|
|
|
// Sign the SPK with the Identity Signing Key
|
|
final signature = await _signingAlgo.sign(
|
|
spkPublic.bytes,
|
|
keyPair: _identitySigningKeyPair!,
|
|
);
|
|
final spkSignature = Uint8List.fromList(signature.bytes);
|
|
|
|
// 4. One-Time PreKeys (X25519)
|
|
final opks = <SimpleKeyPair>[];
|
|
for (int i = 0; i < 20; i++) {
|
|
opks.add(await _dhAlgo.newKeyPair());
|
|
}
|
|
_oneTimePreKeys = opks;
|
|
|
|
// 5. Save Locally
|
|
await _saveKeysToLocal(userId);
|
|
|
|
// 6. Publish to Server
|
|
await _publishKeys(spkSignature);
|
|
|
|
// 6. Backup Identity to Cloud
|
|
await _backupIdentityToCloud(userId);
|
|
}
|
|
|
|
// --- Core X3DH Encryption ---
|
|
|
|
Future<Map<String, dynamic>> encrypt(String recipientId, String plaintext) async {
|
|
if (!_auth.isAuthenticated) throw Exception('Not authenticated');
|
|
await initialize();
|
|
|
|
|
|
// 1. Fetch Bundle
|
|
final bundle = await ApiService(AuthService.instance).getKeyBundle(recipientId);
|
|
|
|
// DEBUG: Validate Bundle
|
|
|
|
// Handle both formats:
|
|
// Flat (from getKeyBundle normalization): { "identity_key_public": "...", "signed_prekey_public": "...", "signed_prekey_signature": "..." }
|
|
// Nested (raw): { "identity_key": {"public_key": "..."}, "signed_prekey": {"public_key": "...", "signature": "..."} }
|
|
String? ikField;
|
|
String? spkField;
|
|
String? spkSignature;
|
|
String? otkField;
|
|
int? otkId;
|
|
|
|
// Identity Key - check flat first (most common after normalization)
|
|
ikField = bundle['identity_key_public'];
|
|
if (ikField == null && bundle['identity_key'] is Map) {
|
|
ikField = bundle['identity_key']['public_key'];
|
|
} else if (ikField == null) {
|
|
ikField = bundle['identity_key'];
|
|
}
|
|
|
|
// Signed PreKey - check flat first
|
|
spkField = bundle['signed_prekey_public'];
|
|
spkSignature = bundle['signed_prekey_signature'];
|
|
if (spkField == null && bundle['signed_prekey'] is Map) {
|
|
spkField = bundle['signed_prekey']['public_key'];
|
|
spkSignature = bundle['signed_prekey']['signature'];
|
|
} else if (spkField == null) {
|
|
spkField = bundle['signed_prekey'];
|
|
}
|
|
|
|
// One-Time PreKey - check if nested or flat
|
|
if (bundle['one_time_prekey'] is Map) {
|
|
otkField = bundle['one_time_prekey']['public_key'];
|
|
otkId = bundle['one_time_prekey']['key_id'];
|
|
} else if (bundle['one_time_prekey'] is String) {
|
|
otkField = bundle['one_time_prekey'];
|
|
otkId = bundle['one_time_prekey_id'];
|
|
} else {
|
|
otkField = null;
|
|
otkId = bundle['one_time_prekey_id'];
|
|
}
|
|
|
|
|
|
if (ikField == null || ikField.isEmpty) {
|
|
throw Exception('Recipient identity_key not found in bundle. Structure: $bundle');
|
|
}
|
|
if (spkField == null || spkField.isEmpty) {
|
|
throw Exception('Recipient signed_prekey not found in bundle');
|
|
}
|
|
|
|
final flattenedBundle = {
|
|
'identity_key': ikField,
|
|
'signed_prekey': spkField,
|
|
'signed_prekey_signature': spkSignature,
|
|
'one_time_prekey': otkField,
|
|
'one_time_prekey_id': otkId,
|
|
};
|
|
|
|
return await _encryptX25519Only(recipientId, plaintext, flattenedBundle);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _encryptX25519Only(String recipientId, String plaintext, Map<String, dynamic> bundle) async {
|
|
final ikFull = bundle['identity_key'] as String;
|
|
final ikParts = ikFull.split(':');
|
|
|
|
Uint8List theirSkBytes;
|
|
Uint8List theirIkDhBytes;
|
|
|
|
if (ikParts.length == 2) {
|
|
theirSkBytes = base64Decode(ikParts[0]);
|
|
theirIkDhBytes = base64Decode(ikParts[1]);
|
|
} else {
|
|
// Legacy fallback (assume single key is DH for now, or bail)
|
|
theirSkBytes = Uint8List(0); // Cannot verify
|
|
theirIkDhBytes = base64Decode(ikFull);
|
|
}
|
|
|
|
final theirSpkBytes = base64Decode(bundle['signed_prekey']);
|
|
final theirSpkSignature = base64Decode(bundle['signed_prekey_signature'] ?? '');
|
|
|
|
// --- SIGNATURE VERIFICATION ---
|
|
// Always verify SPK signature - no more legacy user exceptions
|
|
if (theirSkBytes.isEmpty || theirSpkSignature.isEmpty) {
|
|
throw Exception('E2EE SECURITY ALERT: Recipient missing signing key or signature!');
|
|
}
|
|
|
|
final theirSk = SimplePublicKey(theirSkBytes, type: KeyPairType.ed25519);
|
|
final isVerified = await _signingAlgo.verify(
|
|
theirSpkBytes,
|
|
signature: Signature(theirSpkSignature, publicKey: theirSk),
|
|
);
|
|
if (!isVerified) {
|
|
throw Exception('E2EE SECURITY ALERT: Recipient Signed PreKey signature verification failed!');
|
|
}
|
|
|
|
final theirIk = SimplePublicKey(theirIkDhBytes, type: KeyPairType.x25519);
|
|
final theirSpk = SimplePublicKey(theirSpkBytes, type: KeyPairType.x25519);
|
|
final theirOtkBytes = bundle['one_time_prekey'] != null ? base64Decode(bundle['one_time_prekey']) : null;
|
|
final theirOtk = theirOtkBytes != null ? SimplePublicKey(theirOtkBytes, type: KeyPairType.x25519) : null;
|
|
final theirOtkId = bundle['one_time_prekey_id'];
|
|
|
|
final ephemeralKeyPair = await _dhAlgo.newKeyPair();
|
|
final ephemeralPublic = await ephemeralKeyPair.extractPublicKey();
|
|
|
|
// DH calculations
|
|
final dh1 = await _dhAlgo.sharedSecretKey(keyPair: _identityDhKeyPair!, remotePublicKey: theirSpk);
|
|
final dh2 = await _dhAlgo.sharedSecretKey(keyPair: ephemeralKeyPair, remotePublicKey: theirIk);
|
|
final dh3 = await _dhAlgo.sharedSecretKey(keyPair: ephemeralKeyPair, remotePublicKey: theirSpk);
|
|
|
|
List<int> dhBytes = [];
|
|
dhBytes.addAll(await dh1.extractBytes());
|
|
dhBytes.addAll(await dh2.extractBytes());
|
|
dhBytes.addAll(await dh3.extractBytes());
|
|
|
|
if (theirOtk != null) {
|
|
final dh4 = await _dhAlgo.sharedSecretKey(keyPair: ephemeralKeyPair, remotePublicKey: theirOtk);
|
|
dhBytes.addAll(await dh4.extractBytes());
|
|
|
|
// Delete the used OTK from server to prevent reuse
|
|
if (theirOtkId != null) {
|
|
_deleteUsedOTK(theirOtkId); // Fire-and-forget
|
|
}
|
|
}
|
|
|
|
final rootSecret = await _kdf(dhBytes);
|
|
final nonce = _cipher.newNonce();
|
|
final secretBox = await _cipher.encrypt(
|
|
utf8.encode(plaintext),
|
|
secretKey: SecretKey(rootSecret),
|
|
nonce: nonce,
|
|
);
|
|
|
|
final header = {
|
|
'v': 1,
|
|
'ik': base64Encode((await _identityDhKeyPair!.extractPublicKey()).bytes),
|
|
'ek': base64Encode(ephemeralPublic.bytes),
|
|
'opk_id': theirOtkId,
|
|
'm': base64Encode(secretBox.mac.bytes),
|
|
};
|
|
|
|
return {
|
|
'ciphertext': base64Encode(secretBox.cipherText),
|
|
'iv': base64Encode(nonce),
|
|
'header': header, // Return as Map
|
|
};
|
|
}
|
|
|
|
Future<String> decrypt(String ciphertext, String iv, dynamic headerData) async {
|
|
await initialize();
|
|
|
|
try {
|
|
// Handle both String and Map inputs for header
|
|
final Map<String, dynamic> header;
|
|
if (headerData is String) {
|
|
try {
|
|
header = jsonDecode(headerData);
|
|
} catch (e) {
|
|
throw Exception('Invalid Header JSON: $e');
|
|
}
|
|
} else if (headerData is Map) {
|
|
header = Map<String, dynamic>.from(headerData);
|
|
} else {
|
|
throw Exception('Invalid header type: ${headerData.runtimeType}');
|
|
}
|
|
|
|
final nonce = base64Decode(iv);
|
|
final ciphertextBytes = base64Decode(ciphertext);
|
|
final macBytes = base64Decode(header['m'] ?? '');
|
|
|
|
if (header['ik'] == null || header['ek'] == null) {
|
|
throw Exception('Invalid Header: Missing IK or EK');
|
|
}
|
|
|
|
final senderIkBytes = base64Decode(header['ik']);
|
|
final senderEkBytes = base64Decode(header['ek']);
|
|
|
|
final senderIk = SimplePublicKey(senderIkBytes, type: KeyPairType.x25519);
|
|
final senderEk = SimplePublicKey(senderEkBytes, type: KeyPairType.x25519);
|
|
|
|
final dh1 = await _dhAlgo.sharedSecretKey(keyPair: _signedPreKey!, remotePublicKey: senderIk);
|
|
final dh2 = await _dhAlgo.sharedSecretKey(keyPair: _identityDhKeyPair!, remotePublicKey: senderEk);
|
|
final dh3 = await _dhAlgo.sharedSecretKey(keyPair: _signedPreKey!, remotePublicKey: senderEk);
|
|
|
|
List<int> dhBytes = [];
|
|
dhBytes.addAll(await dh1.extractBytes());
|
|
dhBytes.addAll(await dh2.extractBytes());
|
|
dhBytes.addAll(await dh3.extractBytes());
|
|
|
|
if (header['opk_id'] != null && _oneTimePreKeys != null && _oneTimePreKeys!.isNotEmpty) {
|
|
final otkId = header['opk_id'] as int;
|
|
// The opk_id refers to the key_id that was published (0-19 position in our array)
|
|
// Since we generate OTKs in order and publish them with key_id = array_index,
|
|
// we can use the opk_id directly as the array index
|
|
if (otkId >= 0 && otkId < _oneTimePreKeys!.length) {
|
|
final matchingOtk = _oneTimePreKeys![otkId];
|
|
final dh4 = await _dhAlgo.sharedSecretKey(keyPair: matchingOtk, remotePublicKey: senderEk);
|
|
dhBytes.addAll(await dh4.extractBytes());
|
|
} else {
|
|
}
|
|
}
|
|
|
|
final rootSecret = await _kdf(dhBytes);
|
|
final secretBox = SecretBox(ciphertextBytes, nonce: nonce, mac: Mac(macBytes));
|
|
final plaintextBytes = await _cipher.decrypt(secretBox, secretKey: SecretKey(rootSecret));
|
|
final plaintext = utf8.decode(plaintextBytes);
|
|
// Decryption successful - plaintext not logged for security
|
|
return plaintext;
|
|
} catch (e) {
|
|
if (e.toString().contains('MAC') || e.toString().contains('SecretBoxAuthenticationError')) {
|
|
// Automatic key recovery on MAC errors
|
|
_handleMacError();
|
|
return '[Message encrypted with old keys - cannot decrypt]';
|
|
}
|
|
if (e.toString().contains('Invalid Header')) {
|
|
return '[Message encrypted with old keys - cannot decrypt]';
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
// Automatic MAC error handling
|
|
int _macErrorCount = 0;
|
|
static const int _maxMacErrors = 50;
|
|
DateTime? _lastMacErrorTime;
|
|
|
|
void _handleMacError() {
|
|
_macErrorCount++;
|
|
_lastMacErrorTime = DateTime.now();
|
|
|
|
|
|
// If we get multiple MAC errors in quick succession, trigger recovery
|
|
if (_macErrorCount >= _maxMacErrors) {
|
|
_triggerAutomaticRecovery();
|
|
_macErrorCount = 0; // Reset counter
|
|
}
|
|
}
|
|
|
|
Future<void> _triggerAutomaticRecovery() async {
|
|
final userId = _auth.currentUser?.id;
|
|
if (userId == null) return;
|
|
|
|
|
|
// Show user-friendly notification
|
|
|
|
// Initiate smart recovery
|
|
await initiateKeyRecovery(userId);
|
|
|
|
// Broadcast key recovery event to all user's devices
|
|
_broadcastKeyRecovery(userId);
|
|
|
|
}
|
|
|
|
void _broadcastKeyRecovery(String userId) {
|
|
// Broadcast key recovery event to all user's devices via WebSocket
|
|
_chatService?.broadcastKeyRecovery(userId);
|
|
}
|
|
|
|
// Delete used OTK from server to prevent reuse
|
|
Future<void> _deleteUsedOTK(int keyId) async {
|
|
try {
|
|
await _api.callGoApi('/keys/otk/$keyId', method: 'DELETE');
|
|
} catch (e) {
|
|
final message = e.toString();
|
|
if (message.contains('route not found') || message.contains('404')) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
Future<List<int>> _kdf(List<int> inputKeyMaterial) async {
|
|
final sink = _sha256.newHashSink();
|
|
sink.add(inputKeyMaterial);
|
|
sink.close();
|
|
final hash = await sink.hash();
|
|
return hash.bytes;
|
|
}
|
|
|
|
Future<void> _publishKeys(List<int> spkSignature) async {
|
|
|
|
try {
|
|
final skPublic = await _identitySigningKeyPair!.extractPublicKey();
|
|
final ikDhPublic = await _identityDhKeyPair!.extractPublicKey();
|
|
|
|
// Concatenate SK:IK_dh
|
|
final identityCombined = '${base64Encode(skPublic.bytes)}:${base64Encode(ikDhPublic.bytes)}';
|
|
|
|
final spk = await _signedPreKey!.extractPublicKey();
|
|
final otks = <Map<String, dynamic>>[];
|
|
for (int i = 0; i < _oneTimePreKeys!.length; i++) {
|
|
final k = _oneTimePreKeys![i];
|
|
otks.add({
|
|
'key_id': i,
|
|
'public_key': base64Encode((await k.extractPublicKey()).bytes)
|
|
});
|
|
}
|
|
|
|
|
|
// Verify signature is not all zeros before upload
|
|
final allZeros = spkSignature.every((b) => b == 0);
|
|
if (allZeros) {
|
|
throw Exception('CRITICAL: SPK signature is all zeros before upload!');
|
|
}
|
|
|
|
await _api.publishKeys(
|
|
identityKeyPublic: identityCombined,
|
|
registrationId: 1,
|
|
signedPrekeyPublic: base64Encode(spk.bytes),
|
|
signedPrekeyId: 1,
|
|
signedPrekeySignature: base64Encode(spkSignature),
|
|
oneTimePrekeys: otks,
|
|
);
|
|
|
|
} catch (e) {
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> _saveKeysToLocal(String userId) async {
|
|
final otksEncoded = <String>[];
|
|
if (_oneTimePreKeys != null) {
|
|
for (final otk in _oneTimePreKeys!) {
|
|
otksEncoded.add(base64Encode(await otk.extractPrivateKeyBytes()));
|
|
}
|
|
}
|
|
|
|
final data = jsonEncode({
|
|
'ik_dh': base64Encode(await _identityDhKeyPair!.extractPrivateKeyBytes()),
|
|
'ik_sk': base64Encode(await _identitySigningKeyPair!.extractPrivateKeyBytes()),
|
|
'spk': base64Encode(await _signedPreKey!.extractPrivateKeyBytes()),
|
|
'otks': otksEncoded,
|
|
});
|
|
await _storage.write(key: 'e2ee_keys_$userId', value: data);
|
|
|
|
// Also save to SharedPreferences on web as a fallback
|
|
if (kIsWeb) {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('e2ee_keys_$userId', data);
|
|
}
|
|
}
|
|
|
|
Future<bool> _loadKeysFromLocal(String userId) async {
|
|
|
|
// Try FlutterSecureStorage first
|
|
var data = await _storage.read(key: 'e2ee_keys_$userId');
|
|
|
|
// Fallback to SharedPreferences on web if secure storage fails
|
|
if (data == null && kIsWeb) {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
data = prefs.getString('e2ee_keys_$userId');
|
|
}
|
|
|
|
if (data == null) {
|
|
return false;
|
|
}
|
|
|
|
final map = jsonDecode(data);
|
|
|
|
if (map['ik_dh'] != null) {
|
|
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(map['ik_dh']));
|
|
} else if (map['ik'] != null) {
|
|
// Legacy load
|
|
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(map['ik']));
|
|
}
|
|
|
|
if (map['ik_sk'] != null) {
|
|
_identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(map['ik_sk']));
|
|
}
|
|
|
|
_signedPreKey = await _dhAlgo.newKeyPairFromSeed(base64Decode(map['spk']));
|
|
|
|
// Load OTKs
|
|
_oneTimePreKeys = [];
|
|
if (map['otks'] != null && map['otks'] is List) {
|
|
for (final otkSeed in map['otks']) {
|
|
_oneTimePreKeys!.add(await _dhAlgo.newKeyPairFromSeed(base64Decode(otkSeed)));
|
|
}
|
|
}
|
|
|
|
return isReady;
|
|
}
|
|
|
|
Future<void> _backupIdentityToCloud(String userId) async {
|
|
final dhSeed = await _identityDhKeyPair!.extractPrivateKeyBytes();
|
|
final skSeed = await _identitySigningKeyPair!.extractPrivateKeyBytes();
|
|
|
|
final blobData = base64Encode(dhSeed) + ":" + base64Encode(skSeed);
|
|
|
|
final pinKey = await _deriveKeyFromPin(_backupPin);
|
|
final nonce = _cipher.newNonce();
|
|
final box = await _cipher.encrypt(utf8.encode(blobData), secretKey: pinKey, nonce: nonce);
|
|
|
|
final blob = jsonEncode({
|
|
'c': base64Encode(box.cipherText),
|
|
'n': base64Encode(nonce),
|
|
'm': base64Encode(box.mac.bytes),
|
|
});
|
|
await ApiService(_auth).updateProfile(encryptedPrivateKey: blob);
|
|
}
|
|
|
|
Future<bool> _restoreFromCloud(String userId) async {
|
|
try {
|
|
// FIX 1: Correct Profile Access
|
|
final profileMap = await ApiService(_auth).getProfile();
|
|
// ApiService returns map with 'profile' key containing Profile object
|
|
final profileObj = profileMap['profile'];
|
|
|
|
String? blobJson;
|
|
// Safety check if it returned Map or Object unexpectedly
|
|
if (profileObj is Map) {
|
|
blobJson = profileObj['encrypted_private_key'];
|
|
} else {
|
|
// Assume Profile object
|
|
// DYNAMIC ACCESS OR CAST
|
|
// Since we can't import Profile here to cast easily without cycle or logic change,
|
|
// we use dynamic access if supported, or assume getProfile implementation.
|
|
// Actually, earlier viewed Profile.dart shows it's a class.
|
|
// We'll trust dynamic dispatch or use `.encryptedPrivateKey` if typed.
|
|
// However, ApiService.getProfile returns Map<String, dynamic>.
|
|
// Whatever is in 'profile' key IS a Profile instance.
|
|
// Dynamic access .encryptedPrivateKey should work.
|
|
blobJson = (profileObj as dynamic).encryptedPrivateKey;
|
|
}
|
|
|
|
if (blobJson == null) return false;
|
|
final blob = jsonDecode(blobJson);
|
|
|
|
final pinKey = await _deriveKeyFromPin(_backupPin);
|
|
final box = SecretBox(base64Decode(blob['c']), nonce: base64Decode(blob['n']), mac: Mac(base64Decode(blob['m'])));
|
|
|
|
final decryptedBytes = await _cipher.decrypt(box, secretKey: pinKey);
|
|
final blobData = utf8.decode(decryptedBytes);
|
|
final seeds = blobData.split(':');
|
|
|
|
if (seeds.length == 2) {
|
|
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(seeds[0]));
|
|
_identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(seeds[1]));
|
|
} else {
|
|
// Legacy restore
|
|
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(seeds[0]));
|
|
}
|
|
|
|
// After cloud restore, regenerate SPK and OTKs
|
|
_signedPreKey = await _dhAlgo.newKeyPair();
|
|
final spkPublic = await _signedPreKey!.extractPublicKey();
|
|
final signature = await _signingAlgo.sign(spkPublic.bytes, keyPair: _identitySigningKeyPair!);
|
|
final spkSignature = Uint8List.fromList(signature.bytes);
|
|
|
|
// Generate new OTKs
|
|
final opks = <SimpleKeyPair>[];
|
|
for (int i = 0; i < 20; i++) {
|
|
opks.add(await _dhAlgo.newKeyPair());
|
|
}
|
|
_oneTimePreKeys = opks;
|
|
|
|
// Save locally and publish
|
|
await _saveKeysToLocal(userId);
|
|
await _publishKeys(spkSignature);
|
|
|
|
return isReady;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> exportAllKeys() async {
|
|
if (!isReady) {
|
|
throw Exception('Keys not ready for export');
|
|
}
|
|
|
|
try {
|
|
|
|
final identityDhPublic = await _identityDhKeyPair!.extractPublicKey();
|
|
final identitySigningPublic = await _identitySigningKeyPair!.extractPublicKey();
|
|
final spkPublic = await _signedPreKey!.extractPublicKey();
|
|
|
|
// Generate SPK signature for backup
|
|
final spkSignature = await _signingAlgo.sign(
|
|
spkPublic.bytes,
|
|
keyPair: _identitySigningKeyPair!,
|
|
);
|
|
|
|
// Export OTKs
|
|
final otkData = <Map<String, dynamic>>[];
|
|
for (int i = 0; i < _oneTimePreKeys!.length; i++) {
|
|
final otk = _oneTimePreKeys![i];
|
|
final otkPublic = await otk.extractPublicKey();
|
|
otkData.add({
|
|
'key_id': i,
|
|
'public_key': base64Encode(otkPublic.bytes),
|
|
'private_key': base64Encode(await otk.extractPrivateKeyBytes()),
|
|
});
|
|
}
|
|
|
|
final exportData = {
|
|
'version': '1.0',
|
|
'exported_at': DateTime.now().toIso8601String(),
|
|
'keys': {
|
|
'identity_dh_private': base64Encode(await _identityDhKeyPair!.extractPrivateKeyBytes()),
|
|
'identity_dh_public': base64Encode(identityDhPublic.bytes),
|
|
'identity_signing_private': base64Encode(await _identitySigningKeyPair!.extractPrivateKeyBytes()),
|
|
'identity_signing_public': base64Encode(identitySigningPublic.bytes),
|
|
'signed_prekey_private': base64Encode(await _signedPreKey!.extractPrivateKeyBytes()),
|
|
'signed_prekey_public': base64Encode(spkPublic.bytes),
|
|
'signed_prekey_signature': base64Encode(spkSignature.bytes),
|
|
'one_time_prekeys': otkData,
|
|
},
|
|
'metadata': {
|
|
'otk_count': _oneTimePreKeys!.length,
|
|
'user_id': _initializedForUserId,
|
|
},
|
|
};
|
|
|
|
return exportData;
|
|
|
|
} catch (e) {
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> importAllKeys(Map<String, dynamic> backupData) async {
|
|
try {
|
|
|
|
if (!backupData.containsKey('keys')) {
|
|
throw ArgumentError('Invalid backup format: missing keys');
|
|
}
|
|
|
|
final keys = backupData['keys'] as Map<String, dynamic>;
|
|
|
|
// 1. Restore Identity Keys
|
|
if (keys.containsKey('identity_dh_private')) {
|
|
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['identity_dh_private']));
|
|
}
|
|
|
|
if (keys.containsKey('identity_signing_private')) {
|
|
_identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(keys['identity_signing_private']));
|
|
}
|
|
|
|
// 2. Restore Signed PreKey
|
|
if (keys.containsKey('signed_prekey_private')) {
|
|
_signedPreKey = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['signed_prekey_private']));
|
|
}
|
|
|
|
// 3. Restore One-Time PreKeys
|
|
if (keys.containsKey('one_time_prekeys') && keys['one_time_prekeys'] is List) {
|
|
final otkList = keys['one_time_prekeys'] as List;
|
|
final importedOTKs = <SimpleKeyPair>[];
|
|
for (final item in otkList) {
|
|
if (item is Map && item.containsKey('private_key')) {
|
|
importedOTKs.add(await _dhAlgo.newKeyPairFromSeed(base64Decode(item['private_key'])));
|
|
}
|
|
}
|
|
_oneTimePreKeys = importedOTKs;
|
|
}
|
|
|
|
// 4. Set User Context from metadata
|
|
if (backupData.containsKey('metadata')) {
|
|
final metadata = backupData['metadata'] as Map<String, dynamic>;
|
|
if (metadata.containsKey('user_id')) {
|
|
_initializedForUserId = metadata['user_id'];
|
|
}
|
|
}
|
|
|
|
// Fallback if metadata missing
|
|
if (_initializedForUserId == null) {
|
|
_initializedForUserId = _auth.currentUser?.id;
|
|
}
|
|
|
|
// 5. Persist and Synchronize
|
|
if (_initializedForUserId != null) {
|
|
await _saveKeysToLocal(_initializedForUserId!);
|
|
|
|
// Republish to server to ensure backend is synchronized
|
|
// This is safe even if keys are identical
|
|
if (_identitySigningKeyPair != null && _signedPreKey != null) {
|
|
final spkPublic = await _signedPreKey!.extractPublicKey();
|
|
final signature = await _signingAlgo.sign(
|
|
spkPublic.bytes,
|
|
keyPair: _identitySigningKeyPair!
|
|
);
|
|
await _publishKeys(signature.bytes);
|
|
}
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<SecretKey> _deriveKeyFromPin(String pin) async {
|
|
final sink = _sha256.newHashSink();
|
|
sink.add(utf8.encode(pin));
|
|
sink.close();
|
|
return SecretKey((await sink.hash()).bytes);
|
|
}
|
|
}
|