import 'dart:async'; import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:komet_crypto/komet_crypto.dart'; import '../../backend/modules/messages.dart'; import '../storage/app_database.dart'; import '../storage/chat_encryption_store.dart'; import 'chat_crypto_service.dart'; import 'e2ee_service.dart'; // #***! расшифровалось, ключ не тот, либо копии текста на этом устройстве нет enum MessageDecryptionState { decrypted, wrongKey, unavailable } // #***! результат по одному сообщению @immutable class MessageDecryption { final String? plaintext; final MessageDecryptionState state; const MessageDecryption.decrypted(String this.plaintext) : state = MessageDecryptionState.decrypted; const MessageDecryption.wrongKey() : plaintext = null, state = MessageDecryptionState.wrongKey; const MessageDecryption.unavailable() : plaintext = null, state = MessageDecryptionState.unavailable; bool get isDecrypted => state == MessageDecryptionState.decrypted; } // #***! кэш расшифровок, при смене ключа сбрасывается целиком class MessageDecryptionCache { MessageDecryptionCache._() { ChatEncryptionStore.instance.revision.addListener(clear); E2eeService.instance.revision.addListener(clear); } static final MessageDecryptionCache instance = MessageDecryptionCache._(); // #***! тысяча последних, дальше вытесняем static const int _maxEntries = 1000; int _generation = 0; final LinkedHashMap> _entries = LinkedHashMap(); final Map> _inFlight = {}; // #***! пузырь подписывается сюда и перерисуется когда текст расшифруется ValueListenable listenableFor(String messageId) => _entryFor(messageId); ValueNotifier _entryFor(String messageId) => _entries[messageId] ??= ValueNotifier(null); void _evictStale(String keep) { while (_entries.length > _maxEntries) { final oldest = _entries.keys.first; if (oldest == keep) break; _entries.remove(oldest); } } // #***! известный текст кладём сразу, например своё только что отправленное void seed(String messageId, String plaintext) { _entryFor(messageId).value = MessageDecryption.decrypted(plaintext); } // #***! поменяли временный id на настоящий, переносим расшифровку void adopt(String fromMessageId, String toMessageId) { final value = _entries[fromMessageId]?.value; if (value != null) _entryFor(toMessageId).value = value; } // #***! _inFlight чтоб не расшифровывать одно сообщение дважды void request({ required int accountId, required int chatId, required String messageId, required String cipherText, }) { final job = _begin(accountId, chatId, messageId, cipherText); if (job != null) unawaited(job); } Future resolve({ required int accountId, required int chatId, required String messageId, required String cipherText, }) async { try { await _begin(accountId, chatId, messageId, cipherText); } catch (_) { return const MessageDecryption.unavailable(); } return _entries[messageId]?.value; } String? readableText(CachedMessage message) { if (message.isControl) return null; final decryption = _entries[message.id]?.value; if (decryption == null) { final text = message.selectableText; if (message.e2ee != CachedMessage.e2eeNone || (text != null && ChatCryptoService.instance.looksEncrypted(text))) { return null; } return text; } return decryption.isDecrypted ? decryption.plaintext : null; } Future resolveText(CachedMessage message) async { final generation = _generation; if (message.isControl) return null; await resolve( accountId: message.accountId, chatId: message.chatId, messageId: message.id, cipherText: message.text ?? '', ); if (_generation != generation) return null; return readableText(message); } Future? _begin( int accountId, int chatId, String messageId, String cipherText, ) { if (cipherText.isEmpty) return null; final e2ee = E2eeService.instance.isOn(accountId, chatId) || (E2eeService.instance.available && KometCrypto.classifyText(cipherText) != TextClass.none); if (!e2ee && !ChatCryptoService.instance.isEnabled(accountId, chatId)) { return null; } if (_entryFor(messageId).value != null) return null; final running = _inFlight[messageId]; if (running != null) return running; _evictStale(messageId); final generation = _generation; late final Future job; job = (e2ee ? _resolveSealed(accountId, chatId, messageId, cipherText) : _resolve(accountId, chatId, messageId, cipherText)) .catchError((Object error) { if (_generation == generation) { _entryFor(messageId).value = const MessageDecryption.unavailable(); } }) .whenComplete(() { if (identical(_inFlight[messageId], job)) { _inFlight.remove(messageId); } }); _inFlight[messageId] = job; return job; } // #***! сквозное: открытый текст лежит в базе под локальным ключом Future _resolveSealed( int accountId, int chatId, String messageId, String cipherText, ) async { final generation = _generation; final row = await AppDatabase.loadMessage(accountId, chatId, messageId); if (_generation != generation) return; if (row == null) return; final flag = row['e2ee'] as int? ?? 0; final sealed = row['text_sealed']; if (flag == CachedMessage.e2eeText && sealed is Uint8List) { final text = await E2eeService.instance.openText( accountId, chatId, sealed, ); if (_generation != generation) return; _entryFor(messageId).value = text == null ? const MessageDecryption.unavailable() : MessageDecryption.decrypted(text); return; } if (flag == CachedMessage.e2eeFailed) { _entryFor(messageId).value = const MessageDecryption.wrongKey(); return; } if (flag == CachedMessage.e2eeFile) { _entryFor(messageId).value = const MessageDecryption.decrypted(''); return; } if (flag != CachedMessage.e2eeNone || !E2eeService.instance.available) { return; } switch (KometCrypto.classifyText(cipherText)) { case TextClass.session: _entryFor(messageId).value = const MessageDecryption.unavailable(); case TextClass.offer: case TextClass.answer: _entryFor(messageId).value = MessageDecryption.decrypted( cipherText.split('\n').first, ); case TextClass.legacy: if (ChatCryptoService.instance.isEnabled(accountId, chatId)) { await _resolve(accountId, chatId, messageId, cipherText); } case TextClass.none: break; } } Future _resolve( int accountId, int chatId, String messageId, String cipherText, ) async { final generation = _generation; final crypto = ChatCryptoService.instance; final result = await crypto.decrypt(accountId, chatId, cipherText); if (_generation != generation) return; if (result.isOk) { _entryFor(messageId).value = MessageDecryption.decrypted(result.text!); return; } // #***! ключа нет показываем как неверный ключ только если текст правда похож на шифр switch (result.failure) { case CryptoFailure.wrongKey: _entryFor(messageId).value = const MessageDecryption.wrongKey(); case CryptoFailure.noKey: if (crypto.looksEncrypted(cipherText)) { _entryFor(messageId).value = const MessageDecryption.wrongKey(); } case CryptoFailure.notEncrypted: case CryptoFailure.malformed: case CryptoFailure.unavailable: case null: break; } } void clear() { _generation++; _entries.clear(); _inFlight.clear(); } }