Files
IKAR/lib/core/crypto/message_decryption_cache.dart
T
sevenhill 1ef2b7420e
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Port Komet UI improvements and implement IKAR ghost mode
Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
2026-10-07 22:07:05 +03:00

259 lines
8.5 KiB
Dart

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<String, ValueNotifier<MessageDecryption?>> _entries =
LinkedHashMap();
final Map<String, Future<void>> _inFlight = {};
// #***! пузырь подписывается сюда и перерисуется когда текст расшифруется
ValueListenable<MessageDecryption?> listenableFor(String messageId) =>
_entryFor(messageId);
ValueNotifier<MessageDecryption?> _entryFor(String messageId) =>
_entries[messageId] ??= ValueNotifier<MessageDecryption?>(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<MessageDecryption?> 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<String?> 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<void>? _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<void> 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<void> _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<void> _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();
}
}