diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/replay_attack/ReplayAttackTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/replay_attack/ReplayAttackTest.kt new file mode 100644 index 0000000000..5c9892e264 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/replay_attack/ReplayAttackTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.crypto.replay_attack + +import android.util.Log +import androidx.test.filters.LargeTest +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.CryptoTestHelper +import org.matrix.android.sdk.common.TestConstants + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +@LargeTest +class ReplayAttackTest : InstrumentedTest { + + @Test + fun replayAttackTest() { + val testHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(testHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) + + val e2eRoomID = cryptoTestData.roomId + + // Alice + val aliceSession = cryptoTestData.firstSession + val aliceRoomPOV = aliceSession.roomService().getRoom(e2eRoomID)!! + + // Bob + val bobSession = cryptoTestData.secondSession + val bobRoomPOV = bobSession!!.roomService().getRoom(e2eRoomID)!! + + assertEquals(bobRoomPOV.roomSummary()?.joinedMembersCount, 2) + Log.v("##REPLAY ATTACK", "Alice and Bob are in roomId: $e2eRoomID") + + + val sentEvents = testHelper.sendTextMessage(aliceRoomPOV, "Hello", 20) + +// val aliceMessageId: String? = sendMessageInRoom(aliceRoomPOV, "Hello Bob, I am Alice!", testHelper) + Assert.assertTrue("Message should be sent", sentEvents.size == 20) + Log.v("##REPLAY ATTACK", "Alice sent message to roomId: $e2eRoomID") + + // Bob should be able to decrypt the message +// testHelper.waitWithLatch { latch -> +// testHelper.retryPeriodicallyWithLatch(latch) { +// val timelineEvent = bobSession.roomService().getRoom(e2eRoomID)?.timelineService()?.getTimelineEvent(aliceMessageId!!) +// (timelineEvent != null && +// timelineEvent.isEncrypted() && +// timelineEvent.root.getClearType() == EventType.MESSAGE).also { +// if (it) { +// Log.v("#E2E TEST", "Bob can decrypt the message: ${timelineEvent?.root?.getDecryptedTextSummary()}") +// } +// } +// } +// } +// +// // Create a new user +// val arisSession = testHelper.createAccount("aris", SessionTestParams(true)) +// Log.v("#E2E TEST", "Aris user created") +// +// // Alice invites new user to the room +// testHelper.runBlockingTest { +// Log.v("#E2E TEST", "Alice invites ${arisSession.myUserId}") +// aliceRoomPOV.membershipService().invite(arisSession.myUserId) +// } +// +// waitForAndAcceptInviteInRoom(arisSession, e2eRoomID, testHelper) +// +// ensureMembersHaveJoined(aliceSession, arrayListOf(arisSession), e2eRoomID, testHelper) +// Log.v("#E2E TEST", "Aris has joined roomId: $e2eRoomID") +// +// when (roomHistoryVisibility) { +// RoomHistoryVisibility.WORLD_READABLE, +// RoomHistoryVisibility.SHARED, +// null +// -> { +// // Aris should be able to decrypt the message +// testHelper.waitWithLatch { latch -> +// testHelper.retryPeriodicallyWithLatch(latch) { +// val timelineEvent = arisSession.roomService().getRoom(e2eRoomID)?.timelineService()?.getTimelineEvent(aliceMessageId!!) +// (timelineEvent != null && +// timelineEvent.isEncrypted() && +// timelineEvent.root.getClearType() == EventType.MESSAGE +// ).also { +// if (it) { +// Log.v("#E2E TEST", "Aris can decrypt the message: ${timelineEvent?.root?.getDecryptedTextSummary()}") +// } +// } +// } +// } +// } +// RoomHistoryVisibility.INVITED, +// RoomHistoryVisibility.JOINED -> { +// // Aris should not even be able to get the message +// testHelper.waitWithLatch { latch -> +// testHelper.retryPeriodicallyWithLatch(latch) { +// val timelineEvent = arisSession.roomService().getRoom(e2eRoomID) +// ?.timelineService() +// ?.getTimelineEvent(aliceMessageId!!) +// timelineEvent == null +// } +// } +// } +// } + +// testHelper.signOutAndClose(arisSession) + cryptoTestData.cleanUp(testHelper) + } + + private fun sendMessageInRoom(aliceRoomPOV: Room, text: String, testHelper: CommonTestHelper): String? { + aliceRoomPOV.sendService().sendTextMessage(text) + var sentEventId: String? = null + testHelper.waitWithLatch(4 * TestConstants.timeOutMillis) { latch -> + val timeline = aliceRoomPOV.timelineService().createTimeline(null, TimelineSettings(60)) + timeline.start() + testHelper.retryPeriodicallyWithLatch(latch) { + val decryptedMsg = timeline.getSnapshot() + .filter { it.root.getClearType() == EventType.MESSAGE } + .also { list -> + val message = list.joinToString(",", "[", "]") { "${it.root.type}|${it.root.sendState}" } + Log.v("#E2E TEST", "Timeline snapshot is $message") + } + .filter { it.root.sendState == SendState.SYNCED } + .firstOrNull { it.root.getClearContent().toModel()?.body?.startsWith(text) == true } + sentEventId = decryptedMsg?.eventId + decryptedMsg != null + } + + timeline.dispose() + } + return sentEventId + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt index 68a1519670..16e6bb173d 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt @@ -99,6 +99,8 @@ internal class MXOlmDevice @Inject constructor( // The second level keys are strings of form "||" private val inboundGroupSessionMessageIndexes: MutableMap> = HashMap() + private val replayAttackMap: MutableMap = HashMap() + init { // Retrieve the account from the store try { @@ -763,59 +765,71 @@ internal class MXOlmDevice @Inject constructor( suspend fun decryptGroupMessage(body: String, roomId: String, timeline: String?, + eventId: String, sessionId: String, senderKey: String): OlmDecryptionResult { val sessionHolder = getInboundGroupSession(sessionId, senderKey, roomId) val wrapper = sessionHolder.wrapper val inboundGroupSession = wrapper.olmInboundGroupSession ?: throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, "Session is null") - // Check that the room id matches the original one for the session. This stops - // the HS pretending a message was targeting a different room. - if (roomId == wrapper.roomId) { - val decryptResult = try { - sessionHolder.mutex.withLock { - inboundGroupSession.decryptMessage(body) - } - } catch (e: OlmException) { - Timber.tag(loggerTag.value).e(e, "## decryptGroupMessage () : decryptMessage failed") - throw MXCryptoError.OlmError(e) - } - - if (timeline?.isNotBlank() == true) { - val timelineSet = inboundGroupSessionMessageIndexes.getOrPut(timeline) { mutableSetOf() } - - val messageIndexKey = senderKey + "|" + sessionId + "|" + decryptResult.mIndex - - if (timelineSet.contains(messageIndexKey)) { - val reason = String.format(MXCryptoError.DUPLICATE_MESSAGE_INDEX_REASON, decryptResult.mIndex) - Timber.tag(loggerTag.value).e("## decryptGroupMessage() timelineId=$timeline: $reason") - throw MXCryptoError.Base(MXCryptoError.ErrorType.DUPLICATED_MESSAGE_INDEX, reason) - } - - timelineSet.add(messageIndexKey) - } - - inboundGroupSessionStore.storeInBoundGroupSession(sessionHolder, sessionId, senderKey) - val payload = try { - val adapter = MoshiProvider.providesMoshi().adapter(JSON_DICT_PARAMETERIZED_TYPE) - val payloadString = convertFromUTF8(decryptResult.mDecryptedMessage) - adapter.fromJson(payloadString) - } catch (e: Exception) { - Timber.tag(loggerTag.value).e("## decryptGroupMessage() : fails to parse the payload") - throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_DECRYPTED_FORMAT, MXCryptoError.BAD_DECRYPTED_FORMAT_TEXT_REASON) - } - - return OlmDecryptionResult( - payload, - wrapper.keysClaimed, - senderKey, - wrapper.forwardingCurve25519KeyChain - ) - } else { + if (roomId != wrapper.roomId) { + // Check that the room id matches the original one for the session. This stops + // the HS pretending a message was targeting a different room. val reason = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, roomId, wrapper.roomId) Timber.tag(loggerTag.value).e("## decryptGroupMessage() : $reason") throw MXCryptoError.Base(MXCryptoError.ErrorType.INBOUND_SESSION_MISMATCH_ROOM_ID, reason) } + val decryptResult = try { + sessionHolder.mutex.withLock { + inboundGroupSession.decryptMessage(body) + } + } catch (e: OlmException) { + Timber.tag(loggerTag.value).e(e, "## decryptGroupMessage () : decryptMessage failed") + throw MXCryptoError.OlmError(e) + } + + val messageIndexKey = senderKey + "|" + sessionId + "|" + roomId + "|" + decryptResult.mIndex + Timber.tag(loggerTag.value).d("##########################################################") + Timber.tag(loggerTag.value).d("## decryptGroupMessage() timeline: $timeline") + Timber.tag(loggerTag.value).d("## decryptGroupMessage() senderKey: $senderKey") + Timber.tag(loggerTag.value).d("## decryptGroupMessage() sessionId: $sessionId") + Timber.tag(loggerTag.value).d("## decryptGroupMessage() roomId: $roomId") + Timber.tag(loggerTag.value).d("## decryptGroupMessage() eventId: $eventId") + Timber.tag(loggerTag.value).d("## decryptGroupMessage() mIndex: ${decryptResult.mIndex}") + + if (timeline?.isNotBlank() == true) { + val timelineSet = inboundGroupSessionMessageIndexes.getOrPut(timeline) { mutableSetOf() } + if (timelineSet.contains(messageIndexKey) && messageIndexKey.alreadyUsed(eventId)) { + val reason = String.format(MXCryptoError.DUPLICATE_MESSAGE_INDEX_REASON, decryptResult.mIndex) + Timber.tag(loggerTag.value).e("## decryptGroupMessage() timelineId=$timeline: $reason") + throw MXCryptoError.Base(MXCryptoError.ErrorType.DUPLICATED_MESSAGE_INDEX, reason) + } + timelineSet.add(messageIndexKey) + } + replayAttackMap[messageIndexKey] = eventId + inboundGroupSessionStore.storeInBoundGroupSession(sessionHolder, sessionId, senderKey) + val payload = try { + val adapter = MoshiProvider.providesMoshi().adapter(JSON_DICT_PARAMETERIZED_TYPE) + val payloadString = convertFromUTF8(decryptResult.mDecryptedMessage) + adapter.fromJson(payloadString) + } catch (e: Exception) { + Timber.tag(loggerTag.value).e("## decryptGroupMessage() : fails to parse the payload") + throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_DECRYPTED_FORMAT, MXCryptoError.BAD_DECRYPTED_FORMAT_TEXT_REASON) + } + + return OlmDecryptionResult( + payload, + wrapper.keysClaimed, + senderKey, + wrapper.forwardingCurve25519KeyChain + ) + } + + /** + * Determines whether or not the messageKey has already been used to decrypt another eventId + */ + private fun String.alreadyUsed(eventId: String): Boolean { + return replayAttackMap[this] != null && replayAttackMap[this] != eventId } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt index d65b05f655..aff8013784 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt @@ -78,6 +78,7 @@ internal class MXMegolmDecryption( encryptedEventContent.ciphertext, event.roomId, timeline, + eventId = event.eventId.orEmpty(), encryptedEventContent.sessionId, encryptedEventContent.senderKey )