diff --git a/CHANGELOG.md b/CHANGELOG.md index 210eb12..74a727d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,3 +54,14 @@ ## 0.4.3 - Fix bug that causes ratchets to be unable to decrypt anything after receiving a heartbeat with a completely new session + +## 0.5.0 + +This version is a complete rework of omemo_dart! + +- Removed events from `OmemoManager` +- Removed `OmemoSessionManager` +- Removed serialization/deserialization code +- Replace exceptions with errors inside a result type +- Ratchets and trust data is now loaded and cached on demand +- Accessing the trust manager must happen via `withTrustManager` \ No newline at end of file diff --git a/README.md b/README.md index 38631fd..65529ef 100644 --- a/README.md +++ b/README.md @@ -28,22 +28,32 @@ Include `omemo_dart` in your `pubspec.yaml` like this: dependencies: omemo_dart: hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub - version: ^0.4.3 + version: ^0.5.0 # [...] # [...] ``` -## Contributing +### Example -Due to issues with `protobuf`, `omemo_dart` reimplements the Protobuf encoding for the required -OMEMO messages. As such, `protobuf` is only a dependency for testing that the serialisation and -deserialisation of the custom implementation. In order to run tests, you need the Protbuf -compiler. After that, making sure that -the [Dart Protobuf compiler addon](https://pub.dev/packages/protoc_plugin) and the -Protobuf compiler itself is in your PATH, -run `protoc -I=./protobuf/ --dart_out=lib/protobuf/ ./protobuf/schema.proto` in the -repository's root to generate the real Protobuf bindings. +This repository includes a documented ["example"](./example/omemo_dart_example.dart) that explains the basic usage of the library while +leaving out the XMPP specific bits. For a more functional and integrated example, see the `omemo_client.dart` example from +[moxxmpp](https://codeberg.org/moxxy/moxxmpp). + +### Persistence + +By default, `omemo_dart` uses in-memory implementations for everything. For a real-world application, this is unsuitable as OMEMO devices would be constantly added. +In order to allow persistence, your application needs to keep track of the following: + +- The `OmemoDevice` assigned to the `OmemoManager` +- `JID -> [int]`: The device list for each JID +- `(JID, device) -> Ratchet`: The actual ratchet + +If you also use the `BlindTrustBeforeVerificationTrustManager`, you additionally need to keep track of: + +- `(JID, device) -> (int, bool)`: The trust level and the enablement state + +## Contributing When submitting a PR, please run the linter using `dart analyze` and make sure that all tests still pass using `dart test`. diff --git a/analysis_options.yaml b/analysis_options.yaml index beb23d6..b5d1d46 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,8 +9,5 @@ linter: analyzer: exclude: - - "lib/protobuf/*.dart" - # TODO: Remove once OmemoSessionManager is gone - - "test/omemo_test.dart" + - "lib/src/protobuf/*.dart" - "example/omemo_dart_example.dart" - - "test/serialisation_test.dart" diff --git a/example/omemo_dart_example.dart b/example/omemo_dart_example.dart index 405d465..2a9d741 100644 --- a/example/omemo_dart_example.dart +++ b/example/omemo_dart_example.dart @@ -35,6 +35,10 @@ void main() async { // This needs to be wired into your XMPP library's OMEMO implementation. // For simplicity, we use an empty function and imagine it works. (jid) async {}, + // This function is called whenever our own device bundle has to be republished to our PEP node. + // This needs to be wired into your XMPP library's OMEMO implementation. + // For simplicity, we use an empty function and imagine it works. + (device) async {}, ); // Alice now wants to chat with Bob at his bare Jid "bob@other.server". To make things @@ -42,7 +46,7 @@ void main() async { // request it using PEP and then convert the device bundle into a OmemoBundle object. final bobManager = OmemoManager( await OmemoDevice.generateNewDevice(bobJid), - MemoryBTBVTrustManager(), + BlindTrustBeforeVerificationTrustManager(), (result, recipient) async => {}, (jid) async => [], (jid, id) async => null, @@ -145,6 +149,11 @@ void main() async { /// The text of the element, if it exists. If not, then the message might be /// a hearbeat, where no payload is sent. In that case, use null. payload, + + /// Since we did not receive this message due to a catch-up mechanism, like MAM, we + /// set this to false. If we, however, did use a catch-up mechanism, we must set this + /// to true to prevent the OPKs from being replaced. + false, ), ); diff --git a/lib/omemo_dart.dart b/lib/omemo_dart.dart index f0f9677..acc9b7a 100644 --- a/lib/omemo_dart.dart +++ b/lib/omemo_dart.dart @@ -8,9 +8,10 @@ export 'src/omemo/bundle.dart'; export 'src/omemo/device.dart'; export 'src/omemo/encrypted_key.dart'; export 'src/omemo/encryption_result.dart'; -export 'src/omemo/events.dart'; +export 'src/omemo/errors.dart'; export 'src/omemo/fingerprint.dart'; -export 'src/omemo/omemomanager.dart'; +export 'src/omemo/omemo.dart'; +export 'src/omemo/ratchet_data.dart'; export 'src/omemo/ratchet_map_key.dart'; export 'src/omemo/stanza.dart'; export 'src/trust/base.dart'; diff --git a/lib/src/common/constants.dart b/lib/src/common/constants.dart new file mode 100644 index 0000000..8b0fcbd --- /dev/null +++ b/lib/src/common/constants.dart @@ -0,0 +1,11 @@ +/// The overarching assumption is that we use Ed25519 keys for the identity keys +const omemoX3DHInfoString = 'OMEMO X3DH'; + +/// The info used for when encrypting the AES key for the actual payload. +const omemoPayloadInfoString = 'OMEMO Payload'; + +/// Info string for ENCRYPT +const encryptHkdfInfoString = 'OMEMO Message Key Material'; + +/// Amount of messages we may skip per session +const maxSkip = 1000; diff --git a/lib/src/crypto.dart b/lib/src/crypto.dart index ce421c2..7010b00 100644 --- a/lib/src/crypto.dart +++ b/lib/src/crypto.dart @@ -1,5 +1,7 @@ import 'dart:convert'; import 'package:cryptography/cryptography.dart'; +import 'package:moxlib/moxlib.dart'; +import 'package:omemo_dart/src/errors.dart'; import 'package:omemo_dart/src/keys.dart'; /// Performs X25519 with [kp] and [pk]. If [identityKey] is set, then @@ -92,7 +94,7 @@ Future> aes256CbcEncrypt( /// A small helper function to make AES-256-CBC easier. Decrypt [ciphertext] using [key] as /// the encryption key and [iv] as the IV. Returns the ciphertext. -Future> aes256CbcDecrypt( +Future>> aes256CbcDecrypt( List ciphertext, List key, List iv, @@ -100,13 +102,19 @@ Future> aes256CbcDecrypt( final algorithm = AesCbc.with256bits( macAlgorithm: MacAlgorithm.empty, ); - return algorithm.decrypt( - NoMacSecretBox( - ciphertext, - nonce: iv, - ), - secretKey: SecretKey(key), - ); + try { + return Result( + await algorithm.decrypt( + NoMacSecretBox( + ciphertext, + nonce: iv, + ), + secretKey: SecretKey(key), + ), + ); + } catch (ex) { + return Result(MalformedCiphertextError(ex)); + } } /// OMEMO often uses the output of a HMAC-SHA-256 truncated to its first 16 bytes. diff --git a/lib/src/double_ratchet/crypto.dart b/lib/src/double_ratchet/crypto.dart deleted file mode 100644 index 42033ed..0000000 --- a/lib/src/double_ratchet/crypto.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:omemo_dart/src/crypto.dart'; -import 'package:omemo_dart/src/errors.dart'; -import 'package:omemo_dart/src/helpers.dart'; -import 'package:omemo_dart/src/protobuf/omemo_authenticated_message.dart'; -import 'package:omemo_dart/src/protobuf/omemo_message.dart'; - -/// Info string for ENCRYPT -const encryptHkdfInfoString = 'OMEMO Message Key Material'; - -/// Signals ENCRYPT function as specified by OMEMO 0.8.3. -/// Encrypt [plaintext] using the message key [mk], given associated_data [associatedData] -/// and the AD output from the X3DH [sessionAd]. -Future> encrypt( - List mk, - List plaintext, - List associatedData, - List sessionAd, -) async { - // Generate encryption, authentication key and IV - final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString); - final ciphertext = - await aes256CbcEncrypt(plaintext, keys.encryptionKey, keys.iv); - - final header = - OmemoMessage.fromBuffer(associatedData.sublist(sessionAd.length)) - ..ciphertext = ciphertext; - final headerBytes = header.writeToBuffer(); - final hmacInput = concat([sessionAd, headerBytes]); - final hmacResult = await truncatedHmac(hmacInput, keys.authenticationKey); - final message = OmemoAuthenticatedMessage() - ..mac = hmacResult - ..message = headerBytes; - return message.writeToBuffer(); -} - -/// Signals DECRYPT function as specified by OMEMO 0.8.3. -/// Decrypt [ciphertext] with the message key [mk], given the associated_data [associatedData] -/// and the AD output from the X3DH. -Future> decrypt( - List mk, - List ciphertext, - List associatedData, - List sessionAd, -) async { - // Generate encryption, authentication key and IV - final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString); - - // Assumption ciphertext is a OMEMOAuthenticatedMessage - final message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); - final header = OmemoMessage.fromBuffer(message.message!); - - final hmacInput = concat([sessionAd, header.writeToBuffer()]); - final hmacResult = await truncatedHmac(hmacInput, keys.authenticationKey); - - if (!listsEqual(hmacResult, message.mac!)) { - throw InvalidMessageHMACException(); - } - - return aes256CbcDecrypt(header.ciphertext!, keys.encryptionKey, keys.iv); -} diff --git a/lib/src/double_ratchet/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index b997d4a..2a3a204 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -1,47 +1,24 @@ -import 'dart:convert'; import 'package:cryptography/cryptography.dart'; import 'package:hex/hex.dart'; import 'package:meta/meta.dart'; +import 'package:moxlib/moxlib.dart'; +import 'package:omemo_dart/src/common/constants.dart'; import 'package:omemo_dart/src/crypto.dart'; -import 'package:omemo_dart/src/double_ratchet/crypto.dart'; import 'package:omemo_dart/src/double_ratchet/kdf.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/protobuf/omemo_message.dart'; - -/// Amount of messages we may skip per session -const maxSkip = 1000; - -class RatchetStep { - const RatchetStep(this.header, this.ciphertext); - final OmemoMessage header; - final List ciphertext; -} +import 'package:omemo_dart/src/protobuf/schema.pb.dart'; @immutable class SkippedKey { const SkippedKey(this.dh, this.n); - factory SkippedKey.fromJson(Map data) { - return SkippedKey( - OmemoPublicKey.fromBytes( - base64.decode(data['public']! as String), - KeyPairType.x25519, - ), - data['n']! as int, - ); - } - + /// The DH public key for which we skipped a message key. final OmemoPublicKey dh; - final int n; - Future> toJson() async { - return { - 'public': base64.encode(await dh.getBytes()), - 'n': n, - }; - } + /// The associated number of the message key we skipped. + final int n; @override bool operator ==(Object other) { @@ -52,6 +29,28 @@ class SkippedKey { int get hashCode => dh.hashCode ^ n.hashCode; } +@immutable +class KeyExchangeData { + const KeyExchangeData( + this.pkId, + this.spkId, + this.ik, + this.ek, + ); + + /// The id of the used OPK. + final int pkId; + + /// The id of the used SPK. + final int spkId; + + /// The ephemeral key used while the key exchange. + final OmemoPublicKey ek; + + /// The identity key used in the key exchange. + final OmemoPublicKey ik; +} + class OmemoDoubleRatchet { OmemoDoubleRatchet( this.dhs, // DHs @@ -66,77 +65,9 @@ class OmemoDoubleRatchet { this.sessionAd, this.mkSkipped, // MKSKIPPED this.acknowledged, - this.kexTimestamp, this.kex, ); - factory OmemoDoubleRatchet.fromJson(Map data) { - /* - { - 'dhs': 'base/64/encoded', - 'dhs_pub': 'base/64/encoded', - 'dhr': null | 'base/64/encoded', - 'rk': 'base/64/encoded', - 'cks': null | 'base/64/encoded', - 'ckr': null | 'base/64/encoded', - 'ns': 0, - 'nr': 0, - 'pn': 0, - 'ik_pub': null | 'base/64/encoded', - 'session_ad': 'base/64/encoded', - 'acknowledged': true | false, - 'kex_timestamp': int, - 'kex': 'base/64/encoded', - 'mkskipped': [ - { - 'key': 'base/64/encoded', - 'public': 'base/64/encoded', - 'n': 0 - }, ... - ] - } - */ - // NOTE: Dart has some issues with just casting a List to List>, as - // such we need to convert the items by hand. - final mkSkipped = Map>.fromEntries( - (data['mkskipped']! as List) - .map>>( - (entry) { - final map = entry as Map; - final key = SkippedKey.fromJson(map); - return MapEntry( - key, - base64.decode(map['key']! as String), - ); - }, - ), - ); - - return OmemoDoubleRatchet( - OmemoKeyPair.fromBytes( - base64.decode(data['dhs_pub']! as String), - base64.decode(data['dhs']! as String), - KeyPairType.x25519, - ), - decodeKeyIfNotNull(data, 'dhr', KeyPairType.x25519), - base64.decode(data['rk']! as String), - base64DecodeIfNotNull(data, 'cks'), - base64DecodeIfNotNull(data, 'ckr'), - data['ns']! as int, - data['nr']! as int, - data['pn']! as int, - OmemoPublicKey.fromBytes( - base64.decode(data['ik_pub']! as String), - KeyPairType.ed25519, - ), - base64.decode(data['session_ad']! as String), - mkSkipped, - data['acknowledged']! as bool, - data['kex_timestamp']! as int, - data['kex'] as String?, - ); - } - /// Sending DH keypair OmemoKeyPair dhs; @@ -161,16 +92,14 @@ class OmemoDoubleRatchet { /// for verification purposes final OmemoPublicKey ik; + /// Associated data for this ratchet. final List sessionAd; + /// List of skipped message keys. final Map> mkSkipped; - /// The point in time at which we performed the kex exchange to create this ratchet. - /// Precision is milliseconds since epoch. - int kexTimestamp; - /// The key exchange that was used for initiating the session. - final String? kex; + final KeyExchangeData kex; /// Indicates whether we received an empty OMEMO message after building a session with /// the device. @@ -181,21 +110,22 @@ class OmemoDoubleRatchet { /// a X3DH. [ik] refers to Bob's (the receiver's) IK public key. static Future initiateNewSession( OmemoPublicKey spk, + int spkId, OmemoPublicKey ik, + OmemoPublicKey ownIk, + OmemoPublicKey ek, List sk, List ad, - int timestamp, + int pkId, ) async { final dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); - final dhr = spk; - final rk = await kdfRk(sk, await omemoDH(dhs, dhr, 0)); - final cks = rk; + final rk = await kdfRk(sk, await omemoDH(dhs, spk, 0)); return OmemoDoubleRatchet( dhs, - dhr, - rk, - cks, + spk, + List.from(rk), + List.from(rk), null, 0, 0, @@ -204,8 +134,12 @@ class OmemoDoubleRatchet { ad, {}, false, - timestamp, - '', + KeyExchangeData( + pkId, + spkId, + ownIk, + ek, + ), ); } @@ -215,10 +149,12 @@ class OmemoDoubleRatchet { /// Alice's (the initiator's) IK public key. static Future acceptNewSession( OmemoKeyPair spk, + int spkId, OmemoPublicKey ik, + int pkId, + OmemoPublicKey ek, List sk, List ad, - int kexTimestamp, ) async { return OmemoDoubleRatchet( spk, @@ -233,72 +169,52 @@ class OmemoDoubleRatchet { ad, {}, true, - kexTimestamp, - null, + KeyExchangeData( + pkId, + spkId, + ik, + ek, + ), ); } - Future> toJson() async { - final mkSkippedSerialised = - List>.empty(growable: true); - for (final entry in mkSkipped.entries) { - final result = await entry.key.toJson(); - result['key'] = base64.encode(entry.value); - - mkSkippedSerialised.add(result); - } - - return { - 'dhs': base64.encode(await dhs.sk.getBytes()), - 'dhs_pub': base64.encode(await dhs.pk.getBytes()), - 'dhr': dhr != null ? base64.encode(await dhr!.getBytes()) : null, - 'rk': base64.encode(rk), - 'cks': cks != null ? base64.encode(cks!) : null, - 'ckr': ckr != null ? base64.encode(ckr!) : null, - 'ns': ns, - 'nr': nr, - 'pn': pn, - 'ik_pub': base64.encode(await ik.getBytes()), - 'session_ad': base64.encode(sessionAd), - 'mkskipped': mkSkippedSerialised, - 'acknowledged': acknowledged, - 'kex_timestamp': kexTimestamp, - 'kex': kex, - }; - } - - /// Returns the OMEMO compatible fingerprint of the ratchet session. - Future getOmemoFingerprint() async { - final curveKey = await ik.toCurve25519(); - return HEX.encode(await curveKey.getBytes()); - } - - Future?> _trySkippedMessageKeys( - OmemoMessage header, - List ciphertext, - ) async { - final key = SkippedKey( - OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519), - header.n!, + /// Performs a single ratchet step in case we received a new + /// public key in [header]. + Future _dhRatchet(OMEMOMessage header) async { + pn = ns; + ns = 0; + nr = 0; + dhr = OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519); + final newRk1 = await kdfRk( + rk, + await omemoDH( + dhs, + dhr!, + 0, + ), ); - if (mkSkipped.containsKey(key)) { - final mk = mkSkipped[key]!; - mkSkipped.remove(key); + rk = List.from(newRk1); + ckr = List.from(newRk1); - return decrypt( - mk, - ciphertext, - concat([sessionAd, header.writeToBuffer()]), - sessionAd, - ); - } - - return null; + dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); + final newRk2 = await kdfRk( + rk, + await omemoDH( + dhs, + dhr!, + 0, + ), + ); + rk = List.from(newRk2); + cks = List.from(newRk2); } - Future _skipMessageKeys(int until) async { + /// Skip (and keep track of) message keys until our receive counter is + /// equal to [until]. If we would skip too many messages, returns + /// a [SkippingTooManyKeysError]. If not, returns null. + Future _skipMessageKeys(int until) async { if (nr + maxSkip < until) { - throw SkippingTooManyMessagesException(); + return SkippingTooManyKeysError(); } if (ckr != null) { @@ -306,88 +222,140 @@ class OmemoDoubleRatchet { final newCkr = await kdfCk(ckr!, kdfCkNextChainKey); final mk = await kdfCk(ckr!, kdfCkNextMessageKey); ckr = newCkr; + mkSkipped[SkippedKey(dhr!, nr)] = mk; nr++; } } + + return null; } - Future _dhRatchet(OmemoMessage header) async { - pn = ns; - ns = 0; - nr = 0; - dhr = OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519); - - final newRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0)); - rk = List.from(newRk); - ckr = List.from(newRk); - dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); - final newNewRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0)); - rk = List.from(newNewRk); - cks = List.from(newNewRk); - } - - /// Encrypt [plaintext] using the Double Ratchet. - Future ratchetEncrypt(List plaintext) async { - final newCks = await kdfCk(cks!, kdfCkNextChainKey); - final mk = await kdfCk(cks!, kdfCkNextMessageKey); - - cks = newCks; - final header = OmemoMessage() - ..dhPub = await dhs.pk.getBytes() - ..pn = pn - ..n = ns; - - ns++; - - return RatchetStep( - header, - await encrypt( - mk, - plaintext, - concat([sessionAd, header.writeToBuffer()]), - sessionAd, - ), - ); - } - - /// Decrypt a [ciphertext] that was sent with the header [header] using the Double - /// Ratchet. Returns the decrypted (raw) plaintext. + /// Decrypt [ciphertext] using keys derived from the message key [mk]. Also computes the + /// HMAC from the [OMEMOMessage] embedded in [message]. /// - /// Throws an SkippingTooManyMessagesException if too many messages were to be skipped. - Future> ratchetDecrypt( - OmemoMessage header, + /// If the computed HMAC does not match the HMAC in [message], returns + /// [InvalidMessageHMACError]. If it matches, returns the decrypted + /// payload. + Future>> _decrypt( + OMEMOAuthenticatedMessage message, List ciphertext, + List mk, ) async { - // Check if we skipped too many messages - final plaintext = await _trySkippedMessageKeys(header, ciphertext); - if (plaintext != null) { - return plaintext; + final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString); + + final hmacInput = concat([sessionAd, message.message]); + final hmacResult = await truncatedHmac(hmacInput, keys.authenticationKey); + if (!listsEqual(hmacResult, message.mac)) { + return Result(InvalidMessageHMACError()); } - final dhPubMatches = listsEqual( - header.dhPub!, - (await dhr?.getBytes()) ?? [], + final plaintext = + await aes256CbcDecrypt(ciphertext, keys.encryptionKey, keys.iv); + if (plaintext.isType()) { + return Result(plaintext.get()); + } + + return Result(plaintext.get>()); + } + + /// Checks whether we could decrypt the payload in [header] with a skipped key. If yes, + /// attempts to decrypt it. If not, returns null. + /// + /// If the decryption is successful, returns the plaintext payload. If an error occurs, like + /// an [InvalidMessageHMACError], that is returned instead. + Future?>> _trySkippedMessageKeys( + OMEMOAuthenticatedMessage message, + OMEMOMessage header, + ) async { + final key = SkippedKey( + OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519), + header.n, ); - if (!dhPubMatches) { - await _skipMessageKeys(header.pn!); + if (mkSkipped.containsKey(key)) { + final mk = mkSkipped[key]!; + mkSkipped.remove(key); + + return _decrypt(message, header.ciphertext, mk); + } + + return const Result(null); + } + + /// Decrypt the payload (deeply) embedded in [message]. + /// + /// If everything goes well, returns the plaintext payload. If an error occurs, that + /// is returned instead. + Future>> ratchetDecrypt( + OMEMOAuthenticatedMessage message, + ) async { + final header = OMEMOMessage.fromBuffer(message.message); + + // Try skipped keys + final plaintextRaw = await _trySkippedMessageKeys(message, header); + if (plaintextRaw.isType()) { + // Propagate the error + return Result(plaintextRaw.get()); + } + + final plaintext = plaintextRaw.get?>(); + if (plaintext != null) { + return Result(plaintext); + } + + if (dhr == null || !listsEqual(header.dhPub, await dhr!.getBytes())) { + final skipResult1 = await _skipMessageKeys(header.pn); + if (skipResult1 != null) { + return Result(skipResult1); + } + await _dhRatchet(header); } - await _skipMessageKeys(header.n!); - final newCkr = await kdfCk(ckr!, kdfCkNextChainKey); + final skipResult2 = await _skipMessageKeys(header.n); + if (skipResult2 != null) { + return Result(skipResult2); + } + + final ck = await kdfCk(ckr!, kdfCkNextChainKey); final mk = await kdfCk(ckr!, kdfCkNextMessageKey); - ckr = newCkr; + ckr = ck; nr++; - return decrypt( - mk, - ciphertext, - concat([sessionAd, header.writeToBuffer()]), - sessionAd, - ); + return _decrypt(message, header.ciphertext, mk); } + /// Encrypt the payload [plaintext] using the double ratchet session. + Future ratchetEncrypt(List plaintext) async { + // Advance the ratchet + final ck = await kdfCk(cks!, kdfCkNextChainKey); + final mk = await kdfCk(cks!, kdfCkNextMessageKey); + cks = ck; + + // Generate encryption, authentication key and IV + final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString); + final ciphertext = + await aes256CbcEncrypt(plaintext, keys.encryptionKey, keys.iv); + + // Fill-in the header and serialize it here so we do it only once + final header = OMEMOMessage() + ..dhPub = await dhs.pk.getBytes() + ..pn = pn + ..n = ns + ..ciphertext = ciphertext; + final headerBytes = header.writeToBuffer(); + + // Increment the send counter + ns++; + + final newAd = concat([sessionAd, headerBytes]); + final hmac = await truncatedHmac(newAd, keys.authenticationKey); + return OMEMOAuthenticatedMessage() + ..mac = hmac + ..message = headerBytes; + } + + /// Returns a copy of the ratchet. OmemoDoubleRatchet clone() { return OmemoDoubleRatchet( dhs, @@ -402,27 +370,16 @@ class OmemoDoubleRatchet { sessionAd, Map>.from(mkSkipped), acknowledged, - kexTimestamp, kex, ); } - OmemoDoubleRatchet cloneWithKex(String kex) { - return OmemoDoubleRatchet( - dhs, - dhr, - rk, - cks != null ? List.from(cks!) : null, - ckr != null ? List.from(ckr!) : null, - ns, - nr, - pn, - ik, - sessionAd, - Map>.from(mkSkipped), - acknowledged, - kexTimestamp, - kex, + /// Computes the fingerprint of the double ratchet, according to + /// XEP-0384. + Future get fingerprint async { + final curveKey = await ik.toCurve25519(); + return HEX.encode( + await curveKey.getBytes(), ); } @@ -454,7 +411,6 @@ class OmemoDoubleRatchet { ns == other.ns && nr == other.nr && pn == other.pn && - listsEqual(sessionAd, other.sessionAd) && - kexTimestamp == other.kexTimestamp; + listsEqual(sessionAd, other.sessionAd); } } diff --git a/lib/src/double_ratchet/kdf.dart b/lib/src/double_ratchet/kdf.dart index 751e208..719368c 100644 --- a/lib/src/double_ratchet/kdf.dart +++ b/lib/src/double_ratchet/kdf.dart @@ -8,7 +8,7 @@ const kdfRkInfoString = 'OMEMO Root Chain'; const kdfCkNextMessageKey = 0x01; const kdfCkNextChainKey = 0x02; -/// Signals KDF_CK function as specified by OMEMO 0.8.0. +/// Signals KDF_CK function as specified by OMEMO 0.8.3. Future> kdfCk(List ck, int constant) async { final hkdf = Hkdf(hmac: Hmac(Sha256()), outputLength: 32); final result = await hkdf.deriveKey( @@ -19,7 +19,7 @@ Future> kdfCk(List ck, int constant) async { return result.extractBytes(); } -/// Signals KDF_RK function as specified by OMEMO 0.8.0. +/// Signals KDF_RK function as specified by OMEMO 0.8.3. Future> kdfRk(List rk, List dhOut) async { final algorithm = Hkdf( hmac: Hmac(Sha256()), diff --git a/lib/src/errors.dart b/lib/src/errors.dart index 7a4b854..0925a16 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -1,61 +1,39 @@ -abstract class OmemoException {} +abstract class OmemoError {} /// Triggered during X3DH if the signature if the SPK does verify to the actual SPK. -class InvalidSignatureException extends OmemoException implements Exception { - String errMsg() => - 'The signature of the SPK does not match the provided signature'; -} +class InvalidKeyExchangeSignatureError extends OmemoError {} /// Triggered by the Double Ratchet if the computed HMAC does not match the attached HMAC. -/// Triggered by the Session Manager if the computed HMAC does not match the attached HMAC. -class InvalidMessageHMACException extends OmemoException implements Exception { - String errMsg() => 'The computed HMAC does not match the provided HMAC'; -} +class InvalidMessageHMACError extends OmemoError {} /// Triggered by the Double Ratchet if skipping messages would cause skipping more than /// MAXSKIP messages -class SkippingTooManyMessagesException extends OmemoException - implements Exception { - String errMsg() => 'Skipping messages would cause a skip bigger than MAXSKIP'; -} +class SkippingTooManyKeysError extends OmemoError {} /// Triggered by the Session Manager if the message key is not encrypted for the device. -class NotEncryptedForDeviceException extends OmemoException - implements Exception { - String errMsg() => 'Not encrypted for this device'; -} - -/// Triggered by the Session Manager when there is no key for decrypting the message. -class NoDecryptionKeyException extends OmemoException implements Exception { - String errMsg() => 'No key available for decrypting the message'; -} +class NotEncryptedForDeviceError extends OmemoError {} /// Triggered by the Session Manager when the identifier of the used Signed Prekey /// is neither the current SPK's identifier nor the old one's. -class UnknownSignedPrekeyException extends OmemoException implements Exception { - String errMsg() => 'Unknown Signed Prekey used.'; -} - -/// Triggered by the Session Manager when the received Key Exchange message does not meet -/// the requirement that a key exchange, given that the ratchet already exists, must be -/// sent after its creation. -class InvalidKeyExchangeException extends OmemoException implements Exception { - String errMsg() => 'The key exchange was sent before the last kex finished'; -} - -/// Triggered by the Session Manager when a message's sequence number is smaller than we -/// expect it to be. -class MessageAlreadyDecryptedException extends OmemoException - implements Exception { - String errMsg() => 'The message has already been decrypted'; -} +class UnknownSignedPrekeyError extends OmemoError {} /// Triggered by the OmemoManager when we could not encrypt a message as we have /// no key material available. That happens, for example, when we want to create a /// ratchet session with a JID we had no session with but fetching the device bundle /// failed. -class NoKeyMaterialAvailableException extends OmemoException - implements Exception { - String errMsg() => - 'No key material available to create a ratchet session with'; +class NoKeyMaterialAvailableError extends OmemoError {} + +/// A non-key-exchange message was received that was encrypted for our device, but we have no ratchet with +/// the device that sent the message. +class NoSessionWithDeviceError extends OmemoError {} + +/// Caused when the AES-256 CBC decryption failed. +class MalformedCiphertextError extends OmemoError { + MalformedCiphertextError(this.ex); + + /// The exception that was raised while decryption. + final Object ex; } + +/// Caused by an empty element +class MalformedEncryptedKeyError extends OmemoError {} diff --git a/lib/src/helpers.dart b/lib/src/helpers.dart index 4f23632..a99531f 100644 --- a/lib/src/helpers.dart +++ b/lib/src/helpers.dart @@ -1,7 +1,5 @@ import 'dart:convert'; import 'dart:math'; -import 'package:cryptography/cryptography.dart'; -import 'package:omemo_dart/src/keys.dart'; /// Flattens [inputs] and concatenates the elements. List concat(List> inputs) { @@ -43,41 +41,35 @@ int generateRandom32BitNumber() { return Random.secure().nextInt(4294967295 /*pow(2, 32) - 1*/); } -OmemoPublicKey? decodeKeyIfNotNull( - Map map, - String key, - KeyPairType type, -) { - if (map[key] == null) return null; +/// Describes the differences between two lists in terms of its items. +class ListDiff { + ListDiff(this.added, this.removed); - return OmemoPublicKey.fromBytes( - base64.decode(map[key]! as String), - type, - ); + /// The items that were added. + final List added; + + /// The items that were removed. + final List removed; } -List? base64DecodeIfNotNull(Map map, String key) { - if (map[key] == null) return null; - - return base64.decode(map[key]! as String); +extension AppendToListOrCreateExtension on Map> { + /// Create or append [value] to the list identified with key [key]. + void appendOrCreate(K key, V value, {bool checkExistence = false}) { + if (containsKey(key)) { + if (!checkExistence) { + this[key]!.add(value); + } + if (!this[key]!.contains(value)) { + this[key]!.add(value); + } + } else { + this[key] = [value]; + } + } } -String? base64EncodeIfNotNull(List? bytes) { - if (bytes == null) return null; - - return base64.encode(bytes); -} - -OmemoKeyPair? decodeKeyPairIfNotNull(String? pk, String? sk, KeyPairType type) { - if (pk == null || sk == null) return null; - - return OmemoKeyPair.fromBytes( - base64.decode(pk), - base64.decode(sk), - type, - ); -} - -int getTimestamp() { - return DateTime.now().millisecondsSinceEpoch; +extension StringFromBase64Extension on String { + /// Base64-decode this string. Useful for doing `someString?.fromBase64()` instead + /// of `someString != null ? base64Decode(someString) : null`. + List fromBase64() => base64Decode(this); } diff --git a/lib/src/omemo/constants.dart b/lib/src/omemo/constants.dart deleted file mode 100644 index 31e2fe6..0000000 --- a/lib/src/omemo/constants.dart +++ /dev/null @@ -1,2 +0,0 @@ -/// The info used for when encrypting the AES key for the actual payload. -const omemoPayloadInfoString = 'OMEMO Payload'; diff --git a/lib/src/omemo/decryption_result.dart b/lib/src/omemo/decryption_result.dart index 5b020e1..d865e23 100644 --- a/lib/src/omemo/decryption_result.dart +++ b/lib/src/omemo/decryption_result.dart @@ -3,7 +3,15 @@ import 'package:omemo_dart/src/errors.dart'; @immutable class DecryptionResult { - const DecryptionResult(this.payload, this.error); + const DecryptionResult(this.payload, this.usedOpkId, this.error); + + /// The decrypted payload or null, if it was an empty OMEMO message. final String? payload; - final OmemoException? error; + + /// In case a key exchange has been performed: The id of the used OPK. Useful for + /// replacing the OPK after a message catch-up. + final int? usedOpkId; + + /// The error that occurred during decryption or null, if no error occurred. + final OmemoError? error; } diff --git a/lib/src/omemo/device.dart b/lib/src/omemo/device.dart index 354c795..c944fca 100644 --- a/lib/src/omemo/device.dart +++ b/lib/src/omemo/device.dart @@ -22,76 +22,6 @@ class OmemoDevice { this.opks, ); - /// Deserialize the Device - factory OmemoDevice.fromJson(Map data) { - // NOTE: We use the way OpenSSH names their keys, meaning that ik is the Identity - // Keypair's private key, while ik_pub refers to the Identity Keypair's public - // key. - /* - { - 'jid': 'alice@...', - 'id': 123, - 'ik': 'base/64/encoded', - 'ik_pub': 'base/64/encoded', - 'spk': 'base/64/encoded', - 'spk_pub': 'base/64/encoded', - 'spk_id': 123, - 'spk_sig': 'base/64/encoded', - 'old_spk': 'base/64/encoded', - 'old_spk_pub': 'base/64/encoded', - 'old_spk_id': 122, - 'opks': [ - { - 'id': 0, - 'public': 'base/64/encoded', - 'private': 'base/64/encoded' - }, ... - ] - } - */ - // NOTE: Dart has some issues with just casting a List to List>, as - // such we need to convert the items by hand. - final opks = Map.fromEntries( - (data['opks']! as List).map>( - (opk) { - final map = opk as Map; - return MapEntry( - map['id']! as int, - OmemoKeyPair.fromBytes( - base64.decode(map['public']! as String), - base64.decode(map['private']! as String), - KeyPairType.x25519, - ), - ); - }, - ), - ); - - return OmemoDevice( - data['jid']! as String, - data['id']! as int, - OmemoKeyPair.fromBytes( - base64.decode(data['ik_pub']! as String), - base64.decode(data['ik']! as String), - KeyPairType.ed25519, - ), - OmemoKeyPair.fromBytes( - base64.decode(data['spk_pub']! as String), - base64.decode(data['spk']! as String), - KeyPairType.x25519, - ), - data['spk_id']! as int, - base64.decode(data['spk_sig']! as String), - decodeKeyPairIfNotNull( - data['old_spk_pub'] as String?, - data['old_spk'] as String?, - KeyPairType.x25519, - ), - data['old_spk_id'] as int?, - opks, - ); - } - /// Generate a completely new device, i.e. cryptographic identity. static Future generateNewDevice( String jid, { @@ -105,7 +35,16 @@ class OmemoDevice { final opks = {}; for (var i = 0; i < opkAmount; i++) { - opks[i] = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); + // Generate unique ids for each key + while (true) { + final opkId = generateRandom32BitNumber(); + if (opks.containsKey(opkId)) { + continue; + } + + opks[opkId] = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); + break; + } } return OmemoDevice(jid, id, ik, spk, spkId, signature, null, null, opks); @@ -142,7 +81,18 @@ class OmemoDevice { /// a new Device object that copies over everything but replaces said key. @internal Future replaceOnetimePrekey(int id) async { - opks[id] = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); + opks.remove(id); + + // Generate a new unique id for the OPK. + while (true) { + final newId = generateRandom32BitNumber(); + if (opks.containsKey(newId)) { + continue; + } + + opks[newId] = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); + break; + } return OmemoDevice( jid, @@ -221,34 +171,6 @@ class OmemoDevice { return HEX.encode(await curveKey.getBytes()); } - /// Serialise the device information. - Future> toJson() async { - /// Serialise the OPKs - final serialisedOpks = List>.empty(growable: true); - for (final entry in opks.entries) { - serialisedOpks.add({ - 'id': entry.key, - 'public': base64.encode(await entry.value.pk.getBytes()), - 'private': base64.encode(await entry.value.sk.getBytes()), - }); - } - - return { - 'jid': jid, - 'id': id, - 'ik': base64.encode(await ik.sk.getBytes()), - 'ik_pub': base64.encode(await ik.pk.getBytes()), - 'spk': base64.encode(await spk.sk.getBytes()), - 'spk_pub': base64.encode(await spk.pk.getBytes()), - 'spk_id': spkId, - 'spk_sig': base64.encode(spkSignature), - 'old_spk': base64EncodeIfNotNull(await oldSpk?.sk.getBytes()), - 'old_spk_pub': base64EncodeIfNotNull(await oldSpk?.pk.getBytes()), - 'old_spk_id': oldSpkId, - 'opks': serialisedOpks, - }; - } - @visibleForTesting Future equals(OmemoDevice other) async { var opksMatch = true; diff --git a/lib/src/omemo/encrypted_key.dart b/lib/src/omemo/encrypted_key.dart index d1fc4f3..2e1e1eb 100644 --- a/lib/src/omemo/encrypted_key.dart +++ b/lib/src/omemo/encrypted_key.dart @@ -1,12 +1,23 @@ +import 'dart:convert'; + import 'package:meta/meta.dart'; /// EncryptedKey is the intermediary format of a element in the OMEMO message's /// header. @immutable class EncryptedKey { - const EncryptedKey(this.jid, this.rid, this.value, this.kex); - final String jid; + const EncryptedKey(this.rid, this.value, this.kex); + + /// The id of the device the key is encrypted for. final int rid; + + /// The base64-encoded payload. final String value; + + /// Flag indicating whether the payload is a OMEMOKeyExchange (true) or + /// an OMEMOAuthenticatedMessage (false). final bool kex; + + /// The base64-decoded payload. + List get data => base64Decode(value); } diff --git a/lib/src/omemo/encryption_result.dart b/lib/src/omemo/encryption_result.dart index cb2494a..639b757 100644 --- a/lib/src/omemo/encryption_result.dart +++ b/lib/src/omemo/encryption_result.dart @@ -1,7 +1,6 @@ import 'package:meta/meta.dart'; -import 'package:omemo_dart/src/errors.dart'; import 'package:omemo_dart/src/omemo/encrypted_key.dart'; -import 'package:omemo_dart/src/omemo/ratchet_map_key.dart'; +import 'package:omemo_dart/src/omemo/errors.dart'; @immutable class EncryptionResult { @@ -9,7 +8,7 @@ class EncryptionResult { this.ciphertext, this.encryptedKeys, this.deviceEncryptionErrors, - this.jidEncryptionErrors, + this.canSend, ); /// The actual message that was encrypted. @@ -17,17 +16,12 @@ class EncryptionResult { /// Mapping of the device Id to the key for decrypting ciphertext, encrypted /// for the ratchet with said device Id. - final List encryptedKeys; + final Map> encryptedKeys; - /// Mapping of a ratchet map keys to a possible exception. - final Map deviceEncryptionErrors; + /// Mapping of a JID to + final Map> deviceEncryptionErrors; - /// Mapping of a JID to a possible exception. - final Map jidEncryptionErrors; - - /// True if the encryption was a success. This means that we could encrypt for - /// at least one ratchet. - bool isSuccess(int numberOfRecipients) => - encryptedKeys.isNotEmpty && - jidEncryptionErrors.length < numberOfRecipients; + /// A flag indicating that the message could be sent like that, i.e. we were able + /// to encrypt to at-least one device per recipient. + final bool canSend; } diff --git a/lib/src/omemo/errors.dart b/lib/src/omemo/errors.dart new file mode 100644 index 0000000..1c773f8 --- /dev/null +++ b/lib/src/omemo/errors.dart @@ -0,0 +1,12 @@ +import 'package:omemo_dart/src/errors.dart'; + +/// Returned on encryption, if encryption failed for some reason. +class EncryptToJidError extends OmemoError { + EncryptToJidError(this.device, this.error); + + /// The device the error occurred with + final int? device; + + /// The actual error. + final OmemoError error; +} diff --git a/lib/src/omemo/events.dart b/lib/src/omemo/events.dart deleted file mode 100644 index 5999125..0000000 --- a/lib/src/omemo/events.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart'; -import 'package:omemo_dart/src/omemo/device.dart'; - -abstract class OmemoEvent {} - -/// Triggered when a ratchet has been modified -class RatchetModifiedEvent extends OmemoEvent { - RatchetModifiedEvent( - this.jid, - this.deviceId, - this.ratchet, - this.added, - this.replaced, - ); - final String jid; - final int deviceId; - final OmemoDoubleRatchet ratchet; - - /// Indicates whether the ratchet has just been created (true) or just modified (false). - final bool added; - - /// Indicates whether the ratchet has been replaced (true) or not. - final bool replaced; -} - -/// Triggered when a ratchet has been removed and should be removed from storage. -class RatchetRemovedEvent extends OmemoEvent { - RatchetRemovedEvent(this.jid, this.deviceId); - final String jid; - final int deviceId; -} - -/// Triggered when the device map has been modified -class DeviceListModifiedEvent extends OmemoEvent { - DeviceListModifiedEvent(this.list); - final Map> list; -} - -/// Triggered by the OmemoSessionManager when our own device bundle was modified -/// and thus should be republished. -class DeviceModifiedEvent extends OmemoEvent { - DeviceModifiedEvent(this.device); - final OmemoDevice device; -} diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart new file mode 100644 index 0000000..1a7fef5 --- /dev/null +++ b/lib/src/omemo/omemo.dart @@ -0,0 +1,1040 @@ +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:moxlib/moxlib.dart'; +import 'package:omemo_dart/src/common/constants.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/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 devices; + + /// The ratchets for the JID. + final Map ratchets; +} + +/// Callback type definitions + +/// Directly "package" [result] into an OMEMO message and send it to [recipientJid]. +typedef SendEmptyOmemoMessageFunction = Future Function( + EncryptionResult result, + String recipientJid, +); + +/// Fetches the device list for [jid]. If no device list could be fetched, returns null. +typedef FetchDeviceListFunction = Future?> 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 Function( + String jid, + int id, +); + +/// Subscribes to the device list node of [jid]. +typedef DeviceListSubscribeFunction = Future Function(String jid); + +/// Publishes the device bundle on our own PEP node. +typedef PublishDeviceBundleFunction = Future Function(OmemoDevice device); + +/// Commits the device list [devices] for [jid] to persistent storage. +typedef CommitDeviceListCallback = Future Function( + String jid, + List devices, +); + +/// A stub implementation of [CommitDeviceListCallback]. +Future commitDeviceListStub( + String _, + List __, +) async {} + +/// Commits the mapping of the (new) ratchets in [ratchets] to persistent storage. +typedef CommitRatchetsCallback = Future Function( + List ratchets, +); + +/// A stub implementation of [CommitRatchetsCallback]; +Future commitRatchetsStub(List _) async {} + +/// Commits the device [device] to persistent storage. +typedef CommitDeviceCallback = Future Function(OmemoDevice device); + +/// A stub implementation of [CommitDeviceCallback]. +Future commitDeviceStub(OmemoDevice device) async {} + +/// Removes the ratchets identified by their keys in [ratchets] from persistent storage. +typedef RemoveRatchetsFunction = Future Function( + List ratchets, +); + +/// A stub implementation of [RemoveRatchetsFunction]. +Future removeRatchetsStub(List ratchets) async {} + +/// Loads all the required data for the ratchets of [jid]. +typedef LoadRatchetsCallback = Future Function(String jid); + +/// A stub implementation of [LoadRatchetsCallback]. +Future loadRatchetsStub(String _) async => null; + +class OmemoManager { + OmemoManager( + this._device, + this._trustManager, + this.sendEmptyOmemoMessageImpl, + this.fetchDeviceListImpl, + this.fetchDeviceBundleImpl, + this.subscribeToDeviceListNodeImpl, + this.publishDeviceBundle, { + 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; + + /// Publishes the device bundle on the PEP node. + final PublishDeviceBundleFunction publishDeviceBundle; + + /// 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> _deviceList = {}; + + /// Map bare JIDs to whether we already requested the device list once + final Map _deviceListRequested = {}; + + /// Map bare a ratchet key to its ratchet. Note that this is also locked by + /// _ratchetCriticalSectionLock. + final Map _ratchetMap = {}; + + /// Map bare JID to whether we already tried to subscribe to the device list node. + final Map _subscriptionMap = {}; + + /// List of JIDs for which we cached trust data, the device list, and the ratchets. + final List _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 _cacheJidsIfNeccessary(List jids) async { + for (final jid in jids) { + await _cacheJidIfNeccessary(jid); + } + } + + Future _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> _decryptAndVerifyHmac( + List? ciphertext, + List 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()) { + return Result( + result.get(), + ); + } + + return Result( + utf8.decode( + result.get>(), + ), + ); + } + + /// 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> _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 = []; + 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 _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 onIncomingStanza(OmemoIncomingStanza stanza) async { + return _ratchetQueue.synchronized( + [stanza.bareSenderJid], + () => _onIncomingStanzaImpl(stanza), + ); + } + + Future _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, + ); + + final keyAndHmac = await ratchet.ratchetDecrypt( + kexMessage.message, + ); + if (keyAndHmac.isType()) { + final error = keyAndHmac.get(); + _log.warning('Failed to decrypt symmetric key: $error'); + + return DecryptionResult(null, null, error); + } + + Result result; + if (stanza.payload != null) { + result = await _decryptAndVerifyHmac( + stanza.payload?.fromBase64(), + keyAndHmac.get>(), + ); + if (result.isType()) { + final error = result.get(); + _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() == 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); + + // Publish the device bundle + unawaited( + publishDeviceBundle(_device), + ); + }); + } + + // Send the hearbeat, if we have to + await _maybeSendEmptyMessage( + ratchetKey, + true, + _ratchetMap.containsKey(ratchetKey), + ); + + return DecryptionResult( + result.get(), + 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()) { + final error = keyAndHmac.get(); + _log.warning('Failed to decrypt symmetric key: $error'); + return DecryptionResult(null, null, error); + } + + Result result; + if (stanza.payload != null) { + result = await _decryptAndVerifyHmac( + stanza.payload?.fromBase64(), + keyAndHmac.get>(), + ); + if (result.isType()) { + final error = result.get(); + _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() == 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(), + null, + null, + ); + } + } + + Future onOutgoingStanza(OmemoOutgoingStanza stanza) async { + return _ratchetQueue.synchronized( + stanza.recipientJids, + () => _onOutgoingStanzaImpl(stanza), + ); + } + + Future _onOutgoingStanzaImpl( + OmemoOutgoingStanza stanza, + ) async { + // Populate the cache + await _cacheJidsIfNeccessary(stanza.recipientJids); + + // Encrypt the payload, if we have any + final List payloadKey; + final List? 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.filled(32, 0x0); + ciphertext = null; + } + + final successfulEncryptions = Map.fromEntries( + stanza.recipientJids.map((jid) => MapEntry(jid, 0)), + ); + final encryptionErrors = >{}; + final addedRatchetKeys = List.empty(growable: true); + final kex = {}; + 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()) { + encryptionErrors.appendOrCreate( + jid, + EncryptToJidError( + bundle.id, + kexResultRaw.get(), + ), + ); + continue; + } + + final kexResult = kexResultRaw.get(); + final newRatchet = await OmemoDoubleRatchet.initiateNewSession( + bundle.spk, + bundle.spkId, + bundle.ik, + ownDevice.ik.pk, + kexResult.ek.pk, + kexResult.sk, + kexResult.ad, + 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 = >{}; + 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 sendOmemoHeartbeat(String jid) async { + await _ratchetQueue.synchronized( + [jid], + () => _sendOmemoHeartbeat(jid), + ); + } + + /// Like [sendOmemoHeartbeat], but does not acquire the lock for [jid]. + Future _sendOmemoHeartbeat(String jid) async { + final result = await _onOutgoingStanzaImpl( + OmemoOutgoingStanza( + [jid], + null, + ), + ); + await sendEmptyOmemoMessageImpl(result, jid); + } + + /// Removes all ratchets associated with [jid]. + Future removeAllRatchets(String jid) async { + await _ratchetQueue.synchronized( + [jid], + () async { + // Remove the ratchet and commit + final keys = (_deviceList[jid] ?? []) + .map((device) => RatchetMapKey(jid, device)); + for (final key in keys) { + _ratchetMap.remove(key); + } + await removeRatchets(keys.toList()); + + // Tell the trust manager + await _trustManager.removeTrustDecisionsForJid(jid); + + // Clear the device list + await commitDeviceList( + 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 onDeviceListUpdate(String jid, List devices) async { + await _ratchetQueue.synchronized( + [jid], + () async { + // Update our state + _deviceList[jid] = devices; + _deviceListRequested[jid] = true; + + // Commit the device list + await commitDeviceList(jid, devices); + }, + ); + } + + /// 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 onNewConnection() async { + _deviceListRequested.clear(); + _subscriptionMap.clear(); + } + + // Mark the ratchet [jid]:[device] as acknowledged. + Future ratchetAcknowledged(String jid, int device) async { + await _ratchetQueue.synchronized( + [jid], + () => _ratchetAcknowledged(jid, device), + ); + } + + /// Like [ratchetAcknowledged], but does not acquire the lock for [jid]. + Future _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?> getFingerprintsForJid(String jid) async { + return _ratchetQueue.synchronized( + [jid], + () => _getFingerprintsForJidImpl(jid), + ); + } + + /// Same as [getFingerprintsForJid], but without acquiring the lock for [jid]. + Future?> _getFingerprintsForJidImpl( + String jid, + ) async { + // Check if we know of the JID. + if (!_deviceList.containsKey(jid)) { + return null; + } + + final devices = _deviceList[jid]!; + final fingerprints = List.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 getDevice() => _deviceLock.synchronized(() => _device); + + /// Returns the id of the device used for encryption and decryption. + Future 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 replaceOnetimePrekey(int opkId) async { + await _deviceLock.synchronized(() async { + // Replace OPK + await _device.replaceOnetimePrekey(opkId); + + // Commit the device + await commitDevice(_device); + + // Publish + unawaited( + publishDeviceBundle(_device), + ); + }); + } + + /// Replaces the SPK of our device and commits it to storage. + Future replaceSignedPrekey() async { + await _deviceLock.synchronized(() async { + // Replace SPK + await _device.replaceSignedPrekey(); + + // Commit the device + await commitDevice(_device); + }); + } + + /// Generates a completely new device to use. + Future regenerateDevice() async { + // Generate the new device + final oldDevice = await getDevice(); + final newDevice = await OmemoDevice.generateNewDevice( + oldDevice.jid, + opkAmount: oldDevice.opks.length, + ); + + await _deviceLock.synchronized(() async { + // Replace the old device + _device = newDevice; + + // Commit + await commitDevice(newDevice); + + // Publish + unawaited( + publishDeviceBundle(newDevice), + ); + }); + + return newDevice; + } + + /// 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 withTrustManager( + String jid, + Future Function(TrustManager) callback, + ) async { + await _ratchetQueue.synchronized( + [jid], + () => callback(_trustManager), + ); + } +} diff --git a/lib/src/omemo/omemomanager.dart b/lib/src/omemo/omemomanager.dart deleted file mode 100644 index 20cc3e2..0000000 --- a/lib/src/omemo/omemomanager.dart +++ /dev/null @@ -1,854 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -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/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/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/omemo/stanza.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'; - -class _InternalDecryptionResult { - const _InternalDecryptionResult( - this.ratchetCreated, - this.ratchetReplaced, - this.payload, - ) : assert( - !ratchetCreated || !ratchetReplaced, - 'Ratchet must be either replaced or created', - ); - final bool ratchetCreated; - final bool ratchetReplaced; - final String? payload; -} - -class OmemoManager { - OmemoManager( - this._device, - this._trustManager, - this.sendEmptyOmemoMessageImpl, - this.fetchDeviceListImpl, - this.fetchDeviceBundleImpl, - this.subscribeToDeviceListNodeImpl, - ); - - 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 Future Function(EncryptionResult result, String recipientJid) - sendEmptyOmemoMessageImpl; - - /// Fetch the list of device ids associated with @jid. If the device list cannot be - /// fetched, return null. - final Future?> Function(String jid) fetchDeviceListImpl; - - /// Fetch the device bundle for the device with id @id of jid. If it cannot be fetched, return null. - final Future Function(String jid, int id) fetchDeviceBundleImpl; - - /// Subscribe to the device list PEP node of @jid. - final Future Function(String jid) subscribeToDeviceListNodeImpl; - - /// Map bare JID to its known devices - Map> _deviceList = {}; - - /// Map bare JIDs to whether we already requested the device list once - final Map _deviceListRequested = {}; - - /// Map bare a ratchet key to its ratchet. Note that this is also locked by - /// _ratchetCriticalSectionLock. - Map _ratchetMap = {}; - - /// Map bare JID to whether we already tried to subscribe to the device list node. - final Map _subscriptionMap = {}; - - /// For preventing a race condition in encryption/decryption - final Map>> _ratchetCriticalSectionQueue = {}; - final Lock _ratchetCriticalSectionLock = Lock(); - - /// The OmemoManager's trust management - final TrustManager _trustManager; - TrustManager get trustManager => _trustManager; - - /// Our own keys... - final Lock _deviceLock = Lock(); - // ignore: prefer_final_fields - OmemoDevice _device; - - /// The event bus of the session manager - final StreamController _eventStreamController = - StreamController.broadcast(); - Stream get eventStream => _eventStreamController.stream; - - /// Enter the critical section for performing cryptographic operations on the ratchets - Future _enterRatchetCriticalSection(String jid) async { - final completer = await _ratchetCriticalSectionLock.synchronized(() { - if (_ratchetCriticalSectionQueue.containsKey(jid)) { - final c = Completer(); - _ratchetCriticalSectionQueue[jid]!.addLast(c); - return c; - } - - _ratchetCriticalSectionQueue[jid] = Queue(); - return null; - }); - - if (completer != null) { - await completer.future; - } - } - - /// Leave the critical section for the ratchets. - Future _leaveRatchetCriticalSection(String jid) async { - await _ratchetCriticalSectionLock.synchronized(() { - if (_ratchetCriticalSectionQueue.containsKey(jid)) { - if (_ratchetCriticalSectionQueue[jid]!.isEmpty) { - _ratchetCriticalSectionQueue.remove(jid); - } else { - _ratchetCriticalSectionQueue[jid]!.removeFirst().complete(); - } - } - }); - } - - 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, - ), - ); - } - - /// Add a session [ratchet] with the [deviceId] to the internal tracking state. - /// NOTE: Must be called from within the ratchet critical section. - void _addSession(String jid, int deviceId, OmemoDoubleRatchet ratchet) { - // Add the bundle Id - if (!_deviceList.containsKey(jid)) { - _deviceList[jid] = [deviceId]; - - // Commit the device map - _eventStreamController.add(DeviceListModifiedEvent(_deviceList)); - } else { - // Prevent having the same device multiple times in the list - if (!_deviceList[jid]!.contains(deviceId)) { - _deviceList[jid]!.add(deviceId); - - // Commit the device map - _eventStreamController.add(DeviceListModifiedEvent(_deviceList)); - } - } - - // Add the ratchet session - final key = RatchetMapKey(jid, deviceId); - _ratchetMap[key] = ratchet; - - // Commit the ratchet - _eventStreamController - .add(RatchetModifiedEvent(jid, deviceId, ratchet, true, false)); - } - - /// 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(); - OmemoKeyPair spk; - if (kex.spkId == _device.spkId) { - spk = _device.spk; - } else if (kex.spkId == _device.oldSpkId) { - spk = _device.oldSpk!; - } else { - 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(), - ); - - // Notify the trust manager - await trustManager.onNewSession(jid, deviceId); - - return ratchet; - } - - /// 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); - _addSession(jid, deviceId, ratchet); - - return OmemoKeyExchange() - ..pkId = kexResult.opkId - ..spkId = bundle.spkId - ..ik = await device.ik.pk.getBytes() - ..ek = await kexResult.ek.pk.getBytes(); - } - - /// 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]. - /// NOTE: Must be called from within the ratchet critical section - void _restoreRatchet(RatchetMapKey mapKey, OmemoDoubleRatchet oldRatchet) { - _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, - ), - ); - } - - /// 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<_InternalDecryptionResult> _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 decodedRawKey = base64.decode(rawKey.value); - List? keyAndHmac; - OmemoAuthenticatedMessage authMessage; - OmemoMessage? message; - - // If the ratchet already existed, we store it. If it didn't, oldRatchet will stay - // null. - final ratchetKey = RatchetMapKey(senderJid, senderDeviceId); - final oldRatchet = getRatchet(ratchetKey)?.clone(); - if (rawKey.kex) { - 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 ${ratchetKey.toJsonKey()}. ${oldRatchet.kexTimestamp} > $timestamp: ${oldRatchet.kexTimestamp > timestamp}', - ); - if (oldRatchet.kexTimestamp > timestamp) { - throw InvalidKeyExchangeException(); - } - } - - final r = - await _addSessionFromKeyExchange(senderJid, senderDeviceId, kex); - - // Try to decrypt with the new ratchet r - try { - keyAndHmac = - await r.ratchetDecrypt(message, authMessage.writeToBuffer()); - final result = await _decryptAndVerifyHmac(ciphertext, keyAndHmac); - - // Add the new ratchet - _addSession(senderJid, senderDeviceId, r); - - // Replace the OPK - await _deviceLock.synchronized(() async { - device = await device.replaceOnetimePrekey(kex.pkId!); - - // Commit the device - _eventStreamController.add(DeviceModifiedEvent(device)); - }); - - // Commit the ratchet - _eventStreamController.add( - RatchetModifiedEvent( - senderJid, - senderDeviceId, - r, - oldRatchet == null, - oldRatchet != null, - ), - ); - - return _InternalDecryptionResult( - oldRatchet == null, - oldRatchet != null, - result, - ); - } catch (ex) { - _log.finest('Kex failed due to $ex. Not proceeding with kex.'); - } - } else { - authMessage = OmemoAuthenticatedMessage.fromBuffer(decodedRawKey); - message = OmemoMessage.fromBuffer(authMessage.message!); - } - - final devices = _deviceList[senderJid]; - if (devices?.contains(senderDeviceId) != true) { - throw NoDecryptionKeyException(); - } - - // TODO(PapaTutuWawa): When receiving a message that is not an OMEMOKeyExchange from a device there is no session with, clients SHOULD create a session with that device and notify it about the new session by responding with an empty OMEMO message as per Sending a message. - - // We can guarantee that the ratchet exists at this point in time - final ratchet = getRatchet(ratchetKey)!; - - try { - if (rawKey.kex) { - keyAndHmac = - await ratchet.ratchetDecrypt(message, authMessage.writeToBuffer()); - } else { - keyAndHmac = await ratchet.ratchetDecrypt(message, decodedRawKey); - } - } catch (_) { - _restoreRatchet(ratchetKey, oldRatchet!); - rethrow; - } - - // Commit the ratchet - _eventStreamController.add( - RatchetModifiedEvent( - senderJid, - senderDeviceId, - ratchet, - false, - false, - ), - ); - - try { - return _InternalDecryptionResult( - false, - false, - await _decryptAndVerifyHmac(ciphertext, keyAndHmac), - ); - } catch (_) { - _restoreRatchet(ratchetKey, oldRatchet!); - rethrow; - } - } - - /// Returns, if it exists, the ratchet associated with [key]. - /// NOTE: Must be called from within the ratchet critical section. - @visibleForTesting - OmemoDoubleRatchet? getRatchet(RatchetMapKey key) => _ratchetMap[key]; - - /// Figure out what bundles we have to still build a session with. - Future> _fetchNewBundles(String jid) async { - // Check if we already requested the device list for [jid] - List bundlesToFetch; - if (!_deviceListRequested.containsKey(jid) || - !_deviceList.containsKey(jid)) { - // We don't have an up-to-date version of the device list - final newDeviceList = await fetchDeviceListImpl(jid); - if (newDeviceList == null) return []; - - _deviceList[jid] = newDeviceList; - bundlesToFetch = newDeviceList.where((id) { - return !_ratchetMap.containsKey(RatchetMapKey(jid, id)) || - _deviceList[jid]?.contains(id) == false; - }).toList(); - - // Trigger an event with the new device list - _eventStreamController.add(DeviceListModifiedEvent(_deviceList)); - } else { - // We already have an up-to-date version of the device list - bundlesToFetch = _deviceList[jid]! - .where((id) => !_ratchetMap.containsKey(RatchetMapKey(jid, id))) - .toList(); - } - - if (bundlesToFetch.isNotEmpty) { - _log.finest('Fetching bundles $bundlesToFetch for $jid'); - } - - final device = await getDevice(); - final newBundles = List.empty(growable: true); - for (final id in bundlesToFetch) { - if (jid == device.jid && id == device.id) continue; - - final bundle = await fetchDeviceBundleImpl(jid, id); - if (bundle != null) newBundles.add(bundle); - } - - return newBundles; - } - - /// 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. - /// NOTE: Must be called within the ratchet critical section - Future _encryptToJids( - List jids, - String? plaintext, - ) 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 = {}; - for (final jid in jids) { - for (final newSession in await _fetchNewBundles(jid)) { - kex[RatchetMapKey(jid, newSession.id)] = await addSessionFromBundle( - newSession.jid, - newSession.id, - newSession, - ); - } - } - - // We assume that the user already checked if the session exists - final deviceEncryptionErrors = {}; - final jidEncryptionErrors = {}; - for (final jid in jids) { - final devices = _deviceList[jid]; - if (devices == null) { - _log.severe('Device list does not exist for $jid.'); - jidEncryptionErrors[jid] = NoKeyMaterialAvailableException(); - continue; - } - - if (!_subscriptionMap.containsKey(jid)) { - unawaited(subscribeToDeviceListNodeImpl(jid)); - _subscriptionMap[jid] = true; - } - - for (final deviceId in devices) { - // 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; - - // Only encrypt to devices that are enabled - if (!(await _trustManager.isEnabled(jid, deviceId))) continue; - } - - final ratchetKey = RatchetMapKey(jid, deviceId); - var ratchet = _ratchetMap[ratchetKey]; - if (ratchet == null) { - _log.severe('Ratchet ${ratchetKey.toJsonKey()} does not exist.'); - deviceEncryptionErrors[ratchetKey] = - NoKeyMaterialAvailableException(); - continue; - } - - final ciphertext = - (await ratchet.ratchetEncrypt(keyPayload)).ciphertext; - - if (kex.containsKey(ratchetKey)) { - // The ratchet did not exist - final k = kex[ratchetKey]! - ..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, - deviceEncryptionErrors, - jidEncryptionErrors, - ); - } - - /// Call when receiving an OMEMO:2 encrypted stanza. Will handle everything and - /// decrypt it. - Future onIncomingStanza(OmemoIncomingStanza stanza) async { - await _enterRatchetCriticalSection(stanza.bareSenderJid); - - if (!_subscriptionMap.containsKey(stanza.bareSenderJid)) { - unawaited(subscribeToDeviceListNodeImpl(stanza.bareSenderJid)); - _subscriptionMap[stanza.bareSenderJid] = true; - } - - final ratchetKey = - RatchetMapKey(stanza.bareSenderJid, stanza.senderDeviceId); - final _InternalDecryptionResult result; - try { - result = await _decryptMessage( - stanza.payload != null ? base64.decode(stanza.payload!) : null, - stanza.bareSenderJid, - stanza.senderDeviceId, - stanza.keys, - stanza.timestamp, - ); - } on OmemoException catch (ex) { - await _leaveRatchetCriticalSection(stanza.bareSenderJid); - return DecryptionResult( - null, - ex, - ); - } - - // Check if the ratchet is acked - final ratchet = getRatchet(ratchetKey); - assert( - ratchet != null, - 'We decrypted the message, so the ratchet must exist', - ); - - if (ratchet!.acknowledged) { - // Ratchet is acknowledged - if (ratchet.nr > 53 || result.ratchetCreated || result.ratchetReplaced) { - await sendEmptyOmemoMessageImpl( - await _encryptToJids( - [stanza.bareSenderJid], - null, - ), - stanza.bareSenderJid, - ); - } - - // Ratchet is acked - await _leaveRatchetCriticalSection(stanza.bareSenderJid); - return DecryptionResult( - result.payload, - null, - ); - } else { - // Ratchet is not acked. - // Mark as acked and send an empty OMEMO message. - await ratchetAcknowledged( - stanza.bareSenderJid, - stanza.senderDeviceId, - enterCriticalSection: false, - ); - await sendEmptyOmemoMessageImpl( - await _encryptToJids( - [stanza.bareSenderJid], - null, - ), - stanza.bareSenderJid, - ); - - await _leaveRatchetCriticalSection(stanza.bareSenderJid); - return DecryptionResult( - result.payload, - null, - ); - } - } - - /// Call when sending out an encrypted stanza. Will handle everything and - /// encrypt it. - Future onOutgoingStanza(OmemoOutgoingStanza stanza) async { - _log.finest('Waiting to enter critical section'); - await _enterRatchetCriticalSection(stanza.recipientJids.first); - _log.finest('Entered critical section'); - - final result = _encryptToJids( - stanza.recipientJids, - stanza.payload, - ); - - await _leaveRatchetCriticalSection(stanza.recipientJids.first); - - return result; - } - - // Sends a hearbeat message as specified by XEP-0384 to [jid]. - Future sendOmemoHeartbeat(String jid) async { - // TODO(Unknown): Include some error handling - final result = await _encryptToJids( - [jid], - null, - ); - await sendEmptyOmemoMessageImpl(result, jid); - } - - /// Mark the ratchet for device [deviceId] from [jid] as acked. - Future ratchetAcknowledged( - String jid, - int deviceId, { - bool enterCriticalSection = true, - }) async { - if (enterCriticalSection) await _enterRatchetCriticalSection(jid); - - final key = RatchetMapKey(jid, deviceId); - if (_ratchetMap.containsKey(key)) { - final ratchet = _ratchetMap[key]!..acknowledged = true; - - // Commit it - _eventStreamController - .add(RatchetModifiedEvent(jid, deviceId, ratchet, false, false)); - } else { - _log.severe( - 'Attempted to acknowledge ratchet ${key.toJsonKey()}, even though it does not exist', - ); - } - - if (enterCriticalSection) await _leaveRatchetCriticalSection(jid); - } - - /// 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() async { - await _deviceLock.synchronized(() async { - _device = await OmemoDevice.generateNewDevice(_device.jid); - - // Commit it - _eventStreamController.add(DeviceModifiedEvent(_device)); - }); - } - - /// Returns the device used for encryption and decryption. - Future getDevice() => _deviceLock.synchronized(() => _device); - - /// Returns the id of the device used for encryption and decryption. - Future getDeviceId() async => (await getDevice()).id; - - /// Directly aquire the current device as a OMEMO device bundle. - Future getDeviceBundle() async => (await getDevice()).toBundle(); - - /// Directly aquire the current device's fingerprint. - Future getDeviceFingerprint() async => - (await getDevice()).getFingerprint(); - - /// Returns the fingerprints for all devices of [jid] that we have a session with. - /// If there are not sessions with [jid], then returns null. - Future?> getFingerprintsForJid(String jid) async { - if (!_deviceList.containsKey(jid)) return null; - - await _enterRatchetCriticalSection(jid); - - final fingerprintKeys = _deviceList[jid]! - .map((id) => RatchetMapKey(jid, id)) - .where((key) => _ratchetMap.containsKey(key)); - - final fingerprints = List.empty(growable: true); - for (final key in fingerprintKeys) { - final curveKey = await _ratchetMap[key]!.ik.toCurve25519(); - fingerprints.add( - DeviceFingerprint( - key.deviceId, - HEX.encode(await curveKey.getBytes()), - ), - ); - } - - await _leaveRatchetCriticalSection(jid); - return fingerprints; - } - - /// Ensures that the device list is fetched again on the next message sending. - void onNewConnection() { - _deviceListRequested.clear(); - _subscriptionMap.clear(); - } - - /// Sets the device list for [jid] to [devices]. Triggers a DeviceListModifiedEvent. - void onDeviceListUpdate(String jid, List devices) { - _deviceList[jid] = devices; - _deviceListRequested[jid] = true; - - // Trigger an event - _eventStreamController.add(DeviceListModifiedEvent(_deviceList)); - } - - void initialize( - Map ratchetMap, - Map> deviceList, - ) { - _deviceList = deviceList; - _ratchetMap = ratchetMap; - } - - /// Removes all ratchets for JID [jid]. This also removes all trust decisions for - /// [jid] from the trust manager. This function triggers a RatchetRemovedEvent for - /// every removed ratchet and a DeviceListModifiedEvent afterwards. Behaviour for - /// the trust manager is dependent on its implementation. - Future removeAllRatchets(String jid) async { - await _enterRatchetCriticalSection(jid); - - for (final deviceId in _deviceList[jid]!) { - // Remove the ratchet and commit it - _ratchetMap.remove(RatchetMapKey(jid, deviceId)); - _eventStreamController.add(RatchetRemovedEvent(jid, deviceId)); - } - - // Remove the devices from the device list cache and commit it - _deviceList.remove(jid); - _deviceListRequested.remove(jid); - _eventStreamController.add(DeviceListModifiedEvent(_deviceList)); - - // Remove trust decisions - await _trustManager.removeTrustDecisionsForJid(jid); - - await _leaveRatchetCriticalSection(jid); - } - - /// Replaces the internal device with [newDevice]. Does not trigger an event. - Future replaceDevice(OmemoDevice newDevice) async { - await _deviceLock.synchronized(() { - _device = newDevice; - }); - } -} diff --git a/lib/src/omemo/queue.dart b/lib/src/omemo/queue.dart new file mode 100644 index 0000000..8f99df7 --- /dev/null +++ b/lib/src/omemo/queue.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:meta/meta.dart'; +import 'package:synchronized/synchronized.dart'; + +extension UtilAllMethodsList on List { + void removeAll(List values) { + for (final value in values) { + remove(value); + } + } + + bool containsAll(List values) { + for (final value in values) { + if (!contains(value)) { + return false; + } + } + + return true; + } +} + +class _RatchetAccessQueueEntry { + _RatchetAccessQueueEntry( + this.jids, + this.completer, + ); + + final List jids; + final Completer completer; +} + +class RatchetAccessQueue { + final Queue<_RatchetAccessQueueEntry> _queue = Queue(); + + @visibleForTesting + final List runningOperations = List.empty(growable: true); + + final Lock lock = Lock(); + + bool canBypass(List jids) { + for (final jid in jids) { + if (runningOperations.contains(jid)) { + return false; + } + } + + return true; + } + + Future enterCriticalSection(List jids) async { + final completer = await lock.synchronized?>(() { + if (canBypass(jids)) { + runningOperations.addAll(jids); + return null; + } + + final completer = Completer(); + _queue.add( + _RatchetAccessQueueEntry( + jids, + completer, + ), + ); + + return completer; + }); + + await completer?.future; + } + + Future leaveCriticalSection(List jids) async { + await lock.synchronized(() { + runningOperations.removeAll(jids); + + while (_queue.isNotEmpty) { + if (canBypass(_queue.first.jids)) { + final head = _queue.removeFirst(); + runningOperations.addAll(head.jids); + head.completer.complete(); + } else { + break; + } + } + }); + } + + Future synchronized( + List jids, + Future Function() function, + ) async { + await enterCriticalSection(jids); + final result = await function(); + await leaveCriticalSection(jids); + + return result; + } +} diff --git a/lib/src/omemo/ratchet_data.dart b/lib/src/omemo/ratchet_data.dart new file mode 100644 index 0000000..5574571 --- /dev/null +++ b/lib/src/omemo/ratchet_data.dart @@ -0,0 +1,26 @@ +import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart'; + +class OmemoRatchetData { + const OmemoRatchetData( + this.jid, + this.id, + this.ratchet, + this.added, + this.replaced, + ); + + /// The JID we have the ratchet with. + final String jid; + + /// The device id we have the ratchet with. + final int id; + + /// The actual double ratchet to commit. + final OmemoDoubleRatchet ratchet; + + /// Indicates whether the ratchet has just been created (true) or just modified (false). + final bool added; + + /// Indicates whether the ratchet has been replaced (true) or not. + final bool replaced; +} diff --git a/lib/src/omemo/stanza.dart b/lib/src/omemo/stanza.dart index c883887..1e751f1 100644 --- a/lib/src/omemo/stanza.dart +++ b/lib/src/omemo/stanza.dart @@ -5,9 +5,9 @@ class OmemoIncomingStanza { const OmemoIncomingStanza( this.bareSenderJid, this.senderDeviceId, - this.timestamp, this.keys, this.payload, + this.isCatchup, ); /// The bare JID of the sender of the stanza. @@ -16,14 +16,14 @@ class OmemoIncomingStanza { /// The device ID of the sender. final int senderDeviceId; - /// The timestamp when the stanza was received. - final int timestamp; - - /// The included encrypted keys + /// The included encrypted keys for our own JID final List keys; /// The string payload included in the element. final String? payload; + + /// Flag indicating whether the message was received due to a catchup. + final bool isCatchup; } /// Describes a stanza that is to be sent out @@ -37,5 +37,5 @@ class OmemoOutgoingStanza { final List recipientJids; /// The serialised XML data that should be encrypted. - final String payload; + final String? payload; } diff --git a/lib/protobuf/.gitkeep b/lib/src/protobuf/.gitkeep similarity index 100% rename from lib/protobuf/.gitkeep rename to lib/src/protobuf/.gitkeep diff --git a/lib/src/protobuf/omemo_authenticated_message.dart b/lib/src/protobuf/omemo_authenticated_message.dart deleted file mode 100644 index 3d34e5b..0000000 --- a/lib/src/protobuf/omemo_authenticated_message.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:omemo_dart/src/helpers.dart'; -import 'package:omemo_dart/src/protobuf/protobuf.dart'; - -class OmemoAuthenticatedMessage { - OmemoAuthenticatedMessage(); - - factory OmemoAuthenticatedMessage.fromBuffer(List data) { - var i = 0; - - // required bytes mac = 1; - if (data[0] != fieldId(1, fieldTypeByteArray)) { - throw Exception(); - } - final mac = data.sublist(2, i + 2 + data[1]); - i += data[1] + 2; - - if (data[i] != fieldId(2, fieldTypeByteArray)) { - throw Exception(); - } - final message = data.sublist(i + 2, i + 2 + data[i + 1]); - - return OmemoAuthenticatedMessage() - ..mac = mac - ..message = message; - } - - List? mac; - List? message; - - List writeToBuffer() { - return concat([ - [fieldId(1, fieldTypeByteArray), mac!.length], - mac!, - [fieldId(2, fieldTypeByteArray), message!.length], - message!, - ]); - } -} diff --git a/lib/src/protobuf/omemo_key_exchange.dart b/lib/src/protobuf/omemo_key_exchange.dart deleted file mode 100644 index 46ccce4..0000000 --- a/lib/src/protobuf/omemo_key_exchange.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:omemo_dart/src/helpers.dart'; -import 'package:omemo_dart/src/protobuf/omemo_authenticated_message.dart'; -import 'package:omemo_dart/src/protobuf/protobuf.dart'; - -class OmemoKeyExchange { - OmemoKeyExchange(); - - factory OmemoKeyExchange.fromBuffer(List data) { - var i = 0; - - if (data[i] != fieldId(1, fieldTypeUint32)) { - throw Exception(); - } - var decoded = decodeVarint(data, 1); - final pkId = decoded.n; - i += decoded.length + 1; - - if (data[i] != fieldId(2, fieldTypeUint32)) { - throw Exception(); - } - decoded = decodeVarint(data, i + 1); - final spkId = decoded.n; - i += decoded.length + 1; - - if (data[i] != fieldId(3, fieldTypeByteArray)) { - throw Exception(); - } - final ik = data.sublist(i + 2, i + 2 + data[i + 1]); - i += 2 + data[i + 1]; - - if (data[i] != fieldId(4, fieldTypeByteArray)) { - throw Exception(); - } - final ek = data.sublist(i + 2, i + 2 + data[i + 1]); - i += 2 + data[i + 1]; - - if (data[i] != fieldId(5, fieldTypeByteArray)) { - throw Exception(); - } - final message = OmemoAuthenticatedMessage.fromBuffer(data.sublist(i + 2)); - - return OmemoKeyExchange() - ..pkId = pkId - ..spkId = spkId - ..ik = ik - ..ek = ek - ..message = message; - } - - int? pkId; - int? spkId; - List? ik; - List? ek; - OmemoAuthenticatedMessage? message; - - List writeToBuffer() { - final msg = message!.writeToBuffer(); - return concat([ - [fieldId(1, fieldTypeUint32)], - encodeVarint(pkId!), - [fieldId(2, fieldTypeUint32)], - encodeVarint(spkId!), - [fieldId(3, fieldTypeByteArray), ik!.length], - ik!, - [fieldId(4, fieldTypeByteArray), ek!.length], - ek!, - [fieldId(5, fieldTypeByteArray), msg.length], - msg, - ]); - } -} diff --git a/lib/src/protobuf/omemo_message.dart b/lib/src/protobuf/omemo_message.dart deleted file mode 100644 index ccb4599..0000000 --- a/lib/src/protobuf/omemo_message.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:omemo_dart/src/helpers.dart'; -import 'package:omemo_dart/src/protobuf/protobuf.dart'; - -class OmemoMessage { - OmemoMessage(); - - factory OmemoMessage.fromBuffer(List data) { - var i = 0; - - // required uint32 n = 1; - if (data[0] != fieldId(1, fieldTypeUint32)) { - throw Exception(); - } - var decode = decodeVarint(data, 1); - final n = decode.n; - i += decode.length + 1; - - // required uint32 pn = 2; - if (data[i] != fieldId(2, fieldTypeUint32)) { - throw Exception(); - } - decode = decodeVarint(data, i + 1); - final pn = decode.n; - i += decode.length + 1; - - // required bytes dh_pub = 3; - if (data[i] != fieldId(3, fieldTypeByteArray)) { - throw Exception(); - } - final dhPub = data.sublist(i + 2, i + 2 + data[i + 1]); - i += 2 + data[i + 1]; - - // optional bytes ciphertext = 4; - List? ciphertext; - if (i < data.length) { - if (data[i] != fieldId(4, fieldTypeByteArray)) { - throw Exception(); - } - - ciphertext = data.sublist(i + 2, i + 2 + data[i + 1]); - } - - return OmemoMessage() - ..n = n - ..pn = pn - ..dhPub = dhPub - ..ciphertext = ciphertext; - } - - int? n; - int? pn; - List? dhPub; - List? ciphertext; - - List writeToBuffer() { - final data = concat([ - [fieldId(1, fieldTypeUint32)], - encodeVarint(n!), - [fieldId(2, fieldTypeUint32)], - encodeVarint(pn!), - [fieldId(3, fieldTypeByteArray), dhPub!.length], - dhPub!, - ]); - - if (ciphertext != null) { - return concat([ - data, - [fieldId(4, fieldTypeByteArray), ciphertext!.length], - ciphertext!, - ]); - } - - return data; - } -} diff --git a/lib/src/protobuf/protobuf.dart b/lib/src/protobuf/protobuf.dart deleted file mode 100644 index 2aed675..0000000 --- a/lib/src/protobuf/protobuf.dart +++ /dev/null @@ -1,64 +0,0 @@ -/// Masks the 7 LSB -const lsb7Mask = 0x7F; - -/// Constant for setting the MSB -const msb = 1 << 7; - -/// Field types -const fieldTypeUint32 = 0; -const fieldTypeByteArray = 2; - -int fieldId(int number, int type) { - return (number << 3) | type; -} - -class VarintDecode { - const VarintDecode(this.n, this.length); - final int n; - final int length; -} - -/// Decode a Varint that begins at [input]'s index [offset]. -VarintDecode decodeVarint(List input, int offset) { - // The return value - var n = 0; - // The byte offset counter - var i = 0; - - // Iterate until the MSB of the byte is 0 - while (true) { - // Mask only the 7 LSB and "move" them accordingly - n += (input[offset + i] & lsb7Mask) << (7 * i); - - // Break if we reached the end - if (input[offset + i] & 1 << 7 == 0) { - break; - } - i++; - } - - return VarintDecode(n, i + 1); -} - -// Encodes the integer [i] into a Varint. -List encodeVarint(int i) { - assert(i >= 0, "Two's complement is not implemented"); - final ret = List.empty(growable: true); - - // Thanks to https://github.com/hathibelagal-dev/LEB128 for the trick with toRadixString! - final numSevenBlocks = (i.toRadixString(2).length / 7).ceil(); - for (var j = 0; j < numSevenBlocks; j++) { - // The 7 LSB of the byte we're creating - final x = (i & (lsb7Mask << j * 7)) >> j * 7; - - if (j == numSevenBlocks - 1) { - // If we were to shift further, we only get zero, so we're at the end - ret.add(x); - } else { - // We still have at least one bit more to go, so set the MSB to 1 - ret.add(x + msb); - } - } - - return ret; -} diff --git a/lib/protobuf/schema.pb.dart b/lib/src/protobuf/schema.pb.dart similarity index 100% rename from lib/protobuf/schema.pb.dart rename to lib/src/protobuf/schema.pb.dart diff --git a/lib/protobuf/schema.pbenum.dart b/lib/src/protobuf/schema.pbenum.dart similarity index 100% rename from lib/protobuf/schema.pbenum.dart rename to lib/src/protobuf/schema.pbenum.dart diff --git a/lib/protobuf/schema.pbjson.dart b/lib/src/protobuf/schema.pbjson.dart similarity index 100% rename from lib/protobuf/schema.pbjson.dart rename to lib/src/protobuf/schema.pbjson.dart diff --git a/lib/protobuf/schema.pbserver.dart b/lib/src/protobuf/schema.pbserver.dart similarity index 100% rename from lib/protobuf/schema.pbserver.dart rename to lib/src/protobuf/schema.pbserver.dart diff --git a/lib/src/trust/always.dart b/lib/src/trust/always.dart index dd0fcec..d35a0de 100644 --- a/lib/src/trust/always.dart +++ b/lib/src/trust/always.dart @@ -22,5 +22,5 @@ class AlwaysTrustingTrustManager extends TrustManager { Future removeTrustDecisionsForJid(String jid) async {} @override - Future> toJson() async => {}; + Future loadTrustData(String jid) async {} } diff --git a/lib/src/trust/base.dart b/lib/src/trust/base.dart index 86f46bb..8d2da8a 100644 --- a/lib/src/trust/base.dart +++ b/lib/src/trust/base.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + /// The base class for managing trust in OMEMO sessions. // ignore: one_member_abstracts abstract class TrustManager { @@ -7,6 +9,7 @@ abstract class TrustManager { /// Called by the OmemoSessionManager when a new session has been built. Should set /// a default trust state to [jid]'s device with identifier [deviceId]. + @internal Future onNewSession(String jid, int deviceId); /// Return true if the device with id [deviceId] of Jid [jid] should be used for encryption. @@ -17,9 +20,14 @@ abstract class TrustManager { /// if [enabled] is false. Future setEnabled(String jid, int deviceId, bool enabled); - /// Serialize the trust manager to JSON. - Future> toJson(); - /// Removes all trust decisions for [jid]. + @internal Future removeTrustDecisionsForJid(String jid); + + // ignore: comment_references + /// Called from within the [OmemoManager]. + /// Loads the trust data for the JID [jid] from persistent storage + /// into the internal cache, if applicable. + @internal + Future loadTrustData(String jid); } diff --git a/lib/src/trust/btbv.dart b/lib/src/trust/btbv.dart index 7ea4304..5d45aca 100644 --- a/lib/src/trust/btbv.dart +++ b/lib/src/trust/btbv.dart @@ -1,71 +1,116 @@ import 'package:meta/meta.dart'; +import 'package:omemo_dart/src/helpers.dart'; import 'package:omemo_dart/src/omemo/ratchet_map_key.dart'; import 'package:omemo_dart/src/trust/base.dart'; -import 'package:synchronized/synchronized.dart'; + +@immutable +class BTBVTrustData { + const BTBVTrustData( + this.jid, + this.device, + this.state, + this.enabled, + this.trusted, + ); + + /// The JID in question. + final String jid; + + /// The device (ratchet) in question. + final int device; + + /// The trust state of the ratchet. + final BTBVTrustState state; + + /// Flag indicating whether the ratchet is enabled (true) or not (false). + final bool enabled; + + /// Flag indicating whether the ratchet is trusted. For loading and commiting a ratchet, this field + /// contains an arbitrary value. + /// When using [BlindTrustBeforeVerificationTrustManager.getDevicesTrust], this flag will be true if + /// the ratchet is trusted and false if not. + final bool trusted; +} + +/// A callback for when a trust decision is to be commited to persistent storage. +typedef BTBVTrustCommitCallback = Future Function(BTBVTrustData data); + +/// A stub-implementation of [BTBVTrustCommitCallback]. +Future btbvCommitStub(BTBVTrustData _) async {} + +/// A callback for when all trust decisions for a JID should be removed from persistent storage. +typedef BTBVRemoveTrustForJidCallback = Future Function(String jid); + +/// A stub-implementation of [BTBVRemoveTrustForJidCallback]. +Future btbvRemoveTrustStub(String _) async {} + +/// A callback for when trust data should be loaded. +typedef BTBVLoadDataCallback = Future> Function(String jid); + +/// A stub-implementation for [BTBVLoadDataCallback]. +Future> btbvLoadDataStub(String _) async => []; /// Every device is in either of those two trust states: /// - notTrusted: The device is absolutely not trusted /// - blindTrust: The fingerprint is not verified using OOB means /// - verified: The fingerprint has been verified using OOB means enum BTBVTrustState { - notTrusted, // = 1 - blindTrust, // = 2 - verified, // = 3 -} + notTrusted(1), + blindTrust(2), + verified(3); -int _trustToInt(BTBVTrustState state) { - switch (state) { - case BTBVTrustState.notTrusted: - return 1; - case BTBVTrustState.blindTrust: - return 2; - case BTBVTrustState.verified: - return 3; - } -} + const BTBVTrustState(this.value); -BTBVTrustState _trustFromInt(int i) { - switch (i) { - case 1: - return BTBVTrustState.notTrusted; - case 2: - return BTBVTrustState.blindTrust; - case 3: - return BTBVTrustState.verified; - default: - return BTBVTrustState.notTrusted; + factory BTBVTrustState.fromInt(int value) { + switch (value) { + case 1: + return BTBVTrustState.notTrusted; + case 2: + return BTBVTrustState.blindTrust; + case 3: + return BTBVTrustState.verified; + // TODO(Unknown): Should we handle this better? + default: + return BTBVTrustState.notTrusted; + } } + + /// The value backing the trust state. + final int value; } /// A TrustManager that implements the idea of Blind Trust Before Verification. /// See https://gultsch.de/trust.html for more details. -abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager { +class BlindTrustBeforeVerificationTrustManager extends TrustManager { BlindTrustBeforeVerificationTrustManager({ - Map? trustCache, - Map? enablementCache, - Map>? devices, - }) : trustCache = trustCache ?? {}, - enablementCache = enablementCache ?? {}, - devices = devices ?? {}, - _lock = Lock(); + this.loadData = btbvLoadDataStub, + this.commit = btbvCommitStub, + this.removeTrust = btbvRemoveTrustStub, + }); /// The cache for mapping a RatchetMapKey to its trust state @visibleForTesting @protected - final Map trustCache; + final Map trustCache = {}; /// The cache for mapping a RatchetMapKey to whether it is enabled or not @visibleForTesting @protected - final Map enablementCache; + final Map enablementCache = {}; /// Mapping of Jids to their device identifiers @visibleForTesting @protected - final Map> devices; + final Map> devices = {}; - /// The lock for devices and trustCache - final Lock _lock; + /// Callback for loading trust data. + final BTBVLoadDataCallback loadData; + + /// Callback for commiting trust data to persistent storage. + final BTBVTrustCommitCallback commit; + + /// Callback for removing trust data for a JID. + final BTBVRemoveTrustForJidCallback removeTrust; /// Returns true if [jid] has at least one device that is verified. If not, returns false. /// Note that this function accesses devices and trustCache, which requires that the @@ -80,69 +125,72 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager { @override Future isTrusted(String jid, int deviceId) async { - var returnValue = false; - await _lock.synchronized(() async { - final trustCacheValue = trustCache[RatchetMapKey(jid, deviceId)]; - if (trustCacheValue == BTBVTrustState.notTrusted) { - returnValue = false; - return; - } else if (trustCacheValue == BTBVTrustState.verified) { - // The key is verified, so it's safe. - returnValue = true; - return; + final trustCacheValue = trustCache[RatchetMapKey(jid, deviceId)]; + if (trustCacheValue == BTBVTrustState.notTrusted) { + return false; + } else if (trustCacheValue == BTBVTrustState.verified) { + // The key is verified, so it's safe. + return true; + } else { + if (_hasAtLeastOneVerifiedDevice(jid)) { + // Do not trust if there is at least one device with full trust + return false; } else { - if (_hasAtLeastOneVerifiedDevice(jid)) { - // Do not trust if there is at least one device with full trust - returnValue = false; - return; - } else { - // We have not verified a key from [jid], so it is blind trust all the way. - returnValue = true; - return; - } + // We have not verified a key from [jid], so it is blind trust all the way. + return true; } - }); - - return returnValue; + } } @override Future onNewSession(String jid, int deviceId) async { - await _lock.synchronized(() async { - final key = RatchetMapKey(jid, deviceId); - if (_hasAtLeastOneVerifiedDevice(jid)) { - trustCache[key] = BTBVTrustState.notTrusted; - enablementCache[key] = false; - } else { - trustCache[key] = BTBVTrustState.blindTrust; - enablementCache[key] = true; - } + final key = RatchetMapKey(jid, deviceId); + if (_hasAtLeastOneVerifiedDevice(jid)) { + trustCache[key] = BTBVTrustState.notTrusted; + enablementCache[key] = false; + } else { + trustCache[key] = BTBVTrustState.blindTrust; + enablementCache[key] = true; + } - if (devices.containsKey(jid)) { - devices[jid]!.add(deviceId); - } else { - devices[jid] = List.from([deviceId]); - } + // Append to the device list + devices.appendOrCreate(jid, deviceId, checkExistence: true); - // Commit the state - await commitState(); - }); + // Commit the state + await commit( + BTBVTrustData( + jid, + deviceId, + trustCache[key]!, + enablementCache[key]!, + false, + ), + ); } /// Returns a mapping from the device identifiers of [jid] to their trust state. If /// there are no devices known for [jid], then an empty map is returned. - Future> getDevicesTrust(String jid) async { - return _lock.synchronized(() async { - final map = {}; + Future> getDevicesTrust(String jid) async { + final map = {}; - if (!devices.containsKey(jid)) return map; + if (!devices.containsKey(jid)) return map; - for (final deviceId in devices[jid]!) { - map[deviceId] = trustCache[RatchetMapKey(jid, deviceId)]!; + for (final deviceId in devices[jid]!) { + final key = RatchetMapKey(jid, deviceId); + if (!trustCache.containsKey(key) || !enablementCache.containsKey(key)) { + continue; } - return map; - }); + map[deviceId] = BTBVTrustData( + jid, + deviceId, + trustCache[key]!, + enablementCache[key]!, + await isTrusted(jid, deviceId), + ); + } + + return map; } /// Sets the trust of [jid]'s device with identifier [deviceId] to [state]. @@ -151,108 +199,71 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager { int deviceId, BTBVTrustState state, ) async { - await _lock.synchronized(() async { - trustCache[RatchetMapKey(jid, deviceId)] = state; + final key = RatchetMapKey(jid, deviceId); + trustCache[key] = state; - // Commit the state - await commitState(); - }); + // Commit the state + await commit( + BTBVTrustData( + jid, + deviceId, + state, + enablementCache[key]!, + false, + ), + ); } @override Future isEnabled(String jid, int deviceId) async { - return _lock.synchronized(() async { - final value = enablementCache[RatchetMapKey(jid, deviceId)]; + final value = enablementCache[RatchetMapKey(jid, deviceId)]; - if (value == null) return false; - return value; - }); + if (value == null) return false; + return value; } @override Future setEnabled(String jid, int deviceId, bool enabled) async { - await _lock.synchronized(() async { - enablementCache[RatchetMapKey(jid, deviceId)] = enabled; - }); + final key = RatchetMapKey(jid, deviceId); + enablementCache[key] = enabled; // Commit the state - await commitState(); - } - - @override - Future> toJson() async { - return { - 'devices': devices, - 'trust': trustCache.map( - (key, value) => MapEntry( - key.toJsonKey(), - _trustToInt(value), - ), - ), - 'enable': - enablementCache.map((key, value) => MapEntry(key.toJsonKey(), value)), - }; - } - - /// From a serialized version of a BTBV trust manager, extract the device list. - /// NOTE: This is needed as Dart cannot just cast a List to List and so on. - static Map> deviceListFromJson(Map json) { - return (json['devices']! as Map).map>( - (key, value) => MapEntry( - key, - (value as List).map((i) => i as int).toList(), - ), - ); - } - - /// From a serialized version of a BTBV trust manager, extract the trust cache. - /// NOTE: This is needed as Dart cannot just cast a List to List and so on. - static Map trustCacheFromJson( - Map json, - ) { - return (json['trust']! as Map) - .map( - (key, value) => MapEntry( - RatchetMapKey.fromJsonKey(key), - _trustFromInt(value as int), - ), - ); - } - - /// From a serialized version of a BTBV trust manager, extract the enable cache. - /// NOTE: This is needed as Dart cannot just cast a List to List and so on. - static Map enableCacheFromJson( - Map json, - ) { - return (json['enable']! as Map).map( - (key, value) => MapEntry( - RatchetMapKey.fromJsonKey(key), - value as bool, + await commit( + BTBVTrustData( + jid, + deviceId, + trustCache[key]!, + enabled, + false, ), ); } @override Future removeTrustDecisionsForJid(String jid) async { - await _lock.synchronized(() async { - devices.remove(jid); - await commitState(); - }); + // Clear the caches + for (final device in devices[jid]!) { + final key = RatchetMapKey(jid, device); + trustCache.remove(key); + enablementCache.remove(key); + } + devices.remove(jid); + + // Commit the state + await removeTrust(jid); } - /// Called when the state of the trust manager has been changed. Allows the user to - /// commit the trust state to persistent storage. - @visibleForOverriding - Future commitState(); + @override + Future loadTrustData(String jid) async { + for (final result in await loadData(jid)) { + final key = RatchetMapKey(jid, result.device); + trustCache[key] = result.state; + enablementCache[key] = result.enabled; + devices.appendOrCreate(jid, result.device, checkExistence: true); + } + } @visibleForTesting BTBVTrustState getDeviceTrust(String jid, int deviceId) => trustCache[RatchetMapKey(jid, deviceId)]!; } - -/// A BTBV TrustManager that does not commit its state to persistent storage. Well suited -/// for testing. -class MemoryBTBVTrustManager extends BlindTrustBeforeVerificationTrustManager { - @override - Future commitState() async {} -} diff --git a/lib/src/trust/never.dart b/lib/src/trust/never.dart index 63444c9..0afba81 100644 --- a/lib/src/trust/never.dart +++ b/lib/src/trust/never.dart @@ -22,5 +22,5 @@ class NeverTrustingTrustManager extends TrustManager { Future removeTrustDecisionsForJid(String jid) async {} @override - Future> toJson() async => {}; + Future loadTrustData(String jid) async {} } diff --git a/lib/src/x3dh/x3dh.dart b/lib/src/x3dh/x3dh.dart index 670a805..b6b5a66 100644 --- a/lib/src/x3dh/x3dh.dart +++ b/lib/src/x3dh/x3dh.dart @@ -1,15 +1,14 @@ import 'dart:convert'; import 'dart:math'; import 'package:cryptography/cryptography.dart'; +import 'package:moxlib/moxlib.dart'; +import 'package:omemo_dart/src/common/constants.dart'; import 'package:omemo_dart/src/crypto.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'; -/// The overarching assumption is that we use Ed25519 keys for the identity keys -const omemoX3DHInfoString = 'OMEMO X3DH'; - /// Performed by Alice class X3DHAliceResult { const X3DHAliceResult(this.ek, this.sk, this.opkId, this.ad); @@ -70,7 +69,8 @@ Future> kdf(List km) async { /// Alice builds a session with Bob using his bundle [bundle] and Alice's identity key /// pair [ik]. -Future x3dhFromBundle( +Future> + x3dhFromBundle( OmemoBundle bundle, OmemoKeyPair ik, ) async { @@ -84,7 +84,7 @@ Future x3dhFromBundle( ); if (!signatureValue) { - throw InvalidSignatureException(); + return Result(InvalidKeyExchangeSignatureError()); } // Generate EK @@ -106,7 +106,7 @@ Future x3dhFromBundle( await bundle.ik.getBytes(), ]); - return X3DHAliceResult(ek, sk, opkId, ad); + return Result(X3DHAliceResult(ek, sk, opkId, ad)); } /// Bob builds the X3DH shared secret from the inital message [msg], the SPK [spk], the diff --git a/pubspec.yaml b/pubspec.yaml index ee5d811..fb77c60 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: omemo_dart description: An XMPP library independent OMEMO library -version: 0.4.3 +version: 0.5.0 homepage: https://github.com/PapaTutuWawa/omemo_dart publish_to: https://git.polynom.me/api/packages/PapaTutuWawa/pub @@ -13,12 +13,15 @@ dependencies: hex: ^0.2.0 logging: ^1.0.2 meta: ^1.7.0 + moxlib: + version: ^0.2.0 + hosted: https://git.polynom.me/api/packages/Moxxy/pub pinenacl: ^0.5.1 + protobuf: ^2.1.0 + protoc_plugin: ^20.0.1 synchronized: ^3.0.0+2 dev_dependencies: lints: ^2.0.0 - protobuf: ^2.1.0 - protoc_plugin: ^20.0.1 test: ^1.21.0 very_good_analysis: ^3.0.1 diff --git a/test/double_ratchet_test.dart b/test/double_ratchet_test.dart index ec73e7f..e4137d6 100644 --- a/test/double_ratchet_test.dart +++ b/test/double_ratchet_test.dart @@ -1,39 +1,10 @@ -// ignore_for_file: avoid_print import 'dart:convert'; +import 'dart:developer'; import 'package:cryptography/cryptography.dart'; import 'package:omemo_dart/omemo_dart.dart'; -import 'package:omemo_dart/protobuf/schema.pb.dart'; -import 'package:omemo_dart/src/double_ratchet/crypto.dart'; import 'package:test/test.dart'; void main() { - test('Test encrypting and decrypting', () async { - final sessionAd = List.filled(32, 0x0); - final mk = List.filled(32, 0x1); - final plaintext = utf8.encode('Hallo'); - final header = OMEMOMessage() - ..n = 0 - ..pn = 0 - ..dhPub = List.empty(); - final asd = concat([sessionAd, header.writeToBuffer()]); - - final ciphertext = await encrypt( - mk, - plaintext, - asd, - sessionAd, - ); - - final decrypted = await decrypt( - mk, - ciphertext, - asd, - sessionAd, - ); - - expect(decrypted, plaintext); - }); - test('Test the Double Ratchet', () async { // Generate keys const bobJid = 'bob@other.example.server'; @@ -57,7 +28,8 @@ void main() { ); // Alice does X3DH - final resultAlice = await x3dhFromBundle(bundleBob, ikAlice); + final resultAliceRaw = await x3dhFromBundle(bundleBob, ikAlice); + final resultAlice = resultAliceRaw.get(); // Alice sends the inital message to Bob // ... @@ -74,23 +46,28 @@ void main() { ikBob, ); - print('X3DH key exchange done'); + log('X3DH key exchange done'); // Alice and Bob now share sk as a common secret and ad // Build a session final alicesRatchet = await OmemoDoubleRatchet.initiateNewSession( spkBob.pk, + bundleBob.spkId, ikBob.pk, + ikAlice.pk, + resultAlice.ek.pk, resultAlice.sk, resultAlice.ad, - 0, + resultAlice.opkId, ); final bobsRatchet = await OmemoDoubleRatchet.acceptNewSession( spkBob, + bundleBob.spkId, ikAlice.pk, + 2, + resultAlice.ek.pk, resultBob.sk, resultBob.ad, - 0, ); expect(alicesRatchet.sessionAd, bobsRatchet.sessionAd); @@ -98,40 +75,42 @@ void main() { for (var i = 0; i < 100; i++) { final messageText = 'Hello, dear $i'; + log('${i + 1}/100'); if (i.isEven) { // Alice encrypts a message final aliceRatchetResult = await alicesRatchet.ratchetEncrypt(utf8.encode(messageText)); - print('Alice sent the message'); + log('Alice sent the message'); // Alice sends it to Bob // ... // Bob tries to decrypt it final bobRatchetResult = await bobsRatchet.ratchetDecrypt( - aliceRatchetResult.header, - aliceRatchetResult.ciphertext, + aliceRatchetResult, ); - print('Bob decrypted the message'); + log('Bob decrypted the message'); - expect(utf8.encode(messageText), bobRatchetResult); + expect(bobRatchetResult.isType>(), true); + expect(bobRatchetResult.get>(), utf8.encode(messageText)); } else { // Bob sends a message to Alice final bobRatchetResult = await bobsRatchet.ratchetEncrypt(utf8.encode(messageText)); - print('Bob sent the message'); + log('Bob sent the message'); // Bobs sends it to Alice // ... // Alice tries to decrypt it final aliceRatchetResult = await alicesRatchet.ratchetDecrypt( - bobRatchetResult.header, - bobRatchetResult.ciphertext, + bobRatchetResult, ); - print('Alice decrypted the message'); + log('Alice decrypted the message'); - expect(utf8.encode(messageText), aliceRatchetResult); + expect(aliceRatchetResult.isType>(), true); + expect(aliceRatchetResult.get>(), utf8.encode(messageText)); + expect(utf8.encode(messageText), aliceRatchetResult.get>()); } } }); diff --git a/test/omemo_test.dart b/test/omemo_test.dart index da0b0c5..547e78f 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -1,9 +1,24 @@ +import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:omemo_dart/omemo_dart.dart'; +import 'package:omemo_dart/src/protobuf/schema.pb.dart'; import 'package:omemo_dart/src/trust/always.dart'; -import 'package:omemo_dart/src/trust/never.dart'; import 'package:test/test.dart'; +extension BytesToBase64 on List { + String toBase64() => base64Encode(this); +} + +class TestingTrustManager extends AlwaysTrustingTrustManager { + final Map devices = {}; + + @override + Future onNewSession(String jid, int deviceId) async { + devices[jid] = deviceId; + } +} + void main() { Logger.root ..level = Level.ALL @@ -12,945 +27,1880 @@ void main() { print('${record.level.name}: ${record.message}'); }); - test('Test replacing a onetime prekey', () async { - const aliceJid = 'alice@server.example'; - final device = await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + test('Test sending a message without the device list cache', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + var aliceEmptyMessageSent = 0; + var bobEmptyMessageSent = 0; - final newDevice = await device.replaceOnetimePrekey(0); + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - expect(device.jid, newDevice.jid); - expect(device.id, newDevice.id); + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async { + aliceEmptyMessageSent++; + }, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + final bobManager = OmemoManager( + bobDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async { + bobEmptyMessageSent++; + }, + (jid) async { + expect(jid, aliceJid); + return [aliceDevice.id]; + }, + (jid, id) async { + expect(jid, aliceJid); + return aliceDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); - var opksMatch = true; - if (newDevice.opks.length != device.opks.length) { - opksMatch = false; - } else { - for (final entry in device.opks.entries) { - final m = await newDevice.opks[entry.key]?.equals(entry.value) ?? false; - if (!m) opksMatch = false; - } + // Alice sends a message + final aliceResult = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello world', + ), + ); + + // Bob must be able to decrypt the message + final bobResult = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult.encryptedKeys[bobJid]!, + base64.encode(aliceResult.ciphertext!), + false, + ), + ); + + expect(bobResult.payload, 'Hello world'); + expect(bobResult.error, null); + expect(aliceEmptyMessageSent, 0); + expect(bobEmptyMessageSent, 1); + + // Alice receives the ack message + await aliceManager.ratchetAcknowledged( + bobJid, + bobDevice.id, + ); + + // Bob now responds + final bobResult2 = await bobManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [aliceJid], + 'Hello world, Alice', + ), + ); + final aliceResult2 = await aliceManager.onIncomingStanza( + OmemoIncomingStanza( + bobJid, + bobDevice.id, + bobResult2.encryptedKeys[aliceJid]!, + base64.encode(bobResult2.ciphertext!), + false, + ), + ); + + expect(aliceResult2.error, null); + expect(aliceEmptyMessageSent, 0); + expect(bobEmptyMessageSent, 1); + expect(aliceResult2.payload, 'Hello world, Alice'); + }); + + test('Test triggering the heartbeat', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + var aliceEmptyMessageSent = 0; + var bobEmptyMessageSent = 0; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + EncryptionResult? bobEmptyMessage; + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async { + aliceEmptyMessageSent++; + }, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + final bobManager = OmemoManager( + bobDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async { + bobEmptyMessageSent++; + bobEmptyMessage = result; + }, + (jid) async { + expect(jid, aliceJid); + return [aliceDevice.id]; + }, + (jid, id) async { + expect(jid, aliceJid); + return aliceDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + + // Alice sends a message + final aliceResult = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello world', + ), + ); + + // Bob must be able to decrypt the message + final bobResult = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult.encryptedKeys[bobJid]!, + base64.encode(aliceResult.ciphertext!), + false, + ), + ); + + expect(aliceEmptyMessageSent, 0); + expect(bobEmptyMessageSent, 1); + expect(bobResult.payload, 'Hello world'); + + // Bob acknowledges the message + await aliceManager.onIncomingStanza( + OmemoIncomingStanza( + bobJid, + bobDevice.id, + bobEmptyMessage!.encryptedKeys[aliceJid]!, + null, + false, + ), + ); + + // Alice now sends 52 messages that Bob decrypts + for (var i = 0; i < 52; i++) { + Logger.root.finest('${i + 1}/52'); + final aliceResultLoop = await aliceManager.onOutgoingStanza( + OmemoOutgoingStanza( + [bobJid], + 'Test message $i', + ), + ); + + expect(aliceResultLoop.encryptedKeys[bobJid]!.first.kex, isFalse); + + final bobResultLoop = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResultLoop.encryptedKeys[bobJid]!, + base64.encode(aliceResultLoop.ciphertext!), + false, + ), + ); + + expect(bobResultLoop.error, null); + expect(aliceEmptyMessageSent, 0); + expect(bobEmptyMessageSent, 1); + expect(bobResultLoop.payload, 'Test message $i'); } - expect(opksMatch, true); - expect(await device.ik.equals(newDevice.ik), true); - expect(await device.spk.equals(newDevice.spk), true); + // Alice sends a final message that triggers a heartbeat + final aliceResultFinal = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Test message last', + ), + ); - final oldSpkMatch = device.oldSpk != null - ? await device.oldSpk!.equals(newDevice.oldSpk!) - : newDevice.oldSpk == null; - expect(oldSpkMatch, true); - expect(listsEqual(device.spkSignature, newDevice.spkSignature), true); + final bobResultFinal = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResultFinal.encryptedKeys[bobJid]!, + base64.encode(aliceResultFinal.ciphertext!), + false, + ), + ); + + expect(aliceEmptyMessageSent, 0); + expect(bobEmptyMessageSent, 2); + expect(bobResultFinal.payload, 'Test message last'); + + // Alice receives it and sends another message + final aliceResultPostFinal = await aliceManager.onIncomingStanza( + OmemoIncomingStanza( + bobJid, + bobDevice.id, + bobEmptyMessage!.encryptedKeys[aliceJid]!, + null, + false, + ), + ); + expect(aliceResultPostFinal.error, null); + final aliceMessagePostFinal = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + "I'm not done yet!", + ), + ); + + // And Bob decrypts it + final bobResultPostFinal = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceMessagePostFinal.encryptedKeys[bobJid]!, + base64Encode(aliceMessagePostFinal.ciphertext!), + false, + ), + ); + + expect(bobResultPostFinal.error, null); + expect(bobResultPostFinal.payload, "I'm not done yet!"); + expect(aliceEmptyMessageSent, 0); + expect(bobEmptyMessageSent, 2); }); - test('Test using OMEMO sessions with only one device per user', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - // Alice and Bob generate their sessions - var deviceModified = false; - var ratchetModified = 0; - var deviceMapModified = 0; - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, + test('Test accessing data without it existing', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, AlwaysTrustingTrustManager(), - opkAmount: 1, + (result, recipientJid) async {}, + (jid) async => [], + (jid, id) async => null, + (jid) async {}, + (_) async {}, ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobOpks = (await bobSession.getDevice()).opks.values.toList(); - bobSession.eventStream.listen((event) { - if (event is DeviceModifiedEvent) { - deviceModified = true; - } else if (event is RatchetModifiedEvent) { - ratchetModified++; - } else if (event is DeviceListModifiedEvent) { - deviceMapModified++; - } - }); - // Alice encrypts a message for Bob - const messagePlaintext = 'Hello Bob!'; - final aliceMessage = await aliceSession.encryptToJid( - bobJid, - messagePlaintext, - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - expect(aliceMessage.encryptedKeys.length, 1); - - // Alice sends the message to Bob - // ... - - // Bob decrypts it - final bobMessage = await bobSession.decryptMessage( - aliceMessage.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - aliceMessage.encryptedKeys, - 0, - ); - expect(messagePlaintext, bobMessage); - // The ratchet should be modified two times: Once for when the ratchet is created and - // other time for when the message is decrypted - expect(ratchetModified, 2); - // Bob's device map should be modified once - expect(deviceMapModified, 1); - // The event should be triggered - expect(deviceModified, true); - // Bob should have replaced his OPK + // Get non-existant fingerprints expect( - listsEqual(bobOpks, (await bobSession.getDevice()).opks.values.toList()), - false, - ); - - // Ratchets are acked - await aliceSession.ratchetAcknowledged( - bobJid, await bobSession.getDeviceId()); - await bobSession.ratchetAcknowledged( - aliceJid, await aliceSession.getDeviceId()); - - // Bob responds to Alice - const bobResponseText = 'Oh, hello Alice!'; - final bobResponseMessage = await bobSession.encryptToJid( - aliceJid, - bobResponseText, - ); - - // Bob sends the message to Alice - // ... - - // Alice decrypts it - final aliceReceivedMessage = await aliceSession.decryptMessage( - bobResponseMessage.ciphertext, - bobJid, - await bobSession.getDeviceId(), - bobResponseMessage.encryptedKeys, - 0, - ); - expect(bobResponseText, aliceReceivedMessage); - }); - - test('Test using OMEMO sessions with only two devices for the receiver', - () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - // Bob's other device - final bobSession2 = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - - // Alice encrypts a message for Bob - const messagePlaintext = 'Hello Bob!'; - final aliceMessage = await aliceSession.encryptToJid( - bobJid, - messagePlaintext, - newSessions: [ - await bobSession.getDeviceBundle(), - await bobSession2.getDeviceBundle(), - ], - ); - expect(aliceMessage.encryptedKeys.length, 2); - expect(aliceMessage.encryptedKeys[0].kex, true); - expect(aliceMessage.encryptedKeys[1].kex, true); - - // Alice sends the message to Bob - // ... - - // Bob decrypts it - final bobMessage = await bobSession.decryptMessage( - aliceMessage.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - aliceMessage.encryptedKeys, - 0, - ); - expect(messagePlaintext, bobMessage); - - // Ratchets are acked - await aliceSession.ratchetAcknowledged( - bobJid, await bobSession.getDeviceId()); - await bobSession.ratchetAcknowledged( - aliceJid, await aliceSession.getDeviceId()); - - // Bob responds to Alice - const bobResponseText = 'Oh, hello Alice!'; - final bobResponseMessage = await bobSession.encryptToJid( - aliceJid, - bobResponseText, - ); - - // Bob sends the message to Alice - // ... - - // Alice decrypts it - final aliceReceivedMessage = await aliceSession.decryptMessage( - bobResponseMessage.ciphertext, - bobJid, - await bobSession.getDeviceId(), - bobResponseMessage.encryptedKeys, - 0, - ); - expect(bobResponseText, aliceReceivedMessage); - - // Alice checks the fingerprints - final fingerprints = await aliceSession.getHexFingerprintsForJid(bobJid); - // Check that they the fingerprints are correct - expect(fingerprints.length, 2); - expect(fingerprints[0] != fingerprints[1], true); - // Check that those two calls do not throw an exception - aliceSession - ..getRatchet(bobJid, fingerprints[0].deviceId) - ..getRatchet(bobJid, fingerprints[1].deviceId); - }); - - test('Test using OMEMO sessions with encrypt to self', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - - // Alice and Bob generate their sessions - final aliceSession1 = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final aliceSession2 = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - - // Alice encrypts a message for Bob - const messagePlaintext = 'Hello Bob!'; - final aliceMessage = await aliceSession1.encryptToJids( - [bobJid, aliceJid], - messagePlaintext, - newSessions: [ - await bobSession.getDeviceBundle(), - await aliceSession2.getDeviceBundle(), - ], - ); - expect(aliceMessage.encryptedKeys.length, 2); - - // Alice sends the message to Bob - // ... - - // Bob decrypts it - final bobMessage = await bobSession.decryptMessage( - aliceMessage.ciphertext, - aliceJid, - await aliceSession1.getDeviceId(), - aliceMessage.encryptedKeys, - 0, - ); - expect(messagePlaintext, bobMessage); - - // Alice's other device decrypts it - final aliceMessage2 = await aliceSession2.decryptMessage( - aliceMessage.ciphertext, - aliceJid, - await aliceSession1.getDeviceId(), - aliceMessage.encryptedKeys, - 0, - ); - expect(messagePlaintext, aliceMessage2); - }); - - test('Test sending empty OMEMO messages', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - - // Alice encrypts a message for Bob - final aliceMessage = await aliceSession.encryptToJid( - bobJid, + await aliceManager.getFingerprintsForJid(bobJid), null, - newSessions: [ - await bobSession.getDeviceBundle(), - ], ); - expect(aliceMessage.encryptedKeys.length, 1); - expect(aliceMessage.ciphertext, null); - // Alice sends the message to Bob - // ... + // Ack a non-existant ratchet + await aliceManager.ratchetAcknowledged( + bobJid, + 42, + ); + }); + + test('Test receiving a message encrypted for another device', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + var oldDevice = true; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobOldDevice = + await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + final bobCurrentDevice = + await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + + return oldDevice ? [bobOldDevice.id] : [bobCurrentDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return oldDevice + ? bobOldDevice.toBundle() + : bobCurrentDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + final bobManager = OmemoManager( + bobCurrentDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => [], + (jid, id) async => null, + (jid) async {}, + (_) async {}, + ); + + // Alice encrypts a message to Bob + final aliceResult1 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob', + ), + ); + + // Bob's current device receives it + final bobResult1 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); + + expect(bobResult1.payload, null); + expect(bobResult1.error, const TypeMatcher()); + + // Now Alice's client loses and regains the connection + await aliceManager.onNewConnection(); + oldDevice = false; + + // And Alice sends a new message + final aliceResult2 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob x2', + ), + ); + final bobResult2 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult2.encryptedKeys[bobJid]!, + base64.encode(aliceResult2.ciphertext!), + false, + ), + ); + + expect(aliceResult2.encryptedKeys.length, 1); + expect(bobResult2.error, null); + expect(bobResult2.payload, 'Hello Bob x2'); + }); + + test('Test receiving a response from a new device', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + var bothDevices = false; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice1 = + await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + final bobDevice2 = + await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + + return [ + bobDevice1.id, + if (bothDevices) bobDevice2.id, + ]; + }, + (jid, id) async { + expect(jid, bobJid); + + if (bothDevices) { + if (id == bobDevice1.id) { + return bobDevice1.toBundle(); + } else if (id == bobDevice2.id) { + return bobDevice2.toBundle(); + } + } else { + if (id == bobDevice1.id) return bobDevice1.toBundle(); + } + + return null; + }, + (jid) async {}, + (_) async {}, + ); + final bobManager1 = OmemoManager( + bobDevice1, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => [], + (jid, id) async => null, + (jid) async {}, + (_) async {}, + ); + final bobManager2 = OmemoManager( + bobDevice2, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, aliceJid); + return [aliceDevice.id]; + }, + (jid, id) async { + expect(jid, aliceJid); + return aliceDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + + // Alice sends a message to Bob + final aliceResult1 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob!', + ), + ); // Bob decrypts it - final bobMessage = await bobSession.decryptMessage( - aliceMessage.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - aliceMessage.encryptedKeys, - 0, + final bobResult1 = await bobManager1.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), ); - expect(bobMessage, null); - // This call must not cause an exception - bobSession.getRatchet(aliceJid, await aliceSession.getDeviceId()); + expect(aliceResult1.encryptedKeys.length, 1); + expect(bobResult1.payload, 'Hello Bob!'); + + // Now Bob encrypts from his new device + bothDevices = true; + final bobResult2 = await bobManager2.onOutgoingStanza( + const OmemoOutgoingStanza( + [aliceJid], + 'Hello from my new device', + ), + ); + + // And Alice decrypts it + final aliceResult2 = await aliceManager.onIncomingStanza( + OmemoIncomingStanza( + bobJid, + bobDevice2.id, + bobResult2.encryptedKeys[aliceJid]!, + base64.encode(bobResult2.ciphertext!), + false, + ), + ); + + expect(aliceResult2.payload, 'Hello from my new device'); }); - test('Test rotating the Signed Prekey', () async { - // Generate the session - const aliceJid = 'alice@some.server'; - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, + test('Test receiving a device list update', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + var bothDevices = false; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice1 = + await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + final bobDevice2 = + await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, AlwaysTrustingTrustManager(), - opkAmount: 1, + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + + return [ + bobDevice1.id, + if (bothDevices) bobDevice2.id, + ]; + }, + (jid, id) async { + expect(jid, bobJid); + + if (bothDevices) { + if (id == bobDevice1.id) { + return bobDevice1.toBundle(); + } else if (id == bobDevice2.id) { + return bobDevice2.toBundle(); + } + } else { + if (id == bobDevice1.id) return bobDevice1.toBundle(); + } + + return null; + }, + (jid) async {}, + (_) async {}, + ); + final bobManager1 = OmemoManager( + bobDevice1, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => null, + (jid, id) async => null, + (jid) async {}, + (_) async {}, + ); + final bobManager2 = OmemoManager( + bobDevice2, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => null, + (jid, id) async => null, + (jid) async {}, + (_) async {}, ); - // Setup an event listener - final oldDevice = await aliceSession.getDevice(); - OmemoDevice? newDevice; - aliceSession.eventStream.listen((event) { - if (event is DeviceModifiedEvent) { - newDevice = event.device; - } - }); - - // Rotate the Signed Prekey - await aliceSession.rotateSignedPrekey(); - - // Just for safety... - await Future.delayed(const Duration(seconds: 2)); - - expect(await oldDevice.equals(newDevice!), false); - expect(await newDevice!.equals(await aliceSession.getDevice()), true); - - expect(await newDevice!.oldSpk!.equals(oldDevice.spk), true); - expect(newDevice!.oldSpkId, oldDevice.spkId); - }); - - test('Test accepting a session with an old SPK', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, + // Alice sends a message to Bob + final aliceResult1 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob!', + ), ); - final bobSession = await OmemoSessionManager.generateNewIdentity( + + // Bob decrypts it + final bobResult1 = await bobManager1.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); + + expect(aliceResult1.encryptedKeys.length, 1); + expect(bobResult1.payload, 'Hello Bob!'); + + // Bob acks the ratchet session + await aliceManager.ratchetAcknowledged(bobJid, bobDevice1.id); + + // Bob now publishes a new device + bothDevices = true; + await aliceManager.onDeviceListUpdate( bobJid, + [ + bobDevice1.id, + bobDevice2.id, + ], + ); + + // Now Alice encrypts another message + final aliceResult2 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob! x2', + ), + ); + + expect(aliceResult2.encryptedKeys[bobJid]!.length, 2); + + // And Bob decrypts it + final bobResult21 = await bobManager1.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult2.encryptedKeys[bobJid]!, + base64.encode(aliceResult2.ciphertext!), + false, + ), + ); + final bobResult22 = await bobManager2.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult2.encryptedKeys[bobJid]!, + base64.encode(aliceResult2.ciphertext!), + false, + ), + ); + + expect(bobResult21.payload, 'Hello Bob! x2'); + expect(bobResult22.payload, 'Hello Bob! x2'); + + // Bob2 now responds + final bobResult32 = await bobManager2.onOutgoingStanza( + const OmemoOutgoingStanza( + [aliceJid], + 'Hello Alice!', + ), + ); + + // And Alice responds + final aliceResult3 = await aliceManager.onIncomingStanza( + OmemoIncomingStanza( + bobJid, + bobDevice2.id, + bobResult32.encryptedKeys[aliceJid]!, + base64.encode(bobResult32.ciphertext!), + false, + ), + ); + + expect(aliceResult3.payload, 'Hello Alice!'); + }); + + test('Test sending a message to two different JIDs', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + const cocoJid = 'coco@server3'; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + final cocoDevice = + await OmemoDevice.generateNewDevice(cocoJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, AlwaysTrustingTrustManager(), - opkAmount: 1, + (result, recipientJid) async {}, + (jid) async { + if (jid == bobJid) { + return [bobDevice.id]; + } else if (jid == cocoJid) { + return [cocoDevice.id]; + } + + return null; + }, + (jid, id) async { + if (jid == bobJid) { + return bobDevice.toBundle(); + } else if (jid == cocoJid) { + return cocoDevice.toBundle(); + } + + return null; + }, + (jid) async {}, + (_) async {}, + ); + final bobManager = OmemoManager( + bobDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => null, + (jid, id) async => null, + (jid) async {}, + (_) async {}, + ); + final cocoManager = OmemoManager( + cocoDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => null, + (jid, id) async => null, + (jid) async {}, + (_) async {}, + ); + + // Alice sends a message to Bob and Coco + final aliceResult = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid, cocoJid], + 'Hello Bob and Coco!', + ), + ); + + // Bob and Coco decrypt them + final bobResult = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult.encryptedKeys[bobJid]!, + base64.encode(aliceResult.ciphertext!), + false, + ), + ); + final cocoResult = await cocoManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult.encryptedKeys[cocoJid]!, + base64.encode(aliceResult.ciphertext!), + false, + ), + ); + + expect(bobResult.error, null); + expect(cocoResult.error, null); + expect(bobResult.payload, 'Hello Bob and Coco!'); + expect(cocoResult.payload, 'Hello Bob and Coco!'); + }); + + test('Test a fetch failure', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + var failure = false; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + + return failure ? null : [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + + return failure ? null : bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + final bobManager = OmemoManager( + bobDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => null, + (jid, id) async => null, + (jid) async {}, + (_) async {}, + ); + + // Alice sends a message to Bob and Coco + final aliceResult1 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob!', + ), + ); + + // Bob decrypts it + final bobResult1 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); + + expect(bobResult1.error, null); + expect(bobResult1.payload, 'Hello Bob!'); + + // Bob acks the message + await aliceManager.ratchetAcknowledged( + bobJid, + bobDevice.id, + ); + + // Alice has to reconnect but has no connection yet + failure = true; + await aliceManager.onNewConnection(); + + // Alice sends another message to Bob + final aliceResult2 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob! x2', + ), + ); + + // And Bob decrypts it + final bobResult2 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult2.encryptedKeys[bobJid]!, + base64.encode(aliceResult2.ciphertext!), + false, + ), + ); + + expect(bobResult2.error, null); + expect(bobResult2.payload, 'Hello Bob! x2'); + }); + + test('Test sending a message with failed lookups', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + + return null; + }, + (jid, id) async { + expect(jid, bobJid); + + return null; + }, + (jid) async {}, + (_) async {}, + ); + + // Alice sends a message to Bob + final aliceResult = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob!', + ), + ); + + expect(aliceResult.canSend, isFalse); + expect(aliceResult.deviceEncryptionErrors[bobJid]!.length, 1); + final error = aliceResult.deviceEncryptionErrors[bobJid]!.first; + expect(error.error, const TypeMatcher()); + }); + + test('Test sending a message two two JIDs with failed lookups', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + const cocoJid = 'coco@server3'; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + if (jid == bobJid) { + return [bobDevice.id]; + } + + return null; + }, + (jid, id) async { + if (jid == bobJid) { + return bobDevice.toBundle(); + } + + return null; + }, + (jid) async {}, + (_) async {}, + ); + final bobManager = OmemoManager( + bobDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => null, + (jid, id) async => null, + (jid) async {}, + (_) async {}, + ); + + // Alice sends a message to Bob and Coco + final aliceResult = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid, cocoJid], + 'Hello Bob and Coco!', + ), + ); + + expect(aliceResult.canSend, isFalse); + expect(aliceResult.deviceEncryptionErrors[cocoJid]!.length, 1); + expect( + aliceResult.deviceEncryptionErrors[cocoJid]!.first.error, + const TypeMatcher(), + ); + + // Bob decrypts it + final bobResult = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult.encryptedKeys[bobJid]!, + base64.encode(aliceResult.ciphertext!), + false, + ), + ); + + expect(bobResult.payload, 'Hello Bob and Coco!'); + }); + + test('Test sending multiple messages back and forth', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + final bobManager = OmemoManager( + bobDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => null, + (jid, id) async => null, + (jid) async {}, + (_) async {}, ); // Alice encrypts a message for Bob - const messagePlaintext = 'Hello Bob!'; - final aliceMessage = await aliceSession.encryptToJid( - bobJid, - messagePlaintext, - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - expect(aliceMessage.encryptedKeys.length, 1); - - // Alice loses her Internet connection. Bob rotates his SPK. - await bobSession.rotateSignedPrekey(); - - // Alice regains her Internet connection and sends the message to Bob - // ... - - // Bob decrypts it - final bobMessage = await bobSession.decryptMessage( - aliceMessage.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - aliceMessage.encryptedKeys, - 0, - ); - expect(messagePlaintext, bobMessage); - }); - - test('Test trust bypassing with empty OMEMO messages', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, - NeverTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - NeverTrustingTrustManager(), - opkAmount: 1, + final aliceMessage = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob!', + ), ); - // Alice encrypts an empty message for Bob - final aliceMessage = await aliceSession.encryptToJid( - bobJid, - null, - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - - // Despite Alice not trusting Bob's device, we should have encrypted it for his - // untrusted device. - expect(aliceMessage.encryptedKeys.length, 1); - }); - - test('Test by sending multiple messages back and forth', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - - // Alice encrypts a message for Bob - final aliceMessage = await aliceSession.encryptToJid( - bobJid, - 'Hello Bob!', - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - - // Alice sends the message to Bob - // ... - - await bobSession.decryptMessage( - aliceMessage.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - aliceMessage.encryptedKeys, - 0, + // And Bob decrypts it + await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceMessage.encryptedKeys[bobJid]!, + base64.encode(aliceMessage.ciphertext!), + false, + ), ); // Ratchets are acked - await aliceSession.ratchetAcknowledged( - bobJid, await bobSession.getDeviceId()); - await bobSession.ratchetAcknowledged( - aliceJid, await aliceSession.getDeviceId()); + await aliceManager.ratchetAcknowledged( + bobJid, + bobDevice.id, + ); for (var i = 0; i < 100; i++) { final messageText = 'Test Message #$i'; // Bob responds to Alice - final bobResponseMessage = await bobSession.encryptToJid( - aliceJid, - messageText, + final bobResponseMessage = await bobManager.onOutgoingStanza( + OmemoOutgoingStanza( + [aliceJid], + messageText, + ), ); + expect(bobResponseMessage.canSend, isTrue); - // Bob sends the message to Alice - // ... - - // Alice decrypts it - final aliceReceivedMessage = await aliceSession.decryptMessage( - bobResponseMessage.ciphertext, - bobJid, - await bobSession.getDeviceId(), - bobResponseMessage.encryptedKeys, - 0, + final aliceReceivedMessage = await aliceManager.onIncomingStanza( + OmemoIncomingStanza( + bobJid, + bobDevice.id, + bobResponseMessage.encryptedKeys[aliceJid]!, + base64.encode(bobResponseMessage.ciphertext!), + false, + ), ); - expect(messageText, aliceReceivedMessage); + expect(aliceReceivedMessage.payload, messageText); } }); - group('Test removing a ratchet', () { - test('Test removing a ratchet when the user has multiple', () async { - const aliceJid = 'alice@server.local'; - const bobJid = 'bob@some.server.local'; - final aliceSession = await OmemoSessionManager.generateNewIdentity( + test('Test removing all ratchets and sending a message', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + EncryptionResult? aliceEmptyMessage; + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async { + aliceEmptyMessage = result; + }, + (jid) async { + expect(jid, bobJid); + + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + final bobManager = OmemoManager( + bobDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async => null, + (jid, id) async => null, + (jid) async {}, + (_) async {}, + ); + + // Alice encrypts a message for Bob + final aliceResult1 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob!', + ), + ); + + // And Bob decrypts it + await bobManager.onIncomingStanza( + OmemoIncomingStanza( aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession1 = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession2 = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); + aliceDevice.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); - // Alice sends a message to those two Bobs - await aliceSession.encryptToJid( - bobJid, - 'Hallo Welt', - newSessions: [ - await bobSession1.getDeviceBundle(), - await bobSession2.getDeviceBundle(), - ], - ); + // Ratchets are acked + await aliceManager.ratchetAcknowledged( + bobJid, + bobDevice.id, + ); - // One of those two sessions is broken, so Alice removes the session2 ratchet - final id1 = await bobSession1.getDeviceId(); - final id2 = await bobSession2.getDeviceId(); - await aliceSession.removeRatchet(bobJid, id1); + // Alice now removes all ratchets for Bob and sends another new message + await aliceManager.removeAllRatchets(bobJid); - final map = aliceSession.getRatchetMap(); - expect(map.containsKey(RatchetMapKey(bobJid, id1)), false); - expect(map.containsKey(RatchetMapKey(bobJid, id2)), true); - final deviceMap = await aliceSession.getDeviceMap(); - expect(deviceMap.containsKey(bobJid), true); - expect(deviceMap[bobJid], [id2]); - }); + expect(aliceManager.getRatchet(RatchetMapKey(bobJid, bobDevice.id)), null); - test('Test removing a ratchet when the user has only one', () async { - const aliceJid = 'alice@server.local'; - const bobJid = 'bob@some.server.local'; - final aliceSession = await OmemoSessionManager.generateNewIdentity( + // Alice prepares an empty OMEMO message + await aliceManager.sendOmemoHeartbeat(bobJid); + + // And Bob receives it + final bobResult2 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( + await aliceManager.getDeviceId(), + aliceEmptyMessage!.encryptedKeys[bobJid]!, + null, + false, + ), + ); + expect(bobResult2.error, null); + + // Bob acks the new ratchet + await aliceManager.ratchetAcknowledged( + bobJid, + await bobManager.getDeviceId(), + ); + + // Alice sends another message + final aliceResult3 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'I did not trust your last device, Bob!', + ), + ); + + // Bob decrypts it + final bobResult3 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult3.encryptedKeys[bobJid]!, + base64.encode(aliceResult3.ciphertext!), + false, + ), + ); + + expect(bobResult3.error, null); + expect(bobResult3.payload, 'I did not trust your last device, Bob!'); + + // Bob responds + final bobResult4 = await bobManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [aliceJid], + "That's okay.", + ), + ); + + // Alice decrypts + final aliceResult4 = await aliceManager.onIncomingStanza( + OmemoIncomingStanza( bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); + bobDevice.id, + bobResult4.encryptedKeys[aliceJid]!, + base64.encode(bobResult4.ciphertext!), + false, + ), + ); - // Alice sends a message to those two Bobs - await aliceSession.encryptToJid( - bobJid, - 'Hallo Welt', - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - - // One of those two sessions is broken, so Alice removes the session2 ratchet - final id = await bobSession.getDeviceId(); - await aliceSession.removeRatchet(bobJid, id); - - final map = aliceSession.getRatchetMap(); - expect(map.containsKey(RatchetMapKey(bobJid, id)), false); - final deviceMap = await aliceSession.getDeviceMap(); - expect(deviceMap.containsKey(bobJid), false); - }); + expect(aliceResult4.error, null); + expect(aliceResult4.payload, "That's okay."); }); - test('Test acknowledging a ratchet', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, + test( + 'Test removing all ratchets and sending a message without post-heartbeat ack', + () async { + // This test is the same as "Test removing all ratchets and sending a message" except + // that Bob does not ack the ratchet after Alice's heartbeat after she recreated + // all ratchets. + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + EncryptionResult? aliceEmptyMessage; + final aliceManager = OmemoManager( + aliceDevice, AlwaysTrustingTrustManager(), - opkAmount: 1, + (result, recipientJid) async { + aliceEmptyMessage = result; + }, + (jid) async { + expect(jid, bobJid); + + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, + final bobManager = OmemoManager( + bobDevice, AlwaysTrustingTrustManager(), - opkAmount: 1, + (result, recipientJid) async {}, + (jid) async => null, + (jid, id) async => null, + (jid) async {}, + (_) async {}, ); - // Alice sends Bob a message - await aliceSession.encryptToJid( - bobJid, - 'Hallo Welt', - newSessions: [ - await bobSession.getDeviceBundle(), - ], + // Alice encrypts a message for Bob + final aliceResult1 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob!', + ), ); + + // And Bob decrypts it + final bobResult1 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); + expect(bobResult1.error, isNull); + + // Ratchets are acked + await aliceManager.ratchetAcknowledged( + bobJid, + bobDevice.id, + ); + + // Alice now removes all ratchets for Bob and sends another new message + Logger.root.info('Removing all ratchets for $bobJid'); + await aliceManager.removeAllRatchets(bobJid); + expect( - await aliceSession.getUnacknowledgedRatchets(bobJid), - [ - await bobSession.getDeviceId(), - ], + aliceManager.getRatchet(RatchetMapKey(bobJid, bobDevice.id)), + isNull, ); - // Bob sends alice an empty message - // ... + // Alice prepares an empty OMEMO message + await aliceManager.sendOmemoHeartbeat(bobJid); - // Alice decrypts it - // ... - - // Alice marks the ratchet as acknowledged - await aliceSession.ratchetAcknowledged( - bobJid, await bobSession.getDeviceId()); - expect( - (await aliceSession.getUnacknowledgedRatchets(bobJid))!.isEmpty, - true, + // And Bob receives it + final bobResult2 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + await aliceManager.getDeviceId(), + aliceEmptyMessage!.encryptedKeys[bobJid]!, + null, + false, + ), ); - }); + expect(bobResult2.error, null); - test('Test overwriting sessions', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, + // Alice sends another message + Logger.root.info('Sending final message'); + final aliceResult3 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'I did not trust your last device, Bob!', + ), ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 2, + expect(aliceResult3.encryptedKeys[bobJid]!.first.kex, isTrue); + + // Bob decrypts it + final bobResult3 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult3.encryptedKeys[bobJid]!, + base64.encode(aliceResult3.ciphertext!), + false, + ), ); - // Alice sends Bob a message - final msg1 = await aliceSession.encryptToJid( - bobJid, - 'Hallo Welt', - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - await bobSession.decryptMessage( - msg1.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg1.encryptedKeys, - 0, - ); - final aliceRatchet1 = aliceSession.getRatchet( - bobJid, - await bobSession.getDeviceId(), - ); - final bobRatchet1 = bobSession.getRatchet( - aliceJid, - await aliceSession.getDeviceId(), + expect(bobResult3.error, null); + expect(bobResult3.payload, 'I did not trust your last device, Bob!'); + + // Bob responds + final bobResult4 = await bobManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [aliceJid], + "That's okay.", + ), ); - // Alice is impatient and immediately sends another message before the original one - // can be acknowledged by Bob - final msg2 = await aliceSession.encryptToJid( - bobJid, - "Why don't you answer?", - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - await bobSession.decryptMessage( - msg2.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg2.encryptedKeys, - getTimestamp(), - ); - final aliceRatchet2 = aliceSession.getRatchet( - bobJid, - await bobSession.getDeviceId(), - ); - final bobRatchet2 = bobSession.getRatchet( - aliceJid, - await aliceSession.getDeviceId(), + // Alice decrypts + final aliceResult4 = await aliceManager.onIncomingStanza( + OmemoIncomingStanza( + bobJid, + bobDevice.id, + bobResult4.encryptedKeys[aliceJid]!, + base64.encode(bobResult4.ciphertext!), + false, + ), ); - // Both should only have one ratchet - expect(aliceSession.getRatchetMap().length, 1); - expect(bobSession.getRatchetMap().length, 1); - - // The ratchets should both be different - expect(await aliceRatchet1.equals(aliceRatchet2), false); - expect(await bobRatchet1.equals(bobRatchet2), false); + expect(aliceResult4.error, null); + expect(aliceResult4.payload, "That's okay."); }); test('Test resending key exchanges', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, AlwaysTrustingTrustManager(), - opkAmount: 1, + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, + final bobManager = OmemoManager( + bobDevice, AlwaysTrustingTrustManager(), - opkAmount: 2, + (result, recipientJid) async {}, + (jid) async { + expect(jid, aliceJid); + return [aliceDevice.id]; + }, + (jid, id) async { + expect(jid, aliceJid); + return aliceDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, ); // Alice sends Bob a message - final msg1 = await aliceSession.encryptToJid( + final aliceResult1 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello World!', + ), + ); + + // The first message must be a KEX message + expect(aliceResult1.encryptedKeys[bobJid]!.first.kex, isTrue); + + // Bob decrypts Alice's message + final bobResult1 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); + expect(bobResult1.error, null); + expect(bobResult1.payload, 'Hello World!'); + + // Alice immediately sends another message + final aliceResult2 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob', + ), + ); + + // The response should contain a KEX + expect(aliceResult2.encryptedKeys[bobJid]!.first.kex, isTrue); + + // The basic data should be the same + final parsedFirstKex = OMEMOKeyExchange.fromBuffer( + base64.decode(aliceResult1.encryptedKeys[bobJid]!.first.value), + ); + final parsedSecondKex = OMEMOKeyExchange.fromBuffer( + base64.decode(aliceResult2.encryptedKeys[bobJid]!.first.value), + ); + expect(parsedSecondKex.pkId, parsedFirstKex.pkId); + expect(parsedSecondKex.spkId, parsedFirstKex.spkId); + expect(parsedSecondKex.ik, parsedFirstKex.ik); + expect(parsedSecondKex.ek, parsedFirstKex.ek); + + // Bob decrypts it + final bobResult2 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult2.encryptedKeys[bobJid]!, + base64.encode(aliceResult2.ciphertext!), + false, + ), + ); + expect(bobResult2.error, null); + expect(bobResult2.payload, 'Hello Bob'); + + // Bob also sends a message + final bobResult3 = await bobManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [aliceJid], + 'Hello Alice!', + ), + ); + + // Alice decrypts it + final aliceResult3 = await aliceManager.onIncomingStanza( + OmemoIncomingStanza( + bobJid, + bobDevice.id, + bobResult3.encryptedKeys[aliceJid]!, + base64.encode(bobResult3.ciphertext!), + false, + ), + ); + expect(aliceResult3.error, null); + expect(aliceResult3.payload, 'Hello Alice!'); + + // Bob now acks the ratchet + await aliceManager.ratchetAcknowledged( bobJid, - 'Hallo Welt', - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - // The first message should be a kex message - expect(msg1.encryptedKeys.first.kex, true); - - await bobSession.decryptMessage( - msg1.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg1.encryptedKeys, - 0, + bobDevice.id, ); - // Alice is impatient and immediately sends another message before the original one - // can be acknowledged by Bob - final msg2 = await aliceSession.encryptToJid( - bobJid, - "Why don't you answer?", + // Alice replies + final aliceResult4 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hi Bob', + ), ); - expect(msg2.encryptedKeys.first.kex, true); - await bobSession.decryptMessage( - msg2.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg2.encryptedKeys, - getTimestamp(), + // The response should contain no KEX + expect(aliceResult4.encryptedKeys[bobJid]!.first.kex, isFalse); + + // Bob decrypts it + final bobResult4 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult4.encryptedKeys[bobJid]!, + base64.encode(aliceResult4.ciphertext!), + false, + ), ); + expect(bobResult4.error, null); + expect(bobResult4.payload, 'Hi Bob'); }); - test('Test receiving old messages including a KEX', () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 2, - ); + test('Test correct trust behaviour on receiving', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; - final bobsReceivedMessages = List.empty(growable: true); - final bobsReceivedMessagesTimestamps = List.empty(growable: true); + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + final bobManager = OmemoManager( + bobDevice, + TestingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, aliceJid); + return [aliceDevice.id]; + }, + (jid, id) async { + expect(jid, aliceJid); + return aliceDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); // Alice sends Bob a message - final msg1 = await aliceSession.encryptToJid( - bobJid, - 'Hallo Welt', - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - bobsReceivedMessages.add(msg1); - final t1 = getTimestamp(); - bobsReceivedMessagesTimestamps.add(t1); - - await bobSession.decryptMessage( - msg1.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg1.encryptedKeys, - t1, + final aliceResult1 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello World!', + ), ); - // Ratchets are acked - await aliceSession.ratchetAcknowledged( - bobJid, await bobSession.getDeviceId()); - await bobSession.ratchetAcknowledged( - aliceJid, await aliceSession.getDeviceId()); - - // Bob responds - final msg2 = await bobSession.encryptToJid( - aliceJid, - 'Hello!', - ); - - await aliceSession.decryptMessage( - msg2.ciphertext, - bobJid, - await bobSession.getDeviceId(), - msg2.encryptedKeys, - getTimestamp(), - ); - - // Send some messages between the two - for (var i = 0; i < 100; i++) { - final msg = await aliceSession.encryptToJid( - bobJid, - 'Hello $i', - ); - bobsReceivedMessages.add(msg); - final t = getTimestamp(); - bobsReceivedMessagesTimestamps.add(t); - final result = await bobSession.decryptMessage( - msg.ciphertext, + // Bob decrypts Alice's message + final bobResult1 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( aliceJid, - await aliceSession.getDeviceId(), - msg.encryptedKeys, - t, - ); + aliceDevice.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); + expect(bobResult1.error, null); - expect(result, 'Hello $i'); - } - - // Due to some issue with the transport protocol, the messages to Bob are received - // again. - final ratchetPreError = bobSession - .getRatchet(aliceJid, await aliceSession.getDeviceId()) - .clone(); - var invalidKex = 0; - var errorCounter = 0; - for (var i = 0; i < bobsReceivedMessages.length; i++) { - final msg = bobsReceivedMessages[i]; - try { - await bobSession.decryptMessage( - msg.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg.encryptedKeys, - bobsReceivedMessagesTimestamps[i], + // Bob should have some trust state + await bobManager.withTrustManager( + bobJid, + (tm) async { + expect( + (tm as TestingTrustManager).devices[aliceJid], + await aliceManager.getDeviceId(), ); - expect(true, false); - } on InvalidMessageHMACException catch (_) { - errorCounter++; - } on InvalidKeyExchangeException catch (_) { - invalidKex++; - } - } - final ratchetPostError = bobSession - .getRatchet(aliceJid, await aliceSession.getDeviceId()) - .clone(); - - // The 100 messages including the initial KEX message - expect(invalidKex, 1); - expect(errorCounter, 100); - expect(await ratchetPreError.equals(ratchetPostError), true); - - final msg3 = await aliceSession.encryptToJid( - bobJid, - 'Are you okay?', + }, ); - final result = await bobSession.decryptMessage( - msg3.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg3.encryptedKeys, - 104, - ); - - expect(result, 'Are you okay?'); }); - test("Test ignoring a new KEX when we haven't acket it yet", () async { - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - // Alice and Bob generate their sessions - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, + test('Test receiving a non-KEX from a new device', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + + final aliceDevice1 = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final aliceDevice2 = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager1 = OmemoManager( + aliceDevice1, AlwaysTrustingTrustManager(), - opkAmount: 1, + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, + final aliceManager2 = OmemoManager( + aliceDevice2, + TestingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + + EncryptionResult? bobEmptyMessage; + var includeAlice2 = false; + final bobManager = OmemoManager( + bobDevice, + TestingTrustManager(), + (result, recipientJid) async { + bobEmptyMessage = result; + }, + (jid) async { + expect(jid, aliceJid); + return [ + aliceDevice1.id, + if (includeAlice2) aliceDevice2.id, + ]; + }, + (jid, id) async { + expect(jid, aliceJid); + + if (id == aliceDevice1.id) { + return aliceDevice1.toBundle(); + } else if (id == aliceDevice2.id) { + return aliceDevice2.toBundle(); + } + + return null; + }, + (jid) async {}, + (_) async {}, ); // Alice sends Bob a message - final msg1 = await aliceSession.encryptToJid( - bobJid, - 'Hallo Welt', - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - expect(msg1.encryptedKeys.first.kex, true); - - await bobSession.decryptMessage( - msg1.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg1.encryptedKeys, - getTimestamp(), + final aliceResult1 = await aliceManager1.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello World!', + ), ); - // Alice sends another message before the ack can reach us - final msg2 = await aliceSession.encryptToJid( - bobJid, - 'ANSWER ME!', - ); - expect(msg2.encryptedKeys.first.kex, true); - - await bobSession.decryptMessage( - msg2.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg2.encryptedKeys, - getTimestamp(), - ); - - // Now the acks reach us - await aliceSession.ratchetAcknowledged( - bobJid, await bobSession.getDeviceId()); - await bobSession.ratchetAcknowledged( - aliceJid, await aliceSession.getDeviceId()); - - // Alice sends another message - final msg3 = await aliceSession.encryptToJid( - bobJid, - "You read the message, didn't you?", - ); - expect(msg3.encryptedKeys.first.kex, false); - - await bobSession.decryptMessage( - msg3.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg3.encryptedKeys, - getTimestamp(), - ); - - for (var i = 0; i < 100; i++) { - final messageText = 'Test Message #$i'; - // Bob responds to Alice - final bobResponseMessage = await bobSession.encryptToJid( + // Bob decrypts Alice's message + final bobResult1 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( aliceJid, - messageText, - ); + aliceDevice1.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); + expect(bobResult1.error, null); + expect(bobEmptyMessage, isNotNull); - // Bob sends the message to Alice - // ... + // Somehow create a non-KEX message without Bob creating a ratchet + await aliceManager2.onOutgoingStanza( + const OmemoOutgoingStanza([bobJid], 'lol'), + ); + await aliceManager2.ratchetAcknowledged(bobJid, bobDevice.id); + final aliceResult2 = await aliceManager2.onOutgoingStanza( + const OmemoOutgoingStanza([bobJid], 'lol x2'), + ); - // Alice decrypts it - final aliceReceivedMessage = await aliceSession.decryptMessage( - bobResponseMessage.ciphertext, + // Bob decrypts it and fails, but builds a session with the new device + bobEmptyMessage = null; + includeAlice2 = true; + final bobResult2 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice2.id, + aliceResult2.encryptedKeys[bobJid]!, + base64Encode(aliceResult2.ciphertext!), + false, + ), + ); + expect(bobResult2.error, const TypeMatcher()); + expect(bobEmptyMessage, isNotNull); + + // Check that the empty message is encrypted for both of Alice's devices + expect( + bobEmptyMessage!.encryptedKeys[aliceJid]! + .firstWhereOrNull((key) => key.rid == aliceDevice1.id), + isNotNull, + ); + expect( + bobEmptyMessage!.encryptedKeys[aliceJid]! + .firstWhereOrNull((key) => key.rid == aliceDevice1.id), + isNotNull, + ); + }); + + test( + 'Test receiving a non-KEX from a new device without device list inclusion', + () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + + final aliceDevice1 = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final aliceDevice2 = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager1 = OmemoManager( + aliceDevice1, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + final aliceManager2 = OmemoManager( + aliceDevice2, + TestingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + + EncryptionResult? bobEmptyMessage; + final bobManager = OmemoManager( + bobDevice, + TestingTrustManager(), + (result, recipientJid) async { + bobEmptyMessage = result; + }, + (jid) async { + expect(jid, aliceJid); + return [ + aliceDevice1.id, + ]; + }, + (jid, id) async { + expect(jid, aliceJid); + + if (id == aliceDevice1.id) { + return aliceDevice1.toBundle(); + } else if (id == aliceDevice2.id) { + return aliceDevice2.toBundle(); + } + + return null; + }, + (jid) async {}, + (_) async {}, + ); + + // Alice sends Bob a message + final aliceResult1 = await aliceManager1.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello World!', + ), + ); + + // Bob decrypts Alice's message + final bobResult1 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice1.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); + expect(bobResult1.error, null); + expect(bobEmptyMessage, isNotNull); + + // Somehow create a non-KEX message without Bob creating a ratchet + await aliceManager2.onOutgoingStanza( + const OmemoOutgoingStanza([bobJid], 'lol'), + ); + await aliceManager2.ratchetAcknowledged(bobJid, bobDevice.id); + final aliceResult2 = await aliceManager2.onOutgoingStanza( + const OmemoOutgoingStanza([bobJid], 'lol x2'), + ); + + // Bob decrypts it and fails, but builds a session with the new device + bobEmptyMessage = null; + final bobResult2 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice2.id, + aliceResult2.encryptedKeys[bobJid]!, + base64Encode(aliceResult2.ciphertext!), + false, + ), + ); + expect(bobResult2.error, const TypeMatcher()); + expect(bobEmptyMessage, isNotNull); + + // Check that the empty message is encrypted for both of Alice's devices + expect( + bobEmptyMessage!.encryptedKeys[aliceJid]! + .firstWhereOrNull((key) => key.rid == aliceDevice1.id), + isNotNull, + ); + expect( + bobEmptyMessage!.encryptedKeys[aliceJid]! + .firstWhereOrNull((key) => key.rid == aliceDevice1.id), + isNotNull, + ); + }); + + test('Test receiving an empty OMEMO message', () async { + const aliceJid = 'alice@server1'; + const bobJid = 'bob@server2'; + + final aliceDevice = + await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); + final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + + final aliceManager = OmemoManager( + aliceDevice, + AlwaysTrustingTrustManager(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) async {}, + (_) async {}, + ); + + EncryptionResult? bobEmptyMessage; + final bobManager = OmemoManager( + bobDevice, + TestingTrustManager(), + (result, recipientJid) async { + bobEmptyMessage = result; + }, + (jid) async { + expect(jid, aliceJid); + return [ + aliceDevice.id, + ]; + }, + (jid, id) async { + expect(jid, aliceJid); + + if (id == aliceDevice.id) { + return aliceDevice.toBundle(); + } + + return null; + }, + (jid) async {}, + (_) async {}, + ); + + // Alice sends Bob a message + final aliceResult1 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello World!', + ), + ); + + // Bob decrypts Alice's message + final bobResult1 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult1.encryptedKeys[bobJid]!, + base64.encode(aliceResult1.ciphertext!), + false, + ), + ); + expect(bobResult1.error, null); + expect(bobEmptyMessage, isNotNull); + + // Bob now sends the empty OMEMO message to Alice, who then decrypts + // it. + final aliceResult2 = await aliceManager.onIncomingStanza( + OmemoIncomingStanza( bobJid, - await bobSession.getDeviceId(), - bobResponseMessage.encryptedKeys, - 0, - ); - expect(messageText, aliceReceivedMessage); - } + bobDevice.id, + bobEmptyMessage!.encryptedKeys[aliceJid]!, + bobEmptyMessage!.ciphertext?.toBase64(), + false, + ), + ); + expect(aliceResult2.error, isNull); + expect(aliceResult2.payload, isNull); + expect(aliceManager.getRatchet(RatchetMapKey(bobJid, bobDevice.id))!.acknowledged, isTrue); + + // Now Alice sends something to Bob + final aliceResult3 = await aliceManager.onOutgoingStanza( + const OmemoOutgoingStanza( + [bobJid], + 'Hello Bob', + ), + ); + + // And Bob decrypts it + final bobResult = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice.id, + aliceResult3.encryptedKeys[bobJid]!, + aliceResult3.ciphertext?.toBase64(), + false, + ), + ); + expect(bobResult.error, isNull); + expect(bobResult.payload, 'Hello Bob'); }); } diff --git a/test/omemomanager_test.dart b/test/omemomanager_test.dart deleted file mode 100644 index 9ce7cc4..0000000 --- a/test/omemomanager_test.dart +++ /dev/null @@ -1,1446 +0,0 @@ -import 'dart:convert'; -import 'package:logging/logging.dart'; -import 'package:omemo_dart/omemo_dart.dart'; -import 'package:omemo_dart/src/protobuf/omemo_key_exchange.dart'; -import 'package:omemo_dart/src/trust/always.dart'; -import 'package:test/test.dart'; - -class TestingTrustManager extends AlwaysTrustingTrustManager { - final Map devices = {}; - - @override - Future onNewSession(String jid, int deviceId) async { - devices[jid] = deviceId; - } -} - -void main() { - Logger.root - ..level = Level.ALL - ..onRecord.listen((record) { - // ignore: avoid_print - print('${record.level.name}: ${record.message}'); - }); - - test('Test sending a message without the device list cache', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - var aliceEmptyMessageSent = 0; - var bobEmptyMessageSent = 0; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async { - aliceEmptyMessageSent++; - }, - (jid) async { - expect(jid, bobJid); - return [bobDevice.id]; - }, - (jid, id) async { - expect(jid, bobJid); - return bobDevice.toBundle(); - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async { - bobEmptyMessageSent++; - }, - (jid) async { - expect(jid, aliceJid); - return [aliceDevice.id]; - }, - (jid, id) async { - expect(jid, aliceJid); - return aliceDevice.toBundle(); - }, - (jid) async {}, - ); - - // Alice sends a message - final aliceResult = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello world', - ), - ); - - // Bob must be able to decrypt the message - final bobResult = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, - base64.encode(aliceResult.ciphertext!), - ), - ); - - expect(bobResult.payload, 'Hello world'); - expect(bobResult.error, null); - expect(aliceEmptyMessageSent, 0); - expect(bobEmptyMessageSent, 1); - - // Alice receives the ack message - await aliceManager.ratchetAcknowledged( - bobJid, - bobDevice.id, - ); - - // Bob now responds - final bobResult2 = await bobManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [aliceJid], - 'Hello world, Alice', - ), - ); - final aliceResult2 = await aliceManager.onIncomingStanza( - OmemoIncomingStanza( - bobJid, - bobDevice.id, - DateTime.now().millisecondsSinceEpoch, - bobResult2.encryptedKeys, - base64.encode(bobResult2.ciphertext!), - ), - ); - - expect(aliceResult2.error, null); - expect(aliceEmptyMessageSent, 0); - expect(bobEmptyMessageSent, 1); - expect(aliceResult2.payload, 'Hello world, Alice'); - }); - - test('Test triggering the heartbeat', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - var aliceEmptyMessageSent = 0; - var bobEmptyMessageSent = 0; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async { - aliceEmptyMessageSent++; - }, - (jid) async { - expect(jid, bobJid); - return [bobDevice.id]; - }, - (jid, id) async { - expect(jid, bobJid); - return bobDevice.toBundle(); - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async { - bobEmptyMessageSent++; - }, - (jid) async { - expect(jid, aliceJid); - return [aliceDevice.id]; - }, - (jid, id) async { - expect(jid, aliceJid); - return aliceDevice.toBundle(); - }, - (jid) async {}, - ); - - // Alice sends a message - final aliceResult = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello world', - ), - ); - - // Bob must be able to decrypt the message - final bobResult = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, - base64.encode(aliceResult.ciphertext!), - ), - ); - - expect(aliceEmptyMessageSent, 0); - expect(bobEmptyMessageSent, 1); - expect(bobResult.payload, 'Hello world'); - - // Bob acknowledges the message - await aliceManager.ratchetAcknowledged(bobJid, bobDevice.id); - - // Alice now sends 52 messages that Bob decrypts - for (var i = 0; i <= 51; i++) { - final aliceResultLoop = await aliceManager.onOutgoingStanza( - OmemoOutgoingStanza( - [bobJid], - 'Test message $i', - ), - ); - - final bobResultLoop = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResultLoop.encryptedKeys, - base64.encode(aliceResultLoop.ciphertext!), - ), - ); - - expect(aliceEmptyMessageSent, 0); - expect(bobEmptyMessageSent, 1); - expect(bobResultLoop.payload, 'Test message $i'); - } - - // Alice sends a final message that triggers a heartbeat - final aliceResultFinal = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Test message last', - ), - ); - - final bobResultFinal = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResultFinal.encryptedKeys, - base64.encode(aliceResultFinal.ciphertext!), - ), - ); - - expect(aliceEmptyMessageSent, 0); - expect(bobEmptyMessageSent, 2); - expect(bobResultFinal.payload, 'Test message last'); - }); - - test('Test accessing data without it existing', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => [], - (jid, id) async => null, - (jid) async {}, - ); - - // Get non-existant fingerprints - expect( - await aliceManager.getFingerprintsForJid(bobJid), - null, - ); - - // Ack a non-existant ratchet - await aliceManager.ratchetAcknowledged( - bobJid, - 42, - ); - }); - - test('Test receiving a message encrypted for another device', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - var oldDevice = true; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobOldDevice = - await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - final bobCurrentDevice = - await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, bobJid); - - return oldDevice ? [bobOldDevice.id] : [bobCurrentDevice.id]; - }, - (jid, id) async { - expect(jid, bobJid); - return oldDevice - ? bobOldDevice.toBundle() - : bobCurrentDevice.toBundle(); - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobCurrentDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => [], - (jid, id) async => null, - (jid) async {}, - ); - - // Alice encrypts a message to Bob - final aliceResult1 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob', - ), - ); - - // Bob's current device receives it - final bobResult1 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, - base64.encode(aliceResult1.ciphertext!), - ), - ); - - expect(bobResult1.payload, null); - expect(bobResult1.error is NotEncryptedForDeviceException, true); - - // Now Alice's client loses and regains the connection - aliceManager.onNewConnection(); - oldDevice = false; - - // And Alice sends a new message - final aliceResult2 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob x2', - ), - ); - final bobResult2 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, - base64.encode(aliceResult2.ciphertext!), - ), - ); - - expect(aliceResult2.encryptedKeys.length, 1); - expect(bobResult2.payload, 'Hello Bob x2'); - }); - - test('Test receiving a response from a new device', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - var bothDevices = false; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice1 = - await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - final bobDevice2 = - await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, bobJid); - - return [ - bobDevice1.id, - if (bothDevices) bobDevice2.id, - ]; - }, - (jid, id) async { - expect(jid, bobJid); - - if (bothDevices) { - if (id == bobDevice1.id) { - return bobDevice1.toBundle(); - } else if (id == bobDevice2.id) { - return bobDevice2.toBundle(); - } - } else { - if (id == bobDevice1.id) return bobDevice1.toBundle(); - } - - return null; - }, - (jid) async {}, - ); - final bobManager1 = OmemoManager( - bobDevice1, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => [], - (jid, id) async => null, - (jid) async {}, - ); - final bobManager2 = OmemoManager( - bobDevice2, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, aliceJid); - return [aliceDevice.id]; - }, - (jid, id) async { - expect(jid, aliceJid); - return aliceDevice.toBundle(); - }, - (jid) async {}, - ); - - // Alice sends a message to Bob - final aliceResult1 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob!', - ), - ); - - // Bob decrypts it - final bobResult1 = await bobManager1.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, - base64.encode(aliceResult1.ciphertext!), - ), - ); - - expect(aliceResult1.encryptedKeys.length, 1); - expect(bobResult1.payload, 'Hello Bob!'); - - // Now Bob encrypts from his new device - bothDevices = true; - final bobResult2 = await bobManager2.onOutgoingStanza( - const OmemoOutgoingStanza( - [aliceJid], - 'Hello from my new device', - ), - ); - - // And Alice decrypts it - final aliceResult2 = await aliceManager.onIncomingStanza( - OmemoIncomingStanza( - bobJid, - bobDevice2.id, - DateTime.now().millisecondsSinceEpoch, - bobResult2.encryptedKeys, - base64.encode(bobResult2.ciphertext!), - ), - ); - - expect(aliceResult2.payload, 'Hello from my new device'); - }); - - test('Test receiving a device list update', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - var bothDevices = false; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice1 = - await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - final bobDevice2 = - await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, bobJid); - - return [ - bobDevice1.id, - if (bothDevices) bobDevice2.id, - ]; - }, - (jid, id) async { - expect(jid, bobJid); - - if (bothDevices) { - if (id == bobDevice1.id) { - return bobDevice1.toBundle(); - } else if (id == bobDevice2.id) { - return bobDevice2.toBundle(); - } - } else { - if (id == bobDevice1.id) return bobDevice1.toBundle(); - } - - return null; - }, - (jid) async {}, - ); - final bobManager1 = OmemoManager( - bobDevice1, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => null, - (jid, id) async => null, - (jid) async {}, - ); - final bobManager2 = OmemoManager( - bobDevice2, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => null, - (jid, id) async => null, - (jid) async {}, - ); - - // Alice sends a message to Bob - final aliceResult1 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob!', - ), - ); - - // Bob decrypts it - final bobResult1 = await bobManager1.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, - base64.encode(aliceResult1.ciphertext!), - ), - ); - - expect(aliceResult1.encryptedKeys.length, 1); - expect(bobResult1.payload, 'Hello Bob!'); - - // Bob acks the ratchet session - await aliceManager.ratchetAcknowledged(bobJid, bobDevice1.id); - - // Bob now publishes a new device - bothDevices = true; - aliceManager.onDeviceListUpdate( - bobJid, - [ - bobDevice1.id, - bobDevice2.id, - ], - ); - - // Now Alice encrypts another message - final aliceResult2 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob! x2', - ), - ); - - expect(aliceResult2.encryptedKeys.length, 2); - - // And Bob decrypts it - final bobResult21 = await bobManager1.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, - base64.encode(aliceResult2.ciphertext!), - ), - ); - final bobResult22 = await bobManager2.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, - base64.encode(aliceResult2.ciphertext!), - ), - ); - - expect(bobResult21.payload, 'Hello Bob! x2'); - expect(bobResult22.payload, 'Hello Bob! x2'); - - // Bob2 now responds - final bobResult32 = await bobManager2.onOutgoingStanza( - const OmemoOutgoingStanza( - [aliceJid], - 'Hello Alice!', - ), - ); - - // And Alice responds - final aliceResult3 = await aliceManager.onIncomingStanza( - OmemoIncomingStanza( - bobJid, - bobDevice2.id, - DateTime.now().millisecondsSinceEpoch, - bobResult32.encryptedKeys, - base64.encode(bobResult32.ciphertext!), - ), - ); - - expect(aliceResult3.payload, 'Hello Alice!'); - }); - - test('Test sending a message to two different JIDs', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - const cocoJid = 'coco@server3'; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - final cocoDevice = - await OmemoDevice.generateNewDevice(cocoJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - if (jid == bobJid) { - return [bobDevice.id]; - } else if (jid == cocoJid) { - return [cocoDevice.id]; - } - - return null; - }, - (jid, id) async { - if (jid == bobJid) { - return bobDevice.toBundle(); - } else if (jid == cocoJid) { - return cocoDevice.toBundle(); - } - - return null; - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => null, - (jid, id) async => null, - (jid) async {}, - ); - final cocoManager = OmemoManager( - cocoDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => null, - (jid, id) async => null, - (jid) async {}, - ); - - // Alice sends a message to Bob and Coco - final aliceResult = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid, cocoJid], - 'Hello Bob and Coco!', - ), - ); - - // Bob and Coco decrypt them - final bobResult = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, - base64.encode(aliceResult.ciphertext!), - ), - ); - final cocoResult = await cocoManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, - base64.encode(aliceResult.ciphertext!), - ), - ); - - expect(bobResult.error, null); - expect(cocoResult.error, null); - expect(bobResult.payload, 'Hello Bob and Coco!'); - expect(cocoResult.payload, 'Hello Bob and Coco!'); - }); - - test('Test a fetch failure', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - var failure = false; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, bobJid); - - return failure ? null : [bobDevice.id]; - }, - (jid, id) async { - expect(jid, bobJid); - - return failure ? null : bobDevice.toBundle(); - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => null, - (jid, id) async => null, - (jid) async {}, - ); - - // Alice sends a message to Bob and Coco - final aliceResult1 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob!', - ), - ); - - // Bob decrypts it - final bobResult1 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, - base64.encode(aliceResult1.ciphertext!), - ), - ); - - expect(bobResult1.error, null); - expect(bobResult1.payload, 'Hello Bob!'); - - // Bob acks the message - await aliceManager.ratchetAcknowledged( - bobJid, - bobDevice.id, - ); - - // Alice has to reconnect but has no connection yet - failure = true; - aliceManager.onNewConnection(); - - // Alice sends another message to Bob - final aliceResult2 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob! x2', - ), - ); - - // And Bob decrypts it - final bobResult2 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, - base64.encode(aliceResult2.ciphertext!), - ), - ); - - expect(bobResult2.error, null); - expect(bobResult2.payload, 'Hello Bob! x2'); - }); - - test('Test sending a message with failed lookups', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, bobJid); - - return null; - }, - (jid, id) async { - expect(jid, bobJid); - - return null; - }, - (jid) async {}, - ); - - // Alice sends a message to Bob - final aliceResult = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob!', - ), - ); - - expect(aliceResult.isSuccess(1), false); - expect( - aliceResult.jidEncryptionErrors[bobJid] - is NoKeyMaterialAvailableException, - true, - ); - }); - - test('Test sending a message two two JIDs with failed lookups', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - const cocoJid = 'coco@server3'; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - if (jid == bobJid) { - return [bobDevice.id]; - } - - return null; - }, - (jid, id) async { - if (jid == bobJid) { - return bobDevice.toBundle(); - } - - return null; - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => null, - (jid, id) async => null, - (jid) async {}, - ); - - // Alice sends a message to Bob and Coco - final aliceResult = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid, cocoJid], - 'Hello Bob and Coco!', - ), - ); - - expect(aliceResult.isSuccess(2), true); - expect( - aliceResult.jidEncryptionErrors[cocoJid] - is NoKeyMaterialAvailableException, - true, - ); - - // Bob decrypts it - final bobResult = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, - base64.encode(aliceResult.ciphertext!), - ), - ); - - expect(bobResult.payload, 'Hello Bob and Coco!'); - }); - - test('Test sending multiple messages back and forth', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, bobJid); - - return [bobDevice.id]; - }, - (jid, id) async { - expect(jid, bobJid); - - return bobDevice.toBundle(); - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => null, - (jid, id) async => null, - (jid) async {}, - ); - - // Alice encrypts a message for Bob - final aliceMessage = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob!', - ), - ); - - // And Bob decrypts it - await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceMessage.encryptedKeys, - base64.encode(aliceMessage.ciphertext!), - ), - ); - - // Ratchets are acked - await aliceManager.ratchetAcknowledged( - bobJid, - bobDevice.id, - ); - - for (var i = 0; i < 100; i++) { - final messageText = 'Test Message #$i'; - // Bob responds to Alice - final bobResponseMessage = await bobManager.onOutgoingStanza( - OmemoOutgoingStanza( - [aliceJid], - messageText, - ), - ); - expect(bobResponseMessage.isSuccess(1), true); - - final aliceReceivedMessage = await aliceManager.onIncomingStanza( - OmemoIncomingStanza( - bobJid, - bobDevice.id, - DateTime.now().millisecondsSinceEpoch, - bobResponseMessage.encryptedKeys, - base64.encode(bobResponseMessage.ciphertext!), - ), - ); - expect(aliceReceivedMessage.payload, messageText); - } - }); - - test('Test removing all ratchets and sending a message', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - EncryptionResult? aliceEmptyMessage; - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async { - aliceEmptyMessage = result; - }, - (jid) async { - expect(jid, bobJid); - - return [bobDevice.id]; - }, - (jid, id) async { - expect(jid, bobJid); - - return bobDevice.toBundle(); - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => null, - (jid, id) async => null, - (jid) async {}, - ); - - // Alice encrypts a message for Bob - final aliceResult1 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob!', - ), - ); - - // And Bob decrypts it - await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, - base64.encode(aliceResult1.ciphertext!), - ), - ); - - // Ratchets are acked - await aliceManager.ratchetAcknowledged( - bobJid, - bobDevice.id, - ); - - // Alice now removes all ratchets for Bob and sends another new message - await aliceManager.removeAllRatchets(bobJid); - - expect(aliceManager.getRatchet(RatchetMapKey(bobJid, bobDevice.id)), null); - - // Alice prepares an empty OMEMO message - await aliceManager.sendOmemoHeartbeat(bobJid); - - // And Bob receives it - final bobResult2 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - await aliceManager.getDeviceId(), - DateTime.now().millisecondsSinceEpoch, - aliceEmptyMessage!.encryptedKeys, - null, - ), - ); - expect(bobResult2.error, null); - - // Bob acks the new ratchet - await aliceManager.ratchetAcknowledged( - bobJid, - await bobManager.getDeviceId(), - ); - - // Alice sends another message - final aliceResult3 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'I did not trust your last device, Bob!', - ), - ); - - // Bob decrypts it - final bobResult3 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult3.encryptedKeys, - base64.encode(aliceResult3.ciphertext!), - ), - ); - - expect(bobResult3.error, null); - expect(bobResult3.payload, 'I did not trust your last device, Bob!'); - - // Bob responds - final bobResult4 = await bobManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [aliceJid], - "That's okay.", - ), - ); - - // Alice decrypts - final aliceResult4 = await aliceManager.onIncomingStanza( - OmemoIncomingStanza( - bobJid, - bobDevice.id, - DateTime.now().millisecondsSinceEpoch, - bobResult4.encryptedKeys, - base64.encode(bobResult4.ciphertext!), - ), - ); - - expect(aliceResult4.error, null); - expect(aliceResult4.payload, "That's okay."); - }); - - test( - 'Test removing all ratchets and sending a message without post-heartbeat ack', - () async { - // This test is the same as "Test removing all ratchets and sending a message" except - // that bob does not ack the ratchet after Alice's heartbeat after she recreated - // all ratchets. - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - EncryptionResult? aliceEmptyMessage; - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async { - aliceEmptyMessage = result; - }, - (jid) async { - expect(jid, bobJid); - - return [bobDevice.id]; - }, - (jid, id) async { - expect(jid, bobJid); - - return bobDevice.toBundle(); - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async => null, - (jid, id) async => null, - (jid) async {}, - ); - - // Alice encrypts a message for Bob - final aliceResult1 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob!', - ), - ); - - // And Bob decrypts it - await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, - base64.encode(aliceResult1.ciphertext!), - ), - ); - - // Ratchets are acked - await aliceManager.ratchetAcknowledged( - bobJid, - bobDevice.id, - ); - - // Alice now removes all ratchets for Bob and sends another new message - await aliceManager.removeAllRatchets(bobJid); - - expect(aliceManager.getRatchet(RatchetMapKey(bobJid, bobDevice.id)), null); - - // Alice prepares an empty OMEMO message - await aliceManager.sendOmemoHeartbeat(bobJid); - - // And Bob receives it - final bobResult2 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - await aliceManager.getDeviceId(), - DateTime.now().millisecondsSinceEpoch, - aliceEmptyMessage!.encryptedKeys, - null, - ), - ); - expect(bobResult2.error, null); - - // Alice sends another message - final aliceResult3 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'I did not trust your last device, Bob!', - ), - ); - - // Bob decrypts it - final bobResult3 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult3.encryptedKeys, - base64.encode(aliceResult3.ciphertext!), - ), - ); - - expect(bobResult3.error, null); - expect(bobResult3.payload, 'I did not trust your last device, Bob!'); - - // Bob responds - final bobResult4 = await bobManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [aliceJid], - "That's okay.", - ), - ); - - // Alice decrypts - final aliceResult4 = await aliceManager.onIncomingStanza( - OmemoIncomingStanza( - bobJid, - bobDevice.id, - DateTime.now().millisecondsSinceEpoch, - bobResult4.encryptedKeys, - base64.encode(bobResult4.ciphertext!), - ), - ); - - expect(aliceResult4.error, null); - expect(aliceResult4.payload, "That's okay."); - }); - - test('Test resending key exchanges', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, bobJid); - return [bobDevice.id]; - }, - (jid, id) async { - expect(jid, bobJid); - return bobDevice.toBundle(); - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, aliceJid); - return [aliceDevice.id]; - }, - (jid, id) async { - expect(jid, aliceJid); - return aliceDevice.toBundle(); - }, - (jid) async {}, - ); - - // Alice sends Bob a message - final aliceResult1 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello World!', - ), - ); - - // The first message must be a KEX message - expect(aliceResult1.encryptedKeys.first.kex, true); - - // Bob decrypts Alice's message - final bobResult1 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, - base64.encode(aliceResult1.ciphertext!), - ), - ); - expect(bobResult1.error, null); - expect(bobResult1.payload, 'Hello World!'); - - // Alice immediately sends another message - final aliceResult2 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello Bob', - ), - ); - - // The response should contain a KEX - expect(aliceResult2.encryptedKeys.first.kex, true); - - // The basic data should be the same - final parsedFirstKex = OmemoKeyExchange.fromBuffer( - base64.decode(aliceResult1.encryptedKeys.first.value), - ); - final parsedSecondKex = OmemoKeyExchange.fromBuffer( - base64.decode(aliceResult2.encryptedKeys.first.value), - ); - expect(parsedSecondKex.pkId, parsedFirstKex.pkId); - expect(parsedSecondKex.spkId, parsedFirstKex.spkId); - expect(parsedSecondKex.ik, parsedFirstKex.ik); - expect(parsedSecondKex.ek, parsedFirstKex.ek); - - // Alice decrypts it - final bobResult2 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, - base64.encode(aliceResult2.ciphertext!), - ), - ); - expect(bobResult2.error, null); - expect(bobResult2.payload, 'Hello Bob'); - - // Bob also sends a message - final bobResult3 = await bobManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [aliceJid], - 'Hello Alice!', - ), - ); - - // Alice decrypts it - final aliceResult3 = await aliceManager.onIncomingStanza( - OmemoIncomingStanza( - bobJid, - bobDevice.id, - DateTime.now().millisecondsSinceEpoch, - bobResult3.encryptedKeys, - base64.encode(bobResult3.ciphertext!), - ), - ); - expect(aliceResult3.error, null); - expect(aliceResult3.payload, 'Hello Alice!'); - - // Bob now acks the ratchet - await aliceManager.ratchetAcknowledged( - bobJid, - bobDevice.id, - ); - - // Alice replies - final aliceResult4 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hi Bob', - ), - ); - - // The response should contain no KEX - expect(aliceResult4.encryptedKeys.first.kex, false); - - // Bob decrypts it - final bobResult4 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult4.encryptedKeys, - base64.encode(aliceResult4.ciphertext!), - ), - ); - expect(bobResult4.error, null); - expect(bobResult4.payload, 'Hi Bob'); - }); - - test('Test correct trust behaviour on receiving', () async { - const aliceJid = 'alice@server1'; - const bobJid = 'bob@server2'; - - final aliceDevice = - await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); - final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); - - final aliceManager = OmemoManager( - aliceDevice, - AlwaysTrustingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, bobJid); - return [bobDevice.id]; - }, - (jid, id) async { - expect(jid, bobJid); - return bobDevice.toBundle(); - }, - (jid) async {}, - ); - final bobManager = OmemoManager( - bobDevice, - TestingTrustManager(), - (result, recipientJid) async {}, - (jid) async { - expect(jid, aliceJid); - return [aliceDevice.id]; - }, - (jid, id) async { - expect(jid, aliceJid); - return aliceDevice.toBundle(); - }, - (jid) async {}, - ); - - // Alice sends Bob a message - final aliceResult1 = await aliceManager.onOutgoingStanza( - const OmemoOutgoingStanza( - [bobJid], - 'Hello World!', - ), - ); - - // Bob decrypts Alice's message - final bobResult1 = await bobManager.onIncomingStanza( - OmemoIncomingStanza( - aliceJid, - aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, - base64.encode(aliceResult1.ciphertext!), - ), - ); - expect(bobResult1.error, null); - - // Bob should have some trust state - expect( - (bobManager.trustManager as TestingTrustManager).devices[aliceJid], - await aliceManager.getDeviceId(), - ); - }); -} diff --git a/test/protobuf_test.dart b/test/protobuf_test.dart deleted file mode 100644 index 103be11..0000000 --- a/test/protobuf_test.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:omemo_dart/protobuf/schema.pb.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/protobuf/protobuf.dart'; -import 'package:test/test.dart'; - -void main() { - group('Base 128 Varints', () { - test('Test simple parsing of Varints', () { - expect( - decodeVarint([1], 0).n, - 1, - ); - expect( - decodeVarint([1], 0).length, - 1, - ); - expect( - decodeVarint([0x96, 0x01, 0x00], 0).n, - 150, - ); - expect( - decodeVarint([0x96, 0x01, 0x00], 0).length, - 2, - ); - expect( - decodeVarint([172, 2, 0x8], 0).n, - 300, - ); - expect( - decodeVarint([172, 2, 0x8], 0).length, - 2, - ); - }); - - test('Test encoding Varints', () { - expect( - encodeVarint(1), - [1], - ); - expect( - encodeVarint(150), - [0x96, 0x01], - ); - expect( - encodeVarint(300), - [172, 2], - ); - }); - - test('Test some special cases', () { - expect(decodeVarint(encodeVarint(1042464893), 0).n, 1042464893); - }); - }); - - group('OMEMOMessage', () { - test('Decode a OMEMOMessage', () { - final pbMessage = OMEMOMessage() - ..n = 1 - ..pn = 5 - ..dhPub = [1, 2, 3] - ..ciphertext = [4, 5, 6]; - final serial = pbMessage.writeToBuffer(); - final msg = OmemoMessage.fromBuffer(serial); - - expect(msg.n, 1); - expect(msg.pn, 5); - expect(msg.dhPub, [1, 2, 3]); - expect(msg.ciphertext, [4, 5, 6]); - }); - test('Decode a OMEMOMessage without ciphertext', () { - final pbMessage = OMEMOMessage() - ..n = 1 - ..pn = 5 - ..dhPub = [1, 2, 3]; - final serial = pbMessage.writeToBuffer(); - final msg = OmemoMessage.fromBuffer(serial); - - expect(msg.n, 1); - expect(msg.pn, 5); - expect(msg.dhPub, [1, 2, 3]); - expect(msg.ciphertext, null); - }); - test('Encode a OMEMOMessage', () { - final m = OmemoMessage() - ..n = 1 - ..pn = 5 - ..dhPub = [1, 2, 3] - ..ciphertext = [4, 5, 6]; - final serial = m.writeToBuffer(); - final msg = OMEMOMessage.fromBuffer(serial); - - expect(msg.n, 1); - expect(msg.pn, 5); - expect(msg.dhPub, [1, 2, 3]); - expect(msg.ciphertext, [4, 5, 6]); - }); - test('Encode a OMEMOMessage without ciphertext', () { - final m = OmemoMessage() - ..n = 1 - ..pn = 5 - ..dhPub = [1, 2, 3]; - final serial = m.writeToBuffer(); - final msg = OMEMOMessage.fromBuffer(serial); - - expect(msg.n, 1); - expect(msg.pn, 5); - expect(msg.dhPub, [1, 2, 3]); - expect(msg.ciphertext, []); - }); - }); - - group('OMEMOAuthenticatedMessage', () { - test('Test encoding a message', () { - final msg = OmemoAuthenticatedMessage() - ..mac = [1, 2, 3] - ..message = [4, 5, 6]; - final decoded = OMEMOAuthenticatedMessage.fromBuffer(msg.writeToBuffer()); - - expect(decoded.mac, [1, 2, 3]); - expect(decoded.message, [4, 5, 6]); - }); - test('Test decoding a message', () { - final msg = OMEMOAuthenticatedMessage() - ..mac = [1, 2, 3] - ..message = [4, 5, 6]; - final bytes = msg.writeToBuffer(); - final decoded = OmemoAuthenticatedMessage.fromBuffer(bytes); - - expect(decoded.mac, [1, 2, 3]); - expect(decoded.message, [4, 5, 6]); - }); - }); - - group('OMEMOKeyExchange', () { - test('Test encoding a message', () { - final authMessage = OmemoAuthenticatedMessage() - ..mac = [5, 6, 8, 0] - ..message = [4, 5, 7, 3, 2]; - final message = OmemoKeyExchange() - ..pkId = 698 - ..spkId = 245 - ..ik = [1, 4, 6] - ..ek = [4, 6, 7, 80] - ..message = authMessage; - final kex = OMEMOKeyExchange.fromBuffer(message.writeToBuffer()); - - expect(kex.pkId, 698); - expect(kex.spkId, 245); - expect(kex.ik, [1, 4, 6]); - expect(kex.ek, [4, 6, 7, 80]); - - expect(kex.message.mac, [5, 6, 8, 0]); - expect(kex.message.message, [4, 5, 7, 3, 2]); - }); - test('Test decoding a message', () { - final message = OMEMOAuthenticatedMessage() - ..mac = [5, 6, 8, 0] - ..message = [4, 5, 7, 3, 2]; - final kex = OMEMOKeyExchange() - ..pkId = 698 - ..spkId = 245 - ..ik = [1, 4, 6] - ..ek = [4, 6, 7, 80] - ..message = message; - final decoded = OmemoKeyExchange.fromBuffer(kex.writeToBuffer()); - - expect(decoded.pkId, 698); - expect(decoded.spkId, 245); - expect(decoded.ik, [1, 4, 6]); - expect(decoded.ek, [4, 6, 7, 80]); - - expect(decoded.message!.mac, [5, 6, 8, 0]); - expect(decoded.message!.message, [4, 5, 7, 3, 2]); - }); - test('Test decoding an issue', () { - /* - final data = 'CAAQfRogc2GwslU219dUkrMHNM4KdZRmuFnBTae+bQaJ+55IsAMiII7aZKj2sUpb6xR/3Ari7WZUmKFV0G6czUc4NMvjKDBaKnwKEM2ZpI8X3TgcxhxwENANnlsSaAgAEAAaICy8T9WPgLb7RdYd8/4JkrLF0RahEkC3ZaEfk5jw3dsLIkBMILzLyByweLgF4lCn0oNea+kbdrFr6rY7r/7WyI8hXEQz38QpnN+jyGGwC7Ga0dq70WuyqE7VpiFArQwqZh2G'; - final kex = OmemoKeyExchange.fromBuffer(base64Decode(data)); - - expect(kex.spkId!, 1042464893); - */ - }); - }); -} diff --git a/test/queue_test.dart b/test/queue_test.dart new file mode 100644 index 0000000..b424593 --- /dev/null +++ b/test/queue_test.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'package:omemo_dart/src/omemo/queue.dart'; +import 'package:test/test.dart'; + +Future testMethod( + RatchetAccessQueue queue, + List data, + int duration, +) async { + await queue.enterCriticalSection(data); + + await Future.delayed(Duration(seconds: duration)); + + await queue.leaveCriticalSection(data); +} + +void main() { + test('Test blocking due to conflicts', () async { + final queue = RatchetAccessQueue(); + + unawaited(testMethod(queue, ['a', 'b', 'c'], 5)); + unawaited(testMethod(queue, ['a'], 4)); + + await Future.delayed(const Duration(seconds: 1)); + expect( + queue.runningOperations.containsAll(['a', 'b', 'c']), + isTrue, + ); + expect(queue.runningOperations.length, 3); + + await Future.delayed(const Duration(seconds: 4)); + + expect( + queue.runningOperations.containsAll(['a']), + isTrue, + ); + expect(queue.runningOperations.length, 1); + + await Future.delayed(const Duration(seconds: 4)); + expect(queue.runningOperations.length, 0); + }); + + test('Test not blocking due to no conflicts', () async { + final queue = RatchetAccessQueue(); + + unawaited(testMethod(queue, ['a', 'b'], 5)); + unawaited(testMethod(queue, ['c'], 5)); + unawaited(testMethod(queue, ['d'], 5)); + + await Future.delayed(const Duration(seconds: 1)); + expect(queue.runningOperations.length, 4); + expect( + queue.runningOperations.containsAll([ + 'a', + 'b', + 'c', + 'd', + ]), + isTrue, + ); + }); +} diff --git a/test/serialisation_test.dart b/test/serialisation_test.dart deleted file mode 100644 index 902d89d..0000000 --- a/test/serialisation_test.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'dart:convert'; -import 'package:omemo_dart/omemo_dart.dart'; -import 'package:omemo_dart/src/trust/always.dart'; -import 'package:test/test.dart'; - -Map jsonify(Map map) { - return jsonDecode(jsonEncode(map)) as Map; -} - -void main() { - test('Test serialising and deserialising the Device', () async { - // Generate a random session - final oldSession = await OmemoSessionManager.generateNewIdentity( - 'user@test.server', - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final oldDevice = await oldSession.getDevice(); - final serialised = jsonify(await oldDevice.toJson()); - - final newDevice = OmemoDevice.fromJson(serialised); - expect(await oldDevice.equals(newDevice), true); - }); - - test('Test serialising and deserialising the Device after rotating the SPK', - () async { - // Generate a random session - final oldSession = await OmemoSessionManager.generateNewIdentity( - 'user@test.server', - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final oldDevice = - await (await oldSession.getDevice()).replaceSignedPrekey(); - final serialised = jsonify(await oldDevice.toJson()); - - final newDevice = OmemoDevice.fromJson(serialised); - expect(await oldDevice.equals(newDevice), true); - }); - - test('Test serialising and deserialising the OmemoDoubleRatchet', () async { - // Generate a random ratchet - const aliceJid = 'alice@server.example'; - const bobJid = 'bob@other.server.example'; - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final aliceMessage = await aliceSession.encryptToJid( - bobJid, - 'Hello Bob!', - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - await bobSession.decryptMessage( - aliceMessage.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - aliceMessage.encryptedKeys, - getTimestamp(), - ); - final aliceOld = - aliceSession.getRatchet(bobJid, await bobSession.getDeviceId()); - final aliceSerialised = jsonify(await aliceOld.toJson()); - final aliceNew = OmemoDoubleRatchet.fromJson(aliceSerialised); - - expect(await aliceOld.equals(aliceNew), true); - }); - - test('Test serialising and deserialising the OmemoSessionManager', () async { - // Generate a random session - final oldSession = await OmemoSessionManager.generateNewIdentity( - 'a@server', - AlwaysTrustingTrustManager(), - opkAmount: 4, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - 'b@other.server', - AlwaysTrustingTrustManager(), - opkAmount: 4, - ); - await oldSession.addSessionFromBundle( - 'bob@localhost', - await bobSession.getDeviceId(), - await bobSession.getDeviceBundle(), - ); - - // Serialise and deserialise - final serialised = jsonify(await oldSession.toJsonWithoutSessions()); - final newSession = OmemoSessionManager.fromJsonWithoutSessions( - serialised, - // NOTE: At this point, we don't care about this attribute - {}, - AlwaysTrustingTrustManager(), - ); - - final oldDevice = await oldSession.getDevice(); - final newDevice = await newSession.getDevice(); - expect(await oldDevice.equals(newDevice), true); - expect(await oldSession.getDeviceMap(), await newSession.getDeviceMap()); - }); - - test('Test serializing and deserializing RatchetMapKey', () { - const test1 = RatchetMapKey('user@example.org', 1234); - final result1 = RatchetMapKey.fromJsonKey(test1.toJsonKey()); - expect(result1.jid, test1.jid); - expect(result1.deviceId, test1.deviceId); - - const test2 = RatchetMapKey('user@example.org/hallo:welt', 3333); - final result2 = RatchetMapKey.fromJsonKey(test2.toJsonKey()); - expect(result2.jid, test2.jid); - expect(result2.deviceId, test2.deviceId); - }); - - test('Test serializing and deserializing the components of the BTBV manager', - () async { - // Caroline's BTBV manager - final btbv = MemoryBTBVTrustManager(); - // Example data - const aliceJid = 'alice@some.server'; - const bobJid = 'bob@other.server'; - - await btbv.onNewSession(aliceJid, 1); - await btbv.setDeviceTrust(aliceJid, 1, BTBVTrustState.verified); - await btbv.onNewSession(aliceJid, 2); - await btbv.onNewSession(bobJid, 3); - await btbv.onNewSession(bobJid, 4); - - final serialized = jsonify(await btbv.toJson()); - final deviceList = - BlindTrustBeforeVerificationTrustManager.deviceListFromJson( - serialized, - ); - expect(btbv.devices, deviceList); - - final trustCache = - BlindTrustBeforeVerificationTrustManager.trustCacheFromJson( - serialized, - ); - expect(btbv.trustCache, trustCache); - - final enableCache = - BlindTrustBeforeVerificationTrustManager.enableCacheFromJson( - serialized, - ); - expect(btbv.enablementCache, enableCache); - }); -} diff --git a/test/trust_test.dart b/test/trust_test.dart index 363ac63..01f4170 100644 --- a/test/trust_test.dart +++ b/test/trust_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { test('Test the Blind Trust Before Verification TrustManager', () async { // Caroline's BTBV manager - final btbv = MemoryBTBVTrustManager(); + final btbv = BlindTrustBeforeVerificationTrustManager(); // Example data const aliceJid = 'alice@some.server'; const bobJid = 'bob@other.server'; diff --git a/test/x3dh_test.dart b/test/x3dh_test.dart index 68b249d..a8b91c5 100644 --- a/test/x3dh_test.dart +++ b/test/x3dh_test.dart @@ -26,7 +26,8 @@ void main() { ); // Alice does X3DH - final resultAlice = await x3dhFromBundle(bundleBob, ikAlice); + final resultAliceRaw = await x3dhFromBundle(bundleBob, ikAlice); + final resultAlice = resultAliceRaw.get(); // Alice sends the inital message to Bob // ... @@ -68,18 +69,7 @@ void main() { ); // Alice does X3DH - var exception = false; - try { - await x3dhFromBundle(bundleBob, ikAlice); - } catch (e) { - exception = true; - expect( - e is InvalidSignatureException, - true, - reason: 'Expected InvalidSignatureException, but got $e', - ); - } - - expect(exception, true, reason: 'Expected test failure'); + final result = await x3dhFromBundle(bundleBob, ikAlice); + expect(result.isType(), isTrue); }); }