feat: работа с шифрованным текстом/фото

This commit is contained in:
Jganenokk
2026-07-26 16:23:05 +07:00
parent ba1f63ad05
commit ac951ea80a
90 changed files with 7248 additions and 64 deletions
+197
View File
@@ -0,0 +1,197 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:komet_crypto/komet_crypto.dart' as kc;
import '../storage/chat_encryption_store.dart';
import '../utils/logger.dart';
const int kMaxEncryptedMessageLength = 1000;
enum CryptoFailure { noKey, wrongKey, notEncrypted, malformed, unavailable }
class CryptoResult {
final String? text;
final CryptoFailure? failure;
const CryptoResult.ok(String this.text) : failure = null;
const CryptoResult.failed(CryptoFailure this.failure) : text = null;
bool get isOk => text != null;
}
class ChatCryptoService {
ChatCryptoService._() {
ChatEncryptionStore.instance.revision.addListener(clearKeys);
}
static final ChatCryptoService instance = ChatCryptoService._();
final Map<String, Uint8List> _keys = {};
final Map<String, Future<Uint8List?>> _pending = {};
Future<void>? _init;
bool _unavailable = false;
String _cacheKey(int accountId, int chatId) => '$accountId/$chatId';
void clearKeys() {
_keys.clear();
_pending.clear();
}
Future<bool> _ensureInitialized() async {
if (_unavailable) return false;
try {
await (_init ??= kc.RustLib.init());
return true;
} catch (e) {
_init = null;
_unavailable = true;
logger.w('komet_crypto init failed: $e');
return false;
}
}
Future<Uint8List?> _keyFor(int accountId, int chatId) {
final cacheKey = _cacheKey(accountId, chatId);
final cached = _keys[cacheKey];
if (cached != null) return Future.value(cached);
return _pending[cacheKey] ??= _deriveKey(accountId, chatId, cacheKey);
}
Future<Uint8List?> _deriveKey(
int accountId,
int chatId,
String cacheKey,
) async {
try {
if (!await _ensureInitialized()) return null;
final password = await ChatEncryptionStore.instance.readKey(
accountId,
chatId,
);
if (password == null || password.isEmpty) return null;
final key = await kc.deriveKey(password: password);
_keys[cacheKey] = key;
return key;
} catch (e) {
logger.w('derive key for chat $chatId: $e');
return null;
} finally {
_pending.remove(cacheKey);
}
}
bool isEnabled(int accountId, int chatId) =>
ChatEncryptionStore.instance.isEnabled(accountId, chatId);
Future<void> warmKey(int accountId, int chatId) => _keyFor(accountId, chatId);
Future<CryptoResult> encrypt(
int accountId,
int chatId,
String plaintext,
) async {
final key = await _keyFor(accountId, chatId);
if (key == null) {
return CryptoResult.failed(
_unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey,
);
}
try {
return CryptoResult.ok(
await kc.encryptMessage(plaintext: plaintext, key: key),
);
} catch (e) {
logger.w('encrypt for chat $chatId: $e');
return const CryptoResult.failed(CryptoFailure.unavailable);
}
}
Future<CryptoResult> decrypt(int accountId, int chatId, String text) async {
final key = await _keyFor(accountId, chatId);
if (key == null) {
return CryptoResult.failed(
_unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey,
);
}
try {
return CryptoResult.ok(await kc.decryptMessage(text: text, key: key));
} catch (e) {
return CryptoResult.failed(_failureFromCode(e.toString()));
}
}
Future<CryptoFailure?> encryptImageFile(
int accountId,
int chatId,
String sourcePath,
String destPath,
) => _imageOp(
accountId,
chatId,
() => kc.encryptImageFile(
sourcePath: sourcePath,
destPath: destPath,
key: _keys[_cacheKey(accountId, chatId)]!,
),
);
Future<CryptoFailure?> decryptImageFile(
int accountId,
int chatId,
String sourcePath,
String destPath,
) => _imageOp(
accountId,
chatId,
() => kc.decryptImageFile(
sourcePath: sourcePath,
destPath: destPath,
key: _keys[_cacheKey(accountId, chatId)]!,
),
);
Future<CryptoFailure?> _imageOp(
int accountId,
int chatId,
Future<void> Function() run,
) async {
final key = await _keyFor(accountId, chatId);
if (key == null) {
return _unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey;
}
try {
await run();
return null;
} catch (e) {
logger.w('image crypto for chat $chatId: $e');
return _failureFromCode(e.toString());
}
}
Future<bool> looksEncryptedImage(String path) async {
if (!await _ensureInitialized()) return false;
try {
return await kc.looksEncryptedImageFile(path: path);
} catch (_) {
return false;
}
}
Future<bool> looksEncrypted(String text) async {
if (!await _ensureInitialized()) return false;
try {
return await kc.looksEncrypted(text: text);
} catch (_) {
return false;
}
}
CryptoFailure _failureFromCode(String message) {
if (message.contains('wrong_key')) return CryptoFailure.wrongKey;
if (message.contains('not_encrypted')) return CryptoFailure.notEncrypted;
if (message.contains('malformed')) return CryptoFailure.malformed;
return CryptoFailure.unavailable;
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:path_provider/path_provider.dart';
import '../utils/logger.dart';
import '../utils/media_cache.dart';
import 'chat_crypto_service.dart';
const String kEncryptedPhotoExtension = '.png';
class EncryptedPhotoResult {
final File? file;
final CryptoFailure? failure;
const EncryptedPhotoResult.ok(File this.file) : failure = null;
const EncryptedPhotoResult.failed(CryptoFailure this.failure) : file = null;
bool get isOk => file != null;
}
/// Re-encodes an arbitrary image into lossless PNG. Encryption needs a format
/// that survives byte-for-byte; a re-encoded JPEG would not.
Future<File?> reencodeAsPng(File source, String destPath) async {
try {
final bytes = await source.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final data = await frame.image.toByteData(format: ui.ImageByteFormat.png);
frame.image.dispose();
codec.dispose();
if (data == null) return null;
final dest = File(destPath);
await dest.writeAsBytes(data.buffer.asUint8List(), flush: true);
return dest;
} catch (e) {
logger.w('png re-encode failed: $e');
return null;
}
}
Future<Directory> _scratchDir() async {
final dir = Directory('${(await getTemporaryDirectory()).path}/komet_enc');
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
/// Picked image → PNG → encrypted noise PNG, ready to upload as a file.
Future<EncryptedPhotoResult> prepareEncryptedPhoto({
required int accountId,
required int chatId,
required File source,
required String stamp,
}) async {
final dir = await _scratchDir();
final pngPath = '${dir.path}/plain_$stamp.png';
final encPath = '${dir.path}/enc_$stamp.png';
final png = await reencodeAsPng(source, pngPath);
if (png == null) {
return const EncryptedPhotoResult.failed(CryptoFailure.malformed);
}
final failure = await ChatCryptoService.instance.encryptImageFile(
accountId,
chatId,
png.path,
encPath,
);
await _quietDelete(png);
if (failure != null) return EncryptedPhotoResult.failed(failure);
return EncryptedPhotoResult.ok(File(encPath));
}
/// Downloaded noise PNG → original photo, cached for the viewer.
Future<EncryptedPhotoResult> openEncryptedPhoto({
required int accountId,
required int chatId,
required File encrypted,
required String cacheName,
}) async {
final target = await MediaCache.fileFor('decrypted_$cacheName');
if (await target.exists() && await target.length() > 0) {
return EncryptedPhotoResult.ok(target);
}
final failure = await ChatCryptoService.instance.decryptImageFile(
accountId,
chatId,
encrypted.path,
target.path,
);
if (failure != null) {
await _quietDelete(target);
return EncryptedPhotoResult.failed(failure);
}
return EncryptedPhotoResult.ok(target);
}
Future<void> _quietDelete(File file) async {
try {
if (await file.exists()) await file.delete();
} catch (_) {}
}
@@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import '../storage/chat_encryption_store.dart';
import 'chat_crypto_service.dart';
enum MessageDecryptionState { decrypted, wrongKey }
@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;
bool get isDecrypted => state == MessageDecryptionState.decrypted;
}
class MessageDecryptionCache {
MessageDecryptionCache._() {
ChatEncryptionStore.instance.revision.addListener(clear);
}
static final MessageDecryptionCache instance = MessageDecryptionCache._();
static const int _maxEntries = 1000;
final LinkedHashMap<String, ValueNotifier<MessageDecryption?>> _entries =
LinkedHashMap();
final Set<String> _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);
}
void adopt(String fromMessageId, String toMessageId) {
final value = _entries[fromMessageId]?.value;
if (value != null) _entryFor(toMessageId).value = value;
}
void request({
required int accountId,
required int chatId,
required String messageId,
required String cipherText,
}) {
if (cipherText.isEmpty) return;
if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return;
if (_entryFor(messageId).value != null) return;
if (!_inFlight.add(messageId)) return;
_evictStale(messageId);
unawaited(_resolve(accountId, chatId, messageId, cipherText));
}
Future<void> _resolve(
int accountId,
int chatId,
String messageId,
String cipherText,
) async {
try {
final crypto = ChatCryptoService.instance;
final result = await crypto.decrypt(accountId, chatId, cipherText);
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 (await crypto.looksEncrypted(cipherText)) {
_entryFor(messageId).value = const MessageDecryption.wrongKey();
}
case CryptoFailure.notEncrypted:
case CryptoFailure.malformed:
case CryptoFailure.unavailable:
case null:
break;
}
} finally {
_inFlight.remove(messageId);
}
}
void clear() {
_entries.clear();
_inFlight.clear();
}
}
@@ -0,0 +1,38 @@
import 'per_chat_json_store.dart';
import 'token_storage.dart';
class ChatEncryptionStore extends PerChatJsonStore<bool> {
ChatEncryptionStore._()
: super(
prefsKey: 'chat_encryption',
fromJson: (raw) => raw == true ? true : null,
toJson: (value) => value,
);
static final ChatEncryptionStore instance = ChatEncryptionStore._();
static const String _keyPrefix = 'chat_encryption_key';
bool isEnabled(int accountId, int chatId) => read(accountId, chatId) == true;
Future<void> setEnabled(int accountId, int chatId, bool enabled) =>
write(accountId, chatId, enabled ? true : null);
Future<String?> readKey(int accountId, int chatId) async {
if (accountId == 0) return null;
return TokenStorage.readSecure(_secureKey(accountId, chatId));
}
Future<void> writeKey(int accountId, int chatId, String key) async {
if (accountId == 0) return;
await TokenStorage.writeSecure(_secureKey(accountId, chatId), key);
}
Future<void> deleteKey(int accountId, int chatId) async {
if (accountId == 0) return;
await TokenStorage.deleteSecure(_secureKey(accountId, chatId));
}
String _secureKey(int accountId, int chatId) =>
'${_keyPrefix}_${accountId}_$chatId';
}
+15
View File
@@ -15,17 +15,29 @@ class FileDownloadResult {
/// [cacheName] — стабильное имя в кэше (например, `<fileId>_имя.ext`).
/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше,
/// чтобы не дёргать сервер за временной ссылкой повторно.
/// [onReady] вызывается как только файл лежит на диске — до открытия во
/// внешнем приложении, которое может не возвращать управление, пока его не
/// закроют. Без этого индикатор загрузки висел бы всё это время.
Future<FileDownloadResult> openCachedFile(
String cacheName,
Future<String?> Function() resolveUrl, {
void Function(double progress)? onProgress,
void Function()? onReady,
}) async {
var readyFired = false;
void ready() {
if (readyFired) return;
readyFired = true;
onReady?.call();
}
try {
var file = await MediaCache.existing(cacheName);
if (file == null) {
final url = await resolveUrl();
if (url == null || url.isEmpty) {
ready();
return const FileDownloadResult(ok: false, error: 'нет ссылки');
}
file = await MediaCache.getOrDownload(
@@ -34,10 +46,12 @@ Future<FileDownloadResult> openCachedFile(
onProgress: onProgress,
);
if (file == null) {
ready();
return const FileDownloadResult(ok: false, error: 'ошибка загрузки');
}
}
ready();
final opened = await OpenFilex.open(file.path);
return FileDownloadResult(
ok: opened.type == ResultType.done,
@@ -45,6 +59,7 @@ Future<FileDownloadResult> openCachedFile(
error: opened.type == ResultType.done ? null : opened.message,
);
} catch (e) {
ready();
return FileDownloadResult(ok: false, error: e.toString());
}
}