feat: работа с шифрованным текстом/фото
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user