1013 lines
31 KiB
Dart
1013 lines
31 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:cryptography/cryptography.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:meta/meta.dart';
|
|
import 'package:omemo_dart/src/common/result.dart';
|
|
import 'package:omemo_dart/src/crypto.dart';
|
|
import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart';
|
|
import 'package:omemo_dart/src/errors.dart';
|
|
import 'package:omemo_dart/src/helpers.dart';
|
|
import 'package:omemo_dart/src/keys.dart';
|
|
import 'package:omemo_dart/src/omemo/bundle.dart';
|
|
import 'package:omemo_dart/src/omemo/constants.dart';
|
|
import 'package:omemo_dart/src/omemo/decryption_result.dart';
|
|
import 'package:omemo_dart/src/omemo/device.dart';
|
|
import 'package:omemo_dart/src/omemo/encrypted_key.dart';
|
|
import 'package:omemo_dart/src/omemo/encryption_result.dart';
|
|
import 'package:omemo_dart/src/omemo/errors.dart';
|
|
import 'package:omemo_dart/src/omemo/fingerprint.dart';
|
|
import 'package:omemo_dart/src/omemo/queue.dart';
|
|
import 'package:omemo_dart/src/omemo/ratchet_data.dart';
|
|
import 'package:omemo_dart/src/omemo/ratchet_map_key.dart';
|
|
import 'package:omemo_dart/src/omemo/stanza.dart';
|
|
import 'package:omemo_dart/src/protobuf/schema.pb.dart';
|
|
import 'package:omemo_dart/src/trust/base.dart';
|
|
import 'package:omemo_dart/src/x3dh/x3dh.dart';
|
|
import 'package:synchronized/synchronized.dart';
|
|
|
|
class OmemoDataPackage {
|
|
const OmemoDataPackage(this.devices, this.ratchets);
|
|
|
|
/// The device list for the given JID.
|
|
final List<int> devices;
|
|
|
|
/// The ratchets for the JID.
|
|
final Map<RatchetMapKey, OmemoDoubleRatchet> ratchets;
|
|
}
|
|
|
|
/// Callback type definitions
|
|
|
|
/// Directly "package" [result] into an OMEMO message and send it to [recipientJid].
|
|
typedef SendEmptyOmemoMessageFunction = Future<void> Function(
|
|
EncryptionResult result,
|
|
String recipientJid,
|
|
);
|
|
|
|
/// Fetches the device list for [jid]. If no device list could be fetched, returns null.
|
|
typedef FetchDeviceListFunction = Future<List<int>?> Function(String jid);
|
|
|
|
/// Fetch the device bundle for the device with id @id of jid. If it cannot be fetched, return null.
|
|
typedef FetchDeviceBundleFunction = Future<OmemoBundle?> Function(
|
|
String jid,
|
|
int id,
|
|
);
|
|
|
|
/// Subscribes to the device list node of [jid].
|
|
typedef DeviceListSubscribeFunction = Future<void> Function(String jid);
|
|
|
|
/// Commits the device list for [jid] to persistent storage. [added] will be the list of
|
|
/// devices added and [removed] will be the list of removed devices.
|
|
typedef CommitDeviceListCallback = Future<void> Function(
|
|
String jid,
|
|
List<int> added,
|
|
List<int> removed,
|
|
);
|
|
|
|
/// A stub implementation of [CommitDeviceListCallback].
|
|
Future<void> commitDeviceListStub(
|
|
String _,
|
|
List<int> __,
|
|
List<int> ___,
|
|
) async {}
|
|
|
|
/// Commits the mapping of the (new) ratchets in [ratchets] to persistent storage.
|
|
typedef CommitRatchetsCallback = Future<void> Function(
|
|
List<OmemoRatchetData> ratchets,
|
|
);
|
|
|
|
/// A stub implementation of [CommitRatchetsCallback];
|
|
Future<void> commitRatchetsStub(List<OmemoRatchetData> _) async {}
|
|
|
|
/// Commits the device [device] to persistent storage.
|
|
typedef CommitDeviceCallback = Future<void> Function(OmemoDevice device);
|
|
|
|
/// A stub implementation of [CommitDeviceCallback].
|
|
Future<void> commitDeviceStub(OmemoDevice device) async {}
|
|
|
|
/// Removes the ratchets identified by their keys in [ratchets] from persistent storage.
|
|
typedef RemoveRatchetsFunction = Future<void> Function(
|
|
List<RatchetMapKey> ratchets,
|
|
);
|
|
|
|
/// A stub implementation of [RemoveRatchetsFunction].
|
|
Future<void> removeRatchetsStub(List<RatchetMapKey> ratchets) async {}
|
|
|
|
/// Loads all the required data for the ratchets of [jid].
|
|
typedef LoadRatchetsCallback = Future<OmemoDataPackage?> Function(String jid);
|
|
|
|
/// A stub implementation of [LoadRatchetsCallback].
|
|
Future<OmemoDataPackage?> loadRatchetsStub(String _) async => null;
|
|
|
|
class OmemoManager {
|
|
OmemoManager(
|
|
this._device,
|
|
this._trustManager,
|
|
this.sendEmptyOmemoMessageImpl,
|
|
this.fetchDeviceListImpl,
|
|
this.fetchDeviceBundleImpl,
|
|
this.subscribeToDeviceListNodeImpl, {
|
|
this.commitRatchets = commitRatchetsStub,
|
|
this.commitDeviceList = commitDeviceListStub,
|
|
this.commitDevice = commitDeviceStub,
|
|
this.removeRatchets = removeRatchetsStub,
|
|
this.loadRatchets = loadRatchetsStub,
|
|
});
|
|
|
|
final Logger _log = Logger('OmemoManager');
|
|
|
|
/// Functions for connecting with the OMEMO library
|
|
|
|
/// Send an empty OMEMO:2 message using the encrypted payload @result to
|
|
/// @recipientJid.
|
|
final SendEmptyOmemoMessageFunction sendEmptyOmemoMessageImpl;
|
|
|
|
/// Fetch the list of device ids associated with @jid. If the device list cannot be
|
|
/// fetched, return null.
|
|
final FetchDeviceListFunction fetchDeviceListImpl;
|
|
|
|
/// Fetch the device bundle for the device with id @id of jid. If it cannot be fetched, return null.
|
|
final FetchDeviceBundleFunction fetchDeviceBundleImpl;
|
|
|
|
/// Subscribe to the device list PEP node of @jid.
|
|
final DeviceListSubscribeFunction subscribeToDeviceListNodeImpl;
|
|
|
|
/// Callback to commit the ratchet to persistent storage.
|
|
final CommitRatchetsCallback commitRatchets;
|
|
|
|
/// Callback to commit the device list to persistent storage.
|
|
final CommitDeviceListCallback commitDeviceList;
|
|
|
|
/// Callback to commit the device to persistent storage.
|
|
final CommitDeviceCallback commitDevice;
|
|
|
|
/// Callback to remove ratchets from persistent storage.
|
|
final RemoveRatchetsFunction removeRatchets;
|
|
|
|
/// Callback to load ratchets from persistent storage.
|
|
final LoadRatchetsCallback loadRatchets;
|
|
|
|
/// Map bare JID to its known devices
|
|
final Map<String, List<int>> _deviceList = {};
|
|
|
|
/// Map bare JIDs to whether we already requested the device list once
|
|
final Map<String, bool> _deviceListRequested = {};
|
|
|
|
/// Map bare a ratchet key to its ratchet. Note that this is also locked by
|
|
/// _ratchetCriticalSectionLock.
|
|
final Map<RatchetMapKey, OmemoDoubleRatchet> _ratchetMap = {};
|
|
|
|
/// Map bare JID to whether we already tried to subscribe to the device list node.
|
|
final Map<String, bool> _subscriptionMap = {};
|
|
|
|
/// List of JIDs for which we cached trust data, the device list, and the ratchets.
|
|
final List<String> _cachedJids = [];
|
|
|
|
/// For preventing a race condition in encryption/decryption
|
|
final RatchetAccessQueue _ratchetQueue = RatchetAccessQueue();
|
|
|
|
/// The OmemoManager's trust management
|
|
final TrustManager _trustManager;
|
|
|
|
/// Our own keys...
|
|
final Lock _deviceLock = Lock();
|
|
// ignore: prefer_final_fields
|
|
OmemoDevice _device;
|
|
|
|
Future<void> _cacheJidsIfNeccessary(List<String> jids) async {
|
|
for (final jid in jids) {
|
|
await _cacheJidIfNeccessary(jid);
|
|
}
|
|
}
|
|
|
|
Future<void> _cacheJidIfNeccessary(String jid) async {
|
|
// JID is already cached. We don't have to do anything.
|
|
if (_cachedJids.contains(jid)) {
|
|
return;
|
|
}
|
|
|
|
_cachedJids.add(jid);
|
|
final result = await loadRatchets(jid);
|
|
if (result == null) {
|
|
_log.fine('Did not load ratchet data for $jid. Assuming there is none.');
|
|
return;
|
|
}
|
|
|
|
// Cache the data
|
|
_deviceList[jid] = result.devices;
|
|
_ratchetMap.addAll(result.ratchets);
|
|
|
|
// Load trust data
|
|
await _trustManager.loadTrustData(jid);
|
|
}
|
|
|
|
Future<Result<OmemoError, String?>> _decryptAndVerifyHmac(
|
|
List<int>? ciphertext,
|
|
List<int> keyAndHmac,
|
|
) async {
|
|
// Empty OMEMO messages should just have the key decrypted and/or session set up.
|
|
if (ciphertext == null) {
|
|
return const Result(null);
|
|
}
|
|
|
|
final key = keyAndHmac.sublist(0, 32);
|
|
final hmac = keyAndHmac.sublist(32, 48);
|
|
final derivedKeys = await deriveEncryptionKeys(key, omemoPayloadInfoString);
|
|
final computedHmac =
|
|
await truncatedHmac(ciphertext, derivedKeys.authenticationKey);
|
|
if (!listsEqual(hmac, computedHmac)) {
|
|
return Result(InvalidMessageHMACError());
|
|
}
|
|
|
|
final result = await aes256CbcDecrypt(
|
|
ciphertext,
|
|
derivedKeys.encryptionKey,
|
|
derivedKeys.iv,
|
|
);
|
|
if (result.isType<MalformedCiphertextError>()) {
|
|
return Result(
|
|
result.get<MalformedCiphertextError>(),
|
|
);
|
|
}
|
|
|
|
return Result(
|
|
utf8.decode(
|
|
result.get<List<int>>(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Fetches the device list from the server for [jid] and downloads OMEMO bundles
|
|
/// for devices we have no session with.
|
|
///
|
|
/// Returns a list of new bundles, that may be empty.
|
|
Future<List<OmemoBundle>> _fetchNewOmemoBundles(String jid) async {
|
|
// Do we have to request the device list or are we already up-to-date?
|
|
if (!_deviceListRequested.containsKey(jid) ||
|
|
!_deviceList.containsKey(jid)) {
|
|
final newDeviceList = await fetchDeviceListImpl(jid);
|
|
if (newDeviceList != null) {
|
|
// Figure out what bundles we must fetch
|
|
_deviceList[jid] = newDeviceList;
|
|
_deviceListRequested[jid] = true;
|
|
|
|
await commitDeviceList(
|
|
jid,
|
|
newDeviceList,
|
|
[],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check that we have the device list
|
|
if (!_deviceList.containsKey(jid)) {
|
|
_log.warning('$jid not tracked in device list.');
|
|
return [];
|
|
}
|
|
|
|
final ownDevice = await getDevice();
|
|
final bundlesToFetch = _deviceList[jid]!.where((device) {
|
|
// Do not include our current device, if we request bundles for our own JID.
|
|
if (ownDevice.jid == jid && device == ownDevice.id) {
|
|
return false;
|
|
}
|
|
|
|
return !_ratchetMap.containsKey(RatchetMapKey(jid, device));
|
|
});
|
|
if (bundlesToFetch.isEmpty) {
|
|
return [];
|
|
}
|
|
|
|
// Fetch the new bundles
|
|
_log.finest('Fetching bundles $bundlesToFetch for $jid');
|
|
final bundles = <OmemoBundle>[];
|
|
for (final device in bundlesToFetch) {
|
|
final bundle = await fetchDeviceBundleImpl(jid, device);
|
|
if (bundle != null) {
|
|
bundles.add(bundle);
|
|
} else {
|
|
_log.warning('Failed to fetch bundle $jid:$device');
|
|
}
|
|
}
|
|
|
|
return bundles;
|
|
}
|
|
|
|
Future<void> _maybeSendEmptyMessage(
|
|
RatchetMapKey key,
|
|
bool created,
|
|
bool replaced,
|
|
) async {
|
|
final ratchet = _ratchetMap[key]!;
|
|
if (ratchet.acknowledged) {
|
|
// The ratchet is acknowledged
|
|
_log.finest(
|
|
'Checking whether to heartbeat to ${key.jid}, ratchet.nr (${ratchet.nr}) >= 53: ${ratchet.nr >= 53}, created: $created, replaced: $replaced',
|
|
);
|
|
if (ratchet.nr >= 53 || created || replaced) {
|
|
await sendEmptyOmemoMessageImpl(
|
|
await _onOutgoingStanzaImpl(
|
|
OmemoOutgoingStanza(
|
|
[key.jid],
|
|
null,
|
|
),
|
|
),
|
|
key.jid,
|
|
);
|
|
}
|
|
} else {
|
|
// Ratchet is not acknowledged
|
|
_log.finest('Sending acknowledgement heartbeat to ${key.jid}');
|
|
await _ratchetAcknowledged(key.jid, key.deviceId);
|
|
await sendEmptyOmemoMessageImpl(
|
|
await _onOutgoingStanzaImpl(
|
|
OmemoOutgoingStanza(
|
|
[key.jid],
|
|
null,
|
|
),
|
|
),
|
|
key.jid,
|
|
);
|
|
}
|
|
}
|
|
|
|
///
|
|
Future<DecryptionResult> onIncomingStanza(OmemoIncomingStanza stanza) async {
|
|
return _ratchetQueue.synchronized(
|
|
[stanza.bareSenderJid],
|
|
() => _onIncomingStanzaImpl(stanza),
|
|
);
|
|
}
|
|
|
|
Future<DecryptionResult> _onIncomingStanzaImpl(
|
|
OmemoIncomingStanza stanza,
|
|
) async {
|
|
// Populate the cache
|
|
await _cacheJidIfNeccessary(stanza.bareSenderJid);
|
|
|
|
// Find the correct key for our device
|
|
final deviceId = await getDeviceId();
|
|
final key = stanza.keys.firstWhereOrNull((key) => key.rid == deviceId);
|
|
if (key == null) {
|
|
return DecryptionResult(
|
|
null,
|
|
null,
|
|
NotEncryptedForDeviceError(),
|
|
);
|
|
}
|
|
|
|
// Protobuf will happily parse this and return bogus data.
|
|
if (key.value.isEmpty) {
|
|
return DecryptionResult(
|
|
null,
|
|
null,
|
|
MalformedEncryptedKeyError(),
|
|
);
|
|
}
|
|
|
|
// Check how we should process the message
|
|
final ratchetKey =
|
|
RatchetMapKey(stanza.bareSenderJid, stanza.senderDeviceId);
|
|
var processAsKex = key.kex;
|
|
if (key.kex && _ratchetMap.containsKey(ratchetKey)) {
|
|
final ratchet = _ratchetMap[ratchetKey]!;
|
|
final kexMessage = OMEMOKeyExchange.fromBuffer(key.data);
|
|
final ratchetEk = await ratchet.kex.ek.getBytes();
|
|
final sameEk = listsEqual(kexMessage.ek, ratchetEk);
|
|
|
|
if (sameEk) {
|
|
processAsKex = false;
|
|
} else {
|
|
processAsKex = true;
|
|
}
|
|
_log.finest('kexMessage.ek == ratchetEk: $sameEk');
|
|
}
|
|
|
|
// Process the message
|
|
if (processAsKex) {
|
|
_log.finest('Decoding message as OMEMOKeyExchange');
|
|
final kexMessage = OMEMOKeyExchange.fromBuffer(key.data);
|
|
|
|
// Find the correct SPK
|
|
final device = await getDevice();
|
|
OmemoKeyPair spk;
|
|
if (kexMessage.spkId == device.spkId) {
|
|
spk = device.spk;
|
|
} else if (kexMessage.spkId == device.oldSpkId) {
|
|
spk = device.oldSpk!;
|
|
} else {
|
|
return DecryptionResult(
|
|
null,
|
|
null,
|
|
UnknownSignedPrekeyError(),
|
|
);
|
|
}
|
|
|
|
// Build the new ratchet session
|
|
final kexIk = OmemoPublicKey.fromBytes(
|
|
kexMessage.ik,
|
|
KeyPairType.ed25519,
|
|
);
|
|
final kexEk = OmemoPublicKey.fromBytes(
|
|
kexMessage.ek,
|
|
KeyPairType.x25519,
|
|
);
|
|
final kex = await x3dhFromInitialMessage(
|
|
X3DHMessage(
|
|
kexIk,
|
|
kexEk,
|
|
kexMessage.pkId,
|
|
),
|
|
spk,
|
|
device.opks[kexMessage.pkId]!,
|
|
device.ik,
|
|
);
|
|
final ratchet = await OmemoDoubleRatchet.acceptNewSession(
|
|
spk,
|
|
kexMessage.spkId,
|
|
kexIk,
|
|
kexMessage.pkId,
|
|
kexEk,
|
|
kex.sk,
|
|
kex.ad,
|
|
getTimestamp(),
|
|
);
|
|
|
|
final keyAndHmac = await ratchet.ratchetDecrypt(
|
|
kexMessage.message,
|
|
);
|
|
if (keyAndHmac.isType<OmemoError>()) {
|
|
final error = keyAndHmac.get<OmemoError>();
|
|
_log.warning('Failed to decrypt symmetric key: $error');
|
|
|
|
return DecryptionResult(null, null, error);
|
|
}
|
|
|
|
Result<OmemoError, String?> result;
|
|
if (stanza.payload != null) {
|
|
result = await _decryptAndVerifyHmac(
|
|
stanza.payload?.fromBase64(),
|
|
keyAndHmac.get<List<int>>(),
|
|
);
|
|
if (result.isType<OmemoError>()) {
|
|
final error = result.get<OmemoError>();
|
|
_log.warning('Decrypting payload failed: $error');
|
|
|
|
return DecryptionResult(
|
|
null,
|
|
null,
|
|
error,
|
|
);
|
|
}
|
|
} else {
|
|
result = const Result(null);
|
|
}
|
|
|
|
// Notify the trust manager
|
|
await _trustManager.onNewSession(
|
|
stanza.bareSenderJid,
|
|
stanza.senderDeviceId,
|
|
);
|
|
|
|
// If we received an empty OMEMO message, mark the ratchet as acknowledged
|
|
if (result.get<String?>() == null) {
|
|
if (!ratchet.acknowledged) {
|
|
ratchet.acknowledged = true;
|
|
}
|
|
}
|
|
|
|
// Commit the ratchet
|
|
_ratchetMap[ratchetKey] = ratchet;
|
|
_deviceList.appendOrCreate(stanza.bareSenderJid, stanza.senderDeviceId);
|
|
await commitRatchets([
|
|
OmemoRatchetData(
|
|
stanza.bareSenderJid,
|
|
stanza.senderDeviceId,
|
|
ratchet,
|
|
true,
|
|
false,
|
|
),
|
|
]);
|
|
|
|
// Replace the OPK if we're not doing a catchup.
|
|
if (!stanza.isCatchup) {
|
|
await _deviceLock.synchronized(() async {
|
|
await _device.replaceOnetimePrekey(kexMessage.pkId);
|
|
await commitDevice(_device);
|
|
});
|
|
}
|
|
|
|
// Send the hearbeat, if we have to
|
|
await _maybeSendEmptyMessage(
|
|
ratchetKey,
|
|
true,
|
|
_ratchetMap.containsKey(ratchetKey),
|
|
);
|
|
|
|
return DecryptionResult(
|
|
result.get<String?>(),
|
|
kexMessage.pkId,
|
|
null,
|
|
);
|
|
} else {
|
|
// Check if we even have a ratchet
|
|
if (!_ratchetMap.containsKey(ratchetKey)) {
|
|
// TODO(Unknown): Check if we recently failed to build a session with the device
|
|
// This causes omemo_dart to build a session with the device.
|
|
if (!_deviceList[stanza.bareSenderJid]!
|
|
.contains(stanza.senderDeviceId)) {
|
|
_deviceList[stanza.bareSenderJid]!.add(stanza.senderDeviceId);
|
|
}
|
|
await _sendOmemoHeartbeat(stanza.bareSenderJid);
|
|
|
|
return DecryptionResult(
|
|
null,
|
|
null,
|
|
NoSessionWithDeviceError(),
|
|
);
|
|
}
|
|
|
|
_log.finest('Decoding message as OMEMOAuthenticatedMessage');
|
|
final ratchet = _ratchetMap[ratchetKey]!.clone();
|
|
|
|
// Correctly decode the message
|
|
OMEMOAuthenticatedMessage authMessage;
|
|
if (key.kex) {
|
|
_log.finest(
|
|
'Extracting OMEMOAuthenticatedMessage from OMEMOKeyExchange',
|
|
);
|
|
authMessage = OMEMOKeyExchange.fromBuffer(key.data).message;
|
|
} else {
|
|
authMessage = OMEMOAuthenticatedMessage.fromBuffer(key.data);
|
|
}
|
|
|
|
final keyAndHmac = await ratchet.ratchetDecrypt(authMessage);
|
|
if (keyAndHmac.isType<OmemoError>()) {
|
|
final error = keyAndHmac.get<OmemoError>();
|
|
_log.warning('Failed to decrypt symmetric key: $error');
|
|
return DecryptionResult(null, null, error);
|
|
}
|
|
|
|
Result<OmemoError, String?> result;
|
|
if (stanza.payload != null) {
|
|
result = await _decryptAndVerifyHmac(
|
|
stanza.payload?.fromBase64(),
|
|
keyAndHmac.get<List<int>>(),
|
|
);
|
|
if (result.isType<OmemoError>()) {
|
|
final error = result.get<OmemoError>();
|
|
_log.warning('Failed to decrypt message: $error');
|
|
return DecryptionResult(
|
|
null,
|
|
null,
|
|
error,
|
|
);
|
|
}
|
|
} else {
|
|
result = const Result(null);
|
|
}
|
|
|
|
// If we received an empty OMEMO message, mark the ratchet as acknowledged
|
|
if (result.get<String?>() == null) {
|
|
if (!ratchet.acknowledged) {
|
|
ratchet.acknowledged = true;
|
|
}
|
|
}
|
|
|
|
// Message was successfully decrypted, so commit the ratchet
|
|
_ratchetMap[ratchetKey] = ratchet;
|
|
await commitRatchets([
|
|
OmemoRatchetData(
|
|
stanza.bareSenderJid,
|
|
stanza.senderDeviceId,
|
|
ratchet,
|
|
false,
|
|
false,
|
|
),
|
|
]);
|
|
|
|
// Send a heartbeat, if required.
|
|
await _maybeSendEmptyMessage(ratchetKey, false, false);
|
|
|
|
return DecryptionResult(
|
|
result.get<String?>(),
|
|
null,
|
|
null,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<EncryptionResult> onOutgoingStanza(OmemoOutgoingStanza stanza) async {
|
|
return _ratchetQueue.synchronized(
|
|
stanza.recipientJids,
|
|
() => _onOutgoingStanzaImpl(stanza),
|
|
);
|
|
}
|
|
|
|
Future<EncryptionResult> _onOutgoingStanzaImpl(
|
|
OmemoOutgoingStanza stanza,
|
|
) async {
|
|
// Populate the cache
|
|
await _cacheJidsIfNeccessary(stanza.recipientJids);
|
|
|
|
// Encrypt the payload, if we have any
|
|
final List<int> payloadKey;
|
|
final List<int>? ciphertext;
|
|
if (stanza.payload != null) {
|
|
// Generate the key and encrypt the plaintext
|
|
final rawKey = generateRandomBytes(32);
|
|
final keys = await deriveEncryptionKeys(rawKey, omemoPayloadInfoString);
|
|
ciphertext = await aes256CbcEncrypt(
|
|
utf8.encode(stanza.payload!),
|
|
keys.encryptionKey,
|
|
keys.iv,
|
|
);
|
|
final hmac = await truncatedHmac(ciphertext, keys.authenticationKey);
|
|
payloadKey = concat([rawKey, hmac]);
|
|
} else {
|
|
payloadKey = List<int>.filled(32, 0x0);
|
|
ciphertext = null;
|
|
}
|
|
|
|
final successfulEncryptions = Map<String, int>.fromEntries(
|
|
stanza.recipientJids.map((jid) => MapEntry(jid, 0)),
|
|
);
|
|
final encryptionErrors = <String, List<EncryptToJidError>>{};
|
|
final addedRatchetKeys = List<RatchetMapKey>.empty(growable: true);
|
|
final kex = <RatchetMapKey, OMEMOKeyExchange>{};
|
|
for (final jid in stanza.recipientJids) {
|
|
final newBundles = await _fetchNewOmemoBundles(jid);
|
|
if (newBundles.isEmpty) {
|
|
continue;
|
|
}
|
|
|
|
for (final bundle in newBundles) {
|
|
_log.finest('Building new ratchet $jid:${bundle.id}');
|
|
final ratchetKey = RatchetMapKey(jid, bundle.id);
|
|
final ownDevice = await getDevice();
|
|
final kexResultRaw = await x3dhFromBundle(
|
|
bundle,
|
|
ownDevice.ik,
|
|
);
|
|
// TODO(Unknown): Track the failure and do not attempt to encrypt to this device
|
|
// on every send.
|
|
if (kexResultRaw.isType<InvalidKeyExchangeSignatureError>()) {
|
|
encryptionErrors.appendOrCreate(
|
|
jid,
|
|
EncryptToJidError(
|
|
bundle.id,
|
|
kexResultRaw.get<InvalidKeyExchangeSignatureError>(),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
final kexResult = kexResultRaw.get<X3DHAliceResult>();
|
|
final newRatchet = await OmemoDoubleRatchet.initiateNewSession(
|
|
bundle.spk,
|
|
bundle.spkId,
|
|
bundle.ik,
|
|
ownDevice.ik.pk,
|
|
kexResult.ek.pk,
|
|
kexResult.sk,
|
|
kexResult.ad,
|
|
getTimestamp(),
|
|
kexResult.opkId,
|
|
);
|
|
|
|
// Track the ratchet
|
|
_ratchetMap[ratchetKey] = newRatchet;
|
|
addedRatchetKeys.add(ratchetKey);
|
|
|
|
// Initiate trust
|
|
await _trustManager.onNewSession(jid, bundle.id);
|
|
|
|
// Track the KEX for later
|
|
final ik = await ownDevice.ik.pk.getBytes();
|
|
final ek = await kexResult.ek.pk.getBytes();
|
|
kex[ratchetKey] = OMEMOKeyExchange()
|
|
..pkId = newRatchet.kex.pkId
|
|
..spkId = newRatchet.kex.spkId
|
|
..ik = ik
|
|
..ek = ek;
|
|
}
|
|
}
|
|
|
|
// Commit the newly created ratchets, if we created any.
|
|
if (addedRatchetKeys.isNotEmpty) {
|
|
await commitRatchets(
|
|
addedRatchetKeys.map((key) {
|
|
return OmemoRatchetData(
|
|
key.jid,
|
|
key.deviceId,
|
|
_ratchetMap[key]!,
|
|
true,
|
|
false,
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
// Encrypt the symmetric key for all devices.
|
|
final encryptedKeys = <String, List<EncryptedKey>>{};
|
|
for (final jid in stanza.recipientJids) {
|
|
// Check if we know about any devices to use
|
|
final devices = _deviceList[jid];
|
|
if (devices == null) {
|
|
_log.info('No devices for $jid known. Skipping in encryption');
|
|
encryptionErrors.appendOrCreate(
|
|
jid,
|
|
EncryptToJidError(
|
|
null,
|
|
NoKeyMaterialAvailableError(),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Check if we have to subscribe to the device list
|
|
if (!_subscriptionMap.containsKey(jid)) {
|
|
unawaited(subscribeToDeviceListNodeImpl(jid));
|
|
_subscriptionMap[jid] = true;
|
|
}
|
|
|
|
for (final device in devices) {
|
|
// Check if we should encrypt for this device
|
|
// NOTE: Empty OMEMO messages are allowed to bypass trust decisions
|
|
if (stanza.payload != null) {
|
|
// Only encrypt to devices that are trusted
|
|
if (!(await _trustManager.isTrusted(jid, device))) continue;
|
|
|
|
// Only encrypt to devices that are enabled
|
|
if (!(await _trustManager.isEnabled(jid, device))) continue;
|
|
}
|
|
|
|
// Check if the ratchet exists
|
|
final ratchetKey = RatchetMapKey(jid, device);
|
|
if (!_ratchetMap.containsKey(ratchetKey)) {
|
|
// NOTE: The earlier loop should have created a new ratchet
|
|
_log.warning('No ratchet for $jid:$device found.');
|
|
encryptionErrors.appendOrCreate(
|
|
jid,
|
|
EncryptToJidError(
|
|
device,
|
|
NoSessionWithDeviceError(),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Encrypt
|
|
final ratchet = _ratchetMap[ratchetKey]!;
|
|
final authMessage = await ratchet.ratchetEncrypt(payloadKey);
|
|
|
|
// Package
|
|
if (kex.containsKey(ratchetKey)) {
|
|
final kexMessage = kex[ratchetKey]!..message = authMessage;
|
|
encryptedKeys.appendOrCreate(
|
|
jid,
|
|
EncryptedKey(
|
|
device,
|
|
base64Encode(kexMessage.writeToBuffer()),
|
|
true,
|
|
),
|
|
);
|
|
successfulEncryptions[jid] = successfulEncryptions[jid]! + 1;
|
|
} else if (!ratchet.acknowledged) {
|
|
// The ratchet as not yet been acked.
|
|
// Keep sending the old KEX
|
|
_log.finest('Using old KEX data for OMEMOKeyExchange');
|
|
final kexMessage = OMEMOKeyExchange()
|
|
..pkId = ratchet.kex.pkId
|
|
..spkId = ratchet.kex.spkId
|
|
..ik = await ratchet.kex.ik.getBytes()
|
|
..ek = await ratchet.kex.ek.getBytes()
|
|
..message = authMessage;
|
|
|
|
encryptedKeys.appendOrCreate(
|
|
jid,
|
|
EncryptedKey(
|
|
device,
|
|
base64Encode(kexMessage.writeToBuffer()),
|
|
true,
|
|
),
|
|
);
|
|
successfulEncryptions[jid] = successfulEncryptions[jid]! + 1;
|
|
} else {
|
|
// The ratchet exists and is acked
|
|
encryptedKeys.appendOrCreate(
|
|
jid,
|
|
EncryptedKey(
|
|
device,
|
|
base64Encode(authMessage.writeToBuffer()),
|
|
false,
|
|
),
|
|
);
|
|
successfulEncryptions[jid] = successfulEncryptions[jid]! + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return EncryptionResult(
|
|
ciphertext,
|
|
encryptedKeys,
|
|
encryptionErrors,
|
|
successfulEncryptions.values.every((n) => n > 0),
|
|
);
|
|
}
|
|
|
|
/// Sends an empty OMEMO message (heartbeat) to [jid].
|
|
Future<void> sendOmemoHeartbeat(String jid) async {
|
|
await _ratchetQueue.synchronized(
|
|
[jid],
|
|
() => _sendOmemoHeartbeat(jid),
|
|
);
|
|
}
|
|
|
|
/// Like [sendOmemoHeartbeat], but does not acquire the lock for [jid].
|
|
Future<void> _sendOmemoHeartbeat(String jid) async {
|
|
final result = await _onOutgoingStanzaImpl(
|
|
OmemoOutgoingStanza(
|
|
[jid],
|
|
null,
|
|
),
|
|
);
|
|
await sendEmptyOmemoMessageImpl(result, jid);
|
|
}
|
|
|
|
/// Removes all ratchets associated with [jid].
|
|
Future<void> removeAllRatchets(String jid) async {
|
|
await _ratchetQueue.synchronized(
|
|
[jid],
|
|
() async {
|
|
// Remove the ratchet and commit
|
|
final keys = (_deviceList[jid] ?? <int>[])
|
|
.map((device) => RatchetMapKey(jid, device));
|
|
for (final key in keys) {
|
|
_ratchetMap.remove(key);
|
|
}
|
|
await removeRatchets(keys.toList());
|
|
|
|
// TODO: Do we have to tell the trust manager?
|
|
|
|
// Clear the device list
|
|
await commitDeviceList(
|
|
jid,
|
|
[],
|
|
_deviceList[jid]!,
|
|
);
|
|
_deviceList.remove(jid);
|
|
_deviceListRequested.remove(jid);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// To be called when a update to the device list of [jid] is returned.
|
|
/// [devices] is the list of device identifiers contained in the update.
|
|
Future<void> onDeviceListUpdate(String jid, List<int> devices) async {
|
|
await _ratchetQueue.synchronized(
|
|
[jid],
|
|
() async {
|
|
// Compute the delta
|
|
ListDiff<int> delta;
|
|
if (_deviceList.containsKey(jid)) {
|
|
delta = _deviceList[jid]!.diff(devices);
|
|
} else {
|
|
delta = ListDiff(devices, []);
|
|
}
|
|
|
|
// Update our state
|
|
_deviceList[jid] = devices;
|
|
_deviceListRequested[jid] = true;
|
|
|
|
// Commit the device list
|
|
await commitDeviceList(jid, delta.added, delta.removed);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// To be called when a new connection is made, i.e. when the previous stream could
|
|
/// previous stream could not be resumed using XEP-0198.
|
|
Future<void> onNewConnection() async {
|
|
_deviceListRequested.clear();
|
|
_subscriptionMap.clear();
|
|
}
|
|
|
|
// Mark the ratchet [jid]:[device] as acknowledged.
|
|
Future<void> ratchetAcknowledged(String jid, int device) async {
|
|
await _ratchetQueue.synchronized(
|
|
[jid],
|
|
() => _ratchetAcknowledged(jid, device),
|
|
);
|
|
}
|
|
|
|
/// Like [ratchetAcknowledged], but does not acquire the lock for [jid].
|
|
Future<void> _ratchetAcknowledged(String jid, int device) async {
|
|
final ratchetKey = RatchetMapKey(jid, device);
|
|
if (!_ratchetMap.containsKey(ratchetKey)) {
|
|
_log.warning(
|
|
'Cannot mark $jid:$device as acknowledged as the ratchet does not exist',
|
|
);
|
|
} else {
|
|
// Commit
|
|
final ratchet = _ratchetMap[ratchetKey]!..acknowledged = true;
|
|
await commitRatchets([
|
|
OmemoRatchetData(
|
|
jid,
|
|
device,
|
|
ratchet,
|
|
false,
|
|
false,
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/// If ratchets with [jid] exists, returns a list of fingerprints for each
|
|
/// ratchet.
|
|
///
|
|
/// If not ratchets exists, returns null.
|
|
Future<List<DeviceFingerprint>?> getFingerprintsForJid(String jid) async {
|
|
return _ratchetQueue.synchronized(
|
|
[jid],
|
|
() => _getFingerprintsForJidImpl(jid),
|
|
);
|
|
}
|
|
|
|
/// Same as [getFingerprintsForJid], but without acquiring the lock for [jid].
|
|
Future<List<DeviceFingerprint>?> _getFingerprintsForJidImpl(
|
|
String jid,
|
|
) async {
|
|
// Check if we know of the JID.
|
|
if (!_deviceList.containsKey(jid)) {
|
|
return null;
|
|
}
|
|
|
|
final devices = _deviceList[jid]!;
|
|
final fingerprints = List<DeviceFingerprint>.empty(growable: true);
|
|
for (final device in devices) {
|
|
final ratchet = _ratchetMap[RatchetMapKey(jid, device)];
|
|
if (ratchet == null) {
|
|
_log.warning('getFingerprintsForJid: Ratchet $jid:$device not found.');
|
|
continue;
|
|
}
|
|
|
|
fingerprints.add(
|
|
DeviceFingerprint(
|
|
device,
|
|
await ratchet.fingerprint,
|
|
),
|
|
);
|
|
}
|
|
|
|
return fingerprints;
|
|
}
|
|
|
|
/// Returns the device used for encryption and decryption.
|
|
Future<OmemoDevice> getDevice() => _deviceLock.synchronized(() => _device);
|
|
|
|
/// Returns the id of the device used for encryption and decryption.
|
|
Future<int> getDeviceId() async => (await getDevice()).id;
|
|
|
|
@visibleForTesting
|
|
OmemoDoubleRatchet? getRatchet(RatchetMapKey key) => _ratchetMap[key];
|
|
|
|
/// Replaces the OPK with id [opkId] and commits the new device to storage. This
|
|
/// function should not be called. It's only useful for rotating OPKs after message
|
|
/// catch-up, because in that case the OPKs are not rotated automatically.
|
|
Future<void> replaceOnetimePrekey(int opkId) async {
|
|
await _deviceLock.synchronized(() async {
|
|
// Replace OPK
|
|
await _device.replaceOnetimePrekey(opkId);
|
|
|
|
// Commit the device
|
|
await commitDevice(_device);
|
|
});
|
|
}
|
|
|
|
/// Replaces the SPK of our device and commits it to storage.
|
|
Future<void> replaceSignedPrekey() async {
|
|
await _deviceLock.synchronized(() async {
|
|
// Replace SPK
|
|
await _device.replaceSignedPrekey();
|
|
|
|
// Commit the device
|
|
await commitDevice(_device);
|
|
});
|
|
}
|
|
|
|
/// Acquire a lock for interacting with the trust manager for modifying the trust
|
|
/// state of [jid]. [callback] is called from within the critical section with the
|
|
/// trust manager as its parameter.
|
|
Future<void> withTrustManager(
|
|
String jid,
|
|
Future<void> Function(TrustManager) callback,
|
|
) async {
|
|
await _ratchetQueue.synchronized(
|
|
[jid],
|
|
() => callback(_trustManager),
|
|
);
|
|
}
|
|
}
|