import 'dart:async'; import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:cryptography/cryptography.dart'; import 'package:hex/hex.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.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/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/events.dart'; import 'package:omemo_dart/src/omemo/fingerprint.dart'; import 'package:omemo_dart/src/omemo/ratchet_map_key.dart'; import 'package:omemo_dart/src/protobuf/omemo_authenticated_message.dart'; import 'package:omemo_dart/src/protobuf/omemo_key_exchange.dart'; import 'package:omemo_dart/src/protobuf/omemo_message.dart'; import 'package:omemo_dart/src/trust/base.dart'; import 'package:omemo_dart/src/x3dh/x3dh.dart'; import 'package:synchronized/synchronized.dart'; @Deprecated('Use OmemoManager instead') class OmemoSessionManager { @Deprecated('Use OmemoManager instead') OmemoSessionManager(this._device, this._deviceMap, this._ratchetMap, this._trustManager) : _lock = Lock(), _deviceLock = Lock(), _eventStreamController = StreamController.broadcast(), _log = Logger('OmemoSessionManager'); /// Deserialise the OmemoSessionManager from JSON data [data] that does not contain /// the ratchet sessions. @Deprecated('Use OmemoManager instead') factory OmemoSessionManager.fromJsonWithoutSessions( Map data, Map ratchetMap, TrustManager trustManager, ) { // NOTE: Dart has some issues with just casting a List to List>, as // such we need to convert the items by hand. return OmemoSessionManager( OmemoDevice.fromJson(data['device']! as Map), (data['devices']! as Map).map>( (key, value) { return MapEntry( key, (value as List).map((i) => i as int).toList(), ); } ), ratchetMap, trustManager, ); } /// Generate a new cryptographic identity. static Future generateNewIdentity(String jid, TrustManager trustManager, { int opkAmount = 100 }) async { assert(opkAmount > 0, 'opkAmount must be bigger than 0.'); final device = await OmemoDevice.generateNewDevice(jid, opkAmount: opkAmount); return OmemoSessionManager(device, {}, {}, trustManager); } /// Logging Logger _log; /// Lock for _ratchetMap and _bundleMap final Lock _lock; /// Mapping of the Device Id to its OMEMO session final Map _ratchetMap; /// Mapping of a bare Jid to its Device Ids final Map> _deviceMap; /// The event bus of the session manager final StreamController _eventStreamController; /// Our own keys... // ignore: prefer_final_fields OmemoDevice _device; /// and its lock final Lock _deviceLock; /// The trust manager final TrustManager _trustManager; TrustManager get trustManager => _trustManager; /// A stream that receives events regarding the session Stream get eventStream => _eventStreamController.stream; /// Returns our own device. Future getDevice() async { return _deviceLock.synchronized(() => _device); } /// Returns the id attribute of our own device. This is just a short-hand for /// ```await (session.getDevice()).id```. Future getDeviceId() async { return _deviceLock.synchronized(() => _device.id); } /// Returns the device as an OmemoBundle. This is just a short-hand for /// ```await (await session.getDevice()).toBundle()```. Future getDeviceBundle() async { return _deviceLock.synchronized(() async => _device.toBundle()); } /// Add a session [ratchet] with the [deviceId] to the internal tracking state. Future _addSession(String jid, int deviceId, OmemoDoubleRatchet ratchet) async { await _lock.synchronized(() async { // Add the bundle Id if (!_deviceMap.containsKey(jid)) { _deviceMap[jid] = [deviceId]; // Commit the device map _eventStreamController.add(DeviceListModifiedEvent(_deviceMap)); } else { // Prevent having the same device multiple times in the list if (!_deviceMap[jid]!.contains(deviceId)) { _deviceMap[jid]!.add(deviceId); // Commit the device map _eventStreamController.add(DeviceListModifiedEvent(_deviceMap)); } } // Add the ratchet session final key = RatchetMapKey(jid, deviceId); _ratchetMap[key] = ratchet; // Commit the ratchet _eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet, true, false)); }); } /// Create a ratchet session initiated by Alice to the user with Jid [jid] and the device /// [deviceId] from the bundle [bundle]. @visibleForTesting Future addSessionFromBundle(String jid, int deviceId, OmemoBundle bundle) async { final device = await getDevice(); final kexResult = await x3dhFromBundle( bundle, device.ik, ); final ratchet = await OmemoDoubleRatchet.initiateNewSession( bundle.spk, bundle.ik, kexResult.sk, kexResult.ad, getTimestamp(), ); await _trustManager.onNewSession(jid, deviceId); await _addSession(jid, deviceId, ratchet); return OmemoKeyExchange() ..pkId = kexResult.opkId ..spkId = bundle.spkId ..ik = await device.ik.pk.getBytes() ..ek = await kexResult.ek.pk.getBytes(); } /// Build a new session with the user at [jid] with the device [deviceId] using data /// from the key exchange [kex]. In case [kex] contains an unknown Signed Prekey /// identifier an UnknownSignedPrekeyException will be thrown. Future _addSessionFromKeyExchange(String jid, int deviceId, OmemoKeyExchange kex) async { // Pick the correct SPK final device = await getDevice(); final spk = await _lock.synchronized(() async { if (kex.spkId == _device.spkId) { return _device.spk; } else if (kex.spkId == _device.oldSpkId) { return _device.oldSpk; } return null; }); if (spk == null) { throw UnknownSignedPrekeyException(); } final kexResult = await x3dhFromInitialMessage( X3DHMessage( OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519), OmemoPublicKey.fromBytes(kex.ek!, KeyPairType.x25519), kex.pkId!, ), spk, device.opks.values.elementAt(kex.pkId!), device.ik, ); final ratchet = await OmemoDoubleRatchet.acceptNewSession( spk, OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519), kexResult.sk, kexResult.ad, getTimestamp(), ); return ratchet; } /// Like [encryptToJids] but only for one Jid [jid]. Future encryptToJid(String jid, String? plaintext, { List? newSessions }) { return encryptToJids([jid], plaintext, newSessions: newSessions); } /// Encrypt the key [plaintext] for all known bundles of the Jids in [jids]. Returns a /// map that maps the device Id to the ciphertext of [plaintext]. /// /// If [plaintext] is null, then the result will be an empty OMEMO message, i.e. one that /// does not contain a element. This means that the ciphertext attribute of /// the result will be null as well. Future encryptToJids(List jids, String? plaintext, { List? newSessions }) async { final encryptedKeys = List.empty(growable: true); var ciphertext = const []; var keyPayload = const []; if (plaintext != null) { // Generate the key and encrypt the plaintext final key = generateRandomBytes(32); final keys = await deriveEncryptionKeys(key, omemoPayloadInfoString); ciphertext = await aes256CbcEncrypt( utf8.encode(plaintext), keys.encryptionKey, keys.iv, ); final hmac = await truncatedHmac(ciphertext, keys.authenticationKey); keyPayload = concat([key, hmac]); } else { keyPayload = List.filled(32, 0x0); } final kex = {}; if (newSessions != null) { for (final newSession in newSessions) { kex[newSession.id] = await addSessionFromBundle( newSession.jid, newSession.id, newSession, ); } } await _lock.synchronized(() async { // We assume that the user already checked if the session exists for (final jid in jids) { for (final deviceId in _deviceMap[jid]!) { // Empty OMEMO messages are allowed to bypass trust if (plaintext != null) { // Only encrypt to devices that are trusted if (!(await _trustManager.isTrusted(jid, deviceId))) continue; // Onyl encrypt to devices that are enabled if (!(await _trustManager.isEnabled(jid, deviceId))) continue; } final ratchetKey = RatchetMapKey(jid, deviceId); var ratchet = _ratchetMap[ratchetKey]!; final ciphertext = (await ratchet.ratchetEncrypt(keyPayload)).ciphertext; if (kex.isNotEmpty && kex.containsKey(deviceId)) { // The ratchet did not exist final k = kex[deviceId]! ..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); final buffer = base64.encode(k.writeToBuffer()); encryptedKeys.add( EncryptedKey( jid, deviceId, buffer, true, ), ); ratchet = ratchet.cloneWithKex(buffer); _ratchetMap[ratchetKey] = ratchet; } else if (!ratchet.acknowledged) { // The ratchet exists but is not acked if (ratchet.kex != null) { final oldKex = OmemoKeyExchange.fromBuffer(base64.decode(ratchet.kex!)) ..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); encryptedKeys.add( EncryptedKey( jid, deviceId, base64.encode(oldKex.writeToBuffer()), true, ), ); } else { // The ratchet is not acked but we don't have the old key exchange _log.warning('Ratchet for $jid:$deviceId is not acked but the kex attribute is null'); encryptedKeys.add( EncryptedKey( jid, deviceId, base64.encode(ciphertext), false, ), ); } } else { // The ratchet exists and is acked encryptedKeys.add( EncryptedKey( jid, deviceId, base64.encode(ciphertext), false, ), ); } // Commit the ratchet _eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet, false, false)); } } }); return EncryptionResult( plaintext != null ? ciphertext : null, encryptedKeys, const {}, const {}, ); } /// In case a decryption error occurs, the Double Ratchet spec says to just restore /// the ratchet to its old state. As such, this function restores the ratchet at /// [mapKey] with [oldRatchet]. Future _restoreRatchet(RatchetMapKey mapKey, OmemoDoubleRatchet oldRatchet) async { await _lock.synchronized(() { _log.finest('Restoring ratchet ${mapKey.jid}:${mapKey.deviceId} to ${oldRatchet.nr}'); _ratchetMap[mapKey] = oldRatchet; // Commit the ratchet _eventStreamController.add( RatchetModifiedEvent( mapKey.jid, mapKey.deviceId, oldRatchet, false, false, ), ); }); } Future _decryptAndVerifyHmac(List? ciphertext, List keyAndHmac) async { // Empty OMEMO messages should just have the key decrypted and/or session set up. if (ciphertext == null) { return 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)) { throw InvalidMessageHMACException(); } return utf8.decode( await aes256CbcDecrypt(ciphertext, derivedKeys.encryptionKey, derivedKeys.iv), ); } /// Attempt to decrypt [ciphertext]. [keys] refers to the elements inside the /// element with a "jid" attribute matching our own. [senderJid] refers to the /// bare Jid of the sender. [senderDeviceId] refers to the "sid" attribute of the /// element. /// [timestamp] refers to the time the message was sent. This might be either what the /// server tells you via "XEP-0203: Delayed Delivery" or the point in time at which /// you received the stanza, if no Delayed Delivery element was found. /// /// If the received message is an empty OMEMO message, i.e. there is no /// element, then [ciphertext] must be set to null. In this case, this function /// will return null as there is no message to be decrypted. This, however, is used /// to set up sessions or advance the ratchets. Future decryptMessage(List? ciphertext, String senderJid, int senderDeviceId, List keys, int timestamp) async { // Try to find a session we can decrypt with. var device = await getDevice(); final rawKey = keys.firstWhereOrNull((key) => key.rid == device.id); if (rawKey == null) { throw NotEncryptedForDeviceException(); } final ratchetKey = RatchetMapKey(senderJid, senderDeviceId); final decodedRawKey = base64.decode(rawKey.value); List? keyAndHmac; OmemoAuthenticatedMessage authMessage; OmemoDoubleRatchet? oldRatchet; OmemoMessage? message; if (rawKey.kex) { // If the ratchet already existed, we store it. If it didn't, oldRatchet will stay // null. final oldRatchet = (await _getRatchet(ratchetKey))?.clone(); final kex = OmemoKeyExchange.fromBuffer(decodedRawKey); authMessage = kex.message!; message = OmemoMessage.fromBuffer(authMessage.message!); // Guard against old key exchanges if (oldRatchet != null) { _log.finest('KEX for existent ratchet. ${oldRatchet.pn}'); if (oldRatchet.kexTimestamp > timestamp) { throw InvalidKeyExchangeException(); } // Try to decrypt it try { final decrypted = await oldRatchet.ratchetDecrypt(message, authMessage.writeToBuffer()); // Commit the ratchet _eventStreamController.add( RatchetModifiedEvent( senderJid, senderDeviceId, oldRatchet, false, false, ), ); final plaintext = await _decryptAndVerifyHmac( ciphertext, decrypted, ); await _addSession(senderJid, senderDeviceId, oldRatchet); return plaintext; } catch (_) { _log.finest('Failed to use old ratchet with KEX for existing ratchet'); } } final r = await _addSessionFromKeyExchange(senderJid, senderDeviceId, kex); await _trustManager.onNewSession(senderJid, senderDeviceId); await _addSession(senderJid, senderDeviceId, r); // Replace the OPK // TODO(PapaTutuWawa): Replace the OPK when we know that the KEX worked await _deviceLock.synchronized(() async { device = await device.replaceOnetimePrekey(kex.pkId!); // Commit the device _eventStreamController.add(DeviceModifiedEvent(device)); }); } else { authMessage = OmemoAuthenticatedMessage.fromBuffer(decodedRawKey); message = OmemoMessage.fromBuffer(authMessage.message!); } final devices = _deviceMap[senderJid]; if (devices == null) { throw NoDecryptionKeyException(); } if (!devices.contains(senderDeviceId)) { throw NoDecryptionKeyException(); } // We can guarantee that the ratchet exists at this point in time final ratchet = (await _getRatchet(ratchetKey))!; oldRatchet ??= ratchet.clone(); try { if (rawKey.kex) { keyAndHmac = await ratchet.ratchetDecrypt(message, authMessage.writeToBuffer()); } else { keyAndHmac = await ratchet.ratchetDecrypt(message, decodedRawKey); } } catch (_) { await _restoreRatchet(ratchetKey, oldRatchet); rethrow; } // Commit the ratchet _eventStreamController.add( RatchetModifiedEvent( senderJid, senderDeviceId, ratchet, false, false, ), ); try { return _decryptAndVerifyHmac(ciphertext, keyAndHmac); } catch (_) { await _restoreRatchet(ratchetKey, oldRatchet); rethrow; } } /// Returns the list of hex-encoded fingerprints we have for sessions with [jid]. Future> getHexFingerprintsForJid(String jid) async { final fingerprints = List.empty(growable: true); await _lock.synchronized(() async { // Get devices for jid final devices = _deviceMap[jid] ?? []; for (final deviceId in devices) { final ratchet = _ratchetMap[RatchetMapKey(jid, deviceId)]!; fingerprints.add( DeviceFingerprint( deviceId, HEX.encode(await ratchet.ik.getBytes()), ), ); } }); return fingerprints; } /// Returns the hex-encoded fingerprint of the current device. Future getHexFingerprintForDevice() async { final device = await getDevice(); return DeviceFingerprint( device.id, HEX.encode(await device.ik.pk.getBytes()), ); } /// Replaces the Signed Prekey and its signature in our own device bundle. Triggers /// a DeviceModifiedEvent when done. /// See https://xmpp.org/extensions/xep-0384.html#protocol-key_exchange under the point /// "signed PreKey rotation period" for recommendations. Future rotateSignedPrekey() async { await _deviceLock.synchronized(() async { _device = await _device.replaceSignedPrekey(); // Commit the new device _eventStreamController.add(DeviceModifiedEvent(_device)); }); } /// Returns the device map, i.e. the mapping of bare Jid to its device identifiers /// we have built sessions with. Future>> getDeviceMap() async { return _lock.synchronized(() => _deviceMap); } /// Removes the ratchet identified by [jid] and [deviceId] from the session manager. /// Also triggers events for commiting the new device map to storage and removing /// the old ratchet. Future removeRatchet(String jid, int deviceId) async { await _lock.synchronized(() async { // Remove the ratchet _ratchetMap.remove(RatchetMapKey(jid, deviceId)); // Commit it _eventStreamController.add(RatchetRemovedEvent(jid, deviceId)); // Remove the device from jid _deviceMap[jid]!.remove(deviceId); if (_deviceMap[jid]!.isEmpty) { _deviceMap.remove(jid); } // Commit it _eventStreamController.add(DeviceListModifiedEvent(_deviceMap)); }); } /// Removes all ratchets for Jid [jid]. Triggers a DeviceMapModified event at the end and an /// RatchetRemovedEvent for each ratchet. Future removeAllRatchets(String jid) async { await _lock.synchronized(() async { for (final deviceId in _deviceMap[jid]!) { // Remove the ratchet _ratchetMap.remove(RatchetMapKey(jid, deviceId)); // Commit it _eventStreamController.add(RatchetRemovedEvent(jid, deviceId)); } // Remove the device from jid _deviceMap.remove(jid); // Commit it _eventStreamController.add(DeviceListModifiedEvent(_deviceMap)); }); } /// Returns the list of device identifiers belonging to [jid] that are yet unacked, i.e. /// we have not yet received an empty OMEMO message from. Future?> getUnacknowledgedRatchets(String jid) async { return _lock.synchronized(() async { final ret = List.empty(growable: true); final devices = _deviceMap[jid]; if (devices == null) return null; for (final device in devices) { final ratchet = _ratchetMap[RatchetMapKey(jid, device)]!; if (!ratchet.acknowledged) ret.add(device); } return ret; }); } /// Returns true if the ratchet for [jid] with device identifier [deviceId] is /// acknowledged. Returns false if not. Future isRatchetAcknowledged(String jid, int deviceId) async { return _lock.synchronized(() => _ratchetMap[RatchetMapKey(jid, deviceId)]!.acknowledged); } /// Mark the ratchet for device [deviceId] from [jid] as acked. Future ratchetAcknowledged(String jid, int deviceId) async { await _lock.synchronized(() async { final ratchet = _ratchetMap[RatchetMapKey(jid, deviceId)]! ..acknowledged = true; // Commit it _eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet, false, false)); }); } /// Generates an entirely new device. May be useful when the user wants to reset their cryptographic /// identity. Triggers an event to commit it to storage. Future regenerateDevice({ int opkAmount = 100 }) async { await _deviceLock.synchronized(() async { _device = await OmemoDevice.generateNewDevice(_device.jid, opkAmount: opkAmount); // Commit it _eventStreamController.add(DeviceModifiedEvent(_device)); }); } /// Make our device have a new identifier. Only useful before publishing it as a bundle /// to make sure that our device has a id that is account unique. Future regenerateDeviceId() async { await _deviceLock.synchronized(() async { _device = _device.withNewId(); // Commit it _eventStreamController.add(DeviceModifiedEvent(_device)); }); } Future _getRatchet(RatchetMapKey key) async { return _lock.synchronized(() async { return _ratchetMap[key]; }); } @visibleForTesting OmemoDoubleRatchet getRatchet(String jid, int deviceId) => _ratchetMap[RatchetMapKey(jid, deviceId)]!; @visibleForTesting Map getRatchetMap() => _ratchetMap; /// Serialise the entire session manager into a JSON object. Future> toJsonWithoutSessions() async { /* { 'devices': { 'alice@...': [1, 2, ...], 'bob@...': [1], ... }, 'device': { ... }, } */ return { 'devices': _deviceMap, 'device': await (await getDevice()).toJson(), }; } }