1115 lines
36 KiB
Dart
1115 lines
36 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:isolate';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:komet_crypto/komet_crypto.dart';
|
|
|
|
import '../../backend/modules/messages.dart';
|
|
import '../storage/app_database.dart';
|
|
import '../storage/token_storage.dart';
|
|
import '../utils/logger.dart';
|
|
|
|
Future<Uint8List> _transferCrypto(
|
|
String? library,
|
|
Uint8List secret,
|
|
Uint8List data, {
|
|
required bool seal,
|
|
}) => Isolate.run(() {
|
|
KometCrypto.libraryPath = library;
|
|
return seal
|
|
? KometCrypto.exportSeal(
|
|
password: secret,
|
|
memoryKib: 65536,
|
|
passes: 3,
|
|
container: data,
|
|
)
|
|
: KometCrypto.exportOpen(password: secret, blob: data);
|
|
});
|
|
|
|
// #***! сквозное шифрование диалогов: identity-ключ, хендшейк сообщениями, рэтчет в C-ядре
|
|
enum E2eePhase { none, offered, pendingConsent, established, keyChanged }
|
|
|
|
// #***! перенесённая сессия ждёт DH-шага, иначе оба устройства дадут один ключ
|
|
class E2eeAwaitingPeer implements Exception {
|
|
const E2eeAwaitingPeer();
|
|
}
|
|
|
|
const String kE2eeOfferPrefix =
|
|
'🔐 ИКАР: запрос сквозного шифрования. Откройте этот чат в ИКАР или совместимом Komet, чтобы принять.';
|
|
const String kE2eeAnswerPrefix = '🔐 ИКАР: сквозное шифрование включено.';
|
|
const String kE2eeOfferDonePrefix = '🔐 ИКАР: запрос сквозного шифрования.';
|
|
|
|
@immutable
|
|
class E2eeSessionInfo {
|
|
final E2eePhase phase;
|
|
final int peerId;
|
|
final Uint8List? peerPublic;
|
|
final bool verified;
|
|
final String? offerText;
|
|
final String? offerMessageId;
|
|
|
|
const E2eeSessionInfo({
|
|
required this.phase,
|
|
required this.peerId,
|
|
this.peerPublic,
|
|
this.verified = false,
|
|
this.offerText,
|
|
this.offerMessageId,
|
|
});
|
|
|
|
bool get needsAttention =>
|
|
phase == E2eePhase.pendingConsent || phase == E2eePhase.keyChanged;
|
|
|
|
// #***! keyChanged это живая сессия, выключить значит уронить чат в открытый текст
|
|
bool get hasSession =>
|
|
phase == E2eePhase.established || phase == E2eePhase.keyChanged;
|
|
|
|
E2eeSessionInfo copyWith({
|
|
E2eePhase? phase,
|
|
int? peerId,
|
|
Uint8List? peerPublic,
|
|
bool? verified,
|
|
String? offerText,
|
|
bool clearOffer = false,
|
|
String? offerMessageId,
|
|
}) => E2eeSessionInfo(
|
|
phase: phase ?? this.phase,
|
|
peerId: peerId ?? this.peerId,
|
|
peerPublic: peerPublic ?? this.peerPublic,
|
|
verified: verified ?? this.verified,
|
|
offerText: clearOffer ? null : (offerText ?? this.offerText),
|
|
offerMessageId: offerMessageId ?? this.offerMessageId,
|
|
);
|
|
}
|
|
|
|
class E2eeService {
|
|
E2eeService({
|
|
Future<List<Map<String, dynamic>>> Function(int accountId) loadSessions =
|
|
AppDatabase.loadE2eeSessions,
|
|
}) : _loadSessions = loadSessions;
|
|
|
|
static final E2eeService instance = E2eeService();
|
|
|
|
final Future<List<Map<String, dynamic>>> Function(int accountId)
|
|
_loadSessions;
|
|
|
|
// #***! фазы по чатам для синхронных проверок, состояния рэтчета грузим лениво
|
|
final ValueNotifier<int> revision = ValueNotifier(0);
|
|
final Map<String, E2eeSessionInfo> _info = {};
|
|
final Map<String, Uint8List> _states = {};
|
|
final Map<String, Future<void>> _chains = {};
|
|
final Set<int> _loaded = {};
|
|
final Map<int, ({Object token, Future<void> future})> _loading = {};
|
|
final Map<int, Uint8List> _identities = {};
|
|
final Map<int, Uint8List> _localKeys = {};
|
|
MessagesModule? _messages;
|
|
|
|
bool get available => KometCrypto.isAvailable;
|
|
|
|
void attach(MessagesModule messages) => _messages = messages;
|
|
|
|
String _key(int accountId, int chatId) => '$accountId/$chatId';
|
|
|
|
bool isLoaded(int accountId) => _loaded.contains(accountId);
|
|
|
|
Future<void> ensureLoaded(int accountId) {
|
|
if (accountId == 0 || isLoaded(accountId)) return Future<void>.value();
|
|
final pending = _loading[accountId];
|
|
if (pending != null) return pending.future;
|
|
final token = Object();
|
|
final future = _loadAccount(accountId, token);
|
|
_loading[accountId] = (token: token, future: future);
|
|
return future;
|
|
}
|
|
|
|
Future<void> _loadAccount(int accountId, Object token) async {
|
|
try {
|
|
final rows = await Future.sync(() => _loadSessions(accountId));
|
|
if (!identical(_loading[accountId]?.token, token)) {
|
|
throw StateError('E2EE account load was invalidated');
|
|
}
|
|
final loadedInfo = <String, E2eeSessionInfo>{};
|
|
for (final row in rows) {
|
|
loadedInfo[_key(accountId, row['chat_id'] as int)] = _infoFromRow(row);
|
|
}
|
|
_info.addAll(loadedInfo);
|
|
_loaded.add(accountId);
|
|
revision.value++;
|
|
} catch (e) {
|
|
logger.w('e2ee load: $e');
|
|
rethrow;
|
|
} finally {
|
|
if (identical(_loading[accountId]?.token, token)) {
|
|
_loading.remove(accountId);
|
|
}
|
|
}
|
|
}
|
|
|
|
E2eeSessionInfo? info(int accountId, int chatId) =>
|
|
_info[_key(accountId, chatId)];
|
|
|
|
E2eePhase phaseOf(int accountId, int chatId) =>
|
|
info(accountId, chatId)?.phase ?? E2eePhase.none;
|
|
|
|
bool isActive(int accountId, int chatId) =>
|
|
info(accountId, chatId)?.hasSession ?? false;
|
|
|
|
bool isOn(int accountId, int chatId) =>
|
|
phaseOf(accountId, chatId) != E2eePhase.none;
|
|
|
|
E2eeSessionInfo _infoFromRow(Map<String, dynamic> row) => E2eeSessionInfo(
|
|
phase: E2eePhase.values.firstWhere(
|
|
(phase) => phase.name == row['phase'],
|
|
orElse: () => E2eePhase.none,
|
|
),
|
|
peerId: row['peer_id'] as int,
|
|
peerPublic: row['peer_public'] is Uint8List
|
|
? row['peer_public'] as Uint8List
|
|
: null,
|
|
verified: row['verified'] == 1,
|
|
offerText: row['offer_text'] as String?,
|
|
offerMessageId: row['offer_message_id'] as String?,
|
|
);
|
|
|
|
// #***! операции по одному чату строго по очереди, состояние рэтчета мутирует
|
|
Future<T> _serial<T>(String key, Future<T> Function() body) {
|
|
final previous = _chains[key] ?? Future<void>.value();
|
|
final next = previous.then((_) => body());
|
|
_chains[key] = next.then((_) {}, onError: (_) {});
|
|
return next;
|
|
}
|
|
|
|
final Map<String, Future<Uint8List>> _secretsInFlight = {};
|
|
|
|
// #***! схлопываем вывод, две гонки дадут два ключа и запечатанное не откроется
|
|
Future<Uint8List> _secureBytes(String key, Uint8List Function() create) {
|
|
final running = _secretsInFlight[key];
|
|
if (running != null) return running;
|
|
final future = _createSecret(key, create);
|
|
_secretsInFlight[key] = future;
|
|
return future.whenComplete(() => _secretsInFlight.remove(key));
|
|
}
|
|
|
|
Future<Uint8List> _createSecret(
|
|
String key,
|
|
Uint8List Function() create,
|
|
) async {
|
|
final stored = await TokenStorage.readSecure(key);
|
|
if (stored != null && stored.isNotEmpty) return base64Decode(stored);
|
|
final created = create();
|
|
await TokenStorage.writeSecure(key, base64Encode(created));
|
|
return created;
|
|
}
|
|
|
|
// #***! отдаём копию, lock() затирает кэш и посреди операции
|
|
Future<Uint8List> identity(int accountId) async {
|
|
final cached = _identities[accountId];
|
|
if (cached != null) return Uint8List.fromList(cached);
|
|
final identity = await _secureBytes(
|
|
'e2ee_identity_$accountId',
|
|
() => KometCrypto.identityCreate(),
|
|
);
|
|
_identities[accountId] = identity;
|
|
return Uint8List.fromList(identity);
|
|
}
|
|
|
|
Future<Uint8List> identityPublic(int accountId) async =>
|
|
KometCrypto.identityPublicKey(await identity(accountId));
|
|
|
|
Future<Uint8List> _localKey(int accountId) async {
|
|
final cached = _localKeys[accountId];
|
|
if (cached != null) return Uint8List.fromList(cached);
|
|
final key = await _secureBytes(
|
|
'e2ee_local_$accountId',
|
|
() => KometCrypto.randomBytes(KometCryptoSizes.localKey),
|
|
);
|
|
_localKeys[accountId] = key;
|
|
return Uint8List.fromList(key);
|
|
}
|
|
|
|
Uint8List _aad(String kind, int accountId, int chatId) =>
|
|
Uint8List.fromList(utf8.encode('$kind/$accountId/$chatId'));
|
|
|
|
Future<Uint8List> sealBytes(
|
|
int accountId,
|
|
int chatId,
|
|
Uint8List plaintext,
|
|
) async => KometCrypto.localSeal(
|
|
key: await _localKey(accountId),
|
|
plaintext: plaintext,
|
|
aad: _aad('text', accountId, chatId),
|
|
);
|
|
|
|
Future<Uint8List> sealText(int accountId, int chatId, String text) =>
|
|
sealBytes(accountId, chatId, Uint8List.fromList(utf8.encode(text)));
|
|
|
|
Future<Uint8List?> openBytes(
|
|
int accountId,
|
|
int chatId,
|
|
Uint8List sealed,
|
|
) async {
|
|
try {
|
|
return KometCrypto.localOpen(
|
|
key: await _localKey(accountId),
|
|
blob: sealed,
|
|
aad: _aad('text', accountId, chatId),
|
|
);
|
|
} catch (e) {
|
|
logger.w('e2ee local open: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<String?> openText(int accountId, int chatId, Uint8List sealed) async {
|
|
final bytes = await openBytes(accountId, chatId, sealed);
|
|
return bytes == null ? null : utf8.decode(bytes, allowMalformed: true);
|
|
}
|
|
|
|
Future<Uint8List?> _state(int accountId, int chatId) async {
|
|
final key = _key(accountId, chatId);
|
|
final cached = _states[key];
|
|
if (cached != null) return Uint8List.fromList(cached);
|
|
final row = await AppDatabase.loadE2eeSession(accountId, chatId);
|
|
final sealed = row?['state'];
|
|
if (sealed is! Uint8List) return null;
|
|
try {
|
|
final state = KometCrypto.localOpen(
|
|
key: await _localKey(accountId),
|
|
blob: sealed,
|
|
aad: _aad('session', accountId, chatId),
|
|
);
|
|
_states[key] = state;
|
|
return Uint8List.fromList(state);
|
|
} catch (e) {
|
|
logger.w('e2ee session open: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> _persist(
|
|
int accountId,
|
|
int chatId,
|
|
E2eeSessionInfo info, {
|
|
required Uint8List? state,
|
|
}) async {
|
|
final key = _key(accountId, chatId);
|
|
Uint8List? sealed;
|
|
if (state != null) {
|
|
sealed = KometCrypto.localSeal(
|
|
key: await _localKey(accountId),
|
|
plaintext: state,
|
|
aad: _aad('session', accountId, chatId),
|
|
);
|
|
}
|
|
await AppDatabase.saveE2eeSession({
|
|
'account_id': accountId,
|
|
'chat_id': chatId,
|
|
'peer_id': info.peerId,
|
|
'phase': info.phase.name,
|
|
'state': sealed,
|
|
'peer_public': info.peerPublic,
|
|
'verified': info.verified ? 1 : 0,
|
|
'offer_text': info.offerText,
|
|
'offer_message_id': info.offerMessageId,
|
|
'updated': DateTime.now().millisecondsSinceEpoch,
|
|
});
|
|
if (state != null) {
|
|
_states[key] = state;
|
|
} else {
|
|
_states.remove(key);
|
|
}
|
|
final previous = _info[key];
|
|
_info[key] = info;
|
|
if (previous?.phase != info.phase ||
|
|
previous?.verified != info.verified ||
|
|
previous?.offerText != info.offerText) {
|
|
revision.value++;
|
|
}
|
|
}
|
|
|
|
// #***! сессию выбрасываем, личность собеседника и отметку проверки помним
|
|
Future<void> _forget(int accountId, int chatId) async {
|
|
final key = _key(accountId, chatId);
|
|
final current = _info[key];
|
|
final state = _states.remove(key);
|
|
if (state != null) KometCrypto.wipe(state);
|
|
if (current?.peerPublic == null) {
|
|
await AppDatabase.deleteE2eeSession(accountId, chatId);
|
|
_info.remove(key);
|
|
revision.value++;
|
|
return;
|
|
}
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
E2eeSessionInfo(
|
|
phase: E2eePhase.none,
|
|
peerId: current!.peerId,
|
|
peerPublic: current.peerPublic,
|
|
verified: current.verified,
|
|
),
|
|
state: null,
|
|
);
|
|
}
|
|
|
|
bool _samePeer(E2eeSessionInfo? previous, Uint8List peerPublic) =>
|
|
previous?.peerPublic != null &&
|
|
listEquals(previous!.peerPublic, peerPublic);
|
|
|
|
bool _carryVerified(E2eeSessionInfo? previous, Uint8List peerPublic) =>
|
|
_samePeer(previous, peerPublic) && (previous?.verified ?? false);
|
|
|
|
// #***! мы предлагаем шифрование: OFFER уходит обычным сообщением
|
|
Future<bool> startOffer({
|
|
required int accountId,
|
|
required int chatId,
|
|
required int peerId,
|
|
}) => _serial(_key(accountId, chatId), () async {
|
|
final messages = _messages;
|
|
if (messages == null || !available) return false;
|
|
await ensureLoaded(accountId);
|
|
try {
|
|
final offer = KometCrypto.sessionOffer(
|
|
identity: await identity(accountId),
|
|
chatId: chatId,
|
|
myId: accountId,
|
|
peerId: peerId,
|
|
);
|
|
final messageId = await messages.sendMessage(
|
|
accountId,
|
|
chatId,
|
|
'$kE2eeOfferPrefix\n${offer.text}',
|
|
);
|
|
if (messageId.isEmpty) return false;
|
|
final previous = info(accountId, chatId);
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
E2eeSessionInfo(
|
|
phase: E2eePhase.offered,
|
|
peerId: peerId,
|
|
peerPublic: previous?.peerPublic,
|
|
verified: previous?.verified ?? false,
|
|
offerMessageId: messageId,
|
|
),
|
|
state: offer.pending,
|
|
);
|
|
return true;
|
|
} catch (e) {
|
|
logger.w('e2ee offer: $e');
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// #***! собеседник предложил, мы согласились: ANSWER и сессия установлена
|
|
Future<bool> acceptOffer({required int accountId, required int chatId}) =>
|
|
_serial(_key(accountId, chatId), () => _answer(accountId, chatId));
|
|
|
|
Future<bool> _answer(int accountId, int chatId) async {
|
|
final messages = _messages;
|
|
final current = info(accountId, chatId);
|
|
final offerText = current?.offerText;
|
|
if (messages == null || current == null || offerText == null) return false;
|
|
try {
|
|
final hadSession =
|
|
current.phase == E2eePhase.established ||
|
|
current.phase == E2eePhase.keyChanged;
|
|
final answer = KometCrypto.sessionAnswer(
|
|
identity: await identity(accountId),
|
|
chatId: chatId,
|
|
myId: accountId,
|
|
peerId: current.peerId,
|
|
offerText: offerText,
|
|
oldState: hadSession ? await _state(accountId, chatId) : null,
|
|
);
|
|
final messageId = await messages.sendMessage(
|
|
accountId,
|
|
chatId,
|
|
'$kE2eeAnswerPrefix\n${answer.text}',
|
|
);
|
|
if (messageId.isEmpty) return false;
|
|
final peek = KometCrypto.handshakePeek(offerText);
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
E2eeSessionInfo(
|
|
phase: E2eePhase.established,
|
|
peerId: current.peerId,
|
|
peerPublic: peek.publicKey,
|
|
verified: _carryVerified(current, peek.publicKey),
|
|
),
|
|
state: answer.state,
|
|
);
|
|
return true;
|
|
} catch (e) {
|
|
logger.w('e2ee answer: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> declineOffer({required int accountId, required int chatId}) =>
|
|
_serial(_key(accountId, chatId), () async {
|
|
final current = info(accountId, chatId);
|
|
if (current == null) return;
|
|
if (current.phase == E2eePhase.keyChanged) {
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
current.copyWith(phase: E2eePhase.established, clearOffer: true),
|
|
state: await _state(accountId, chatId),
|
|
);
|
|
return;
|
|
}
|
|
await _forget(accountId, chatId);
|
|
});
|
|
|
|
Future<void> resetSession({required int accountId, required int chatId}) =>
|
|
_serial(_key(accountId, chatId), () => _forget(accountId, chatId));
|
|
|
|
Future<void> setVerified({
|
|
required int accountId,
|
|
required int chatId,
|
|
required bool verified,
|
|
}) => _serial(_key(accountId, chatId), () async {
|
|
final current = info(accountId, chatId);
|
|
if (current == null) return;
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
current.copyWith(verified: verified),
|
|
state: await _state(accountId, chatId),
|
|
);
|
|
});
|
|
|
|
// #***! в keyChanged ключ мог и не меняться, а разницу показать надо
|
|
bool offerChangesPeer(int accountId, int chatId) {
|
|
final current = info(accountId, chatId);
|
|
final offer = current?.offerText;
|
|
final known = current?.peerPublic;
|
|
if (offer == null || known == null) return true;
|
|
try {
|
|
return !listEquals(KometCrypto.handshakePeek(offer).publicKey, known);
|
|
} on KometCryptoException {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
Future<String?> fingerprint(int accountId, int chatId) async {
|
|
final current = info(accountId, chatId);
|
|
final peerPublic = current?.peerPublic;
|
|
if (current == null || peerPublic == null) return null;
|
|
try {
|
|
return KometCrypto.fingerprint(
|
|
myId: accountId,
|
|
myPublic: await identityPublic(accountId),
|
|
peerId: current.peerId,
|
|
peerPublic: peerPublic,
|
|
);
|
|
} catch (e) {
|
|
logger.w('e2ee fingerprint: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// #***! исходящее: рэтчет шагает, состояние сразу в базу
|
|
Future<String?> encryptText(int accountId, int chatId, String text) =>
|
|
encryptBytes(
|
|
accountId,
|
|
chatId,
|
|
ContentType.text,
|
|
Uint8List.fromList(utf8.encode(text)),
|
|
);
|
|
|
|
Future<String?> encryptBytes(
|
|
int accountId,
|
|
int chatId,
|
|
ContentType contentType,
|
|
Uint8List plaintext,
|
|
) => _serial(_key(accountId, chatId), () async {
|
|
final current = info(accountId, chatId);
|
|
if (current == null || !current.hasSession) return null;
|
|
final state = await _state(accountId, chatId);
|
|
if (state == null) return null;
|
|
try {
|
|
final result = KometCrypto.sessionEncrypt(
|
|
state: state,
|
|
contentType: contentType,
|
|
plaintext: plaintext,
|
|
);
|
|
await _persist(accountId, chatId, current, state: result.state);
|
|
await AppDatabase.saveE2eeOutgoing(
|
|
accountId,
|
|
chatId,
|
|
result.text,
|
|
await sealBytes(accountId, chatId, plaintext),
|
|
contentType == ContentType.file
|
|
? CachedMessage.e2eeFile
|
|
: CachedMessage.e2eeText,
|
|
);
|
|
return result.text;
|
|
} on KometCryptoException catch (e) {
|
|
logger.w('e2ee encrypt: ${e.status.name_}');
|
|
if (e.status == CryptoStatus.awaitingPeer) {
|
|
throw const E2eeAwaitingPeer();
|
|
}
|
|
return null;
|
|
}
|
|
});
|
|
|
|
bool fitsTransport(int plaintextBytes) =>
|
|
plaintextBytes <= KometCryptoSizes.sessionPlaintextMax;
|
|
|
|
// #***! входящее: хендшейки двигают фазу, шифртекст расшифровывается один раз
|
|
Future<CachedMessage> inspect(
|
|
CachedMessage message, {
|
|
Future<void> Function(CachedMessage message)? commit,
|
|
}) async {
|
|
final text = message.text;
|
|
if (!available || text == null || text.isEmpty) return message;
|
|
if (message.senderId == message.accountId) {
|
|
final saved = await AppDatabase.loadE2eeOutgoing(
|
|
message.accountId,
|
|
message.chatId,
|
|
text,
|
|
);
|
|
return saved == null
|
|
? message
|
|
: message.copyWith(
|
|
sealedText: saved['sealed'] as Uint8List,
|
|
e2ee: saved['e2ee'] as int,
|
|
);
|
|
}
|
|
if (message.senderId == 0) {
|
|
return message;
|
|
}
|
|
await ensureLoaded(message.accountId);
|
|
switch (KometCrypto.classifyText(text)) {
|
|
case TextClass.offer:
|
|
await _serial(
|
|
_key(message.accountId, message.chatId),
|
|
() => _onOffer(message, text),
|
|
);
|
|
return message;
|
|
case TextClass.answer:
|
|
await _serial(
|
|
_key(message.accountId, message.chatId),
|
|
() => _onAnswer(message, text),
|
|
);
|
|
return message;
|
|
case TextClass.session:
|
|
return _serial(
|
|
_key(message.accountId, message.chatId),
|
|
() => _decryptIncoming(message, text, commit),
|
|
);
|
|
case TextClass.legacy:
|
|
// #***! каждый 32-й legacy похож на v2 по длине, решает тег а не длина
|
|
if (isActive(message.accountId, message.chatId)) {
|
|
return _serial(
|
|
_key(message.accountId, message.chatId),
|
|
() => _decryptIncoming(message, text, commit),
|
|
);
|
|
}
|
|
return message;
|
|
case TextClass.none:
|
|
return message;
|
|
}
|
|
}
|
|
|
|
Future<bool> _isDialog(int accountId, int chatId) async {
|
|
final rows = await AppDatabase.loadChat(accountId, chatId);
|
|
if (rows.isEmpty) return false;
|
|
final type = rows.first['type']?.toString() ?? 'DIALOG';
|
|
return type == 'DIALOG';
|
|
}
|
|
|
|
Future<void> _onOffer(CachedMessage message, String text) async {
|
|
final accountId = message.accountId;
|
|
final chatId = message.chatId;
|
|
if (!await _isDialog(accountId, chatId)) return;
|
|
final HandshakePeek peek;
|
|
try {
|
|
peek = KometCrypto.handshakePeek(text);
|
|
} on KometCryptoException {
|
|
return;
|
|
}
|
|
final current = info(accountId, chatId);
|
|
final peerId = message.senderId;
|
|
switch (current?.phase) {
|
|
case E2eePhase.established:
|
|
final samePeer = _samePeer(current, peek.publicKey);
|
|
// #***! повтор старого OFFER выглядит так же, живую сессию только со спросом
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
current!.copyWith(
|
|
phase: E2eePhase.keyChanged,
|
|
peerId: peerId,
|
|
offerText: text,
|
|
verified: samePeer && current.verified,
|
|
),
|
|
state: await _state(accountId, chatId),
|
|
);
|
|
case E2eePhase.offered:
|
|
if (peerId < accountId) {
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
E2eeSessionInfo(
|
|
phase: E2eePhase.pendingConsent,
|
|
peerId: peerId,
|
|
offerText: text,
|
|
),
|
|
state: null,
|
|
);
|
|
await _answer(accountId, chatId);
|
|
}
|
|
case E2eePhase.keyChanged:
|
|
case E2eePhase.pendingConsent:
|
|
case E2eePhase.none:
|
|
case null:
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
(current ?? E2eeSessionInfo(phase: E2eePhase.none, peerId: peerId))
|
|
.copyWith(
|
|
phase: current?.phase == E2eePhase.keyChanged
|
|
? E2eePhase.keyChanged
|
|
: E2eePhase.pendingConsent,
|
|
peerId: peerId,
|
|
offerText: text,
|
|
verified: _carryVerified(current, peek.publicKey),
|
|
),
|
|
state: current?.phase == E2eePhase.keyChanged
|
|
? await _state(accountId, chatId)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _onAnswer(CachedMessage message, String text) async {
|
|
final accountId = message.accountId;
|
|
final chatId = message.chatId;
|
|
final current = info(accountId, chatId);
|
|
if (current == null || current.phase != E2eePhase.offered) return;
|
|
if (current.peerId != message.senderId) return;
|
|
final pending = await _state(accountId, chatId);
|
|
if (pending == null) return;
|
|
final peek = KometCrypto.handshakePeek(text);
|
|
try {
|
|
final state = KometCrypto.sessionAccept(
|
|
identity: await identity(accountId),
|
|
pending: pending,
|
|
answerText: text,
|
|
expectedPeer: current.peerPublic,
|
|
);
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
E2eeSessionInfo(
|
|
phase: E2eePhase.established,
|
|
peerId: current.peerId,
|
|
peerPublic: peek.publicKey,
|
|
verified: _carryVerified(current, peek.publicKey),
|
|
offerMessageId: current.offerMessageId,
|
|
),
|
|
state: state,
|
|
);
|
|
final offerMessageId = current.offerMessageId;
|
|
final messages = _messages;
|
|
if (offerMessageId != null && messages != null) {
|
|
unawaited(
|
|
messages
|
|
.editMessage(chatId, offerMessageId, text: kE2eeOfferDonePrefix)
|
|
.catchError((_) => false),
|
|
);
|
|
}
|
|
} on KometCryptoException catch (e) {
|
|
logger.w('e2ee accept: ${e.status.name_}');
|
|
// #***! ответил не тот, кого мы знали: спрашиваем пользователя, а не молча меняем пира
|
|
if (e.status == CryptoStatus.badPeer) {
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
current.copyWith(
|
|
phase: E2eePhase.keyChanged,
|
|
verified: false,
|
|
clearOffer: true,
|
|
),
|
|
state: null,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
CachedMessage _withStoredEncryption(
|
|
CachedMessage message,
|
|
Map<String, dynamic> row,
|
|
) => message.copyWith(
|
|
sealedText: row['text_sealed'] is Uint8List
|
|
? row['text_sealed'] as Uint8List
|
|
: null,
|
|
e2ee: row['e2ee'] as int,
|
|
);
|
|
|
|
Future<CachedMessage> _decryptIncoming(
|
|
CachedMessage message,
|
|
String text,
|
|
Future<void> Function(CachedMessage message)? commit, {
|
|
bool preserveLegacyFailure = false,
|
|
}) async {
|
|
final accountId = message.accountId;
|
|
final chatId = message.chatId;
|
|
final stored = await AppDatabase.loadMessagesByIds(accountId, chatId, [
|
|
message.id,
|
|
]);
|
|
if (stored.isNotEmpty &&
|
|
stored.first['text'] == message.text &&
|
|
stored.first['text_sealed'] != null &&
|
|
(stored.first['e2ee'] as int? ?? 0) != CachedMessage.e2eeNone) {
|
|
return _withStoredEncryption(message, stored.first);
|
|
}
|
|
final current = info(accountId, chatId);
|
|
if (current == null || !current.hasSession) {
|
|
return message;
|
|
}
|
|
final state = await _state(accountId, chatId);
|
|
if (state == null) return message;
|
|
try {
|
|
final result = KometCrypto.sessionDecrypt(state: state, text: text);
|
|
final decrypted = switch (result.contentType) {
|
|
ContentType.text => message.copyWith(
|
|
sealedText: await sealBytes(accountId, chatId, result.plaintext),
|
|
e2ee: CachedMessage.e2eeText,
|
|
),
|
|
ContentType.file => message.copyWith(
|
|
sealedText: await sealBytes(accountId, chatId, result.plaintext),
|
|
e2ee: CachedMessage.e2eeFile,
|
|
),
|
|
ContentType.control => message.copyWith(e2ee: CachedMessage.e2eeFailed),
|
|
};
|
|
// #***! текст на диск раньше состояния рэтчета, ключ сообщения уже потрачен
|
|
if (commit != null) await commit(decrypted);
|
|
await _persist(accountId, chatId, current, state: result.state);
|
|
return decrypted;
|
|
} on KometCryptoException catch (e) {
|
|
if (preserveLegacyFailure) return message;
|
|
switch (e.status) {
|
|
case CryptoStatus.notEncrypted:
|
|
case CryptoStatus.handshakeMessage:
|
|
return message;
|
|
default:
|
|
logger.w('e2ee decrypt ${message.id}: ${e.status.name_}');
|
|
return message.copyWith(e2ee: CachedMessage.e2eeFailed);
|
|
}
|
|
}
|
|
}
|
|
|
|
// #***! история: уже расшифрованное не трогаем, новое расшифровываем по порядку
|
|
Future<List<CachedMessage>> inspectHistory(
|
|
int accountId,
|
|
int chatId,
|
|
List<CachedMessage> messages,
|
|
) async {
|
|
if (!available || messages.isEmpty) return messages;
|
|
await ensureLoaded(accountId);
|
|
final existing = <String, Map<String, dynamic>>{};
|
|
for (final row in await AppDatabase.loadMessagesByIds(
|
|
accountId,
|
|
chatId,
|
|
messages.map((m) => m.id).toList(),
|
|
)) {
|
|
existing[row['id'].toString()] = row;
|
|
}
|
|
final ordered = [...messages]..sort((a, b) => a.time.compareTo(b.time));
|
|
final result = <String, CachedMessage>{};
|
|
for (final message in ordered) {
|
|
final row = existing[message.id];
|
|
if (row != null &&
|
|
row['text'] == message.text &&
|
|
isActive(accountId, chatId)) {
|
|
final kind = KometCrypto.classifyText(message.text ?? '');
|
|
if (kind == TextClass.offer || kind == TextClass.answer) {
|
|
result[message.id] = message;
|
|
continue;
|
|
}
|
|
}
|
|
if (row != null &&
|
|
row['text'] == message.text &&
|
|
row['text_sealed'] != null &&
|
|
(row['e2ee'] as int? ?? 0) != CachedMessage.e2eeNone) {
|
|
result[message.id] = message.copyWith(
|
|
sealedText: row['text_sealed'] is Uint8List
|
|
? row['text_sealed'] as Uint8List
|
|
: null,
|
|
e2ee: row['e2ee'] as int,
|
|
);
|
|
continue;
|
|
}
|
|
if (row != null &&
|
|
message.senderId != accountId &&
|
|
KometCrypto.classifyText(message.text ?? '') == TextClass.legacy) {
|
|
result[message.id] = await _serial(
|
|
_key(accountId, chatId),
|
|
() => _decryptIncoming(
|
|
message,
|
|
message.text ?? '',
|
|
(decrypted) => AppDatabase.saveMessages([decrypted.toDbRow()]),
|
|
preserveLegacyFailure: true,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
final inspected = await inspect(
|
|
message,
|
|
commit: (decrypted) => AppDatabase.saveMessages([decrypted.toDbRow()]),
|
|
);
|
|
final hadSealed = row?['text_sealed'] != null;
|
|
result[message.id] = hadSealed && inspected.sealedText == null
|
|
? CachedMessage.fromDbRow(row!)
|
|
: inspected;
|
|
}
|
|
return messages.map((m) => result[m.id] ?? m).toList();
|
|
}
|
|
|
|
// #***! новый identity-ключ: все сессии сбрасываются, собеседники увидят смену ключа
|
|
Future<bool> rotateIdentity(int accountId) async {
|
|
await ensureLoaded(accountId);
|
|
final chats = <int>[];
|
|
for (final key in _info.keys) {
|
|
if (key.startsWith('$accountId/')) {
|
|
chats.add(int.parse(key.split('/').last));
|
|
}
|
|
}
|
|
for (final chatId in chats) {
|
|
await _serial(_key(accountId, chatId), () async {
|
|
final current = info(accountId, chatId);
|
|
final state = _states.remove(_key(accountId, chatId));
|
|
if (state != null) KometCrypto.wipe(state);
|
|
if (current == null) return;
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
E2eeSessionInfo(
|
|
phase: E2eePhase.none,
|
|
peerId: current.peerId,
|
|
peerPublic: current.peerPublic,
|
|
),
|
|
state: null,
|
|
);
|
|
});
|
|
}
|
|
final previous = _identities.remove(accountId);
|
|
if (previous != null) KometCrypto.wipe(previous);
|
|
try {
|
|
final identity = KometCrypto.identityCreate();
|
|
await TokenStorage.writeSecure(
|
|
'e2ee_identity_$accountId',
|
|
base64Encode(identity),
|
|
);
|
|
_identities[accountId] = identity;
|
|
revision.value++;
|
|
return true;
|
|
} on KometCryptoException catch (e) {
|
|
logger.w('e2ee rotate identity: ${e.status.name_}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// #***! ушли в фон или сменили аккаунт, секреты из памяти вон
|
|
void lock() {
|
|
for (final state in _states.values) {
|
|
KometCrypto.wipe(state);
|
|
}
|
|
_states.clear();
|
|
for (final identity in _identities.values) {
|
|
KometCrypto.wipe(identity);
|
|
}
|
|
_identities.clear();
|
|
for (final key in _localKeys.values) {
|
|
KometCrypto.wipe(key);
|
|
}
|
|
_localKeys.clear();
|
|
}
|
|
|
|
void forgetAccount(int accountId) {
|
|
_loading.remove(accountId);
|
|
lock();
|
|
_info.removeWhere((key, _) => key.startsWith('$accountId/'));
|
|
_loaded.remove(accountId);
|
|
revision.value++;
|
|
}
|
|
|
|
// #***! без этого ключи и сессии переживают удаление аккаунта
|
|
Future<void> eraseAccount(int accountId) async {
|
|
forgetAccount(accountId);
|
|
_identities.remove(accountId);
|
|
_localKeys.remove(accountId);
|
|
for (final key in ['e2ee_identity_$accountId', 'e2ee_local_$accountId']) {
|
|
try {
|
|
await TokenStorage.deleteSecure(key);
|
|
} catch (e) {
|
|
logger.w('e2ee erase $key: $e');
|
|
}
|
|
}
|
|
try {
|
|
for (final row in await AppDatabase.loadE2eeSessions(accountId)) {
|
|
await AppDatabase.deleteE2eeSession(accountId, row['chat_id'] as int);
|
|
}
|
|
} catch (e) {
|
|
logger.w('e2ee erase sessions: $e');
|
|
} finally {
|
|
forgetAccount(accountId);
|
|
}
|
|
}
|
|
|
|
// #***! перенос на другое устройство: identity и сессии одним паролем
|
|
Future<Uint8List?> exportTransfer(
|
|
int accountId,
|
|
String password, {
|
|
Future<void> Function(Uint8List bytes)? persist,
|
|
}) async {
|
|
await ensureLoaded(accountId);
|
|
final items = <Uint8List>[await identity(accountId)];
|
|
final exported = <int>[];
|
|
for (final entry in _info.entries) {
|
|
if (!entry.key.startsWith('$accountId/')) continue;
|
|
if (!entry.value.hasSession) continue;
|
|
final chatId = int.parse(entry.key.split('/').last);
|
|
final state = await _state(accountId, chatId);
|
|
if (state != null) {
|
|
items.add(state);
|
|
exported.add(chatId);
|
|
}
|
|
}
|
|
final container = BytesBuilder(copy: false);
|
|
container.add(_be32(items.length));
|
|
for (final item in items) {
|
|
container.add(_be32(item.length));
|
|
container.add(item);
|
|
}
|
|
final Uint8List blob;
|
|
final payload = container.toBytes();
|
|
final secret = Uint8List.fromList(utf8.encode(password));
|
|
final library = KometCrypto.libraryPath;
|
|
try {
|
|
blob = await _transferCrypto(library, secret, payload, seal: true);
|
|
} on KometCryptoException catch (e) {
|
|
logger.w('e2ee export: ${e.status.name_}');
|
|
return null;
|
|
}
|
|
if (persist != null) {
|
|
try {
|
|
await persist(blob);
|
|
} catch (error) {
|
|
logger.w('e2ee transfer could not be saved: $error');
|
|
return null;
|
|
}
|
|
}
|
|
// #***! источник отключаем сразу, две копии сессии шлют под одним ключом
|
|
for (final chatId in exported) {
|
|
await _serial(_key(accountId, chatId), () => _forget(accountId, chatId));
|
|
}
|
|
return blob;
|
|
}
|
|
|
|
Future<int?> importTransfer(
|
|
int accountId,
|
|
Uint8List blob,
|
|
String password,
|
|
) async {
|
|
final Uint8List container;
|
|
final secret = Uint8List.fromList(utf8.encode(password));
|
|
final library = KometCrypto.libraryPath;
|
|
try {
|
|
container = await _transferCrypto(library, secret, blob, seal: false);
|
|
} on KometCryptoException catch (e) {
|
|
logger.w('e2ee import: ${e.status.name_}');
|
|
return null;
|
|
}
|
|
final items = <Uint8List>[];
|
|
if (container.length < 4) return null;
|
|
var offset = 4;
|
|
final count = _readBe32(container, 0);
|
|
for (
|
|
var index = 0;
|
|
index < count && offset + 4 <= container.length;
|
|
index++
|
|
) {
|
|
final length = _readBe32(container, offset);
|
|
offset += 4;
|
|
if (offset + length > container.length) return null;
|
|
items.add(Uint8List.sublistView(container, offset, offset + length));
|
|
offset += length;
|
|
}
|
|
if (items.isEmpty || items.first.length != KometCryptoSizes.identity) {
|
|
return null;
|
|
}
|
|
if (items.length != count || offset != container.length) return null;
|
|
final seenChats = <int>{};
|
|
for (final state in items.skip(1)) {
|
|
if (state.length < 66 ||
|
|
_readBe64(state, 10) != accountId ||
|
|
!seenChats.add(_readBe64(state, 2))) {
|
|
return null;
|
|
}
|
|
try {
|
|
KometCrypto.sessionPeerPublicKey(state);
|
|
} on KometCryptoException {
|
|
return null;
|
|
}
|
|
}
|
|
lock();
|
|
await TokenStorage.writeSecure(
|
|
'e2ee_identity_$accountId',
|
|
base64Encode(items.first),
|
|
);
|
|
await ensureLoaded(accountId);
|
|
var imported = 0;
|
|
for (final state in items.skip(1)) {
|
|
if (state.length < 66) continue;
|
|
final chatId = _readBe64(state, 2);
|
|
final myId = _readBe64(state, 10);
|
|
final peerId = _readBe64(state, 18);
|
|
if (myId != accountId) continue;
|
|
final peerPublic = Uint8List.fromList(
|
|
Uint8List.sublistView(state, 34, 66),
|
|
);
|
|
await _persist(
|
|
accountId,
|
|
chatId,
|
|
E2eeSessionInfo(
|
|
phase: E2eePhase.established,
|
|
peerId: peerId,
|
|
peerPublic: peerPublic,
|
|
verified: _carryVerified(info(accountId, chatId), peerPublic),
|
|
),
|
|
state: Uint8List.fromList(state),
|
|
);
|
|
imported++;
|
|
}
|
|
return imported;
|
|
}
|
|
|
|
static Uint8List _be32(int value) => Uint8List(4)
|
|
..[0] = (value >> 24) & 0xff
|
|
..[1] = (value >> 16) & 0xff
|
|
..[2] = (value >> 8) & 0xff
|
|
..[3] = value & 0xff;
|
|
|
|
static int _readBe32(Uint8List bytes, int offset) =>
|
|
(bytes[offset] << 24) |
|
|
(bytes[offset + 1] << 16) |
|
|
(bytes[offset + 2] << 8) |
|
|
bytes[offset + 3];
|
|
|
|
static int _readBe64(Uint8List bytes, int offset) {
|
|
var value = 0;
|
|
for (var index = 0; index < 8; index++) {
|
|
value = (value << 8) | bytes[offset + index];
|
|
}
|
|
return value;
|
|
}
|
|
}
|