From 50f6513c6fd71ce0a3dcdab32591138243717cc8 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Mon, 12 Jun 2023 23:39:08 +0200 Subject: [PATCH 01/36] feat: Remove custom protobuf parsing --- lib/src/double_ratchet/crypto.dart | 15 +- lib/src/double_ratchet/double_ratchet.dart | 24 +- lib/src/omemo/omemomanager.dart | 44 +- .../protobuf/omemo_authenticated_message.dart | 38 - lib/src/protobuf/omemo_key_exchange.dart | 71 -- lib/src/protobuf/omemo_message.dart | 75 -- lib/src/protobuf/protobuf.dart | 64 -- pubspec.yaml | 4 +- test/omemo_test.dart | 956 ------------------ test/omemomanager_test.dart | 6 +- test/protobuf_test.dart | 186 ---- test/serialisation_test.dart | 24 +- 12 files changed, 53 insertions(+), 1454 deletions(-) delete mode 100644 lib/src/protobuf/omemo_authenticated_message.dart delete mode 100644 lib/src/protobuf/omemo_key_exchange.dart delete mode 100644 lib/src/protobuf/omemo_message.dart delete mode 100644 lib/src/protobuf/protobuf.dart delete mode 100644 test/omemo_test.dart delete mode 100644 test/protobuf_test.dart diff --git a/lib/src/double_ratchet/crypto.dart b/lib/src/double_ratchet/crypto.dart index 42033ed..fad14d9 100644 --- a/lib/src/double_ratchet/crypto.dart +++ b/lib/src/double_ratchet/crypto.dart @@ -1,8 +1,7 @@ +import 'package:omemo_dart/protobuf/schema.pb.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/protobuf/omemo_authenticated_message.dart'; -import 'package:omemo_dart/src/protobuf/omemo_message.dart'; /// Info string for ENCRYPT const encryptHkdfInfoString = 'OMEMO Message Key Material'; @@ -22,12 +21,12 @@ Future> encrypt( await aes256CbcEncrypt(plaintext, keys.encryptionKey, keys.iv); final header = - OmemoMessage.fromBuffer(associatedData.sublist(sessionAd.length)) + 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() + final message = OMEMOAuthenticatedMessage() ..mac = hmacResult ..message = headerBytes; return message.writeToBuffer(); @@ -46,15 +45,15 @@ Future> decrypt( final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString); // Assumption ciphertext is a OMEMOAuthenticatedMessage - final message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); - final header = OmemoMessage.fromBuffer(message.message!); + 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!)) { + if (!listsEqual(hmacResult, message.mac)) { throw InvalidMessageHMACException(); } - return aes256CbcDecrypt(header.ciphertext!, keys.encryptionKey, keys.iv); + 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..aa328c8 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -2,20 +2,20 @@ import 'dart:convert'; import 'package:cryptography/cryptography.dart'; import 'package:hex/hex.dart'; import 'package:meta/meta.dart'; +import 'package:omemo_dart/protobuf/schema.pb.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 OMEMOMessage header; final List ciphertext; } @@ -274,12 +274,12 @@ class OmemoDoubleRatchet { } Future?> _trySkippedMessageKeys( - OmemoMessage header, + OMEMOMessage header, List ciphertext, ) async { final key = SkippedKey( - OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519), - header.n!, + OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519), + header.n, ); if (mkSkipped.containsKey(key)) { final mk = mkSkipped[key]!; @@ -312,11 +312,11 @@ class OmemoDoubleRatchet { } } - Future _dhRatchet(OmemoMessage header) async { + Future _dhRatchet(OMEMOMessage header) async { pn = ns; ns = 0; nr = 0; - dhr = OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519); + dhr = OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519); final newRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0)); rk = List.from(newRk); @@ -333,7 +333,7 @@ class OmemoDoubleRatchet { final mk = await kdfCk(cks!, kdfCkNextMessageKey); cks = newCks; - final header = OmemoMessage() + final header = OMEMOMessage() ..dhPub = await dhs.pk.getBytes() ..pn = pn ..n = ns; @@ -356,7 +356,7 @@ class OmemoDoubleRatchet { /// /// Throws an SkippingTooManyMessagesException if too many messages were to be skipped. Future> ratchetDecrypt( - OmemoMessage header, + OMEMOMessage header, List ciphertext, ) async { // Check if we skipped too many messages @@ -366,15 +366,15 @@ class OmemoDoubleRatchet { } final dhPubMatches = listsEqual( - header.dhPub!, + header.dhPub, (await dhr?.getBytes()) ?? [], ); if (!dhPubMatches) { - await _skipMessageKeys(header.pn!); + await _skipMessageKeys(header.pn); await _dhRatchet(header); } - await _skipMessageKeys(header.n!); + await _skipMessageKeys(header.n); final newCkr = await kdfCk(ckr!, kdfCkNextChainKey); final mk = await kdfCk(ckr!, kdfCkNextMessageKey); ckr = newCkr; diff --git a/lib/src/omemo/omemomanager.dart b/lib/src/omemo/omemomanager.dart index 20cc3e2..f58b694 100644 --- a/lib/src/omemo/omemomanager.dart +++ b/lib/src/omemo/omemomanager.dart @@ -6,6 +6,7 @@ import 'package:cryptography/cryptography.dart'; import 'package:hex/hex.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; +import 'package:omemo_dart/protobuf/schema.pb.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'; @@ -21,9 +22,6 @@ 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'; @@ -194,7 +192,7 @@ class OmemoManager { Future _addSessionFromKeyExchange( String jid, int deviceId, - OmemoKeyExchange kex, + OMEMOKeyExchange kex, ) async { // Pick the correct SPK final device = await getDevice(); @@ -209,17 +207,17 @@ class OmemoManager { final kexResult = await x3dhFromInitialMessage( X3DHMessage( - OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519), - OmemoPublicKey.fromBytes(kex.ek!, KeyPairType.x25519), - kex.pkId!, + OmemoPublicKey.fromBytes(kex.ik, KeyPairType.ed25519), + OmemoPublicKey.fromBytes(kex.ek, KeyPairType.x25519), + kex.pkId, ), spk, - device.opks.values.elementAt(kex.pkId!), + device.opks.values.elementAt(kex.pkId), device.ik, ); final ratchet = await OmemoDoubleRatchet.acceptNewSession( spk, - OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519), + OmemoPublicKey.fromBytes(kex.ik, KeyPairType.ed25519), kexResult.sk, kexResult.ad, getTimestamp(), @@ -234,7 +232,7 @@ class OmemoManager { /// Create a ratchet session initiated by Alice to the user with Jid [jid] and the device /// [deviceId] from the bundle [bundle]. @visibleForTesting - Future addSessionFromBundle( + Future addSessionFromBundle( String jid, int deviceId, OmemoBundle bundle, @@ -255,7 +253,7 @@ class OmemoManager { await _trustManager.onNewSession(jid, deviceId); _addSession(jid, deviceId, ratchet); - return OmemoKeyExchange() + return OMEMOKeyExchange() ..pkId = kexResult.opkId ..spkId = bundle.spkId ..ik = await device.ik.pk.getBytes() @@ -312,17 +310,17 @@ class OmemoManager { final decodedRawKey = base64.decode(rawKey.value); List? keyAndHmac; - OmemoAuthenticatedMessage authMessage; - OmemoMessage? message; + 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!); + final kex = OMEMOKeyExchange.fromBuffer(decodedRawKey); + authMessage = kex.message; + message = OMEMOMessage.fromBuffer(authMessage.message); // Guard against old key exchanges if (oldRatchet != null) { @@ -348,7 +346,7 @@ class OmemoManager { // Replace the OPK await _deviceLock.synchronized(() async { - device = await device.replaceOnetimePrekey(kex.pkId!); + device = await device.replaceOnetimePrekey(kex.pkId); // Commit the device _eventStreamController.add(DeviceModifiedEvent(device)); @@ -374,8 +372,8 @@ class OmemoManager { _log.finest('Kex failed due to $ex. Not proceeding with kex.'); } } else { - authMessage = OmemoAuthenticatedMessage.fromBuffer(decodedRawKey); - message = OmemoMessage.fromBuffer(authMessage.message!); + authMessage = OMEMOAuthenticatedMessage.fromBuffer(decodedRawKey); + message = OMEMOMessage.fromBuffer(authMessage.message); } final devices = _deviceList[senderJid]; @@ -499,7 +497,7 @@ class OmemoManager { keyPayload = List.filled(32, 0x0); } - final kex = {}; + final kex = {}; for (final jid in jids) { for (final newSession in await _fetchNewBundles(jid)) { kex[RatchetMapKey(jid, newSession.id)] = await addSessionFromBundle( @@ -551,7 +549,7 @@ class OmemoManager { if (kex.containsKey(ratchetKey)) { // The ratchet did not exist final k = kex[ratchetKey]! - ..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); + ..message = OMEMOAuthenticatedMessage.fromBuffer(ciphertext); final buffer = base64.encode(k.writeToBuffer()); encryptedKeys.add( EncryptedKey( @@ -568,8 +566,8 @@ class OmemoManager { // The ratchet exists but is not acked if (ratchet.kex != null) { final oldKex = - OmemoKeyExchange.fromBuffer(base64.decode(ratchet.kex!)) - ..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); + OMEMOKeyExchange.fromBuffer(base64.decode(ratchet.kex!)) + ..message = OMEMOAuthenticatedMessage.fromBuffer(ciphertext); encryptedKeys.add( EncryptedKey( 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/pubspec.yaml b/pubspec.yaml index ee5d811..6f7ff94 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,11 +14,11 @@ dependencies: logging: ^1.0.2 meta: ^1.7.0 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/omemo_test.dart b/test/omemo_test.dart deleted file mode 100644 index da0b0c5..0000000 --- a/test/omemo_test.dart +++ /dev/null @@ -1,956 +0,0 @@ -import 'package:logging/logging.dart'; -import 'package:omemo_dart/omemo_dart.dart'; -import 'package:omemo_dart/src/trust/always.dart'; -import 'package:omemo_dart/src/trust/never.dart'; -import 'package:test/test.dart'; - -void main() { - Logger.root - ..level = Level.ALL - ..onRecord.listen((record) { - // ignore: avoid_print - 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); - - final newDevice = await device.replaceOnetimePrekey(0); - - expect(device.jid, newDevice.jid); - expect(device.id, newDevice.id); - - 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; - } - } - - expect(opksMatch, true); - expect(await device.ik.equals(newDevice.ik), true); - expect(await device.spk.equals(newDevice.spk), true); - - final oldSpkMatch = device.oldSpk != null - ? await device.oldSpk!.equals(newDevice.oldSpk!) - : newDevice.oldSpk == null; - expect(oldSpkMatch, true); - expect(listsEqual(device.spkSignature, newDevice.spkSignature), true); - }); - - 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, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - 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 - 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, - null, - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - 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.getDeviceId(), - aliceMessage.encryptedKeys, - 0, - ); - expect(bobMessage, null); - - // This call must not cause an exception - bobSession.getRatchet(aliceJid, await aliceSession.getDeviceId()); - }); - - test('Test rotating the Signed Prekey', () async { - // Generate the session - const aliceJid = 'alice@some.server'; - final aliceSession = await OmemoSessionManager.generateNewIdentity( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - - // 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, - ); - final bobSession = 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(), - ], - ); - 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, - ); - - // 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, - ); - - // Ratchets are acked - await aliceSession.ratchetAcknowledged( - bobJid, await bobSession.getDeviceId()); - await bobSession.ratchetAcknowledged( - aliceJid, await aliceSession.getDeviceId()); - - for (var i = 0; i < 100; i++) { - final messageText = 'Test Message #$i'; - // Bob responds to Alice - final bobResponseMessage = await bobSession.encryptToJid( - aliceJid, - messageText, - ); - - // Bob sends the message to Alice - // ... - - // Alice decrypts it - final aliceReceivedMessage = await aliceSession.decryptMessage( - bobResponseMessage.ciphertext, - bobJid, - await bobSession.getDeviceId(), - bobResponseMessage.encryptedKeys, - 0, - ); - expect(messageText, aliceReceivedMessage); - } - }); - - 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( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession1 = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession2 = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - - // Alice sends a message to those two Bobs - await aliceSession.encryptToJid( - bobJid, - 'Hallo Welt', - newSessions: [ - await bobSession1.getDeviceBundle(), - await bobSession2.getDeviceBundle(), - ], - ); - - // 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); - - 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]); - }); - - 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( - aliceJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - - // 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); - }); - }); - - 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, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - - // Alice sends Bob a message - await aliceSession.encryptToJid( - bobJid, - 'Hallo Welt', - newSessions: [ - await bobSession.getDeviceBundle(), - ], - ); - expect( - await aliceSession.getUnacknowledgedRatchets(bobJid), - [ - await bobSession.getDeviceId(), - ], - ); - - // Bob sends alice an empty message - // ... - - // Alice decrypts it - // ... - - // Alice marks the ratchet as acknowledged - await aliceSession.ratchetAcknowledged( - bobJid, await bobSession.getDeviceId()); - expect( - (await aliceSession.getUnacknowledgedRatchets(bobJid))!.isEmpty, - true, - ); - }); - - 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, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 2, - ); - - // 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(), - ); - - // 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(), - ); - - // 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); - }); - - 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, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 2, - ); - - // Alice sends Bob a message - final msg1 = await aliceSession.encryptToJid( - 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, - ); - - // 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?", - ); - expect(msg2.encryptedKeys.first.kex, true); - - await bobSession.decryptMessage( - msg2.ciphertext, - aliceJid, - await aliceSession.getDeviceId(), - msg2.encryptedKeys, - getTimestamp(), - ); - }); - - 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, - ); - - final bobsReceivedMessages = List.empty(growable: true); - final bobsReceivedMessagesTimestamps = List.empty(growable: true); - - // 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, - ); - - // 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, - aliceJid, - await aliceSession.getDeviceId(), - msg.encryptedKeys, - t, - ); - - 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], - ); - 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, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - final bobSession = await OmemoSessionManager.generateNewIdentity( - bobJid, - AlwaysTrustingTrustManager(), - opkAmount: 1, - ); - - // 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(), - ); - - // 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( - aliceJid, - messageText, - ); - - // Bob sends the message to Alice - // ... - - // Alice decrypts it - final aliceReceivedMessage = await aliceSession.decryptMessage( - bobResponseMessage.ciphertext, - bobJid, - await bobSession.getDeviceId(), - bobResponseMessage.encryptedKeys, - 0, - ); - expect(messageText, aliceReceivedMessage); - } - }); -} diff --git a/test/omemomanager_test.dart b/test/omemomanager_test.dart index 9ce7cc4..72be302 100644 --- a/test/omemomanager_test.dart +++ b/test/omemomanager_test.dart @@ -1,7 +1,7 @@ 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/protobuf/schema.pb.dart'; import 'package:omemo_dart/src/trust/always.dart'; import 'package:test/test.dart'; @@ -1304,10 +1304,10 @@ void main() { expect(aliceResult2.encryptedKeys.first.kex, true); // The basic data should be the same - final parsedFirstKex = OmemoKeyExchange.fromBuffer( + final parsedFirstKex = OMEMOKeyExchange.fromBuffer( base64.decode(aliceResult1.encryptedKeys.first.value), ); - final parsedSecondKex = OmemoKeyExchange.fromBuffer( + final parsedSecondKex = OMEMOKeyExchange.fromBuffer( base64.decode(aliceResult2.encryptedKeys.first.value), ); expect(parsedSecondKex.pkId, parsedFirstKex.pkId); 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/serialisation_test.dart b/test/serialisation_test.dart index 902d89d..8233768 100644 --- a/test/serialisation_test.dart +++ b/test/serialisation_test.dart @@ -10,12 +10,8 @@ Map jsonify(Map 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 oldDevice = + await OmemoDevice.generateNewDevice('user@test.server', opkAmount: 5); final serialised = jsonify(await oldDevice.toJson()); final newDevice = OmemoDevice.fromJson(serialised); @@ -25,24 +21,20 @@ void main() { 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 device = + await OmemoDevice.generateNewDevice('user@test.server', opkAmount: 1); + final oldDevice = await device.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 { + /*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( + final aliceManager = OmemoSessionManager.generateNewIdentity( aliceJid, AlwaysTrustingTrustManager(), opkAmount: 1, @@ -151,5 +143,5 @@ void main() { serialized, ); expect(btbv.enablementCache, enableCache); - }); + });*/ } From d2558ea9fad2c7e4be5d65adafbb7170e0c57f2f Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Wed, 14 Jun 2023 14:03:11 +0200 Subject: [PATCH 02/36] test: Test something with protobuf --- test/serialisation_test.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/serialisation_test.dart b/test/serialisation_test.dart index 8233768..1c1475f 100644 --- a/test/serialisation_test.dart +++ b/test/serialisation_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:omemo_dart/omemo_dart.dart'; +import 'package:omemo_dart/protobuf/schema.pb.dart'; import 'package:omemo_dart/src/trust/always.dart'; import 'package:test/test.dart'; @@ -144,4 +145,11 @@ void main() { ); expect(btbv.enablementCache, enableCache); });*/ + + test('Test reading and writing protobuf', () { + final kex = OMEMOKeyExchange.fromBuffer(base64Decode('CFIQqvSv8AEaIHuVKKqf00vYpBIB7PKWheboVKcoKCWkRIUFeokzMYqxIiDOYPRA2vAlHwDLwPn/XIMSmenj3fgUIZHMgUXxVJtedyp8ChA+GfjScvWAllcTavRoyvfIEmgIDhAAGiANff1ES1HdSgtjy9JsoXcAywXJfBmZsFYTKUHRQsCMNiJAAOhS/CMrIdDm+ZZ/fmaOfwD0O7MNUaUMkVahvk4XDAy6mYk65r2TE4REW7h7akcKyoL94YSnTWp8p6fO91VSLA==')); + final newKex = OMEMOKeyExchange.fromBuffer(kex.writeToBuffer()); + + expect(kex.writeToBuffer(), newKex.writeToBuffer()); + }); } From f6f0e145cca11af9454fa56f068560911628d0cd Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Wed, 14 Jun 2023 19:55:47 +0200 Subject: [PATCH 03/36] feat: Rework the double ratchet --- lib/omemo_dart.dart | 2 +- lib/src/common/result.dart | 19 + lib/src/double_ratchet/crypto.dart | 59 -- lib/src/double_ratchet/double_ratchet.dart | 404 ++++------ lib/src/double_ratchet/kdf.dart | 4 +- lib/src/errors.dart | 40 +- lib/src/omemo/decryption_result.dart | 2 +- lib/src/omemo/encryption_result.dart | 4 +- lib/src/omemo/omemo.dart | 362 +++++++++ lib/src/omemo/omemomanager.dart | 852 -------------------- lib/src/omemo/stanza.dart | 6 +- lib/{ => src}/protobuf/.gitkeep | 0 lib/{ => src}/protobuf/schema.pb.dart | 0 lib/{ => src}/protobuf/schema.pbenum.dart | 0 lib/{ => src}/protobuf/schema.pbjson.dart | 0 lib/{ => src}/protobuf/schema.pbserver.dart | 0 test/double_ratchet_test.dart | 45 +- 17 files changed, 565 insertions(+), 1234 deletions(-) create mode 100644 lib/src/common/result.dart delete mode 100644 lib/src/double_ratchet/crypto.dart create mode 100644 lib/src/omemo/omemo.dart delete mode 100644 lib/src/omemo/omemomanager.dart rename lib/{ => src}/protobuf/.gitkeep (100%) rename lib/{ => src}/protobuf/schema.pb.dart (100%) rename lib/{ => src}/protobuf/schema.pbenum.dart (100%) rename lib/{ => src}/protobuf/schema.pbjson.dart (100%) rename lib/{ => src}/protobuf/schema.pbserver.dart (100%) diff --git a/lib/omemo_dart.dart b/lib/omemo_dart.dart index f0f9677..bdf3c42 100644 --- a/lib/omemo_dart.dart +++ b/lib/omemo_dart.dart @@ -10,7 +10,7 @@ export 'src/omemo/encrypted_key.dart'; export 'src/omemo/encryption_result.dart'; export 'src/omemo/events.dart'; export 'src/omemo/fingerprint.dart'; -export 'src/omemo/omemomanager.dart'; +export 'src/omemo/omemo.dart'; export 'src/omemo/ratchet_map_key.dart'; export 'src/omemo/stanza.dart'; export 'src/trust/base.dart'; diff --git a/lib/src/common/result.dart b/lib/src/common/result.dart new file mode 100644 index 0000000..60d4808 --- /dev/null +++ b/lib/src/common/result.dart @@ -0,0 +1,19 @@ +// TODO: Pull into moxlib +class Result { + const Result(this._data) + : assert( + _data is T || _data is V, + 'Invalid data type: Must be either $T or $V', + ); + final dynamic _data; + + bool isType() => _data is S; + + S get() { + assert(_data is S, 'Data is not $S'); + + return _data as S; + } + + Object get dataRuntimeType => _data.runtimeType; +} diff --git a/lib/src/double_ratchet/crypto.dart b/lib/src/double_ratchet/crypto.dart deleted file mode 100644 index fad14d9..0000000 --- a/lib/src/double_ratchet/crypto.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:omemo_dart/protobuf/schema.pb.dart'; -import 'package:omemo_dart/src/crypto.dart'; -import 'package:omemo_dart/src/errors.dart'; -import 'package:omemo_dart/src/helpers.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 aa328c8..45cd650 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -2,46 +2,29 @@ import 'dart:convert'; import 'package:cryptography/cryptography.dart'; import 'package:hex/hex.dart'; import 'package:meta/meta.dart'; -import 'package:omemo_dart/protobuf/schema.pb.dart'; +import 'package:omemo_dart/src/common/result.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/schema.pb.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; -} +/// Info string for ENCRYPT +const encryptHkdfInfoString = 'OMEMO Message Key Material'; @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) { @@ -63,6 +46,7 @@ class OmemoDoubleRatchet { this.nr, // Nr this.pn, // Pn this.ik, + this.ek, this.sessionAd, this.mkSkipped, // MKSKIPPED this.acknowledged, @@ -70,73 +54,6 @@ class OmemoDoubleRatchet { 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,6 +78,11 @@ class OmemoDoubleRatchet { /// for verification purposes final OmemoPublicKey ik; + /// The ephemeral public key of the chat partner. Not used for encryption but for possible + /// checks when replacing the ratchet. As such, this is only non-null for the initiating + /// side. + final OmemoPublicKey? ek; + final List sessionAd; final Map> mkSkipped; @@ -182,25 +104,25 @@ class OmemoDoubleRatchet { static Future initiateNewSession( OmemoPublicKey spk, OmemoPublicKey ik, + OmemoPublicKey ek, List sk, List ad, int timestamp, ) 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, 0, ik, + ek, ad, {}, false, @@ -230,6 +152,7 @@ class OmemoDoubleRatchet { 0, 0, ik, + null, ad, {}, true, @@ -238,67 +161,42 @@ class OmemoDoubleRatchet { ); } - 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); - - return decrypt( - mk, - ciphertext, - concat([sessionAd, header.writeToBuffer()]), - sessionAd, - ); - } - - return null; + rk = List.from(newRk1); + ckr = List.from(newRk1); + 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) { @@ -310,121 +208,119 @@ class OmemoDoubleRatchet { nr++; } } + + return null; } - Future _dhRatchet(OMEMOMessage header) async { - pn = ns; - ns = 0; - nr = 0; - dhr = OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519); + /// Decrypt [ciphertext] using keys derived from the message key [mk]. Also computes the + /// HMAC from the [OMEMOMessage] embedded in [message]. + /// + /// 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 { + final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString); - 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. - /// - /// Throws an SkippingTooManyMessagesException if too many messages were to be skipped. - Future> ratchetDecrypt( - OMEMOMessage header, - List ciphertext, - ) async { - // Check if we skipped too many messages - final plaintext = await _trySkippedMessageKeys(header, ciphertext); - if (plaintext != null) { - return plaintext; + 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); + return Result(plaintext); + } + + /// 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; - nr++; + ckr = ck; - return decrypt( - mk, - ciphertext, - concat([sessionAd, header.writeToBuffer()]), - sessionAd, - ); + return _decrypt(message, header.ciphertext, mk); } - OmemoDoubleRatchet clone() { - 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, - ); + /// 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; } - 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, - ); - } @visibleForTesting Future equals(OmemoDoubleRatchet other) async { 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..39e61fb 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -1,61 +1,47 @@ -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 { +class InvalidSignatureException extends OmemoError implements Exception { String errMsg() => 'The signature of the SPK does not match the provided signature'; } /// 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'; -} +class NotEncryptedForDeviceError extends OmemoError {} /// Triggered by the Session Manager when there is no key for decrypting the message. -class NoDecryptionKeyException extends OmemoException implements Exception { +class NoDecryptionKeyException extends OmemoError implements Exception { String errMsg() => 'No key available for decrypting the message'; } /// 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.'; -} +class UnknownSignedPrekeyError extends OmemoError {} /// 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 { +class InvalidKeyExchangeException extends OmemoError 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'; -} - /// 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 +class NoKeyMaterialAvailableException extends OmemoError implements Exception { String errMsg() => 'No key material available to create a ratchet session with'; } + +/// 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 {} diff --git a/lib/src/omemo/decryption_result.dart b/lib/src/omemo/decryption_result.dart index 5b020e1..e924c6a 100644 --- a/lib/src/omemo/decryption_result.dart +++ b/lib/src/omemo/decryption_result.dart @@ -5,5 +5,5 @@ import 'package:omemo_dart/src/errors.dart'; class DecryptionResult { const DecryptionResult(this.payload, this.error); final String? payload; - final OmemoException? error; + final OmemoError? error; } diff --git a/lib/src/omemo/encryption_result.dart b/lib/src/omemo/encryption_result.dart index cb2494a..633d021 100644 --- a/lib/src/omemo/encryption_result.dart +++ b/lib/src/omemo/encryption_result.dart @@ -20,10 +20,10 @@ class EncryptionResult { final List encryptedKeys; /// Mapping of a ratchet map keys to a possible exception. - final Map deviceEncryptionErrors; + final Map deviceEncryptionErrors; /// Mapping of a JID to a possible exception. - final Map jidEncryptionErrors; + final Map jidEncryptionErrors; /// True if the encryption was a success. This means that we could encrypt for /// at least one ratchet. diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart new file mode 100644 index 0000000..2b14323 --- /dev/null +++ b/lib/src/omemo/omemo.dart @@ -0,0 +1,362 @@ +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/common/result.dart'; +import 'package:omemo_dart/src/crypto.dart'; +import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart'; +import 'package:omemo_dart/src/errors.dart'; +import 'package:omemo_dart/src/helpers.dart'; +import 'package:omemo_dart/src/keys.dart'; +import 'package:omemo_dart/src/omemo/bundle.dart'; +import 'package:omemo_dart/src/omemo/constants.dart'; +import 'package:omemo_dart/src/omemo/decryption_result.dart'; +import 'package:omemo_dart/src/omemo/device.dart'; +import 'package:omemo_dart/src/omemo/encrypted_key.dart'; +import 'package:omemo_dart/src/omemo/encryption_result.dart'; +import 'package:omemo_dart/src/omemo/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/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 _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; +} + +extension AppendToListOrCreateExtension on Map> { + void appendOrCreate(K key, V value) { + if (containsKey(key)) { + this[key]!.add(value); + } else { + this[key] = [value]; + } + } +} + +extension StringFromBase64Extension on String { + List fromBase64() => base64Decode(this); +} + +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 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()); + } + + // TODO: Handle an exception from the crypto implementation + return Result( + utf8.decode( + await aes256CbcDecrypt( + ciphertext, + derivedKeys.encryptionKey, + derivedKeys.iv, + ), + ), + ); + } + + /// + Future onIncomingStanza(OmemoIncomingStanza stanza) async { + // NOTE: We do this so that we cannot forget to acquire and free the critical + // section. + await _enterRatchetCriticalSection(stanza.bareSenderJid); + final result = await _onIncomingStanzaImpl(stanza); + await _leaveRatchetCriticalSection(stanza.bareSenderJid); + + return result; + } + + Future _onIncomingStanzaImpl(OmemoIncomingStanza stanza) async { + // 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, + NotEncryptedForDeviceError(), + ); + } + + final ratchetKey = RatchetMapKey(stanza.bareSenderJid, stanza.senderDeviceId); + if (key.kex) { + final kexMessage = OMEMOKeyExchange.fromBuffer(base64Decode(key.value)); + + // TODO: Check if we already have such a session and if we can build it + // See XEP-0384 4.3 + + // 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, + UnknownSignedPrekeyError(), + ); + } + + // Build the new ratchet session + final kexIk = OmemoPublicKey.fromBytes( + kexMessage.ik, + KeyPairType.ed25519, + ); + final kex = await x3dhFromInitialMessage( + X3DHMessage( + kexIk, + OmemoPublicKey.fromBytes( + kexMessage.ek, + KeyPairType.ed25519, + ), + kexMessage.pkId, + ), + spk, + device.opks[kexMessage.pkId]!, + device.ik, + ); + final ratchet = await OmemoDoubleRatchet.acceptNewSession( + spk, + kexIk, + kex.sk, + kex.ad, + getTimestamp(), + ); + + 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, error); + } + + final result = await _decryptAndVerifyHmac( + stanza.payload != null ? base64Decode(stanza.payload!) : null, + keyAndHmac.get>(), + ); + if (result.isType()) { + final error = result.get(); + _log.warning('Decrypting payload failed: $error'); + + return DecryptionResult( + null, + error, + ); + } + + // Notify the trust manager + await trustManager.onNewSession( + stanza.bareSenderJid, + stanza.senderDeviceId, + ); + + // Commit the ratchet + _ratchetMap[ratchetKey] = ratchet; + _deviceList.appendOrCreate(stanza.bareSenderJid, stanza.senderDeviceId); + _eventStreamController.add( + RatchetModifiedEvent( + 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); + + _eventStreamController.add( + DeviceModifiedEvent(_device), + ); + }); + } + + return DecryptionResult( + result.get(), + null, + ); + } else { + // Check if we even have a ratchet + final ratchet = _ratchetMap[ratchetKey]; + if (ratchet == null) { + // TODO: Build a session with the device + + return DecryptionResult( + null, + NoSessionWithDeviceError(), + ); + } + + final authMessage = OMEMOAuthenticatedMessage.fromBuffer(base64Decode(key.value)); + final keyAndHmac = await ratchet.ratchetDecrypt(authMessage); + if (keyAndHmac.isType()) { + final error = keyAndHmac.get(); + _log.warning('Failed to decrypt symmetric key: $error'); + return DecryptionResult(null, error); + } + + final 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, + error, + ); + } + + // Message was successfully decrypted, so commit the ratchet + _eventStreamController.add( + RatchetModifiedEvent( + stanza.bareSenderJid, + stanza.senderDeviceId, + ratchet, + false, + false, + ), + ); + + return DecryptionResult( + result.get(), + null, + ); + } + } + + /// 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; +} diff --git a/lib/src/omemo/omemomanager.dart b/lib/src/omemo/omemomanager.dart deleted file mode 100644 index f58b694..0000000 --- a/lib/src/omemo/omemomanager.dart +++ /dev/null @@ -1,852 +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/protobuf/schema.pb.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/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/stanza.dart b/lib/src/omemo/stanza.dart index c883887..99b6b7a 100644 --- a/lib/src/omemo/stanza.dart +++ b/lib/src/omemo/stanza.dart @@ -8,6 +8,7 @@ class OmemoIncomingStanza { this.timestamp, this.keys, this.payload, + this.isCatchup, ); /// The bare JID of the sender of the stanza. @@ -19,11 +20,14 @@ class OmemoIncomingStanza { /// 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 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/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/test/double_ratchet_test.dart b/test/double_ratchet_test.dart index ec73e7f..5ec7195 100644 --- a/test/double_ratchet_test.dart +++ b/test/double_ratchet_test.dart @@ -2,38 +2,10 @@ import 'dart:convert'; 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:omemo_dart/src/protobuf/schema.pb.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'; @@ -81,6 +53,7 @@ void main() { final alicesRatchet = await OmemoDoubleRatchet.initiateNewSession( spkBob.pk, ikBob.pk, + resultAlice.ek.pk, resultAlice.sk, resultAlice.ad, 0, @@ -98,6 +71,7 @@ void main() { for (var i = 0; i < 100; i++) { final messageText = 'Hello, dear $i'; + print('${i + 1}/100'); if (i.isEven) { // Alice encrypts a message final aliceRatchetResult = @@ -109,12 +83,12 @@ void main() { // Bob tries to decrypt it final bobRatchetResult = await bobsRatchet.ratchetDecrypt( - aliceRatchetResult.header, - aliceRatchetResult.ciphertext, + aliceRatchetResult, ); print('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 = @@ -126,12 +100,13 @@ void main() { // Alice tries to decrypt it final aliceRatchetResult = await alicesRatchet.ratchetDecrypt( - bobRatchetResult.header, - bobRatchetResult.ciphertext, + bobRatchetResult, ); print('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>()); } } }); From c483585d0bfdecd531f6a3cfd2ce5a38a52e32e7 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Wed, 14 Jun 2023 21:59:59 +0200 Subject: [PATCH 04/36] fix: Get basic tests working --- lib/src/double_ratchet/double_ratchet.dart | 45 ++- lib/src/errors.dart | 6 +- lib/src/omemo/encryption_result.dart | 16 +- lib/src/omemo/errors.dart | 12 + lib/src/omemo/events.dart | 11 +- lib/src/omemo/omemo.dart | 285 +++++++++++++++++- lib/src/omemo/stanza.dart | 2 +- test/double_ratchet_test.dart | 17 +- ...omemomanager_test.dart => omemo_test.dart} | 125 +++++--- 9 files changed, 446 insertions(+), 73 deletions(-) create mode 100644 lib/src/omemo/errors.dart rename test/{omemomanager_test.dart => omemo_test.dart} (93%) diff --git a/lib/src/double_ratchet/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index 45cd650..c31c5c5 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -35,6 +35,21 @@ class SkippedKey { int get hashCode => dh.hashCode ^ n.hashCode; } +@immutable +class KeyExchangeData { + const KeyExchangeData( + this.pkId, + this.spkId, + this.ek, + this.ik, + ); + + final int pkId; + final int spkId; + final OmemoPublicKey ek; + final OmemoPublicKey ik; +} + class OmemoDoubleRatchet { OmemoDoubleRatchet( this.dhs, // DHs @@ -92,7 +107,7 @@ class OmemoDoubleRatchet { 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. @@ -108,6 +123,8 @@ class OmemoDoubleRatchet { List sk, List ad, int timestamp, + int pkId, + int spkId, ) async { final dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); final rk = await kdfRk(sk, await omemoDH(dhs, spk, 0)); @@ -127,7 +144,12 @@ class OmemoDoubleRatchet { {}, false, timestamp, - '', + KeyExchangeData( + pkId, + spkId, + ik, + ek, + ), ); } @@ -321,6 +343,25 @@ class OmemoDoubleRatchet { ..message = headerBytes; } + OmemoDoubleRatchet clone() { + return OmemoDoubleRatchet( + dhs, + dhr, + rk, + cks != null ? List.from(cks!) : null, + ckr != null ? List.from(ckr!) : null, + ns, + nr, + pn, + ik, + ek, + sessionAd, + Map>.from(mkSkipped), + acknowledged, + kexTimestamp, + kex, + ); + } @visibleForTesting Future equals(OmemoDoubleRatchet other) async { diff --git a/lib/src/errors.dart b/lib/src/errors.dart index 39e61fb..1aafd61 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -36,11 +36,7 @@ class InvalidKeyExchangeException extends OmemoError implements Exception { /// 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 OmemoError - 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. diff --git a/lib/src/omemo/encryption_result.dart b/lib/src/omemo/encryption_result.dart index 633d021..866df5d 100644 --- a/lib/src/omemo/encryption_result.dart +++ b/lib/src/omemo/encryption_result.dart @@ -1,6 +1,7 @@ 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/errors.dart'; import 'package:omemo_dart/src/omemo/ratchet_map_key.dart'; @immutable @@ -9,7 +10,6 @@ class EncryptionResult { this.ciphertext, this.encryptedKeys, this.deviceEncryptionErrors, - this.jidEncryptionErrors, ); /// The actual message that was encrypted. @@ -17,17 +17,13 @@ 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 a possible exception. - final Map jidEncryptionErrors; + /// Mapping of a JID to + final Map> deviceEncryptionErrors; /// 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; + /// TODO: + bool isSuccess(int numberOfRecipients) => true; } 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 index 5999125..5568334 100644 --- a/lib/src/omemo/events.dart +++ b/lib/src/omemo/events.dart @@ -1,8 +1,15 @@ -import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart'; -import 'package:omemo_dart/src/omemo/device.dart'; +import 'package:omemo_dart/omemo_dart.dart'; abstract class OmemoEvent {} +/// Triggered when (possibly multiple) ratchets have been created at sending time. +class RatchetsAddedEvent extends OmemoEvent { + RatchetsAddedEvent(this.ratchets); + + /// The mapping of the newly created ratchets. + final Map ratchets; +} + /// Triggered when a ratchet has been modified class RatchetModifiedEvent extends OmemoEvent { RatchetModifiedEvent( diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 2b14323..1e3d4b0 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -18,6 +18,7 @@ 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/events.dart'; import 'package:omemo_dart/src/omemo/fingerprint.dart'; import 'package:omemo_dart/src/omemo/ratchet_map_key.dart'; @@ -176,6 +177,58 @@ class OmemoManager { ); } + /// 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)) { + return []; + } + + final newDeviceList = await fetchDeviceListImpl(jid); + if (newDeviceList == null) { + return []; + } + + // Figure out what bundles we must fetch + _deviceList[jid] = newDeviceList; + _deviceListRequested[jid] = true; + + // TODO: Maybe do this per JID? + _eventStreamController.add( + DeviceListModifiedEvent(_deviceList), + ); + + final ownDevice = await getDevice(); + final bundlesToFetch = newDeviceList.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 onIncomingStanza(OmemoIncomingStanza stanza) async { // NOTE: We do this so that we cannot forget to acquire and free the critical @@ -229,7 +282,7 @@ class OmemoManager { kexIk, OmemoPublicKey.fromBytes( kexMessage.ek, - KeyPairType.ed25519, + KeyPairType.x25519, ), kexMessage.pkId, ), @@ -305,8 +358,7 @@ class OmemoManager { ); } else { // Check if we even have a ratchet - final ratchet = _ratchetMap[ratchetKey]; - if (ratchet == null) { + if (!_ratchetMap.containsKey(ratchetKey)) { // TODO: Build a session with the device return DecryptionResult( @@ -315,6 +367,7 @@ class OmemoManager { ); } + final ratchet = _ratchetMap[key]!.clone(); final authMessage = OMEMOAuthenticatedMessage.fromBuffer(base64Decode(key.value)); final keyAndHmac = await ratchet.ratchetDecrypt(authMessage); if (keyAndHmac.isType()) { @@ -354,9 +407,235 @@ class OmemoManager { } } + Future onOutgoingStanza(OmemoOutgoingStanza stanza) async { + // TODO: Be more smart about the locking + // TODO: Do we even need to lock? + await _enterRatchetCriticalSection(stanza.recipientJids.first); + final result = await _onOutgoingStanzaImpl(stanza); + await _leaveRatchetCriticalSection(stanza.recipientJids.first); + + return result; + } + + Future _onOutgoingStanzaImpl(OmemoOutgoingStanza stanza) async { + // 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 = []; + } + + 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) { + final ratchetKey = RatchetMapKey(jid, bundle.id); + final ownDevice = await getDevice(); + final kexResult = await x3dhFromBundle( + bundle, + ownDevice.ik, + ); + final newRatchet = await OmemoDoubleRatchet.initiateNewSession( + bundle.spk, + bundle.ik, + kexResult.ek.pk, + kexResult.sk, + kexResult.ad, + getTimestamp(), + kexResult.opkId, + bundle.spkId, + ); + + // Track the ratchet + _ratchetMap[ratchetKey] = newRatchet; + addedRatchetKeys.add(ratchetKey); + + // Initiate trust + await trustManager.onNewSession(jid, bundle.id); + + // Track the KEX for later + kex[ratchetKey] = OMEMOKeyExchange() + ..pkId = kexResult.opkId + ..spkId = bundle.spkId + ..ik = await ownDevice.ik.pk.getBytes() + ..ek = await kexResult.ek.pk.getBytes(); + } + } + + // Commit the newly created ratchets, if we created any. + if (addedRatchetKeys.isNotEmpty) { + _eventStreamController.add( + RatchetsAddedEvent( + Map.fromEntries( + addedRatchetKeys.map((key) => MapEntry(key, _ratchetMap[key]!)).toList(), + ), + ), + ); + } + + // Encrypt the symmetric key for all devices. + final encryptionErrors = >{}; + 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]!.clone(); + final authMessage = await ratchet.ratchetEncrypt(payloadKey); + + // Package + if (kex.containsKey(ratchetKey)) { + final kexMessage = kex[ratchetKey]!..message = authMessage; + encryptedKeys.appendOrCreate( + jid, + EncryptedKey( + jid, + device, + base64Encode(kexMessage.writeToBuffer()), + true, + ), + ); + } else if (!ratchet.acknowledged) { + // The ratchet as not yet been acked + if (ratchet.kex == null) { + // The ratchet is not acked but we also don't have an old KEX to send with it + _log.warning('Ratchet $jid:$device is not acked but has no previous KEX.'); + + encryptedKeys.appendOrCreate( + jid, + EncryptedKey( + jid, + device, + base64Encode(authMessage.writeToBuffer()), + false, + ), + ); + continue; + } + + // Keep sending the old KEX + 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( + jid, + device, + base64Encode(kexMessage.writeToBuffer()), + true, + ), + ); + } else { + // The ratchet exists and is acked + encryptedKeys.appendOrCreate( + jid, + EncryptedKey( + jid, + device, + base64Encode(authMessage.writeToBuffer()), + false, + ), + ); + } + } + } + + return EncryptionResult( + ciphertext, + encryptedKeys, + encryptionErrors, + ); + } + + // TODO + Future sendOmemoHeartbeat(String jid) async {} + + // TODO + Future removeAllRatchets(String jid) async {} + + // TODO + Future onDeviceListUpdate(String jid, List devices) async {} + + // TODO + Future onNewConnection() async {} + + // TODO + Future ratchetAcknowledged(String jid, int device) async {} + + // TODO + Future> getFingerprintsForJid(String jid) async => []; + /// 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]!; } diff --git a/lib/src/omemo/stanza.dart b/lib/src/omemo/stanza.dart index 99b6b7a..dc1a3a1 100644 --- a/lib/src/omemo/stanza.dart +++ b/lib/src/omemo/stanza.dart @@ -41,5 +41,5 @@ class OmemoOutgoingStanza { final List recipientJids; /// The serialised XML data that should be encrypted. - final String payload; + final String? payload; } diff --git a/test/double_ratchet_test.dart b/test/double_ratchet_test.dart index 5ec7195..5143b09 100644 --- a/test/double_ratchet_test.dart +++ b/test/double_ratchet_test.dart @@ -1,8 +1,7 @@ -// 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/src/protobuf/schema.pb.dart'; import 'package:test/test.dart'; void main() { @@ -46,7 +45,7 @@ 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 @@ -57,6 +56,8 @@ void main() { resultAlice.sk, resultAlice.ad, 0, + resultAlice.opkId, + bundleBob.spkId, ); final bobsRatchet = await OmemoDoubleRatchet.acceptNewSession( spkBob, @@ -71,12 +72,12 @@ void main() { for (var i = 0; i < 100; i++) { final messageText = 'Hello, dear $i'; - print('${i + 1}/100'); + 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 // ... @@ -85,7 +86,7 @@ void main() { final bobRatchetResult = await bobsRatchet.ratchetDecrypt( aliceRatchetResult, ); - print('Bob decrypted the message'); + log('Bob decrypted the message'); expect(bobRatchetResult.isType>(), true); expect(bobRatchetResult.get>(), utf8.encode(messageText)); @@ -93,7 +94,7 @@ void main() { // 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 // ... @@ -102,7 +103,7 @@ void main() { final aliceRatchetResult = await alicesRatchet.ratchetDecrypt( bobRatchetResult, ); - print('Alice decrypted the message'); + log('Alice decrypted the message'); expect(aliceRatchetResult.isType>(), true); expect(aliceRatchetResult.get>(), utf8.encode(messageText)); diff --git a/test/omemomanager_test.dart b/test/omemo_test.dart similarity index 93% rename from test/omemomanager_test.dart rename to test/omemo_test.dart index 72be302..6b1a5bc 100644 --- a/test/omemomanager_test.dart +++ b/test/omemo_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:logging/logging.dart'; import 'package:omemo_dart/omemo_dart.dart'; -import 'package:omemo_dart/protobuf/schema.pb.dart'; +import 'package:omemo_dart/src/protobuf/schema.pb.dart'; import 'package:omemo_dart/src/trust/always.dart'; import 'package:test/test.dart'; @@ -79,8 +79,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, + aliceResult.encryptedKeys[bobJid]!, base64.encode(aliceResult.ciphertext!), + false, ), ); @@ -107,8 +108,9 @@ void main() { bobJid, bobDevice.id, DateTime.now().millisecondsSinceEpoch, - bobResult2.encryptedKeys, + bobResult2.encryptedKeys[bobJid]!, base64.encode(bobResult2.ciphertext!), + false, ), ); @@ -175,8 +177,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, + aliceResult.encryptedKeys[bobJid]!, base64.encode(aliceResult.ciphertext!), + false, ), ); @@ -201,8 +204,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResultLoop.encryptedKeys, + aliceResultLoop.encryptedKeys[bobJid]!, base64.encode(aliceResultLoop.ciphertext!), + false, ), ); @@ -224,8 +228,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResultFinal.encryptedKeys, + aliceResultFinal.encryptedKeys[bobJid]!, base64.encode(aliceResultFinal.ciphertext!), + false, ), ); @@ -314,16 +319,17 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, + aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), + false, ), ); expect(bobResult1.payload, null); - expect(bobResult1.error is NotEncryptedForDeviceException, true); + expect(bobResult1.error is NotEncryptedForDeviceError, true); // Now Alice's client loses and regains the connection - aliceManager.onNewConnection(); + await aliceManager.onNewConnection(); oldDevice = false; // And Alice sends a new message @@ -338,8 +344,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, + aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), + false, ), ); @@ -425,8 +432,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, + aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), + false, ), ); @@ -448,8 +456,9 @@ void main() { bobJid, bobDevice2.id, DateTime.now().millisecondsSinceEpoch, - bobResult2.encryptedKeys, + bobResult2.encryptedKeys[bobJid]!, base64.encode(bobResult2.ciphertext!), + false, ), ); @@ -528,8 +537,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, + aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), + false, ), ); @@ -541,7 +551,7 @@ void main() { // Bob now publishes a new device bothDevices = true; - aliceManager.onDeviceListUpdate( + await aliceManager.onDeviceListUpdate( bobJid, [ bobDevice1.id, @@ -565,8 +575,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, + aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), + false, ), ); final bobResult22 = await bobManager2.onIncomingStanza( @@ -574,8 +585,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, + aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), + false, ), ); @@ -596,8 +608,9 @@ void main() { bobJid, bobDevice2.id, DateTime.now().millisecondsSinceEpoch, - bobResult32.encryptedKeys, + bobResult32.encryptedKeys[bobJid]!, base64.encode(bobResult32.ciphertext!), + false, ), ); @@ -670,8 +683,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, + aliceResult.encryptedKeys[bobJid]!, base64.encode(aliceResult.ciphertext!), + false, ), ); final cocoResult = await cocoManager.onIncomingStanza( @@ -679,8 +693,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, + aliceResult.encryptedKeys[cocoJid]!, base64.encode(aliceResult.ciphertext!), + false, ), ); @@ -738,8 +753,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, + aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), + false, ), ); @@ -770,8 +786,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, + aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), + false, ), ); @@ -812,11 +829,12 @@ void main() { ); expect(aliceResult.isSuccess(1), false); - expect( + // TODO + /*expect( aliceResult.jidEncryptionErrors[bobJid] is NoKeyMaterialAvailableException, true, - ); + );*/ }); test('Test sending a message two two JIDs with failed lookups', () async { @@ -866,11 +884,13 @@ void main() { ); expect(aliceResult.isSuccess(2), true); + // TODO + /* expect( aliceResult.jidEncryptionErrors[cocoJid] is NoKeyMaterialAvailableException, true, - ); + );*/ // Bob decrypts it final bobResult = await bobManager.onIncomingStanza( @@ -878,8 +898,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult.encryptedKeys, + aliceResult.encryptedKeys[bobJid]!, base64.encode(aliceResult.ciphertext!), + false, ), ); @@ -933,8 +954,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceMessage.encryptedKeys, + aliceMessage.encryptedKeys[bobJid]!, base64.encode(aliceMessage.ciphertext!), + false, ), ); @@ -960,8 +982,9 @@ void main() { bobJid, bobDevice.id, DateTime.now().millisecondsSinceEpoch, - bobResponseMessage.encryptedKeys, + bobResponseMessage.encryptedKeys[aliceJid]!, base64.encode(bobResponseMessage.ciphertext!), + false, ), ); expect(aliceReceivedMessage.payload, messageText); @@ -1018,8 +1041,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, + aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), + false, ), ); @@ -1043,8 +1067,9 @@ void main() { aliceJid, await aliceManager.getDeviceId(), DateTime.now().millisecondsSinceEpoch, - aliceEmptyMessage!.encryptedKeys, + aliceEmptyMessage!.encryptedKeys[bobJid]!, null, + false, ), ); expect(bobResult2.error, null); @@ -1069,8 +1094,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult3.encryptedKeys, + aliceResult3.encryptedKeys[bobJid]!, base64.encode(aliceResult3.ciphertext!), + false, ), ); @@ -1091,8 +1117,9 @@ void main() { bobJid, bobDevice.id, DateTime.now().millisecondsSinceEpoch, - bobResult4.encryptedKeys, + bobResult4.encryptedKeys[aliceJid]!, base64.encode(bobResult4.ciphertext!), + false, ), ); @@ -1155,8 +1182,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, + aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), + false, ), ); @@ -1180,8 +1208,9 @@ void main() { aliceJid, await aliceManager.getDeviceId(), DateTime.now().millisecondsSinceEpoch, - aliceEmptyMessage!.encryptedKeys, + aliceEmptyMessage!.encryptedKeys[bobJid]!, null, + false, ), ); expect(bobResult2.error, null); @@ -1200,8 +1229,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult3.encryptedKeys, + aliceResult3.encryptedKeys[bobJid]!, base64.encode(aliceResult3.ciphertext!), + false, ), ); @@ -1222,8 +1252,9 @@ void main() { bobJid, bobDevice.id, DateTime.now().millisecondsSinceEpoch, - bobResult4.encryptedKeys, + bobResult4.encryptedKeys[aliceJid]!, base64.encode(bobResult4.ciphertext!), + false, ), ); @@ -1277,7 +1308,8 @@ void main() { ); // The first message must be a KEX message - expect(aliceResult1.encryptedKeys.first.kex, true); + // TODO + //expect(aliceResult1.encryptedKeys.first.kex, true); // Bob decrypts Alice's message final bobResult1 = await bobManager.onIncomingStanza( @@ -1285,8 +1317,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, + aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), + false, ), ); expect(bobResult1.error, null); @@ -1301,6 +1334,8 @@ void main() { ); // The response should contain a KEX + // TODO + /* expect(aliceResult2.encryptedKeys.first.kex, true); // The basic data should be the same @@ -1314,6 +1349,7 @@ void main() { expect(parsedSecondKex.spkId, parsedFirstKex.spkId); expect(parsedSecondKex.ik, parsedFirstKex.ik); expect(parsedSecondKex.ek, parsedFirstKex.ek); + */ // Alice decrypts it final bobResult2 = await bobManager.onIncomingStanza( @@ -1321,8 +1357,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult2.encryptedKeys, + aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), + false, ), ); expect(bobResult2.error, null); @@ -1342,8 +1379,9 @@ void main() { bobJid, bobDevice.id, DateTime.now().millisecondsSinceEpoch, - bobResult3.encryptedKeys, + bobResult3.encryptedKeys[bobJid]!, base64.encode(bobResult3.ciphertext!), + false, ), ); expect(aliceResult3.error, null); @@ -1364,7 +1402,8 @@ void main() { ); // The response should contain no KEX - expect(aliceResult4.encryptedKeys.first.kex, false); + // TODO + //expect(aliceResult4.encryptedKeys.first.kex, false); // Bob decrypts it final bobResult4 = await bobManager.onIncomingStanza( @@ -1372,8 +1411,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult4.encryptedKeys, + aliceResult4.encryptedKeys[bobJid]!, base64.encode(aliceResult4.ciphertext!), + false, ), ); expect(bobResult4.error, null); @@ -1431,8 +1471,9 @@ void main() { aliceJid, aliceDevice.id, DateTime.now().millisecondsSinceEpoch, - aliceResult1.encryptedKeys, + aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), + false, ), ); expect(bobResult1.error, null); From 87a985fee05c637cee559377fd3eceaeb5917fe0 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 01:26:49 +0200 Subject: [PATCH 05/36] fix: Fix ratchets going out of sync --- analysis_options.yaml | 4 +- lib/src/double_ratchet/double_ratchet.dart | 3 + lib/src/omemo/omemo.dart | 109 ++++++++++++++++++--- test/omemo_test.dart | 60 +++++++++++- 4 files changed, 154 insertions(+), 22 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index beb23d6..ffefcce 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,8 +9,6 @@ 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/lib/src/double_ratchet/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index c31c5c5..65cc4d6 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -200,6 +200,7 @@ class OmemoDoubleRatchet { ); rk = List.from(newRk1); ckr = List.from(newRk1); + dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); final newRk2 = await kdfRk( rk, @@ -226,6 +227,7 @@ class OmemoDoubleRatchet { final newCkr = await kdfCk(ckr!, kdfCkNextChainKey); final mk = await kdfCk(ckr!, kdfCkNextMessageKey); ckr = newCkr; + mkSkipped[SkippedKey(dhr!, nr)] = mk; nr++; } @@ -309,6 +311,7 @@ class OmemoDoubleRatchet { final ck = await kdfCk(ckr!, kdfCkNextChainKey); final mk = await kdfCk(ckr!, kdfCkNextMessageKey); ckr = ck; + nr++; return _decrypt(message, header.ciphertext, mk); } diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 1e3d4b0..03b5d7d 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -118,6 +118,7 @@ class OmemoManager { /// Enter the critical section for performing cryptographic operations on the ratchets Future _enterRatchetCriticalSection(String jid) async { + return; final completer = await _ratchetCriticalSectionLock.synchronized(() { if (_ratchetCriticalSectionQueue.containsKey(jid)) { final c = Completer(); @@ -136,6 +137,7 @@ class OmemoManager { /// Leave the critical section for the ratchets. Future _leaveRatchetCriticalSection(String jid) async { + return; await _ratchetCriticalSectionLock.synchronized(() { if (_ratchetCriticalSectionQueue.containsKey(jid)) { if (_ratchetCriticalSectionQueue[jid]!.isEmpty) { @@ -229,6 +231,38 @@ class OmemoManager { 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 { // NOTE: We do this so that we cannot forget to acquire and free the critical @@ -253,6 +287,7 @@ class OmemoManager { final ratchetKey = RatchetMapKey(stanza.bareSenderJid, stanza.senderDeviceId); if (key.kex) { + _log.finest('Decoding message as OMEMOKeyExchange'); final kexMessage = OMEMOKeyExchange.fromBuffer(base64Decode(key.value)); // TODO: Check if we already have such a session and if we can build it @@ -328,6 +363,13 @@ class OmemoManager { 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); @@ -352,6 +394,10 @@ class OmemoManager { }); } + // Send the hearbeat, if we have to + // TODO: Handle replace + await _maybeSendEmptyMessage(ratchetKey, true, false); + return DecryptionResult( result.get(), null, @@ -367,7 +413,8 @@ class OmemoManager { ); } - final ratchet = _ratchetMap[key]!.clone(); + _log.finest('Decoding message as OMEMOAuthenticatedMessage'); + final ratchet = _ratchetMap[ratchetKey]!.clone(); final authMessage = OMEMOAuthenticatedMessage.fromBuffer(base64Decode(key.value)); final keyAndHmac = await ratchet.ratchetDecrypt(authMessage); if (keyAndHmac.isType()) { @@ -389,7 +436,15 @@ class OmemoManager { ); } + // 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; _eventStreamController.add( RatchetModifiedEvent( stanza.bareSenderJid, @@ -400,6 +455,9 @@ class OmemoManager { ), ); + // Send a heartbeat, if required. + await _maybeSendEmptyMessage(ratchetKey, false, false); + return DecryptionResult( result.get(), null, @@ -541,21 +599,21 @@ class OmemoManager { } // Encrypt - final ratchet = _ratchetMap[ratchetKey]!.clone(); + 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( + encryptedKeys.appendOrCreate( jid, - device, - base64Encode(kexMessage.writeToBuffer()), - true, - ), - ); + EncryptedKey( + jid, + device, + base64Encode(kexMessage.writeToBuffer()), + true, + ), + ); } else if (!ratchet.acknowledged) { // The ratchet as not yet been acked if (ratchet.kex == null) { @@ -612,8 +670,16 @@ class OmemoManager { ); } - // TODO - Future sendOmemoHeartbeat(String jid) async {} + // Sends an empty OMEMO message (heartbeat) to [jid]. + Future sendOmemoHeartbeat(String jid) async { + final result = await onOutgoingStanza( + OmemoOutgoingStanza( + [jid], + null, + ), + ); + await sendEmptyOmemoMessageImpl(result, jid); + } // TODO Future removeAllRatchets(String jid) async {} @@ -624,8 +690,23 @@ class OmemoManager { // TODO Future onNewConnection() async {} - // TODO - Future ratchetAcknowledged(String jid, int device) async {} + // Mark the ratchet [jid]:[device] as acknowledged. + Future ratchetAcknowledged(String jid, int device) async { + await _enterRatchetCriticalSection(jid); + + 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; + _eventStreamController.add( + RatchetModifiedEvent(jid, device, ratchet, false, false), + ); + } + + await _leaveRatchetCriticalSection(jid); + } // TODO Future> getFingerprintsForJid(String jid) async => []; diff --git a/test/omemo_test.dart b/test/omemo_test.dart index 6b1a5bc..56b9381 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -1,7 +1,6 @@ import 'dart:convert'; 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:test/test.dart'; @@ -108,7 +107,7 @@ void main() { bobJid, bobDevice.id, DateTime.now().millisecondsSinceEpoch, - bobResult2.encryptedKeys[bobJid]!, + bobResult2.encryptedKeys[aliceJid]!, base64.encode(bobResult2.ciphertext!), false, ), @@ -130,6 +129,7 @@ void main() { await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1); final bobDevice = await OmemoDevice.generateNewDevice(bobJid, opkAmount: 1); + EncryptionResult? bobEmptyMessage; final aliceManager = OmemoManager( aliceDevice, AlwaysTrustingTrustManager(), @@ -151,6 +151,7 @@ void main() { AlwaysTrustingTrustManager(), (result, recipientJid) async { bobEmptyMessageSent++; + bobEmptyMessage = result; }, (jid) async { expect(jid, aliceJid); @@ -188,10 +189,20 @@ void main() { expect(bobResult.payload, 'Hello world'); // Bob acknowledges the message - await aliceManager.ratchetAcknowledged(bobJid, bobDevice.id); + await aliceManager.onIncomingStanza( + OmemoIncomingStanza( + bobJid, + bobDevice.id, + getTimestamp(), + bobEmptyMessage!.encryptedKeys[aliceJid]!, + null, + false, + ), + ); // Alice now sends 52 messages that Bob decrypts - for (var i = 0; i <= 51; i++) { + for (var i = 0; i < 52; i++) { + Logger.root.finest('${i+1}/52'); final aliceResultLoop = await aliceManager.onOutgoingStanza( OmemoOutgoingStanza( [bobJid], @@ -199,6 +210,8 @@ void main() { ), ); + expect(aliceResultLoop.encryptedKeys[bobJid]!.first.kex, false); + final bobResultLoop = await bobManager.onIncomingStanza( OmemoIncomingStanza( aliceJid, @@ -210,6 +223,7 @@ void main() { ), ); + expect(bobResultLoop.error, null); expect(aliceEmptyMessageSent, 0); expect(bobEmptyMessageSent, 1); expect(bobResultLoop.payload, 'Test message $i'); @@ -237,6 +251,42 @@ void main() { 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, + getTimestamp(), + 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, + getTimestamp(), + 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 accessing data without it existing', () async { @@ -770,7 +820,7 @@ void main() { // Alice has to reconnect but has no connection yet failure = true; - aliceManager.onNewConnection(); + await aliceManager.onNewConnection(); // Alice sends another message to Bob final aliceResult2 = await aliceManager.onOutgoingStanza( From c7ded4c82461608d8a403d941aab5eb9ccc7073c Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 15:54:32 +0200 Subject: [PATCH 06/36] fix: Pass all tests --- lib/src/double_ratchet/double_ratchet.dart | 37 +++-- lib/src/omemo/encryption_result.dart | 7 +- lib/src/omemo/events.dart | 9 +- lib/src/omemo/omemo.dart | 181 ++++++++++++--------- test/double_ratchet_test.dart | 6 +- test/omemo_test.dart | 65 ++++---- test/serialisation_test.dart | 2 +- 7 files changed, 176 insertions(+), 131 deletions(-) diff --git a/lib/src/double_ratchet/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index 65cc4d6..bd3297a 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -40,13 +40,20 @@ class KeyExchangeData { const KeyExchangeData( this.pkId, this.spkId, - this.ek, 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; } @@ -61,7 +68,6 @@ class OmemoDoubleRatchet { this.nr, // Nr this.pn, // Pn this.ik, - this.ek, this.sessionAd, this.mkSkipped, // MKSKIPPED this.acknowledged, @@ -93,13 +99,10 @@ class OmemoDoubleRatchet { /// for verification purposes final OmemoPublicKey ik; - /// The ephemeral public key of the chat partner. Not used for encryption but for possible - /// checks when replacing the ratchet. As such, this is only non-null for the initiating - /// side. - final OmemoPublicKey? ek; - + /// 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. @@ -107,7 +110,7 @@ class OmemoDoubleRatchet { int kexTimestamp; /// The key exchange that was used for initiating the session. - final KeyExchangeData? kex; + final KeyExchangeData kex; /// Indicates whether we received an empty OMEMO message after building a session with /// the device. @@ -118,13 +121,14 @@ 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, - int spkId, ) async { final dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); final rk = await kdfRk(sk, await omemoDH(dhs, spk, 0)); @@ -139,7 +143,6 @@ class OmemoDoubleRatchet { 0, 0, ik, - ek, ad, {}, false, @@ -147,7 +150,7 @@ class OmemoDoubleRatchet { KeyExchangeData( pkId, spkId, - ik, + ownIk, ek, ), ); @@ -159,7 +162,10 @@ 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, @@ -174,12 +180,16 @@ class OmemoDoubleRatchet { 0, 0, ik, - null, ad, {}, true, kexTimestamp, - null, + KeyExchangeData( + pkId, + spkId, + ik, + ek, + ), ); } @@ -357,7 +367,6 @@ class OmemoDoubleRatchet { nr, pn, ik, - ek, sessionAd, Map>.from(mkSkipped), acknowledged, diff --git a/lib/src/omemo/encryption_result.dart b/lib/src/omemo/encryption_result.dart index 866df5d..112b2fd 100644 --- a/lib/src/omemo/encryption_result.dart +++ b/lib/src/omemo/encryption_result.dart @@ -22,8 +22,9 @@ class EncryptionResult { /// Mapping of a JID to final Map> deviceEncryptionErrors; + // TODO: Turn this into a property that is computed in [onOutgoingStanza]. /// True if the encryption was a success. This means that we could encrypt for - /// at least one ratchet. - /// TODO: - bool isSuccess(int numberOfRecipients) => true; + /// at least one ratchet per recipient. [recipients] is the number of recipients + /// that the message should've been encrypted for. + bool isSuccess(int recipients) => encryptedKeys.length == recipients; } diff --git a/lib/src/omemo/events.dart b/lib/src/omemo/events.dart index 5568334..01c770e 100644 --- a/lib/src/omemo/events.dart +++ b/lib/src/omemo/events.dart @@ -39,8 +39,13 @@ class RatchetRemovedEvent extends OmemoEvent { /// Triggered when the device map has been modified class DeviceListModifiedEvent extends OmemoEvent { - DeviceListModifiedEvent(this.list); - final Map> list; + DeviceListModifiedEvent(this.jid, this.devices); + + /// The JID of the user. + final String jid; + + /// The list of devices for [jid]. + final List devices; } /// Triggered by the OmemoSessionManager when our own device bundle was modified diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 03b5d7d..ed9cf4f 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -3,7 +3,6 @@ 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/common/result.dart'; @@ -28,20 +27,6 @@ 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; -} - extension AppendToListOrCreateExtension on Map> { void appendOrCreate(K key, V value) { if (containsKey(key)) { @@ -185,26 +170,27 @@ class OmemoManager { /// 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)) { - return []; + 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; + + _eventStreamController.add( + DeviceListModifiedEvent(jid, newDeviceList), + ); + } } - final newDeviceList = await fetchDeviceListImpl(jid); - if (newDeviceList == null) { + // Check that we have the device list + if (!_deviceList.containsKey(jid)) { + _log.warning('$jid not tracked in device list.'); return []; } - // Figure out what bundles we must fetch - _deviceList[jid] = newDeviceList; - _deviceListRequested[jid] = true; - - // TODO: Maybe do this per JID? - _eventStreamController.add( - DeviceListModifiedEvent(_deviceList), - ); - final ownDevice = await getDevice(); - final bundlesToFetch = newDeviceList.where((device) { + 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; @@ -285,14 +271,28 @@ class OmemoManager { ); } + // Check how we should process the message final ratchetKey = RatchetMapKey(stanza.bareSenderJid, stanza.senderDeviceId); - if (key.kex) { + var processAsKex = key.kex; + if (key.kex && _ratchetMap.containsKey(ratchetKey)) { + final ratchet = _ratchetMap[ratchetKey]!; + final kexMessage = OMEMOKeyExchange.fromBuffer(base64Decode(key.value)); + 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(base64Decode(key.value)); - // TODO: Check if we already have such a session and if we can build it - // See XEP-0384 4.3 - // Find the correct SPK final device = await getDevice(); OmemoKeyPair spk; @@ -312,13 +312,16 @@ class OmemoManager { kexMessage.ik, KeyPairType.ed25519, ); + final kexEk = OmemoPublicKey.fromBytes( + kexMessage.ek, + KeyPairType.x25519, + ); + + // TODO: Guard against invalid signatures final kex = await x3dhFromInitialMessage( X3DHMessage( kexIk, - OmemoPublicKey.fromBytes( - kexMessage.ek, - KeyPairType.x25519, - ), + kexEk, kexMessage.pkId, ), spk, @@ -327,7 +330,10 @@ class OmemoManager { ); final ratchet = await OmemoDoubleRatchet.acceptNewSession( spk, + kexMessage.spkId, kexIk, + kexMessage.pkId, + kexEk, kex.sk, kex.ad, getTimestamp(), @@ -395,8 +401,7 @@ class OmemoManager { } // Send the hearbeat, if we have to - // TODO: Handle replace - await _maybeSendEmptyMessage(ratchetKey, true, false); + await _maybeSendEmptyMessage(ratchetKey, true, _ratchetMap.containsKey(ratchetKey)); return DecryptionResult( result.get(), @@ -415,7 +420,16 @@ class OmemoManager { _log.finest('Decoding message as OMEMOAuthenticatedMessage'); final ratchet = _ratchetMap[ratchetKey]!.clone(); - final authMessage = OMEMOAuthenticatedMessage.fromBuffer(base64Decode(key.value)); + + // Correctly decode the message + OMEMOAuthenticatedMessage authMessage; + if (key.kex) { + _log.finest('Extracting OMEMOAuthenticatedMessage from OMEMOKeyExchange'); + authMessage = OMEMOKeyExchange.fromBuffer(base64Decode(key.value)).message; + } else { + authMessage = OMEMOAuthenticatedMessage.fromBuffer(base64Decode(key.value)); + } + final keyAndHmac = await ratchet.ratchetDecrypt(authMessage); if (keyAndHmac.isType()) { final error = keyAndHmac.get(); @@ -504,6 +518,7 @@ class OmemoManager { } for (final bundle in newBundles) { + _log.finest('Building new ratchet $jid:${bundle.id}'); final ratchetKey = RatchetMapKey(jid, bundle.id); final ownDevice = await getDevice(); final kexResult = await x3dhFromBundle( @@ -512,13 +527,14 @@ class OmemoManager { ); final newRatchet = await OmemoDoubleRatchet.initiateNewSession( bundle.spk, + bundle.spkId, bundle.ik, + ownDevice.ik.pk, kexResult.ek.pk, kexResult.sk, kexResult.ad, getTimestamp(), kexResult.opkId, - bundle.spkId, ); // Track the ratchet @@ -529,11 +545,13 @@ class OmemoManager { 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 = kexResult.opkId - ..spkId = bundle.spkId - ..ik = await ownDevice.ik.pk.getBytes() - ..ek = await kexResult.ek.pk.getBytes(); + ..pkId = newRatchet.kex.pkId + ..spkId = newRatchet.kex.spkId + ..ik = ik + ..ek = ek; } } @@ -615,30 +633,16 @@ class OmemoManager { ), ); } else if (!ratchet.acknowledged) { - // The ratchet as not yet been acked - if (ratchet.kex == null) { - // The ratchet is not acked but we also don't have an old KEX to send with it - _log.warning('Ratchet $jid:$device is not acked but has no previous KEX.'); - - encryptedKeys.appendOrCreate( - jid, - EncryptedKey( - jid, - device, - base64Encode(authMessage.writeToBuffer()), - false, - ), - ); - continue; - } - + // 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() + ..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( @@ -681,14 +685,43 @@ class OmemoManager { await sendEmptyOmemoMessageImpl(result, jid); } - // TODO - Future removeAllRatchets(String jid) async {} + /// Removes all ratchets associated with [jid]. + Future removeAllRatchets(String jid) async { + await _enterRatchetCriticalSection(jid); - // TODO - Future onDeviceListUpdate(String jid, List devices) async {} + for (final device in _deviceList[jid] ?? []) { + // Remove the ratchet and commit + _ratchetMap.remove(RatchetMapKey(jid, device)); + _eventStreamController.add(RatchetRemovedEvent(jid, device)); + } - // TODO - Future onNewConnection() async {} + // Clear the device list + _deviceList.remove(jid); + _deviceListRequested.remove(jid); + _eventStreamController.add(DeviceListModifiedEvent(jid, [])); + + await _leaveRatchetCriticalSection(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 { + // Update our state + _deviceList[jid] = devices; + _deviceListRequested[jid] = true; + + // Commit the device list + _eventStreamController.add( + DeviceListModifiedEvent(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 { @@ -709,7 +742,7 @@ class OmemoManager { } // TODO - Future> getFingerprintsForJid(String jid) async => []; + Future?> getFingerprintsForJid(String jid) async => null; /// Returns the device used for encryption and decryption. Future getDevice() => _deviceLock.synchronized(() => _device); @@ -718,5 +751,5 @@ class OmemoManager { Future getDeviceId() async => (await getDevice()).id; @visibleForTesting - OmemoDoubleRatchet getRatchet(RatchetMapKey key) => _ratchetMap[key]!; + OmemoDoubleRatchet? getRatchet(RatchetMapKey key) => _ratchetMap[key]; } diff --git a/test/double_ratchet_test.dart b/test/double_ratchet_test.dart index 5143b09..1347e57 100644 --- a/test/double_ratchet_test.dart +++ b/test/double_ratchet_test.dart @@ -51,17 +51,21 @@ void main() { // 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, - bundleBob.spkId, ); final bobsRatchet = await OmemoDoubleRatchet.acceptNewSession( spkBob, + bundleBob.spkId, ikAlice.pk, + 2, + resultAlice.ek.pk, resultBob.sk, resultBob.ad, 0, diff --git a/test/omemo_test.dart b/test/omemo_test.dart index 56b9381..3aa9110 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; 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:test/test.dart'; @@ -210,7 +211,7 @@ void main() { ), ); - expect(aliceResultLoop.encryptedKeys[bobJid]!.first.kex, false); + expect(aliceResultLoop.encryptedKeys[bobJid]!.first.kex, isFalse); final bobResultLoop = await bobManager.onIncomingStanza( OmemoIncomingStanza( @@ -376,7 +377,7 @@ void main() { ); expect(bobResult1.payload, null); - expect(bobResult1.error is NotEncryptedForDeviceError, true); + expect(bobResult1.error, const TypeMatcher()); // Now Alice's client loses and regains the connection await aliceManager.onNewConnection(); @@ -401,6 +402,7 @@ void main() { ); expect(aliceResult2.encryptedKeys.length, 1); + expect(bobResult2.error, null); expect(bobResult2.payload, 'Hello Bob x2'); }); @@ -506,7 +508,7 @@ void main() { bobJid, bobDevice2.id, DateTime.now().millisecondsSinceEpoch, - bobResult2.encryptedKeys[bobJid]!, + bobResult2.encryptedKeys[aliceJid]!, base64.encode(bobResult2.ciphertext!), false, ), @@ -617,7 +619,7 @@ void main() { ), ); - expect(aliceResult2.encryptedKeys.length, 2); + expect(aliceResult2.encryptedKeys[bobJid]!.length, 2); // And Bob decrypts it final bobResult21 = await bobManager1.onIncomingStanza( @@ -658,7 +660,7 @@ void main() { bobJid, bobDevice2.id, DateTime.now().millisecondsSinceEpoch, - bobResult32.encryptedKeys[bobJid]!, + bobResult32.encryptedKeys[aliceJid]!, base64.encode(bobResult32.ciphertext!), false, ), @@ -878,13 +880,10 @@ void main() { ), ); - expect(aliceResult.isSuccess(1), false); - // TODO - /*expect( - aliceResult.jidEncryptionErrors[bobJid] - is NoKeyMaterialAvailableException, - true, - );*/ + expect(aliceResult.isSuccess(1), 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 { @@ -933,14 +932,9 @@ void main() { ), ); - expect(aliceResult.isSuccess(2), true); - // TODO - /* - expect( - aliceResult.jidEncryptionErrors[cocoJid] - is NoKeyMaterialAvailableException, - true, - );*/ + expect(aliceResult.isSuccess(2), isFalse); + expect(aliceResult.deviceEncryptionErrors[cocoJid]!.length, 1); + expect(aliceResult.deviceEncryptionErrors[cocoJid]!.first.error, const TypeMatcher(),); // Bob decrypts it final bobResult = await bobManager.onIncomingStanza( @@ -1025,7 +1019,7 @@ void main() { messageText, ), ); - expect(bobResponseMessage.isSuccess(1), true); + expect(bobResponseMessage.isSuccess(1), isTrue); final aliceReceivedMessage = await aliceManager.onIncomingStanza( OmemoIncomingStanza( @@ -1181,7 +1175,7 @@ void main() { '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 + // that Bob does not ack the ratchet after Alice's heartbeat after she recreated // all ratchets. const aliceJid = 'alice@server1'; const bobJid = 'bob@server2'; @@ -1227,7 +1221,7 @@ void main() { ); // And Bob decrypts it - await bobManager.onIncomingStanza( + final bobResult1 = await bobManager.onIncomingStanza( OmemoIncomingStanza( aliceJid, aliceDevice.id, @@ -1237,6 +1231,7 @@ void main() { false, ), ); + expect(bobResult1.error, isNull); // Ratchets are acked await aliceManager.ratchetAcknowledged( @@ -1245,9 +1240,10 @@ void main() { ); // 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(aliceManager.getRatchet(RatchetMapKey(bobJid, bobDevice.id)), null); + expect(aliceManager.getRatchet(RatchetMapKey(bobJid, bobDevice.id)), isNull); // Alice prepares an empty OMEMO message await aliceManager.sendOmemoHeartbeat(bobJid); @@ -1266,12 +1262,14 @@ void main() { expect(bobResult2.error, null); // 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!', ), ); + expect(aliceResult3.encryptedKeys[bobJid]!.first.kex, isTrue); // Bob decrypts it final bobResult3 = await bobManager.onIncomingStanza( @@ -1358,8 +1356,7 @@ void main() { ); // The first message must be a KEX message - // TODO - //expect(aliceResult1.encryptedKeys.first.kex, true); + expect(aliceResult1.encryptedKeys[bobJid]!.first.kex, isTrue); // Bob decrypts Alice's message final bobResult1 = await bobManager.onIncomingStanza( @@ -1384,24 +1381,21 @@ void main() { ); // The response should contain a KEX - // TODO - /* - expect(aliceResult2.encryptedKeys.first.kex, true); + expect(aliceResult2.encryptedKeys[bobJid]!.first.kex, isTrue); // The basic data should be the same final parsedFirstKex = OMEMOKeyExchange.fromBuffer( - base64.decode(aliceResult1.encryptedKeys.first.value), + base64.decode(aliceResult1.encryptedKeys[bobJid]!.first.value), ); final parsedSecondKex = OMEMOKeyExchange.fromBuffer( - base64.decode(aliceResult2.encryptedKeys.first.value), + 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); - */ - // Alice decrypts it + // Bob decrypts it final bobResult2 = await bobManager.onIncomingStanza( OmemoIncomingStanza( aliceJid, @@ -1429,7 +1423,7 @@ void main() { bobJid, bobDevice.id, DateTime.now().millisecondsSinceEpoch, - bobResult3.encryptedKeys[bobJid]!, + bobResult3.encryptedKeys[aliceJid]!, base64.encode(bobResult3.ciphertext!), false, ), @@ -1452,8 +1446,7 @@ void main() { ); // The response should contain no KEX - // TODO - //expect(aliceResult4.encryptedKeys.first.kex, false); + expect(aliceResult4.encryptedKeys[bobJid]!.first.kex, isFalse); // Bob decrypts it final bobResult4 = await bobManager.onIncomingStanza( diff --git a/test/serialisation_test.dart b/test/serialisation_test.dart index 1c1475f..379bf60 100644 --- a/test/serialisation_test.dart +++ b/test/serialisation_test.dart @@ -1,6 +1,6 @@ import 'dart:convert'; import 'package:omemo_dart/omemo_dart.dart'; -import 'package:omemo_dart/protobuf/schema.pb.dart'; +import 'package:omemo_dart/src/protobuf/schema.pb.dart'; import 'package:omemo_dart/src/trust/always.dart'; import 'package:test/test.dart'; From af33ed51d14e0316c69278043b55f28fbec71f9c Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 16:07:23 +0200 Subject: [PATCH 07/36] feat: Guard against invalid X3DH signatures --- lib/src/errors.dart | 5 +---- lib/src/omemo/omemo.dart | 18 ++++++++++++++++-- lib/src/x3dh/x3dh.dart | 7 ++++--- test/double_ratchet_test.dart | 3 ++- test/x3dh_test.dart | 18 ++++-------------- 5 files changed, 27 insertions(+), 24 deletions(-) diff --git a/lib/src/errors.dart b/lib/src/errors.dart index 1aafd61..858a618 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -1,10 +1,7 @@ abstract class OmemoError {} /// Triggered during X3DH if the signature if the SPK does verify to the actual SPK. -class InvalidSignatureException extends OmemoError 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. class InvalidMessageHMACError extends OmemoError {} diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index ed9cf4f..1c5fd33 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -509,6 +509,7 @@ class OmemoManager { ciphertext = []; } + final encryptionErrors = >{}; final addedRatchetKeys = List.empty(growable: true); final kex = {}; for (final jid in stanza.recipientJids) { @@ -521,10 +522,24 @@ class OmemoManager { _log.finest('Building new ratchet $jid:${bundle.id}'); final ratchetKey = RatchetMapKey(jid, bundle.id); final ownDevice = await getDevice(); - final kexResult = await x3dhFromBundle( + final kexResultRaw = await x3dhFromBundle( bundle, ownDevice.ik, ); + // TODO: 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, @@ -567,7 +582,6 @@ class OmemoManager { } // Encrypt the symmetric key for all devices. - final encryptionErrors = >{}; final encryptedKeys = >{}; for (final jid in stanza.recipientJids) { // Check if we know about any devices to use diff --git a/lib/src/x3dh/x3dh.dart b/lib/src/x3dh/x3dh.dart index 670a805..8f261a1 100644 --- a/lib/src/x3dh/x3dh.dart +++ b/lib/src/x3dh/x3dh.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:cryptography/cryptography.dart'; +import 'package:omemo_dart/src/common/result.dart'; import 'package:omemo_dart/src/crypto.dart'; import 'package:omemo_dart/src/errors.dart'; import 'package:omemo_dart/src/helpers.dart'; @@ -70,7 +71,7 @@ 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 +85,7 @@ Future x3dhFromBundle( ); if (!signatureValue) { - throw InvalidSignatureException(); + return Result(InvalidKeyExchangeSignatureError()); } // Generate EK @@ -106,7 +107,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/test/double_ratchet_test.dart b/test/double_ratchet_test.dart index 1347e57..f630bae 100644 --- a/test/double_ratchet_test.dart +++ b/test/double_ratchet_test.dart @@ -28,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 // ... 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); }); } From f1ec8d1793bfdb7f63f3e3f172384e7c34c81b4c Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 16:18:42 +0200 Subject: [PATCH 08/36] feat: Implement getting fingerprints --- lib/src/double_ratchet/double_ratchet.dart | 11 +++++- lib/src/omemo/omemo.dart | 39 ++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/lib/src/double_ratchet/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index bd3297a..78b7bbd 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'package:cryptography/cryptography.dart'; import 'package:hex/hex.dart'; import 'package:meta/meta.dart'; @@ -356,6 +355,7 @@ class OmemoDoubleRatchet { ..message = headerBytes; } + /// Returns a copy of the ratchet. OmemoDoubleRatchet clone() { return OmemoDoubleRatchet( dhs, @@ -375,6 +375,15 @@ class OmemoDoubleRatchet { ); } + /// 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(), + ); + } + @visibleForTesting Future equals(OmemoDoubleRatchet other) async { final dhrMatch = dhr == null diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 1c5fd33..060a683 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -755,8 +755,43 @@ class OmemoManager { await _leaveRatchetCriticalSection(jid); } - // TODO - Future?> getFingerprintsForJid(String jid) async => null; + /// If ratchets with [jid] exists, returns a list of fingerprints for each + /// ratchet. + /// + /// If not ratchets exists, returns null. + Future?> getFingerprintsForJid(String jid) async { + await _getFingerprintsForJidImpl(jid); + final result = await _getFingerprintsForJidImpl(jid); + await _leaveRatchetCriticalSection(jid); + + return result; + } + + 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); From 6c301ab88fa1fad8a1c303af2703645535884f2b Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 16:42:17 +0200 Subject: [PATCH 09/36] feat: Guard against malformed ciphertext --- lib/src/common/result.dart | 2 +- lib/src/crypto.dart | 24 ++++++++++++++-------- lib/src/double_ratchet/double_ratchet.dart | 6 +++++- lib/src/errors.dart | 20 ++++++++---------- lib/src/omemo/omemo.dart | 20 ++++++++++-------- 5 files changed, 42 insertions(+), 30 deletions(-) diff --git a/lib/src/common/result.dart b/lib/src/common/result.dart index 60d4808..911b7dd 100644 --- a/lib/src/common/result.dart +++ b/lib/src/common/result.dart @@ -3,7 +3,7 @@ class Result { const Result(this._data) : assert( _data is T || _data is V, - 'Invalid data type: Must be either $T or $V', + 'Invalid data type $_data: Must be either $T or $V', ); final dynamic _data; diff --git a/lib/src/crypto.dart b/lib/src/crypto.dart index ce421c2..4933615 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:omemo_dart/src/common/result.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/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index 78b7bbd..122aa7c 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -261,7 +261,11 @@ class OmemoDoubleRatchet { } final plaintext = await aes256CbcDecrypt(ciphertext, keys.encryptionKey, keys.iv); - return Result(plaintext); + 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, diff --git a/lib/src/errors.dart b/lib/src/errors.dart index 858a618..8e7aecf 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -13,22 +13,10 @@ class SkippingTooManyKeysError extends OmemoError {} /// Triggered by the Session Manager if the message key is not encrypted for the device. class NotEncryptedForDeviceError extends OmemoError {} -/// Triggered by the Session Manager when there is no key for decrypting the message. -class NoDecryptionKeyException extends OmemoError implements Exception { - String errMsg() => 'No key available for decrypting the message'; -} - /// 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 UnknownSignedPrekeyError extends OmemoError {} -/// 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 OmemoError implements Exception { - String errMsg() => 'The key exchange was sent before the last kex finished'; -} - /// 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 @@ -38,3 +26,11 @@ 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; +} diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 060a683..2c6a54e 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -152,14 +152,20 @@ class OmemoManager { return Result(InvalidMessageHMACError()); } - // TODO: Handle an exception from the crypto implementation + final result = await aes256CbcDecrypt( + ciphertext, + derivedKeys.encryptionKey, + derivedKeys.iv, + ); + if (result.isType()) { + return Result( + result.get(), + ); + } + return Result( utf8.decode( - await aes256CbcDecrypt( - ciphertext, - derivedKeys.encryptionKey, - derivedKeys.iv, - ), + result.get>(), ), ); } @@ -316,8 +322,6 @@ class OmemoManager { kexMessage.ek, KeyPairType.x25519, ); - - // TODO: Guard against invalid signatures final kex = await x3dhFromInitialMessage( X3DHMessage( kexIk, From 6e734ec0c33df0bbd76cf432f6b303f022d528bb Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 18:11:35 +0200 Subject: [PATCH 10/36] feat: Remove serialization code --- lib/src/helpers.dart | 22 ----- lib/src/omemo/device.dart | 98 ---------------------- lib/src/omemo/omemo.dart | 7 +- test/serialisation_test.dart | 155 ----------------------------------- 4 files changed, 6 insertions(+), 276 deletions(-) delete mode 100644 test/serialisation_test.dart diff --git a/lib/src/helpers.dart b/lib/src/helpers.dart index 4f23632..8c4ac98 100644 --- a/lib/src/helpers.dart +++ b/lib/src/helpers.dart @@ -56,28 +56,6 @@ OmemoPublicKey? decodeKeyIfNotNull( ); } -List? base64DecodeIfNotNull(Map map, String key) { - if (map[key] == null) return null; - - return base64.decode(map[key]! as String); -} - -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; } diff --git a/lib/src/omemo/device.dart b/lib/src/omemo/device.dart index 354c795..14e7a7d 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, { @@ -221,34 +151,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/omemo.dart b/lib/src/omemo/omemo.dart index 2c6a54e..70fa49c 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -414,7 +414,12 @@ class OmemoManager { } else { // Check if we even have a ratchet if (!_ratchetMap.containsKey(ratchetKey)) { - // TODO: Build a session with the device + // TODO: 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, diff --git a/test/serialisation_test.dart b/test/serialisation_test.dart deleted file mode 100644 index 379bf60..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/protobuf/schema.pb.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 oldDevice = - await OmemoDevice.generateNewDevice('user@test.server', opkAmount: 5); - 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 device = - await OmemoDevice.generateNewDevice('user@test.server', opkAmount: 1); - final oldDevice = await device.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 aliceManager = 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); - });*/ - - test('Test reading and writing protobuf', () { - final kex = OMEMOKeyExchange.fromBuffer(base64Decode('CFIQqvSv8AEaIHuVKKqf00vYpBIB7PKWheboVKcoKCWkRIUFeokzMYqxIiDOYPRA2vAlHwDLwPn/XIMSmenj3fgUIZHMgUXxVJtedyp8ChA+GfjScvWAllcTavRoyvfIEmgIDhAAGiANff1ES1HdSgtjy9JsoXcAywXJfBmZsFYTKUHRQsCMNiJAAOhS/CMrIdDm+ZZ/fmaOfwD0O7MNUaUMkVahvk4XDAy6mYk65r2TE4REW7h7akcKyoL94YSnTWp8p6fO91VSLA==')); - final newKex = OMEMOKeyExchange.fromBuffer(kex.writeToBuffer()); - - expect(kex.writeToBuffer(), newKex.writeToBuffer()); - }); -} From da11e60f79964d7069794e235fe35d65146f49e8 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 21:02:53 +0200 Subject: [PATCH 11/36] feat: Re-introduce locking the ratchet map/device list This makes the locking much more intelligent, allowing us to encrypt to/decrypt from groups while still being able to bypass the lock for unaffiliated JIDs. --- lib/src/omemo/omemo.dart | 126 ++++++++++++++++----------------------- lib/src/omemo/queue.dart | 97 ++++++++++++++++++++++++++++++ test/queue_test.dart | 56 +++++++++++++++++ 3 files changed, 203 insertions(+), 76 deletions(-) create mode 100644 lib/src/omemo/queue.dart create mode 100644 test/queue_test.dart diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 70fa49c..3dd4b7e 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -20,6 +20,7 @@ import 'package:omemo_dart/src/omemo/encryption_result.dart'; import 'package:omemo_dart/src/omemo/errors.dart'; import 'package:omemo_dart/src/omemo/events.dart'; import 'package:omemo_dart/src/omemo/fingerprint.dart'; +import 'package:omemo_dart/src/omemo/queue.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'; @@ -71,21 +72,20 @@ class OmemoManager { final Future Function(String jid) subscribeToDeviceListNodeImpl; /// Map bare JID to its known devices - Map> _deviceList = {}; + 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. - Map _ratchetMap = {}; + final 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(); + final RatchetAccessQueue _ratchetQueue = RatchetAccessQueue(); /// The OmemoManager's trust management final TrustManager _trustManager; @@ -101,39 +101,6 @@ class OmemoManager { StreamController.broadcast(); Stream get eventStream => _eventStreamController.stream; - /// Enter the critical section for performing cryptographic operations on the ratchets - Future _enterRatchetCriticalSection(String jid) async { - return; - 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 { - return; - 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, @@ -242,7 +209,7 @@ class OmemoManager { } else { // Ratchet is not acknowledged _log.finest('Sending acknowledgement heartbeat to ${key.jid}'); - await ratchetAcknowledged(key.jid, key.deviceId); + await _ratchetAcknowledged(key.jid, key.deviceId); await sendEmptyOmemoMessageImpl( await _onOutgoingStanzaImpl( OmemoOutgoingStanza( @@ -257,13 +224,10 @@ class OmemoManager { /// Future onIncomingStanza(OmemoIncomingStanza stanza) async { - // NOTE: We do this so that we cannot forget to acquire and free the critical - // section. - await _enterRatchetCriticalSection(stanza.bareSenderJid); - final result = await _onIncomingStanzaImpl(stanza); - await _leaveRatchetCriticalSection(stanza.bareSenderJid); - - return result; + return _ratchetQueue.synchronized( + [stanza.bareSenderJid], + () => _onIncomingStanzaImpl(stanza), + ); } Future _onIncomingStanzaImpl(OmemoIncomingStanza stanza) async { @@ -419,7 +383,7 @@ class OmemoManager { if (!_deviceList[stanza.bareSenderJid]!.contains(stanza.senderDeviceId)) { _deviceList[stanza.bareSenderJid]!.add(stanza.senderDeviceId); } - await sendOmemoHeartbeat(stanza.bareSenderJid); + await _sendOmemoHeartbeat(stanza.bareSenderJid); return DecryptionResult( null, @@ -489,13 +453,10 @@ class OmemoManager { } Future onOutgoingStanza(OmemoOutgoingStanza stanza) async { - // TODO: Be more smart about the locking - // TODO: Do we even need to lock? - await _enterRatchetCriticalSection(stanza.recipientJids.first); - final result = await _onOutgoingStanzaImpl(stanza); - await _leaveRatchetCriticalSection(stanza.recipientJids.first); - - return result; + return _ratchetQueue.synchronized( + stanza.recipientJids, + () => _onOutgoingStanzaImpl(stanza), + ); } Future _onOutgoingStanzaImpl(OmemoOutgoingStanza stanza) async { @@ -697,9 +658,17 @@ class OmemoManager { ); } - // Sends an empty OMEMO message (heartbeat) to [jid]. + /// Sends an empty OMEMO message (heartbeat) to [jid]. Future sendOmemoHeartbeat(String jid) async { - final result = await onOutgoingStanza( + 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, @@ -710,20 +679,21 @@ class OmemoManager { /// Removes all ratchets associated with [jid]. Future removeAllRatchets(String jid) async { - await _enterRatchetCriticalSection(jid); + await _ratchetQueue.synchronized( + [jid], + () async { + for (final device in _deviceList[jid] ?? []) { + // Remove the ratchet and commit + _ratchetMap.remove(RatchetMapKey(jid, device)); + _eventStreamController.add(RatchetRemovedEvent(jid, device)); + } - for (final device in _deviceList[jid] ?? []) { - // Remove the ratchet and commit - _ratchetMap.remove(RatchetMapKey(jid, device)); - _eventStreamController.add(RatchetRemovedEvent(jid, device)); - } - - // Clear the device list - _deviceList.remove(jid); - _deviceListRequested.remove(jid); - _eventStreamController.add(DeviceListModifiedEvent(jid, [])); - - await _leaveRatchetCriticalSection(jid); + // Clear the device list + _deviceList.remove(jid); + _deviceListRequested.remove(jid); + _eventStreamController.add(DeviceListModifiedEvent(jid, [])); + }, + ); } /// To be called when a update to the device list of [jid] is returned. @@ -748,8 +718,14 @@ class OmemoManager { // Mark the ratchet [jid]:[device] as acknowledged. Future ratchetAcknowledged(String jid, int device) async { - await _enterRatchetCriticalSection(jid); + 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'); @@ -760,8 +736,6 @@ class OmemoManager { RatchetModifiedEvent(jid, device, ratchet, false, false), ); } - - await _leaveRatchetCriticalSection(jid); } /// If ratchets with [jid] exists, returns a list of fingerprints for each @@ -769,13 +743,13 @@ class OmemoManager { /// /// If not ratchets exists, returns null. Future?> getFingerprintsForJid(String jid) async { - await _getFingerprintsForJidImpl(jid); - final result = await _getFingerprintsForJidImpl(jid); - await _leaveRatchetCriticalSection(jid); - - return result; + 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)) { diff --git a/lib/src/omemo/queue.dart b/lib/src/omemo/queue.dart new file mode 100644 index 0000000..a63f02e --- /dev/null +++ b/lib/src/omemo/queue.dart @@ -0,0 +1,97 @@ +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/test/queue_test.dart b/test/queue_test.dart new file mode 100644 index 0000000..2cd19d3 --- /dev/null +++ b/test/queue_test.dart @@ -0,0 +1,56 @@ +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, + ); + }); +} From b0bba4fe82c43dba0653869e9157406aeb571c30 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 21:20:24 +0200 Subject: [PATCH 12/36] fix: Fix style issues --- lib/src/crypto.dart | 2 +- lib/src/double_ratchet/double_ratchet.dart | 32 +++++++---- lib/src/omemo/encryption_result.dart | 4 +- lib/src/omemo/omemo.dart | 64 +++++++++++++++------- lib/src/omemo/queue.dart | 5 +- lib/src/x3dh/x3dh.dart | 3 +- test/omemo_test.dart | 12 +++- test/queue_test.dart | 12 +++- 8 files changed, 92 insertions(+), 42 deletions(-) diff --git a/lib/src/crypto.dart b/lib/src/crypto.dart index 4933615..dbe97a0 100644 --- a/lib/src/crypto.dart +++ b/lib/src/crypto.dart @@ -113,7 +113,7 @@ Future>> aes256CbcDecrypt( ), ); } catch (ex) { - return Result(MalformedCiphertextError(ex)); + return Result(MalformedCiphertextError(ex)); } } diff --git a/lib/src/double_ratchet/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index 122aa7c..9c3fcb6 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -247,11 +247,15 @@ class OmemoDoubleRatchet { /// Decrypt [ciphertext] using keys derived from the message key [mk]. Also computes the /// HMAC from the [OMEMOMessage] embedded in [message]. - /// + /// /// 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 { + Future>> _decrypt( + OMEMOAuthenticatedMessage message, + List ciphertext, + List mk, + ) async { final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString); final hmacInput = concat([sessionAd, message.message]); @@ -260,7 +264,8 @@ class OmemoDoubleRatchet { return Result(InvalidMessageHMACError()); } - final plaintext = await aes256CbcDecrypt(ciphertext, keys.encryptionKey, keys.iv); + final plaintext = + await aes256CbcDecrypt(ciphertext, keys.encryptionKey, keys.iv); if (plaintext.isType()) { return Result(plaintext.get()); } @@ -270,10 +275,13 @@ class OmemoDoubleRatchet { /// 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 { + Future?>> _trySkippedMessageKeys( + OMEMOAuthenticatedMessage message, + OMEMOMessage header, + ) async { final key = SkippedKey( OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519), header.n, @@ -289,10 +297,12 @@ class OmemoDoubleRatchet { } /// 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 { + Future>> ratchetDecrypt( + OMEMOAuthenticatedMessage message, + ) async { final header = OMEMOMessage.fromBuffer(message.message); // Try skipped keys @@ -343,10 +353,10 @@ class OmemoDoubleRatchet { // 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; + ..dhPub = await dhs.pk.getBytes() + ..pn = pn + ..n = ns + ..ciphertext = ciphertext; final headerBytes = header.writeToBuffer(); // Increment the send counter diff --git a/lib/src/omemo/encryption_result.dart b/lib/src/omemo/encryption_result.dart index 112b2fd..36a06ad 100644 --- a/lib/src/omemo/encryption_result.dart +++ b/lib/src/omemo/encryption_result.dart @@ -1,8 +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/errors.dart'; -import 'package:omemo_dart/src/omemo/ratchet_map_key.dart'; @immutable class EncryptionResult { @@ -19,7 +17,7 @@ class EncryptionResult { /// for the ratchet with said device Id. final Map> encryptedKeys; - /// Mapping of a JID to + /// Mapping of a JID to final Map> deviceEncryptionErrors; // TODO: Turn this into a property that is computed in [onOutgoingStanza]. diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 3dd4b7e..dff324f 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:collection'; import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:cryptography/cryptography.dart'; @@ -139,11 +138,12 @@ class OmemoManager { /// 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)) { + if (!_deviceListRequested.containsKey(jid) || + !_deviceList.containsKey(jid)) { final newDeviceList = await fetchDeviceListImpl(jid); if (newDeviceList != null) { // Figure out what bundles we must fetch @@ -190,11 +190,17 @@ class OmemoManager { return bundles; } - Future _maybeSendEmptyMessage(RatchetMapKey key, bool created, bool replaced) async { + 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'); + _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( @@ -222,7 +228,7 @@ class OmemoManager { } } - /// + /// Future onIncomingStanza(OmemoIncomingStanza stanza) async { return _ratchetQueue.synchronized( [stanza.bareSenderJid], @@ -230,7 +236,9 @@ class OmemoManager { ); } - Future _onIncomingStanzaImpl(OmemoIncomingStanza stanza) async { + Future _onIncomingStanzaImpl( + OmemoIncomingStanza stanza, + ) async { // Find the correct key for our device final deviceId = await getDeviceId(); final key = stanza.keys.firstWhereOrNull((key) => key.rid == deviceId); @@ -242,7 +250,8 @@ class OmemoManager { } // Check how we should process the message - final ratchetKey = RatchetMapKey(stanza.bareSenderJid, stanza.senderDeviceId); + final ratchetKey = + RatchetMapKey(stanza.bareSenderJid, stanza.senderDeviceId); var processAsKex = key.kex; if (key.kex && _ratchetMap.containsKey(ratchetKey)) { final ratchet = _ratchetMap[ratchetKey]!; @@ -288,7 +297,7 @@ class OmemoManager { ); final kex = await x3dhFromInitialMessage( X3DHMessage( - kexIk, + kexIk, kexEk, kexMessage.pkId, ), @@ -369,7 +378,11 @@ class OmemoManager { } // Send the hearbeat, if we have to - await _maybeSendEmptyMessage(ratchetKey, true, _ratchetMap.containsKey(ratchetKey)); + await _maybeSendEmptyMessage( + ratchetKey, + true, + _ratchetMap.containsKey(ratchetKey), + ); return DecryptionResult( result.get(), @@ -380,7 +393,8 @@ class OmemoManager { if (!_ratchetMap.containsKey(ratchetKey)) { // TODO: 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)) { + if (!_deviceList[stanza.bareSenderJid]! + .contains(stanza.senderDeviceId)) { _deviceList[stanza.bareSenderJid]!.add(stanza.senderDeviceId); } await _sendOmemoHeartbeat(stanza.bareSenderJid); @@ -397,10 +411,14 @@ class OmemoManager { // Correctly decode the message OMEMOAuthenticatedMessage authMessage; if (key.kex) { - _log.finest('Extracting OMEMOAuthenticatedMessage from OMEMOKeyExchange'); - authMessage = OMEMOKeyExchange.fromBuffer(base64Decode(key.value)).message; + _log.finest( + 'Extracting OMEMOAuthenticatedMessage from OMEMOKeyExchange', + ); + authMessage = + OMEMOKeyExchange.fromBuffer(base64Decode(key.value)).message; } else { - authMessage = OMEMOAuthenticatedMessage.fromBuffer(base64Decode(key.value)); + authMessage = + OMEMOAuthenticatedMessage.fromBuffer(base64Decode(key.value)); } final keyAndHmac = await ratchet.ratchetDecrypt(authMessage); @@ -459,7 +477,9 @@ class OmemoManager { ); } - Future _onOutgoingStanzaImpl(OmemoOutgoingStanza stanza) async { + Future _onOutgoingStanzaImpl( + OmemoOutgoingStanza stanza, + ) async { // Encrypt the payload, if we have any final List payloadKey; final List ciphertext; @@ -545,7 +565,9 @@ class OmemoManager { _eventStreamController.add( RatchetsAddedEvent( Map.fromEntries( - addedRatchetKeys.map((key) => MapEntry(key, _ratchetMap[key]!)).toList(), + addedRatchetKeys + .map((key) => MapEntry(key, _ratchetMap[key]!)) + .toList(), ), ), ); @@ -728,7 +750,9 @@ class OmemoManager { 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'); + _log.warning( + 'Cannot mark $jid:$device as acknowledged as the ratchet does not exist', + ); } else { // Commit final ratchet = _ratchetMap[ratchetKey]!..acknowledged = true; @@ -740,7 +764,7 @@ class OmemoManager { /// 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( @@ -750,7 +774,9 @@ class OmemoManager { } /// Same as [getFingerprintsForJid], but without acquiring the lock for [jid]. - Future?> _getFingerprintsForJidImpl(String jid) async { + Future?> _getFingerprintsForJidImpl( + String jid, + ) async { // Check if we know of the JID. if (!_deviceList.containsKey(jid)) { return null; diff --git a/lib/src/omemo/queue.dart b/lib/src/omemo/queue.dart index a63f02e..8f99df7 100644 --- a/lib/src/omemo/queue.dart +++ b/lib/src/omemo/queue.dart @@ -87,7 +87,10 @@ class RatchetAccessQueue { }); } - Future synchronized(List jids, Future Function() function) async { + Future synchronized( + List jids, + Future Function() function, + ) async { await enterCriticalSection(jids); final result = await function(); await leaveCriticalSection(jids); diff --git a/lib/src/x3dh/x3dh.dart b/lib/src/x3dh/x3dh.dart index 8f261a1..1e82d01 100644 --- a/lib/src/x3dh/x3dh.dart +++ b/lib/src/x3dh/x3dh.dart @@ -71,7 +71,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 { diff --git a/test/omemo_test.dart b/test/omemo_test.dart index 3aa9110..c7b7fba 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -203,7 +203,7 @@ void main() { // Alice now sends 52 messages that Bob decrypts for (var i = 0; i < 52; i++) { - Logger.root.finest('${i+1}/52'); + Logger.root.finest('${i + 1}/52'); final aliceResultLoop = await aliceManager.onOutgoingStanza( OmemoOutgoingStanza( [bobJid], @@ -934,7 +934,10 @@ void main() { expect(aliceResult.isSuccess(2), isFalse); expect(aliceResult.deviceEncryptionErrors[cocoJid]!.length, 1); - expect(aliceResult.deviceEncryptionErrors[cocoJid]!.first.error, const TypeMatcher(),); + expect( + aliceResult.deviceEncryptionErrors[cocoJid]!.first.error, + const TypeMatcher(), + ); // Bob decrypts it final bobResult = await bobManager.onIncomingStanza( @@ -1243,7 +1246,10 @@ void main() { Logger.root.info('Removing all ratchets for $bobJid'); await aliceManager.removeAllRatchets(bobJid); - expect(aliceManager.getRatchet(RatchetMapKey(bobJid, bobDevice.id)), isNull); + expect( + aliceManager.getRatchet(RatchetMapKey(bobJid, bobDevice.id)), + isNull, + ); // Alice prepares an empty OMEMO message await aliceManager.sendOmemoHeartbeat(bobJid); diff --git a/test/queue_test.dart b/test/queue_test.dart index 2cd19d3..b424593 100644 --- a/test/queue_test.dart +++ b/test/queue_test.dart @@ -1,9 +1,12 @@ 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 { +Future testMethod( + RatchetAccessQueue queue, + List data, + int duration, +) async { await queue.enterCriticalSection(data); await Future.delayed(Duration(seconds: duration)); @@ -48,7 +51,10 @@ void main() { expect(queue.runningOperations.length, 4); expect( queue.runningOperations.containsAll([ - 'a', 'b', 'c', 'd', + 'a', + 'b', + 'c', + 'd', ]), isTrue, ); From 3d953f0acbe8eaa0f5b31a135583edc5ac858bbe Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 21:47:04 +0200 Subject: [PATCH 13/36] test: Test receiving a non-KEX message from an unknown device --- test/omemo_test.dart | 256 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/test/omemo_test.dart b/test/omemo_test.dart index c7b7fba..f0bdf25 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -1,4 +1,5 @@ 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'; @@ -1533,4 +1534,259 @@ void main() { await aliceManager.getDeviceId(), ); }); + + 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(), + (result, recipientJid) async {}, + (jid) async { + expect(jid, bobJid); + return [bobDevice.id]; + }, + (jid, id) async { + expect(jid, bobJid); + return bobDevice.toBundle(); + }, + (jid) 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 {}, + ); + + 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 {}, + ); + + // 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, + DateTime.now().millisecondsSinceEpoch, + 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; + includeAlice2 = true; + final bobResult2 = await bobManager.onIncomingStanza( + OmemoIncomingStanza( + aliceJid, + aliceDevice2.id, + getTimestamp(), + 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 {}, + ); + 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 {}, + ); + + 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 {}, + ); + + // 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, + DateTime.now().millisecondsSinceEpoch, + 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, + getTimestamp(), + 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, + ); + }); } From 4ed2d3dec3f7ab2101a670a9b2ab009779af5d11 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Thu, 15 Jun 2023 22:46:37 +0200 Subject: [PATCH 14/36] docs: Improve docstrings --- lib/src/omemo/encrypted_key.dart | 15 +++++++++++++-- lib/src/omemo/omemo.dart | 18 ++++++++---------- 2 files changed, 21 insertions(+), 12 deletions(-) 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/omemo.dart b/lib/src/omemo/omemo.dart index dff324f..34c226a 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -28,6 +28,7 @@ import 'package:omemo_dart/src/x3dh/x3dh.dart'; import 'package:synchronized/synchronized.dart'; extension AppendToListOrCreateExtension on Map> { + /// Create or append [value] to the list identified with key [key]. void appendOrCreate(K key, V value) { if (containsKey(key)) { this[key]!.add(value); @@ -38,6 +39,8 @@ extension AppendToListOrCreateExtension on Map> { } extension StringFromBase64Extension on String { + /// Base64-decode this string. Useful for doing `someString?.fromBase64()` instead + /// of `someString != null ? base64Decode(someString) : null`. List fromBase64() => base64Decode(this); } @@ -255,7 +258,7 @@ class OmemoManager { var processAsKex = key.kex; if (key.kex && _ratchetMap.containsKey(ratchetKey)) { final ratchet = _ratchetMap[ratchetKey]!; - final kexMessage = OMEMOKeyExchange.fromBuffer(base64Decode(key.value)); + final kexMessage = OMEMOKeyExchange.fromBuffer(key.data); final ratchetEk = await ratchet.kex.ek.getBytes(); final sameEk = listsEqual(kexMessage.ek, ratchetEk); @@ -270,7 +273,7 @@ class OmemoManager { // Process the message if (processAsKex) { _log.finest('Decoding message as OMEMOKeyExchange'); - final kexMessage = OMEMOKeyExchange.fromBuffer(base64Decode(key.value)); + final kexMessage = OMEMOKeyExchange.fromBuffer(key.data); // Find the correct SPK final device = await getDevice(); @@ -327,7 +330,7 @@ class OmemoManager { } final result = await _decryptAndVerifyHmac( - stanza.payload != null ? base64Decode(stanza.payload!) : null, + stanza.payload?.fromBase64(), keyAndHmac.get>(), ); if (result.isType()) { @@ -414,11 +417,9 @@ class OmemoManager { _log.finest( 'Extracting OMEMOAuthenticatedMessage from OMEMOKeyExchange', ); - authMessage = - OMEMOKeyExchange.fromBuffer(base64Decode(key.value)).message; + authMessage = OMEMOKeyExchange.fromBuffer(key.data).message; } else { - authMessage = - OMEMOAuthenticatedMessage.fromBuffer(base64Decode(key.value)); + authMessage = OMEMOAuthenticatedMessage.fromBuffer(key.data); } final keyAndHmac = await ratchet.ratchetDecrypt(authMessage); @@ -632,7 +633,6 @@ class OmemoManager { encryptedKeys.appendOrCreate( jid, EncryptedKey( - jid, device, base64Encode(kexMessage.writeToBuffer()), true, @@ -652,7 +652,6 @@ class OmemoManager { encryptedKeys.appendOrCreate( jid, EncryptedKey( - jid, device, base64Encode(kexMessage.writeToBuffer()), true, @@ -663,7 +662,6 @@ class OmemoManager { encryptedKeys.appendOrCreate( jid, EncryptedKey( - jid, device, base64Encode(authMessage.writeToBuffer()), false, From 0b2d6f0a97af8d14fcccaa13e2e35f416736d8c5 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Fri, 16 Jun 2023 00:18:14 +0200 Subject: [PATCH 15/36] docs: Update example --- example/omemo_dart_example.dart | 5 +++++ lib/src/omemo/omemo.dart | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/example/omemo_dart_example.dart b/example/omemo_dart_example.dart index 405d465..23f7e05 100644 --- a/example/omemo_dart_example.dart +++ b/example/omemo_dart_example.dart @@ -145,6 +145,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/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 34c226a..7e76f98 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -394,7 +394,7 @@ class OmemoManager { } else { // Check if we even have a ratchet if (!_ratchetMap.containsKey(ratchetKey)) { - // TODO: Check if we recently failed to build a session with the device + // 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)) { @@ -517,8 +517,8 @@ class OmemoManager { bundle, ownDevice.ik, ); - // TODO: Track the failure and do not attempt to encrypt to this device - // on every send. + // TODO(Unknown): Track the failure and do not attempt to encrypt to this device + // on every send. if (kexResultRaw.isType()) { encryptionErrors.appendOrCreate( jid, From e6c792a8ac26119cec6f75833c8957aab385ce2a Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Fri, 16 Jun 2023 20:13:30 +0200 Subject: [PATCH 16/36] feat: DeviceListModifiedEvent now contains a delta --- lib/src/helpers.dart | 24 ++++++++++++++++++++++++ lib/src/omemo/events.dart | 9 ++++++--- lib/src/omemo/omemo.dart | 30 ++++++++++++++++++++++-------- test/helpers_test.dart | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 test/helpers_test.dart diff --git a/lib/src/helpers.dart b/lib/src/helpers.dart index 8c4ac98..fc4e4a6 100644 --- a/lib/src/helpers.dart +++ b/lib/src/helpers.dart @@ -59,3 +59,27 @@ OmemoPublicKey? decodeKeyIfNotNull( int getTimestamp() { return DateTime.now().millisecondsSinceEpoch; } + +/// Describes the differences between two lists in terms of its items. +class ListDiff { + ListDiff(this.added, this.removed); + + /// The items that were added. + final List added; + + /// The items that were removed. + final List removed; +} + +extension BeforeAfterListDiff on List { + /// Compute the set-based changes between this list and [newList]. + ListDiff diff(List newList) { + final oldSet = Set.from(this); + final newSet = Set.from(newList); + + return ListDiff( + newSet.difference(oldSet).toList(), + oldSet.difference(newSet).toList(), + ); + } +} diff --git a/lib/src/omemo/events.dart b/lib/src/omemo/events.dart index 01c770e..12dad1c 100644 --- a/lib/src/omemo/events.dart +++ b/lib/src/omemo/events.dart @@ -39,13 +39,16 @@ class RatchetRemovedEvent extends OmemoEvent { /// Triggered when the device map has been modified class DeviceListModifiedEvent extends OmemoEvent { - DeviceListModifiedEvent(this.jid, this.devices); + DeviceListModifiedEvent(this.jid, this.added, this.removed); /// The JID of the user. final String jid; - /// The list of devices for [jid]. - final List devices; + /// The list of added devices for [jid]. + final List added; + + /// The list of removed devices for [jid]. + final List removed; } /// Triggered by the OmemoSessionManager when our own device bundle was modified diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 7e76f98..92cdc04 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -154,7 +154,7 @@ class OmemoManager { _deviceListRequested[jid] = true; _eventStreamController.add( - DeviceListModifiedEvent(jid, newDeviceList), + DeviceListModifiedEvent(jid, newDeviceList, []), ); } } @@ -709,9 +709,10 @@ class OmemoManager { } // Clear the device list + _eventStreamController + .add(DeviceListModifiedEvent(jid, [], _deviceList[jid]!)); _deviceList.remove(jid); _deviceListRequested.remove(jid); - _eventStreamController.add(DeviceListModifiedEvent(jid, [])); }, ); } @@ -719,13 +720,26 @@ class OmemoManager { /// 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 { - // Update our state - _deviceList[jid] = devices; - _deviceListRequested[jid] = true; + await _ratchetQueue.synchronized( + [jid], + () async { + // Compute the delta + ListDiff delta; + if (_deviceList.containsKey(jid)) { + delta = _deviceList[jid]!.diff(devices); + } else { + delta = ListDiff(devices, []); + } - // Commit the device list - _eventStreamController.add( - DeviceListModifiedEvent(jid, devices), + // Update our state + _deviceList[jid] = devices; + _deviceListRequested[jid] = true; + + // Commit the device list + _eventStreamController.add( + DeviceListModifiedEvent(jid, delta.added, delta.removed), + ); + }, ); } diff --git a/test/helpers_test.dart b/test/helpers_test.dart new file mode 100644 index 0000000..0b718d2 --- /dev/null +++ b/test/helpers_test.dart @@ -0,0 +1,33 @@ +import 'package:omemo_dart/src/helpers.dart'; +import 'package:omemo_dart/src/omemo/queue.dart'; +import 'package:test/test.dart'; + +void main() { + group('List diff', () { + test('Empty list to full list', () { + final result = [].diff([1, 2, 3, 4]); + expect(result.removed, isEmpty); + expect( + result.added.containsAll([1, 2, 3, 4]), + isTrue, + ); + expect(result.added.length, 4); + }); + + test('Full list to empty list', () { + final result = [1, 2, 3, 4].diff([]); + expect(result.added, isEmpty); + expect( + result.removed.containsAll([1, 2, 3, 4]), + isTrue, + ); + expect(result.removed.length, 4); + }); + + test('Full list to full list', () { + final result = [1, 2, 3, 4].diff([1, 2, 4, 5]); + expect(result.added, [5]); + expect(result.removed, [3]); + }); + }); +} From 28e7ad59b05e7223fd1c15d4a36fe0c14066d83a Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Fri, 16 Jun 2023 20:44:37 +0200 Subject: [PATCH 17/36] feat: Remove events from the OmemoManager --- lib/omemo_dart.dart | 2 +- lib/src/helpers.dart | 17 +++ lib/src/omemo/events.dart | 59 ----------- lib/src/omemo/omemo.dart | 180 +++++++++++++++++++++----------- lib/src/omemo/ratchet_data.dart | 26 +++++ 5 files changed, 164 insertions(+), 120 deletions(-) delete mode 100644 lib/src/omemo/events.dart create mode 100644 lib/src/omemo/ratchet_data.dart diff --git a/lib/omemo_dart.dart b/lib/omemo_dart.dart index bdf3c42..199b91f 100644 --- a/lib/omemo_dart.dart +++ b/lib/omemo_dart.dart @@ -8,9 +8,9 @@ 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/fingerprint.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/helpers.dart b/lib/src/helpers.dart index fc4e4a6..287010f 100644 --- a/lib/src/helpers.dart +++ b/lib/src/helpers.dart @@ -83,3 +83,20 @@ extension BeforeAfterListDiff on List { ); } } + +extension AppendToListOrCreateExtension on Map> { + /// Create or append [value] to the list identified with key [key]. + void appendOrCreate(K key, V value) { + if (containsKey(key)) { + this[key]!.add(value); + } else { + this[key] = [value]; + } + } +} + +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/events.dart b/lib/src/omemo/events.dart deleted file mode 100644 index 12dad1c..0000000 --- a/lib/src/omemo/events.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:omemo_dart/omemo_dart.dart'; - -abstract class OmemoEvent {} - -/// Triggered when (possibly multiple) ratchets have been created at sending time. -class RatchetsAddedEvent extends OmemoEvent { - RatchetsAddedEvent(this.ratchets); - - /// The mapping of the newly created ratchets. - final Map ratchets; -} - -/// 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.jid, this.added, this.removed); - - /// The JID of the user. - final String jid; - - /// The list of added devices for [jid]. - final List added; - - /// The list of removed devices for [jid]. - final List removed; -} - -/// 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 index 92cdc04..9359a5e 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -17,9 +17,9 @@ 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/events.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'; @@ -27,22 +27,62 @@ import 'package:omemo_dart/src/trust/base.dart'; import 'package:omemo_dart/src/x3dh/x3dh.dart'; import 'package:synchronized/synchronized.dart'; -extension AppendToListOrCreateExtension on Map> { - /// Create or append [value] to the list identified with key [key]. - void appendOrCreate(K key, V value) { - if (containsKey(key)) { - this[key]!.add(value); - } else { - this[key] = [value]; - } - } -} +/// Callback type definitions -extension StringFromBase64Extension on String { - /// Base64-decode this string. Useful for doing `someString?.fromBase64()` instead - /// of `someString != null ? base64Decode(someString) : null`. - List fromBase64() => base64Decode(this); -} +/// 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); + +/// Commits the device list for [jid] to persistent storage. [added] will be the list of +/// devices added and [removed] will be the list of removed devices. +typedef CommitDeviceListCallback = Future Function( + String jid, + List added, + List removed, +); + +/// A stub implementation of [CommitDeviceListCallback]. +Future commitDeviceListStub( + String _, + List __, + 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 {} class OmemoManager { OmemoManager( @@ -51,8 +91,12 @@ class OmemoManager { this.sendEmptyOmemoMessageImpl, this.fetchDeviceListImpl, this.fetchDeviceBundleImpl, - this.subscribeToDeviceListNodeImpl, - ); + this.subscribeToDeviceListNodeImpl, { + this.commitRatchets = commitRatchetsStub, + this.commitDeviceList = commitDeviceListStub, + this.commitDevice = commitDeviceStub, + this.removeRatchets = removeRatchetsStub, + }); final Logger _log = Logger('OmemoManager'); @@ -60,18 +104,29 @@ class OmemoManager { /// Send an empty OMEMO:2 message using the encrypted payload @result to /// @recipientJid. - final Future Function(EncryptionResult result, String recipientJid) - sendEmptyOmemoMessageImpl; + final SendEmptyOmemoMessageFunction 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; + final FetchDeviceListFunction 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; + final FetchDeviceBundleFunction fetchDeviceBundleImpl; /// Subscribe to the device list PEP node of @jid. - final Future Function(String jid) subscribeToDeviceListNodeImpl; + final DeviceListSubscribeFunction subscribeToDeviceListNodeImpl; + + /// Callback to commit the ratchet to persistent storage. + final CommitRatchetsCallback commitRatchets; + + /// Callback to commit the device list to persistent storage. + final CommitDeviceListCallback commitDeviceList; + + /// Callback to commit the device to persistent storage. + final CommitDeviceCallback commitDevice; + + /// Callback to remove ratchets from persistent storage. + final RemoveRatchetsFunction removeRatchets; /// Map bare JID to its known devices final Map> _deviceList = {}; @@ -98,11 +153,6 @@ class OmemoManager { // ignore: prefer_final_fields OmemoDevice _device; - /// The event bus of the session manager - final StreamController _eventStreamController = - StreamController.broadcast(); - Stream get eventStream => _eventStreamController.stream; - Future> _decryptAndVerifyHmac( List? ciphertext, List keyAndHmac, @@ -153,8 +203,10 @@ class OmemoManager { _deviceList[jid] = newDeviceList; _deviceListRequested[jid] = true; - _eventStreamController.add( - DeviceListModifiedEvent(jid, newDeviceList, []), + await commitDeviceList( + jid, + newDeviceList, + [], ); } } @@ -359,24 +411,21 @@ class OmemoManager { // Commit the ratchet _ratchetMap[ratchetKey] = ratchet; _deviceList.appendOrCreate(stanza.bareSenderJid, stanza.senderDeviceId); - _eventStreamController.add( - RatchetModifiedEvent( + 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); - - _eventStreamController.add( - DeviceModifiedEvent(_device), - ); + await commitDevice(_device); }); } @@ -451,15 +500,15 @@ class OmemoManager { // Message was successfully decrypted, so commit the ratchet _ratchetMap[ratchetKey] = ratchet; - _eventStreamController.add( - RatchetModifiedEvent( + await commitRatchets([ + OmemoRatchetData( stanza.bareSenderJid, stanza.senderDeviceId, ratchet, false, false, ), - ); + ]); // Send a heartbeat, if required. await _maybeSendEmptyMessage(ratchetKey, false, false); @@ -563,14 +612,16 @@ class OmemoManager { // Commit the newly created ratchets, if we created any. if (addedRatchetKeys.isNotEmpty) { - _eventStreamController.add( - RatchetsAddedEvent( - Map.fromEntries( - addedRatchetKeys - .map((key) => MapEntry(key, _ratchetMap[key]!)) - .toList(), - ), - ), + await commitRatchets( + addedRatchetKeys.map((key) { + return OmemoRatchetData( + key.jid, + key.deviceId, + _ratchetMap[key]!, + true, + false, + ); + }).toList(), ); } @@ -702,15 +753,20 @@ class OmemoManager { await _ratchetQueue.synchronized( [jid], () async { - for (final device in _deviceList[jid] ?? []) { - // Remove the ratchet and commit - _ratchetMap.remove(RatchetMapKey(jid, device)); - _eventStreamController.add(RatchetRemovedEvent(jid, device)); + // 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()); // Clear the device list - _eventStreamController - .add(DeviceListModifiedEvent(jid, [], _deviceList[jid]!)); + await commitDeviceList( + jid, + [], + _deviceList[jid]!, + ); _deviceList.remove(jid); _deviceListRequested.remove(jid); }, @@ -736,9 +792,7 @@ class OmemoManager { _deviceListRequested[jid] = true; // Commit the device list - _eventStreamController.add( - DeviceListModifiedEvent(jid, delta.added, delta.removed), - ); + await commitDeviceList(jid, delta.added, delta.removed); }, ); } @@ -768,9 +822,15 @@ class OmemoManager { } else { // Commit final ratchet = _ratchetMap[ratchetKey]!..acknowledged = true; - _eventStreamController.add( - RatchetModifiedEvent(jid, device, ratchet, false, false), - ); + await commitRatchets([ + OmemoRatchetData( + jid, + device, + ratchet, + false, + false, + ), + ]); } } 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; +} From 4fb25a3ab7c4514e97d2eda32d75a2954268f6c6 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 15:21:11 +0200 Subject: [PATCH 18/36] feat: Stop overriding the BTBV manager --- example/omemo_dart_example.dart | 2 +- lib/src/trust/always.dart | 3 - lib/src/trust/base.dart | 3 - lib/src/trust/btbv.dart | 185 +++++++++++++++----------------- lib/src/trust/never.dart | 3 - test/trust_test.dart | 2 +- 6 files changed, 89 insertions(+), 109 deletions(-) diff --git a/example/omemo_dart_example.dart b/example/omemo_dart_example.dart index 23f7e05..d8237fa 100644 --- a/example/omemo_dart_example.dart +++ b/example/omemo_dart_example.dart @@ -42,7 +42,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, diff --git a/lib/src/trust/always.dart b/lib/src/trust/always.dart index dd0fcec..eb6a47b 100644 --- a/lib/src/trust/always.dart +++ b/lib/src/trust/always.dart @@ -20,7 +20,4 @@ class AlwaysTrustingTrustManager extends TrustManager { @override Future removeTrustDecisionsForJid(String jid) async {} - - @override - Future> toJson() async => {}; } diff --git a/lib/src/trust/base.dart b/lib/src/trust/base.dart index 86f46bb..0d2547f 100644 --- a/lib/src/trust/base.dart +++ b/lib/src/trust/base.dart @@ -17,9 +17,6 @@ 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]. Future removeTrustDecisionsForJid(String jid); } diff --git a/lib/src/trust/btbv.dart b/lib/src/trust/btbv.dart index 7ea4304..7640882 100644 --- a/lib/src/trust/btbv.dart +++ b/lib/src/trust/btbv.dart @@ -3,47 +3,64 @@ 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, + ); + + /// 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; +} + +/// 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 {} + /// 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; - } + /// 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, + this.commit = btbvCommitStub, + this.removeTrust = btbvRemoveTrustStub, }) : trustCache = trustCache ?? {}, enablementCache = enablementCache ?? {}, devices = devices ?? {}, @@ -67,6 +84,12 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager { /// The lock for devices and trustCache final Lock _lock; + /// 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 /// lock for those two maps (_lock) has been aquired before calling. @@ -125,7 +148,14 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager { } // Commit the state - await commitState(); + await commit( + BTBVTrustData( + jid, + deviceId, + trustCache[key]!, + enablementCache[key]!, + ), + ); }); } @@ -152,10 +182,18 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager { 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(); + await commit( + BTBVTrustData( + jid, + deviceId, + state, + enablementCache[key]!, + ), + ); }); } @@ -171,88 +209,39 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager { @override Future setEnabled(String jid, int deviceId, bool enabled) async { + final key = RatchetMapKey(jid, deviceId); await _lock.synchronized(() async { - enablementCache[RatchetMapKey(jid, deviceId)] = enabled; - }); + 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), + // Commit the state + await commit( + BTBVTrustData( + jid, + deviceId, + trustCache[key]!, + enabled, ), - ), - '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, - ), - ); + ); + }); } @override Future removeTrustDecisionsForJid(String jid) async { await _lock.synchronized(() async { + // Clear the caches + for (final device in devices[jid]!) { + final key = RatchetMapKey(jid, device); + trustCache.remove(key); + enablementCache.remove(key); + } devices.remove(jid); - await commitState(); + + // 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(); - @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..204ae74 100644 --- a/lib/src/trust/never.dart +++ b/lib/src/trust/never.dart @@ -20,7 +20,4 @@ class NeverTrustingTrustManager extends TrustManager { @override Future removeTrustDecisionsForJid(String jid) async {} - - @override - Future> toJson() async => {}; } 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'; From dad85b8467924634358d9be113b0e20af618b998 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 16:15:09 +0200 Subject: [PATCH 19/36] feat: Implement deferred loading of ratchet data --- lib/src/omemo/omemo.dart | 58 +++++++++++++++++++++++++++++++++++++++ lib/src/trust/always.dart | 3 ++ lib/src/trust/base.dart | 9 ++++++ lib/src/trust/btbv.dart | 39 ++++++++++++++++++-------- lib/src/trust/never.dart | 3 ++ 5 files changed, 101 insertions(+), 11 deletions(-) diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 9359a5e..fb73572 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -27,6 +27,16 @@ 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]. @@ -84,6 +94,12 @@ typedef RemoveRatchetsFunction = Future Function( /// 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, @@ -96,6 +112,7 @@ class OmemoManager { this.commitDeviceList = commitDeviceListStub, this.commitDevice = commitDeviceStub, this.removeRatchets = removeRatchetsStub, + this.loadRatchets = loadRatchetsStub, }); final Logger _log = Logger('OmemoManager'); @@ -128,6 +145,9 @@ class OmemoManager { /// 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 = {}; @@ -141,6 +161,9 @@ class OmemoManager { /// 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(); @@ -153,6 +176,33 @@ class OmemoManager { // 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, @@ -294,6 +344,9 @@ class OmemoManager { 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); @@ -530,6 +583,9 @@ class OmemoManager { 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; @@ -761,6 +817,8 @@ class OmemoManager { } await removeRatchets(keys.toList()); + // TODO: Do we have to tell the trust manager? + // Clear the device list await commitDeviceList( jid, diff --git a/lib/src/trust/always.dart b/lib/src/trust/always.dart index eb6a47b..d35a0de 100644 --- a/lib/src/trust/always.dart +++ b/lib/src/trust/always.dart @@ -20,4 +20,7 @@ class AlwaysTrustingTrustManager extends TrustManager { @override Future removeTrustDecisionsForJid(String jid) async {} + + @override + Future loadTrustData(String jid) async {} } diff --git a/lib/src/trust/base.dart b/lib/src/trust/base.dart index 0d2547f..cd0e72f 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 { @@ -19,4 +21,11 @@ abstract class TrustManager { /// Removes all trust decisions for [jid]. 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 7640882..496fbd2 100644 --- a/lib/src/trust/btbv.dart +++ b/lib/src/trust/btbv.dart @@ -1,4 +1,5 @@ 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'; @@ -37,6 +38,12 @@ 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 @@ -56,33 +63,31 @@ enum BTBVTrustState { /// See https://gultsch.de/trust.html for more details. class BlindTrustBeforeVerificationTrustManager extends TrustManager { BlindTrustBeforeVerificationTrustManager({ - Map? trustCache, - Map? enablementCache, - Map>? devices, + this.loadData = btbvLoadDataStub, this.commit = btbvCommitStub, this.removeTrust = btbvRemoveTrustStub, - }) : trustCache = trustCache ?? {}, - enablementCache = enablementCache ?? {}, - devices = devices ?? {}, - _lock = Lock(); + }); /// 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; + final Lock _lock = Lock(); + + /// Callback for loading trust data. + final BTBVLoadDataCallback loadData; /// Callback for commiting trust data to persistent storage. final BTBVTrustCommitCallback commit; @@ -241,6 +246,18 @@ class BlindTrustBeforeVerificationTrustManager extends TrustManager { }); } + @override + Future loadTrustData(String jid) async { + await _lock.synchronized(() 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); + } + }); + } + @visibleForTesting BTBVTrustState getDeviceTrust(String jid, int deviceId) => trustCache[RatchetMapKey(jid, deviceId)]!; diff --git a/lib/src/trust/never.dart b/lib/src/trust/never.dart index 204ae74..0afba81 100644 --- a/lib/src/trust/never.dart +++ b/lib/src/trust/never.dart @@ -20,4 +20,7 @@ class NeverTrustingTrustManager extends TrustManager { @override Future removeTrustDecisionsForJid(String jid) async {} + + @override + Future loadTrustData(String jid) async {} } From ed0701bdcdf1511bedcb9068bbd2bd55c358544c Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 17:47:07 +0200 Subject: [PATCH 20/36] feat: Remove locking from the BTBV trust manager --- lib/src/omemo/omemo.dart | 16 +++- lib/src/trust/base.dart | 2 + lib/src/trust/btbv.dart | 187 +++++++++++++++++---------------------- test/omemo_test.dart | 11 ++- 4 files changed, 102 insertions(+), 114 deletions(-) diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index fb73572..1d8232d 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -169,7 +169,6 @@ class OmemoManager { /// The OmemoManager's trust management final TrustManager _trustManager; - TrustManager get trustManager => _trustManager; /// Our own keys... final Lock _deviceLock = Lock(); @@ -200,7 +199,7 @@ class OmemoManager { _ratchetMap.addAll(result.ratchets); // Load trust data - await trustManager.loadTrustData(jid); + await _trustManager.loadTrustData(jid); } Future> _decryptAndVerifyHmac( @@ -449,7 +448,7 @@ class OmemoManager { } // Notify the trust manager - await trustManager.onNewSession( + await _trustManager.onNewSession( stanza.bareSenderJid, stanza.senderDeviceId, ); @@ -653,7 +652,7 @@ class OmemoManager { addedRatchetKeys.add(ratchetKey); // Initiate trust - await trustManager.onNewSession(jid, bundle.id); + await _trustManager.onNewSession(jid, bundle.id); // Track the KEX for later final ik = await ownDevice.ik.pk.getBytes(); @@ -940,4 +939,13 @@ class OmemoManager { @visibleForTesting OmemoDoubleRatchet? getRatchet(RatchetMapKey key) => _ratchetMap[key]; + + /// Trust management functions + Future withTrustManager( + String jid, Future Function(TrustManager) callback) async { + await _ratchetQueue.synchronized( + [jid], + () => callback(_trustManager), + ); + } } diff --git a/lib/src/trust/base.dart b/lib/src/trust/base.dart index cd0e72f..8d2da8a 100644 --- a/lib/src/trust/base.dart +++ b/lib/src/trust/base.dart @@ -9,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. @@ -20,6 +21,7 @@ abstract class TrustManager { Future setEnabled(String jid, int deviceId, bool enabled); /// Removes all trust decisions for [jid]. + @internal Future removeTrustDecisionsForJid(String jid); // ignore: comment_references diff --git a/lib/src/trust/btbv.dart b/lib/src/trust/btbv.dart index 496fbd2..4eb1365 100644 --- a/lib/src/trust/btbv.dart +++ b/lib/src/trust/btbv.dart @@ -2,7 +2,6 @@ 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 { @@ -83,9 +82,6 @@ class BlindTrustBeforeVerificationTrustManager extends TrustManager { @protected final Map> devices = {}; - /// The lock for devices and trustCache - final Lock _lock = Lock(); - /// Callback for loading trust data. final BTBVLoadDataCallback loadData; @@ -108,76 +104,63 @@ 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]); - } + if (devices.containsKey(jid)) { + devices[jid]!.add(deviceId); + } else { + devices[jid] = List.from([deviceId]); + } - // Commit the state - await commit( - BTBVTrustData( - jid, - deviceId, - trustCache[key]!, - enablementCache[key]!, - ), - ); - }); + // Commit the state + await commit( + BTBVTrustData( + jid, + deviceId, + trustCache[key]!, + enablementCache[key]!, + ), + ); } /// 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 = {}; + 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]!) { + map[deviceId] = trustCache[RatchetMapKey(jid, deviceId)]!; + } - return map; - }); + return map; } /// Sets the trust of [jid]'s device with identifier [deviceId] to [state]. @@ -186,76 +169,66 @@ class BlindTrustBeforeVerificationTrustManager extends TrustManager { int deviceId, BTBVTrustState state, ) async { - await _lock.synchronized(() async { - final key = RatchetMapKey(jid, deviceId); - trustCache[key] = state; + final key = RatchetMapKey(jid, deviceId); + trustCache[key] = state; - // Commit the state - await commit( - BTBVTrustData( - jid, - deviceId, - state, - enablementCache[key]!, - ), - ); - }); + // Commit the state + await commit( + BTBVTrustData( + jid, + deviceId, + state, + enablementCache[key]!, + ), + ); } @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 { final key = RatchetMapKey(jid, deviceId); - await _lock.synchronized(() async { - enablementCache[key] = enabled; + enablementCache[key] = enabled; - // Commit the state - await commit( - BTBVTrustData( - jid, - deviceId, - trustCache[key]!, - enabled, - ), - ); - }); + // Commit the state + await commit( + BTBVTrustData( + jid, + deviceId, + trustCache[key]!, + enabled, + ), + ); } @override Future removeTrustDecisionsForJid(String jid) async { - await _lock.synchronized(() async { - // Clear the caches - for (final device in devices[jid]!) { - final key = RatchetMapKey(jid, device); - trustCache.remove(key); - enablementCache.remove(key); - } - devices.remove(jid); + // 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); - }); + // Commit the state + await removeTrust(jid); } @override Future loadTrustData(String jid) async { - await _lock.synchronized(() 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); - } - }); + 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); + } } @visibleForTesting diff --git a/test/omemo_test.dart b/test/omemo_test.dart index f0bdf25..8d37052 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -1529,9 +1529,14 @@ void main() { expect(bobResult1.error, null); // Bob should have some trust state - expect( - (bobManager.trustManager as TestingTrustManager).devices[aliceJid], - await aliceManager.getDeviceId(), + await bobManager.withTrustManager( + bobJid, + (tm) async { + expect( + (tm as TestingTrustManager).devices[aliceJid], + await aliceManager.getDeviceId(), + ); + }, ); }); From 234fee167f332adea4b16e3cb16b7e57f427e7d5 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 20:32:06 +0200 Subject: [PATCH 21/36] fix: Fix some issues found by integrating --- CHANGELOG.md | 11 +++++++ lib/src/errors.dart | 3 ++ lib/src/omemo/omemo.dart | 68 ++++++++++++++++++++++++++-------------- pubspec.yaml | 2 +- 4 files changed, 59 insertions(+), 25 deletions(-) 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/lib/src/errors.dart b/lib/src/errors.dart index 8e7aecf..0925a16 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -34,3 +34,6 @@ class MalformedCiphertextError extends OmemoError { /// The exception that was raised while decryption. final Object ex; } + +/// Caused by an empty element +class MalformedEncryptedKeyError extends OmemoError {} diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 1d8232d..dd054d0 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -356,6 +356,14 @@ class OmemoManager { ); } + // Protobuf will happily parse this and return bogus data. + if (key.value.isEmpty) { + return DecryptionResult( + null, + MalformedEncryptedKeyError(), + ); + } + // Check how we should process the message final ratchetKey = RatchetMapKey(stanza.bareSenderJid, stanza.senderDeviceId); @@ -433,18 +441,23 @@ class OmemoManager { return DecryptionResult(null, error); } - final result = await _decryptAndVerifyHmac( - stanza.payload?.fromBase64(), - keyAndHmac.get>(), - ); - if (result.isType()) { - final error = result.get(); - _log.warning('Decrypting payload failed: $error'); - - return DecryptionResult( - 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, + error, + ); + } + } else { + result = const Result(null); } // Notify the trust manager @@ -530,17 +543,22 @@ class OmemoManager { return DecryptionResult(null, error); } - final 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, - 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, + error, + ); + } + } else { + result = const Result(null); } // If we received an empty OMEMO message, mark the ratchet as acknowledged @@ -587,7 +605,7 @@ class OmemoManager { // Encrypt the payload, if we have any final List payloadKey; - final List ciphertext; + final List? ciphertext; if (stanza.payload != null) { // Generate the key and encrypt the plaintext final rawKey = generateRandomBytes(32); @@ -601,7 +619,7 @@ class OmemoManager { payloadKey = concat([rawKey, hmac]); } else { payloadKey = List.filled(32, 0x0); - ciphertext = []; + ciphertext = null; } final encryptionErrors = >{}; @@ -942,7 +960,9 @@ class OmemoManager { /// Trust management functions Future withTrustManager( - String jid, Future Function(TrustManager) callback) async { + String jid, + Future Function(TrustManager) callback, + ) async { await _ratchetQueue.synchronized( [jid], () => callback(_trustManager), diff --git a/pubspec.yaml b/pubspec.yaml index 6f7ff94..8e1d40e 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 From 65c0975a77256c9e2805aea5272413f0b7ca90da Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 20:50:01 +0200 Subject: [PATCH 22/36] feat: Each OPK now gets it's own unique id --- lib/src/omemo/decryption_result.dart | 10 ++++++- lib/src/omemo/device.dart | 24 +++++++++++++++-- lib/src/omemo/omemo.dart | 40 +++++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/lib/src/omemo/decryption_result.dart b/lib/src/omemo/decryption_result.dart index e924c6a..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; + + /// 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 14e7a7d..c944fca 100644 --- a/lib/src/omemo/device.dart +++ b/lib/src/omemo/device.dart @@ -35,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); @@ -72,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, diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index dd054d0..89c2148 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -351,6 +351,7 @@ class OmemoManager { final key = stanza.keys.firstWhereOrNull((key) => key.rid == deviceId); if (key == null) { return DecryptionResult( + null, null, NotEncryptedForDeviceError(), ); @@ -359,6 +360,7 @@ class OmemoManager { // Protobuf will happily parse this and return bogus data. if (key.value.isEmpty) { return DecryptionResult( + null, null, MalformedEncryptedKeyError(), ); @@ -396,6 +398,7 @@ class OmemoManager { spk = device.oldSpk!; } else { return DecryptionResult( + null, null, UnknownSignedPrekeyError(), ); @@ -438,7 +441,7 @@ class OmemoManager { final error = keyAndHmac.get(); _log.warning('Failed to decrypt symmetric key: $error'); - return DecryptionResult(null, error); + return DecryptionResult(null, null, error); } Result result; @@ -452,6 +455,7 @@ class OmemoManager { _log.warning('Decrypting payload failed: $error'); return DecryptionResult( + null, null, error, ); @@ -503,6 +507,7 @@ class OmemoManager { return DecryptionResult( result.get(), + kexMessage.pkId, null, ); } else { @@ -517,6 +522,7 @@ class OmemoManager { await _sendOmemoHeartbeat(stanza.bareSenderJid); return DecryptionResult( + null, null, NoSessionWithDeviceError(), ); @@ -540,7 +546,7 @@ class OmemoManager { if (keyAndHmac.isType()) { final error = keyAndHmac.get(); _log.warning('Failed to decrypt symmetric key: $error'); - return DecryptionResult(null, error); + return DecryptionResult(null, null, error); } Result result; @@ -553,6 +559,7 @@ class OmemoManager { final error = result.get(); _log.warning('Failed to decrypt message: $error'); return DecryptionResult( + null, null, error, ); @@ -586,6 +593,7 @@ class OmemoManager { return DecryptionResult( result.get(), null, + null, ); } } @@ -958,7 +966,33 @@ class OmemoManager { @visibleForTesting OmemoDoubleRatchet? getRatchet(RatchetMapKey key) => _ratchetMap[key]; - /// Trust management functions + /// 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); + }); + } + + /// 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); + }); + } + + /// 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, From fe2b090ea0bddbc660e77f4aa43c5460e172574d Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 20:58:33 +0200 Subject: [PATCH 23/36] feat: Replace isSuccessful with canSend --- lib/src/omemo/encryption_result.dart | 9 ++++----- lib/src/omemo/omemo.dart | 7 +++++++ test/omemo_test.dart | 6 +++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/src/omemo/encryption_result.dart b/lib/src/omemo/encryption_result.dart index 36a06ad..639b757 100644 --- a/lib/src/omemo/encryption_result.dart +++ b/lib/src/omemo/encryption_result.dart @@ -8,6 +8,7 @@ class EncryptionResult { this.ciphertext, this.encryptedKeys, this.deviceEncryptionErrors, + this.canSend, ); /// The actual message that was encrypted. @@ -20,9 +21,7 @@ class EncryptionResult { /// Mapping of a JID to final Map> deviceEncryptionErrors; - // TODO: Turn this into a property that is computed in [onOutgoingStanza]. - /// True if the encryption was a success. This means that we could encrypt for - /// at least one ratchet per recipient. [recipients] is the number of recipients - /// that the message should've been encrypted for. - bool isSuccess(int recipients) => encryptedKeys.length == recipients; + /// 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/omemo.dart b/lib/src/omemo/omemo.dart index 89c2148..ea1e7c7 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -630,6 +630,9 @@ class OmemoManager { ciphertext = null; } + final successfulEncryptions = Map.fromEntries( + stanza.recipientJids.map((jid) => MapEntry(jid, 0)), + ); final encryptionErrors = >{}; final addedRatchetKeys = List.empty(growable: true); final kex = {}; @@ -770,6 +773,7 @@ class OmemoManager { true, ), ); + successfulEncryptions[jid] = successfulEncryptions[jid]! + 1; } else if (!ratchet.acknowledged) { // The ratchet as not yet been acked. // Keep sending the old KEX @@ -789,6 +793,7 @@ class OmemoManager { true, ), ); + successfulEncryptions[jid] = successfulEncryptions[jid]! + 1; } else { // The ratchet exists and is acked encryptedKeys.appendOrCreate( @@ -799,6 +804,7 @@ class OmemoManager { false, ), ); + successfulEncryptions[jid] = successfulEncryptions[jid]! + 1; } } } @@ -807,6 +813,7 @@ class OmemoManager { ciphertext, encryptedKeys, encryptionErrors, + successfulEncryptions.values.every((n) => n > 0), ); } diff --git a/test/omemo_test.dart b/test/omemo_test.dart index 8d37052..56327d4 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -881,7 +881,7 @@ void main() { ), ); - expect(aliceResult.isSuccess(1), isFalse); + expect(aliceResult.canSend, isFalse); expect(aliceResult.deviceEncryptionErrors[bobJid]!.length, 1); final error = aliceResult.deviceEncryptionErrors[bobJid]!.first; expect(error.error, const TypeMatcher()); @@ -933,7 +933,7 @@ void main() { ), ); - expect(aliceResult.isSuccess(2), isFalse); + expect(aliceResult.canSend, isFalse); expect(aliceResult.deviceEncryptionErrors[cocoJid]!.length, 1); expect( aliceResult.deviceEncryptionErrors[cocoJid]!.first.error, @@ -1023,7 +1023,7 @@ void main() { messageText, ), ); - expect(bobResponseMessage.isSuccess(1), isTrue); + expect(bobResponseMessage.canSend, isTrue); final aliceReceivedMessage = await aliceManager.onIncomingStanza( OmemoIncomingStanza( From 4baf8187e10ea67914e06edb37920818d53c5f59 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 21:02:03 +0200 Subject: [PATCH 24/36] feat: Remove the KEX timestamp from the double ratchet data --- lib/src/double_ratchet/double_ratchet.dart | 13 +------------ lib/src/omemo/omemo.dart | 2 -- test/double_ratchet_test.dart | 2 -- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/lib/src/double_ratchet/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index 9c3fcb6..9f33a1f 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -70,7 +70,6 @@ class OmemoDoubleRatchet { this.sessionAd, this.mkSkipped, // MKSKIPPED this.acknowledged, - this.kexTimestamp, this.kex, ); @@ -104,10 +103,6 @@ class OmemoDoubleRatchet { /// 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 KeyExchangeData kex; @@ -126,7 +121,6 @@ class OmemoDoubleRatchet { OmemoPublicKey ek, List sk, List ad, - int timestamp, int pkId, ) async { final dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); @@ -145,7 +139,6 @@ class OmemoDoubleRatchet { ad, {}, false, - timestamp, KeyExchangeData( pkId, spkId, @@ -167,7 +160,6 @@ class OmemoDoubleRatchet { OmemoPublicKey ek, List sk, List ad, - int kexTimestamp, ) async { return OmemoDoubleRatchet( spk, @@ -182,7 +174,6 @@ class OmemoDoubleRatchet { ad, {}, true, - kexTimestamp, KeyExchangeData( pkId, spkId, @@ -384,7 +375,6 @@ class OmemoDoubleRatchet { sessionAd, Map>.from(mkSkipped), acknowledged, - kexTimestamp, kex, ); } @@ -426,7 +416,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/omemo/omemo.dart b/lib/src/omemo/omemo.dart index ea1e7c7..6e31cb8 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -431,7 +431,6 @@ class OmemoManager { kexEk, kex.sk, kex.ad, - getTimestamp(), ); final keyAndHmac = await ratchet.ratchetDecrypt( @@ -672,7 +671,6 @@ class OmemoManager { kexResult.ek.pk, kexResult.sk, kexResult.ad, - getTimestamp(), kexResult.opkId, ); diff --git a/test/double_ratchet_test.dart b/test/double_ratchet_test.dart index f630bae..e4137d6 100644 --- a/test/double_ratchet_test.dart +++ b/test/double_ratchet_test.dart @@ -58,7 +58,6 @@ void main() { resultAlice.ek.pk, resultAlice.sk, resultAlice.ad, - 0, resultAlice.opkId, ); final bobsRatchet = await OmemoDoubleRatchet.acceptNewSession( @@ -69,7 +68,6 @@ void main() { resultAlice.ek.pk, resultBob.sk, resultBob.ad, - 0, ); expect(alicesRatchet.sessionAd, bobsRatchet.sessionAd); From 3829c6c11ba05623ec099ae7f1195b44411793ec Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 21:05:26 +0200 Subject: [PATCH 25/36] feat: Move all constants into their own file --- lib/src/common/constants.dart | 11 +++++++++++ lib/src/double_ratchet/double_ratchet.dart | 7 +------ lib/src/omemo/constants.dart | 2 -- lib/src/omemo/omemo.dart | 2 +- lib/src/x3dh/x3dh.dart | 4 +--- 5 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 lib/src/common/constants.dart delete mode 100644 lib/src/omemo/constants.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/double_ratchet/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index 9f33a1f..51a3954 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -1,6 +1,7 @@ import 'package:cryptography/cryptography.dart'; import 'package:hex/hex.dart'; import 'package:meta/meta.dart'; +import 'package:omemo_dart/src/common/constants.dart'; import 'package:omemo_dart/src/common/result.dart'; import 'package:omemo_dart/src/crypto.dart'; import 'package:omemo_dart/src/double_ratchet/kdf.dart'; @@ -9,12 +10,6 @@ import 'package:omemo_dart/src/helpers.dart'; import 'package:omemo_dart/src/keys.dart'; import 'package:omemo_dart/src/protobuf/schema.pb.dart'; -/// Amount of messages we may skip per session -const maxSkip = 1000; - -/// Info string for ENCRYPT -const encryptHkdfInfoString = 'OMEMO Message Key Material'; - @immutable class SkippedKey { const SkippedKey(this.dh, this.n); 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/omemo.dart b/lib/src/omemo/omemo.dart index 6e31cb8..627c0bb 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:cryptography/cryptography.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; +import 'package:omemo_dart/src/common/constants.dart'; import 'package:omemo_dart/src/common/result.dart'; import 'package:omemo_dart/src/crypto.dart'; import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart'; @@ -11,7 +12,6 @@ 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'; diff --git a/lib/src/x3dh/x3dh.dart b/lib/src/x3dh/x3dh.dart index 1e82d01..f8b0d15 100644 --- a/lib/src/x3dh/x3dh.dart +++ b/lib/src/x3dh/x3dh.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:cryptography/cryptography.dart'; +import 'package:omemo_dart/src/common/constants.dart'; import 'package:omemo_dart/src/common/result.dart'; import 'package:omemo_dart/src/crypto.dart'; import 'package:omemo_dart/src/errors.dart'; @@ -8,9 +9,6 @@ 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); From 207215cc5fe50e42662adf44d71eaaf2dc8f6f29 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 21:22:16 +0200 Subject: [PATCH 26/36] feat: Move the result type into moxlib --- analysis_options.yaml | 1 - lib/src/common/result.dart | 19 ------------------- lib/src/crypto.dart | 2 +- lib/src/double_ratchet/double_ratchet.dart | 2 +- lib/src/omemo/omemo.dart | 2 +- lib/src/x3dh/x3dh.dart | 2 +- pubspec.yaml | 3 +++ 7 files changed, 7 insertions(+), 24 deletions(-) delete mode 100644 lib/src/common/result.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index ffefcce..b5d1d46 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -11,4 +11,3 @@ analyzer: exclude: - "lib/src/protobuf/*.dart" - "example/omemo_dart_example.dart" - - "test/serialisation_test.dart" diff --git a/lib/src/common/result.dart b/lib/src/common/result.dart deleted file mode 100644 index 911b7dd..0000000 --- a/lib/src/common/result.dart +++ /dev/null @@ -1,19 +0,0 @@ -// TODO: Pull into moxlib -class Result { - const Result(this._data) - : assert( - _data is T || _data is V, - 'Invalid data type $_data: Must be either $T or $V', - ); - final dynamic _data; - - bool isType() => _data is S; - - S get() { - assert(_data is S, 'Data is not $S'); - - return _data as S; - } - - Object get dataRuntimeType => _data.runtimeType; -} diff --git a/lib/src/crypto.dart b/lib/src/crypto.dart index dbe97a0..7010b00 100644 --- a/lib/src/crypto.dart +++ b/lib/src/crypto.dart @@ -1,6 +1,6 @@ import 'dart:convert'; import 'package:cryptography/cryptography.dart'; -import 'package:omemo_dart/src/common/result.dart'; +import 'package:moxlib/moxlib.dart'; import 'package:omemo_dart/src/errors.dart'; import 'package:omemo_dart/src/keys.dart'; diff --git a/lib/src/double_ratchet/double_ratchet.dart b/lib/src/double_ratchet/double_ratchet.dart index 51a3954..2a3a204 100644 --- a/lib/src/double_ratchet/double_ratchet.dart +++ b/lib/src/double_ratchet/double_ratchet.dart @@ -1,8 +1,8 @@ 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/common/result.dart'; import 'package:omemo_dart/src/crypto.dart'; import 'package:omemo_dart/src/double_ratchet/kdf.dart'; import 'package:omemo_dart/src/errors.dart'; diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 627c0bb..90ad7de 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -4,8 +4,8 @@ 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/common/result.dart'; import 'package:omemo_dart/src/crypto.dart'; import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart'; import 'package:omemo_dart/src/errors.dart'; diff --git a/lib/src/x3dh/x3dh.dart b/lib/src/x3dh/x3dh.dart index f8b0d15..b6b5a66 100644 --- a/lib/src/x3dh/x3dh.dart +++ b/lib/src/x3dh/x3dh.dart @@ -1,8 +1,8 @@ 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/common/result.dart'; import 'package:omemo_dart/src/crypto.dart'; import 'package:omemo_dart/src/errors.dart'; import 'package:omemo_dart/src/helpers.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 8e1d40e..fb77c60 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,9 @@ 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 From bb5ef414f255d1866a245bc20c43a609f2c69de8 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 21:24:51 +0200 Subject: [PATCH 27/36] feat: Removing ratchets also removed trust --- lib/src/omemo/omemo.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 90ad7de..7b9ffde 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -847,7 +847,8 @@ class OmemoManager { } await removeRatchets(keys.toList()); - // TODO: Do we have to tell the trust manager? + // Tell the trust manager + await _trustManager.removeTrustDecisionsForJid(jid); // Clear the device list await commitDeviceList( From b986096aa01a56d2fb58e0ca01b200dd389c8738 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 23:14:00 +0200 Subject: [PATCH 28/36] feat: Take care of publishing --- example/omemo_dart_example.dart | 4 ++++ lib/src/omemo/omemo.dart | 19 ++++++++++++++++- test/omemo_test.dart | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/example/omemo_dart_example.dart b/example/omemo_dart_example.dart index d8237fa..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 diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 7b9ffde..6ed6e7d 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -57,6 +57,9 @@ typedef FetchDeviceBundleFunction = Future Function( /// 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 for [jid] to persistent storage. [added] will be the list of /// devices added and [removed] will be the list of removed devices. typedef CommitDeviceListCallback = Future Function( @@ -107,7 +110,8 @@ class OmemoManager { this.sendEmptyOmemoMessageImpl, this.fetchDeviceListImpl, this.fetchDeviceBundleImpl, - this.subscribeToDeviceListNodeImpl, { + this.subscribeToDeviceListNodeImpl, + this.publishDeviceBundle, { this.commitRatchets = commitRatchetsStub, this.commitDeviceList = commitDeviceListStub, this.commitDevice = commitDeviceStub, @@ -133,6 +137,9 @@ class OmemoManager { /// 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; @@ -494,6 +501,11 @@ class OmemoManager { await _deviceLock.synchronized(() async { await _device.replaceOnetimePrekey(kexMessage.pkId); await commitDevice(_device); + + // Publish the device bundle + unawaited( + publishDeviceBundle(_device), + ); }); } @@ -982,6 +994,11 @@ class OmemoManager { // Commit the device await commitDevice(_device); + + // Publish + unawaited( + publishDeviceBundle(_device), + ); }); } diff --git a/test/omemo_test.dart b/test/omemo_test.dart index 56327d4..35eb23b 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -48,6 +48,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -64,6 +65,7 @@ void main() { return aliceDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); // Alice sends a message @@ -147,6 +149,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -164,6 +167,7 @@ void main() { return aliceDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); // Alice sends a message @@ -304,6 +308,7 @@ void main() { (jid) async => [], (jid, id) async => null, (jid) async {}, + (_) async {}, ); // Get non-existant fingerprints @@ -347,6 +352,7 @@ void main() { : bobCurrentDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobCurrentDevice, @@ -355,6 +361,7 @@ void main() { (jid) async => [], (jid, id) async => null, (jid) async {}, + (_) async {}, ); // Alice encrypts a message to Bob @@ -447,6 +454,7 @@ void main() { return null; }, (jid) async {}, + (_) async {}, ); final bobManager1 = OmemoManager( bobDevice1, @@ -455,6 +463,7 @@ void main() { (jid) async => [], (jid, id) async => null, (jid) async {}, + (_) async {}, ); final bobManager2 = OmemoManager( bobDevice2, @@ -469,6 +478,7 @@ void main() { return aliceDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); // Alice sends a message to Bob @@ -558,6 +568,7 @@ void main() { return null; }, (jid) async {}, + (_) async {}, ); final bobManager1 = OmemoManager( bobDevice1, @@ -566,6 +577,7 @@ void main() { (jid) async => null, (jid, id) async => null, (jid) async {}, + (_) async {}, ); final bobManager2 = OmemoManager( bobDevice2, @@ -574,6 +586,7 @@ void main() { (jid) async => null, (jid, id) async => null, (jid) async {}, + (_) async {}, ); // Alice sends a message to Bob @@ -704,6 +717,7 @@ void main() { return null; }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -712,6 +726,7 @@ void main() { (jid) async => null, (jid, id) async => null, (jid) async {}, + (_) async {}, ); final cocoManager = OmemoManager( cocoDevice, @@ -720,6 +735,7 @@ void main() { (jid) async => null, (jid, id) async => null, (jid) async {}, + (_) async {}, ); // Alice sends a message to Bob and Coco @@ -782,6 +798,7 @@ void main() { return failure ? null : bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -790,6 +807,7 @@ void main() { (jid) async => null, (jid, id) async => null, (jid) async {}, + (_) async {}, ); // Alice sends a message to Bob and Coco @@ -871,6 +889,7 @@ void main() { return null; }, (jid) async {}, + (_) async {}, ); // Alice sends a message to Bob @@ -915,6 +934,7 @@ void main() { return null; }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -923,6 +943,7 @@ void main() { (jid) async => null, (jid, id) async => null, (jid) async {}, + (_) async {}, ); // Alice sends a message to Bob and Coco @@ -978,6 +999,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -986,6 +1008,7 @@ void main() { (jid) async => null, (jid, id) async => null, (jid) async {}, + (_) async {}, ); // Alice encrypts a message for Bob @@ -1065,6 +1088,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -1073,6 +1097,7 @@ void main() { (jid) async => null, (jid, id) async => null, (jid) async {}, + (_) async {}, ); // Alice encrypts a message for Bob @@ -1206,6 +1231,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -1214,6 +1240,7 @@ void main() { (jid) async => null, (jid, id) async => null, (jid) async {}, + (_) async {}, ); // Alice encrypts a message for Bob @@ -1338,6 +1365,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -1352,6 +1380,7 @@ void main() { return aliceDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); // Alice sends Bob a message @@ -1491,6 +1520,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final bobManager = OmemoManager( bobDevice, @@ -1505,6 +1535,7 @@ void main() { return aliceDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); // Alice sends Bob a message @@ -1563,6 +1594,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final aliceManager2 = OmemoManager( aliceDevice2, @@ -1577,6 +1609,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); EncryptionResult? bobEmptyMessage; @@ -1606,6 +1639,7 @@ void main() { return null; }, (jid) async {}, + (_) async {}, ); // Alice sends Bob a message @@ -1693,6 +1727,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); final aliceManager2 = OmemoManager( aliceDevice2, @@ -1707,6 +1742,7 @@ void main() { return bobDevice.toBundle(); }, (jid) async {}, + (_) async {}, ); EncryptionResult? bobEmptyMessage; @@ -1734,6 +1770,7 @@ void main() { return null; }, (jid) async {}, + (_) async {}, ); // Alice sends Bob a message From c086579b57897e0f2ec4256aa18872aa2db0dd6a Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 23:23:41 +0200 Subject: [PATCH 29/36] feat: Minor changes --- README.md | 23 +++++++++++++---------- lib/src/helpers.dart | 8 ++++++-- lib/src/trust/btbv.dart | 9 +++------ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 38631fd..3321c9c 100644 --- a/README.md +++ b/README.md @@ -28,22 +28,25 @@ 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 +### Persistence -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. +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 mappings: + +- `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/lib/src/helpers.dart b/lib/src/helpers.dart index 287010f..bbf2cbd 100644 --- a/lib/src/helpers.dart +++ b/lib/src/helpers.dart @@ -86,9 +86,13 @@ extension BeforeAfterListDiff on List { extension AppendToListOrCreateExtension on Map> { /// Create or append [value] to the list identified with key [key]. - void appendOrCreate(K key, V value) { + void appendOrCreate(K key, V value, {bool checkExistence = false}) { if (containsKey(key)) { - this[key]!.add(value); + if (!checkExistence) { + this[key]!.add(value); + } if (!this[key]!.contains(value)) { + this[key]!.add(value); + } } else { this[key] = [value]; } diff --git a/lib/src/trust/btbv.dart b/lib/src/trust/btbv.dart index 4eb1365..5a04ab8 100644 --- a/lib/src/trust/btbv.dart +++ b/lib/src/trust/btbv.dart @@ -132,11 +132,8 @@ class BlindTrustBeforeVerificationTrustManager extends TrustManager { 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 commit( @@ -227,7 +224,7 @@ class BlindTrustBeforeVerificationTrustManager extends TrustManager { final key = RatchetMapKey(jid, result.device); trustCache[key] = result.state; enablementCache[key] = result.enabled; - devices.appendOrCreate(jid, result.device); + devices.appendOrCreate(jid, result.device, checkExistence: true); } } From ddb4483d4a29d3eb3bcb07ba69aca31deb6a205e Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 23:27:10 +0200 Subject: [PATCH 30/36] feat: Remove unused helper functions --- lib/src/helpers.dart | 20 ++------------------ lib/src/omemo/stanza.dart | 4 ---- test/omemo_test.dart | 40 --------------------------------------- 3 files changed, 2 insertions(+), 62 deletions(-) diff --git a/lib/src/helpers.dart b/lib/src/helpers.dart index bbf2cbd..102f316 100644 --- a/lib/src/helpers.dart +++ b/lib/src/helpers.dart @@ -43,23 +43,6 @@ 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; - - return OmemoPublicKey.fromBytes( - base64.decode(map[key]! as String), - type, - ); -} - -int getTimestamp() { - return DateTime.now().millisecondsSinceEpoch; -} - /// Describes the differences between two lists in terms of its items. class ListDiff { ListDiff(this.added, this.removed); @@ -90,7 +73,8 @@ extension AppendToListOrCreateExtension on Map> { if (containsKey(key)) { if (!checkExistence) { this[key]!.add(value); - } if (!this[key]!.contains(value)) { + } + if (!this[key]!.contains(value)) { this[key]!.add(value); } } else { diff --git a/lib/src/omemo/stanza.dart b/lib/src/omemo/stanza.dart index dc1a3a1..1e751f1 100644 --- a/lib/src/omemo/stanza.dart +++ b/lib/src/omemo/stanza.dart @@ -5,7 +5,6 @@ class OmemoIncomingStanza { const OmemoIncomingStanza( this.bareSenderJid, this.senderDeviceId, - this.timestamp, this.keys, this.payload, this.isCatchup, @@ -17,9 +16,6 @@ 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 for our own JID final List keys; diff --git a/test/omemo_test.dart b/test/omemo_test.dart index 35eb23b..dee51fa 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -81,7 +81,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult.encryptedKeys[bobJid]!, base64.encode(aliceResult.ciphertext!), false, @@ -110,7 +109,6 @@ void main() { OmemoIncomingStanza( bobJid, bobDevice.id, - DateTime.now().millisecondsSinceEpoch, bobResult2.encryptedKeys[aliceJid]!, base64.encode(bobResult2.ciphertext!), false, @@ -183,7 +181,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult.encryptedKeys[bobJid]!, base64.encode(aliceResult.ciphertext!), false, @@ -199,7 +196,6 @@ void main() { OmemoIncomingStanza( bobJid, bobDevice.id, - getTimestamp(), bobEmptyMessage!.encryptedKeys[aliceJid]!, null, false, @@ -222,7 +218,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResultLoop.encryptedKeys[bobJid]!, base64.encode(aliceResultLoop.ciphertext!), false, @@ -247,7 +242,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResultFinal.encryptedKeys[bobJid]!, base64.encode(aliceResultFinal.ciphertext!), false, @@ -263,7 +257,6 @@ void main() { OmemoIncomingStanza( bobJid, bobDevice.id, - getTimestamp(), bobEmptyMessage!.encryptedKeys[aliceJid]!, null, false, @@ -282,7 +275,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - getTimestamp(), aliceMessagePostFinal.encryptedKeys[bobJid]!, base64Encode(aliceMessagePostFinal.ciphertext!), false, @@ -377,7 +369,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -402,7 +393,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), false, @@ -494,7 +484,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -518,7 +507,6 @@ void main() { OmemoIncomingStanza( bobJid, bobDevice2.id, - DateTime.now().millisecondsSinceEpoch, bobResult2.encryptedKeys[aliceJid]!, base64.encode(bobResult2.ciphertext!), false, @@ -602,7 +590,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -640,7 +627,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), false, @@ -650,7 +636,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), false, @@ -673,7 +658,6 @@ void main() { OmemoIncomingStanza( bobJid, bobDevice2.id, - DateTime.now().millisecondsSinceEpoch, bobResult32.encryptedKeys[aliceJid]!, base64.encode(bobResult32.ciphertext!), false, @@ -751,7 +735,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult.encryptedKeys[bobJid]!, base64.encode(aliceResult.ciphertext!), false, @@ -761,7 +744,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult.encryptedKeys[cocoJid]!, base64.encode(aliceResult.ciphertext!), false, @@ -823,7 +805,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -856,7 +837,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), false, @@ -966,7 +946,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult.encryptedKeys[bobJid]!, base64.encode(aliceResult.ciphertext!), false, @@ -1024,7 +1003,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceMessage.encryptedKeys[bobJid]!, base64.encode(aliceMessage.ciphertext!), false, @@ -1052,7 +1030,6 @@ void main() { OmemoIncomingStanza( bobJid, bobDevice.id, - DateTime.now().millisecondsSinceEpoch, bobResponseMessage.encryptedKeys[aliceJid]!, base64.encode(bobResponseMessage.ciphertext!), false, @@ -1113,7 +1090,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -1139,7 +1115,6 @@ void main() { OmemoIncomingStanza( aliceJid, await aliceManager.getDeviceId(), - DateTime.now().millisecondsSinceEpoch, aliceEmptyMessage!.encryptedKeys[bobJid]!, null, false, @@ -1166,7 +1141,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult3.encryptedKeys[bobJid]!, base64.encode(aliceResult3.ciphertext!), false, @@ -1189,7 +1163,6 @@ void main() { OmemoIncomingStanza( bobJid, bobDevice.id, - DateTime.now().millisecondsSinceEpoch, bobResult4.encryptedKeys[aliceJid]!, base64.encode(bobResult4.ciphertext!), false, @@ -1256,7 +1229,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -1287,7 +1259,6 @@ void main() { OmemoIncomingStanza( aliceJid, await aliceManager.getDeviceId(), - DateTime.now().millisecondsSinceEpoch, aliceEmptyMessage!.encryptedKeys[bobJid]!, null, false, @@ -1310,7 +1281,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult3.encryptedKeys[bobJid]!, base64.encode(aliceResult3.ciphertext!), false, @@ -1333,7 +1303,6 @@ void main() { OmemoIncomingStanza( bobJid, bobDevice.id, - DateTime.now().millisecondsSinceEpoch, bobResult4.encryptedKeys[aliceJid]!, base64.encode(bobResult4.ciphertext!), false, @@ -1399,7 +1368,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -1436,7 +1404,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult2.encryptedKeys[bobJid]!, base64.encode(aliceResult2.ciphertext!), false, @@ -1458,7 +1425,6 @@ void main() { OmemoIncomingStanza( bobJid, bobDevice.id, - DateTime.now().millisecondsSinceEpoch, bobResult3.encryptedKeys[aliceJid]!, base64.encode(bobResult3.ciphertext!), false, @@ -1489,7 +1455,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult4.encryptedKeys[bobJid]!, base64.encode(aliceResult4.ciphertext!), false, @@ -1551,7 +1516,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -1655,7 +1619,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice1.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -1680,7 +1643,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice2.id, - getTimestamp(), aliceResult2.encryptedKeys[bobJid]!, base64Encode(aliceResult2.ciphertext!), false, @@ -1786,7 +1748,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice1.id, - DateTime.now().millisecondsSinceEpoch, aliceResult1.encryptedKeys[bobJid]!, base64.encode(aliceResult1.ciphertext!), false, @@ -1810,7 +1771,6 @@ void main() { OmemoIncomingStanza( aliceJid, aliceDevice2.id, - getTimestamp(), aliceResult2.encryptedKeys[bobJid]!, base64Encode(aliceResult2.ciphertext!), false, From 7f601405832b04f308c41e149b98c5ace0a279f8 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 23:30:52 +0200 Subject: [PATCH 31/36] fix: Expose EncryptToJidError --- lib/omemo_dart.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/omemo_dart.dart b/lib/omemo_dart.dart index 199b91f..acc9b7a 100644 --- a/lib/omemo_dart.dart +++ b/lib/omemo_dart.dart @@ -8,6 +8,7 @@ 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/errors.dart'; export 'src/omemo/fingerprint.dart'; export 'src/omemo/omemo.dart'; export 'src/omemo/ratchet_data.dart'; From b4241db9b6cd5bb2bf1be3c4f75afe21463681c5 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 17 Jun 2023 23:31:16 +0200 Subject: [PATCH 32/36] fix: Remove unused imports --- lib/src/helpers.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/src/helpers.dart b/lib/src/helpers.dart index 102f316..86f8df1 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) { From 499817313d48a2c12d01dbca31ee2fec7fd2662f Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sun, 18 Jun 2023 12:44:09 +0200 Subject: [PATCH 33/36] docs: Add missing note about OmemoDevice --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3321c9c..022c9a3 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ dependencies: ### 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 mappings: +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 From 8b91c07fb862e657dd100a27ed5e297baf720ade Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sun, 18 Jun 2023 20:44:05 +0200 Subject: [PATCH 34/36] feat: Changes based on integration issues --- lib/src/helpers.dart | 13 ------------ lib/src/omemo/omemo.dart | 44 +++++++++++++++++++++++++--------------- lib/src/trust/btbv.dart | 41 ++++++++++++++++++++++++++++++++++--- test/helpers_test.dart | 33 ------------------------------ 4 files changed, 66 insertions(+), 65 deletions(-) delete mode 100644 test/helpers_test.dart diff --git a/lib/src/helpers.dart b/lib/src/helpers.dart index 86f8df1..a99531f 100644 --- a/lib/src/helpers.dart +++ b/lib/src/helpers.dart @@ -52,19 +52,6 @@ class ListDiff { final List removed; } -extension BeforeAfterListDiff on List { - /// Compute the set-based changes between this list and [newList]. - ListDiff diff(List newList) { - final oldSet = Set.from(this); - final newSet = Set.from(newList); - - return ListDiff( - newSet.difference(oldSet).toList(), - oldSet.difference(newSet).toList(), - ); - } -} - extension AppendToListOrCreateExtension on Map> { /// Create or append [value] to the list identified with key [key]. void appendOrCreate(K key, V value, {bool checkExistence = false}) { diff --git a/lib/src/omemo/omemo.dart b/lib/src/omemo/omemo.dart index 6ed6e7d..1a7fef5 100644 --- a/lib/src/omemo/omemo.dart +++ b/lib/src/omemo/omemo.dart @@ -60,19 +60,16 @@ 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 for [jid] to persistent storage. [added] will be the list of -/// devices added and [removed] will be the list of removed devices. +/// Commits the device list [devices] for [jid] to persistent storage. typedef CommitDeviceListCallback = Future Function( String jid, - List added, - List removed, + List devices, ); /// A stub implementation of [CommitDeviceListCallback]. Future commitDeviceListStub( String _, List __, - List ___, ) async {} /// Commits the mapping of the (new) ratchets in [ratchets] to persistent storage. @@ -262,7 +259,6 @@ class OmemoManager { await commitDeviceList( jid, newDeviceList, - [], ); } } @@ -866,7 +862,6 @@ class OmemoManager { await commitDeviceList( jid, [], - _deviceList[jid]!, ); _deviceList.remove(jid); _deviceListRequested.remove(jid); @@ -880,20 +875,12 @@ class OmemoManager { await _ratchetQueue.synchronized( [jid], () async { - // Compute the delta - ListDiff delta; - if (_deviceList.containsKey(jid)) { - delta = _deviceList[jid]!.diff(devices); - } else { - delta = ListDiff(devices, []); - } - // Update our state _deviceList[jid] = devices; _deviceListRequested[jid] = true; // Commit the device list - await commitDeviceList(jid, delta.added, delta.removed); + await commitDeviceList(jid, devices); }, ); } @@ -1013,6 +1000,31 @@ class OmemoManager { }); } + /// 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. diff --git a/lib/src/trust/btbv.dart b/lib/src/trust/btbv.dart index 5a04ab8..5d45aca 100644 --- a/lib/src/trust/btbv.dart +++ b/lib/src/trust/btbv.dart @@ -10,6 +10,7 @@ class BTBVTrustData { this.device, this.state, this.enabled, + this.trusted, ); /// The JID in question. @@ -23,6 +24,12 @@ class BTBVTrustData { /// 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. @@ -54,6 +61,20 @@ enum BTBVTrustState { const BTBVTrustState(this.value); + 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; } @@ -142,19 +163,31 @@ class BlindTrustBeforeVerificationTrustManager extends TrustManager { 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 { - final map = {}; + Future> getDevicesTrust(String jid) async { + final map = {}; if (!devices.containsKey(jid)) return map; for (final deviceId in devices[jid]!) { - map[deviceId] = trustCache[RatchetMapKey(jid, deviceId)]!; + final key = RatchetMapKey(jid, deviceId); + if (!trustCache.containsKey(key) || !enablementCache.containsKey(key)) { + continue; + } + + map[deviceId] = BTBVTrustData( + jid, + deviceId, + trustCache[key]!, + enablementCache[key]!, + await isTrusted(jid, deviceId), + ); } return map; @@ -176,6 +209,7 @@ class BlindTrustBeforeVerificationTrustManager extends TrustManager { deviceId, state, enablementCache[key]!, + false, ), ); } @@ -200,6 +234,7 @@ class BlindTrustBeforeVerificationTrustManager extends TrustManager { deviceId, trustCache[key]!, enabled, + false, ), ); } diff --git a/test/helpers_test.dart b/test/helpers_test.dart deleted file mode 100644 index 0b718d2..0000000 --- a/test/helpers_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:omemo_dart/src/helpers.dart'; -import 'package:omemo_dart/src/omemo/queue.dart'; -import 'package:test/test.dart'; - -void main() { - group('List diff', () { - test('Empty list to full list', () { - final result = [].diff([1, 2, 3, 4]); - expect(result.removed, isEmpty); - expect( - result.added.containsAll([1, 2, 3, 4]), - isTrue, - ); - expect(result.added.length, 4); - }); - - test('Full list to empty list', () { - final result = [1, 2, 3, 4].diff([]); - expect(result.added, isEmpty); - expect( - result.removed.containsAll([1, 2, 3, 4]), - isTrue, - ); - expect(result.removed.length, 4); - }); - - test('Full list to full list', () { - final result = [1, 2, 3, 4].diff([1, 2, 4, 5]); - expect(result.added, [5]); - expect(result.removed, [3]); - }); - }); -} From 9214e964df000bf0f27af60377e204c97d37aa25 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sun, 18 Jun 2023 20:51:49 +0200 Subject: [PATCH 35/36] test: Test receiving an empty OMEMO message --- test/omemo_test.dart | 112 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/test/omemo_test.dart b/test/omemo_test.dart index dee51fa..547e78f 100644 --- a/test/omemo_test.dart +++ b/test/omemo_test.dart @@ -6,6 +6,10 @@ import 'package:omemo_dart/src/protobuf/schema.pb.dart'; import 'package:omemo_dart/src/trust/always.dart'; import 'package:test/test.dart'; +extension BytesToBase64 on List { + String toBase64() => base64Encode(this); +} + class TestingTrustManager extends AlwaysTrustingTrustManager { final Map devices = {}; @@ -1791,4 +1795,112 @@ void main() { 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, + 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'); + }); } From f54a90d5bbd09b0e6bc2a6458bc3f1469024f9a2 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sun, 18 Jun 2023 21:24:50 +0200 Subject: [PATCH 36/36] docs: Add a reference to moxxmpp's OMEMO example --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 022c9a3..65529ef 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ dependencies: # [...] ``` +### Example + +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.