diff --git a/lib/src/omemo/sessionmanager.dart b/lib/src/omemo/sessionmanager.dart index 76f3bc0..7a3357c 100644 --- a/lib/src/omemo/sessionmanager.dart +++ b/lib/src/omemo/sessionmanager.dart @@ -28,7 +28,7 @@ class EncryptionResult { const EncryptionResult(this.ciphertext, this.encryptedKeys); /// The actual message that was encrypted - final List ciphertext; + final List? ciphertext; /// Mapping of the device Id to the key for decrypting ciphertext, encrypted /// for the ratchet with said device Id @@ -186,25 +186,35 @@ class OmemoSessionManager { } /// Like [encryptToJids] but only for one Jid [jid]. - Future encryptToJid(String jid, String plaintext, { List? newSessions }) { + Future encryptToJid(String jid, String? plaintext, { List? newSessions }) { return encryptToJids([jid], plaintext, newSessions: newSessions); } /// Encrypt the key [plaintext] for all known bundles of the Jids in [jids]. Returns a /// map that maps the device Id to the ciphertext of [plaintext]. - Future encryptToJids(List jids, String plaintext, { List? newSessions }) async { + /// + /// If [plaintext] is null, then the result will be an empty OMEMO message, i.e. one that + /// does not contain a element. This means that the ciphertext attribute of + /// the result will be null as well. + Future encryptToJids(List jids, String? plaintext, { List? newSessions }) async { final encryptedKeys = List.empty(growable: true); - // Generate the key and encrypt the plaintext - final key = generateRandomBytes(32); - final keys = await deriveEncryptionKeys(key, omemoPayloadInfoString); - final ciphertext = await aes256CbcEncrypt( - utf8.encode(plaintext), - keys.encryptionKey, - keys.iv, - ); - final hmac = await truncatedHmac(ciphertext, keys.authenticationKey); - final concatKey = concat([key, hmac]); + var ciphertext = const []; + var keyPayload = const []; + if (plaintext != null) { + // Generate the key and encrypt the plaintext + final key = generateRandomBytes(32); + final keys = await deriveEncryptionKeys(key, omemoPayloadInfoString); + ciphertext = await aes256CbcEncrypt( + utf8.encode(plaintext), + keys.encryptionKey, + keys.iv, + ); + final hmac = await truncatedHmac(ciphertext, keys.authenticationKey); + keyPayload = concat([key, hmac]); + } else { + keyPayload = List.filled(32, 0x0); + } final kex = {}; if (newSessions != null) { @@ -219,14 +229,14 @@ class OmemoSessionManager { for (final deviceId in _deviceMap[jid]!) { final ratchetKey = RatchetMapKey(jid, deviceId); final ratchet = _ratchetMap[ratchetKey]!; - final ciphertext = (await ratchet.ratchetEncrypt(concatKey)).ciphertext; + final ciphertext = (await ratchet.ratchetEncrypt(keyPayload)).ciphertext; // Commit the ratchet _eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet)); if (kex.isNotEmpty && kex.containsKey(deviceId)) { final k = kex[deviceId]! - ..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); + ..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); encryptedKeys.add( EncryptedKey( jid, @@ -250,7 +260,7 @@ class OmemoSessionManager { }); return EncryptionResult( - ciphertext, + plaintext != null ? ciphertext : null, encryptedKeys, ); } @@ -259,7 +269,12 @@ class OmemoSessionManager { /// 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. - Future decryptMessage(List ciphertext, String senderJid, int senderDeviceId, List keys) async { + /// + /// If the received message is an empty OMEMO message, i.e. there is no + /// element, then [ciphertext] must be set to null. In this case, this function + /// will return null as there is no message to be decrypted. This, however, is used + /// to set up sessions or advance the ratchets. + Future decryptMessage(List? ciphertext, String senderJid, int senderDeviceId, List keys) async { // Try to find a session we can decrypt with. var device = await getDevice(); final rawKey = keys.firstWhereOrNull((key) => key.rid == device.id); @@ -314,6 +329,11 @@ class OmemoSessionManager { _eventStreamController.add(RatchetModifiedEvent(senderJid, senderDeviceId, ratchet)); }); + // 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); @@ -322,7 +342,7 @@ class OmemoSessionManager { if (!listsEqual(hmac, computedHmac)) { throw InvalidMessageHMACException(); } - + final plaintext = await aes256CbcDecrypt(ciphertext, derivedKeys.encryptionKey, derivedKeys.iv); return utf8.decode(plaintext); } diff --git a/test/omemo_test.dart b/test/omemo_test.dart index cbcc687..bf0a9f7 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -186,4 +186,39 @@ void main() { ); expect(messagePlaintext, aliceMessage2); }); + + test('Test using 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, opkAmount: 1); + final bobSession = await OmemoSessionManager.generateNewIdentity(bobJid, opkAmount: 1); + + // Alice encrypts a message for Bob + final aliceMessage = await aliceSession.encryptToJid( + bobJid, + null, + newSessions: [ + await (await bobSession.getDevice()).toBundle(), + ], + ); + expect(aliceMessage.encryptedKeys.length, 1); + expect(aliceMessage.ciphertext, null); + + // Alice sends the message to Bob + // ... + + // Bob decrypts it + final bobMessage = await bobSession.decryptMessage( + aliceMessage.ciphertext, + aliceJid, + (await aliceSession.getDevice()).id, + aliceMessage.encryptedKeys, + ); + expect(bobMessage, null); + + // This call must not cause an exception + bobSession.getRatchet(aliceJid, (await aliceSession.getDevice()).id); + }); }