From a0a7d3e7f6963aa27ab6a91c580ce580b0721226 Mon Sep 17 00:00:00 2001 From: ariskotsomitopoulos Date: Tue, 17 May 2022 16:28:30 +0300 Subject: [PATCH] Enhance reply attack to prevent DUPLICATED_MESSAGE_INDEX while decrypting the same event --- .../crypto/replay_attack/ReplayAttackTest.kt | 160 ------------------ .../crypto/replayattack/ReplayAttackTest.kt | 109 ++++++++++++ .../sync/handler/room/RoomSyncHandler.kt | 9 +- 3 files changed, 117 insertions(+), 161 deletions(-) delete mode 100644 matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/replay_attack/ReplayAttackTest.kt create mode 100644 matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/replayattack/ReplayAttackTest.kt 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 deleted file mode 100644 index 5c9892e264..0000000000 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/replay_attack/ReplayAttackTest.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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/androidTest/java/org/matrix/android/sdk/internal/crypto/replayattack/ReplayAttackTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/replayattack/ReplayAttackTest.kt new file mode 100644 index 0000000000..cb672f5e8d --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/replayattack/ReplayAttackTest.kt @@ -0,0 +1,109 @@ +/* + * 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.replayattack + +import androidx.test.filters.LargeTest +import org.amshove.kluent.internal.assertFailsWith +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.crypto.MXCryptoError +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.CryptoTestHelper + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +@LargeTest +class ReplayAttackTest : InstrumentedTest { + + @Test + fun replayAttackAlreadyDecryptedEventTest() { + 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) + + // Alice will send a message + val sentEvents = testHelper.sendTextMessage(aliceRoomPOV, "Hello I will be decrypted twice", 1) + Assert.assertTrue("Message should be sent", sentEvents.size == 1) + + val fakeEventId = sentEvents[0].eventId + "_fake" + val fakeEventWithTheSameIndex = + sentEvents[0].copy(eventId = fakeEventId, root = sentEvents[0].root.copy(eventId = fakeEventId)) + + testHelper.runBlockingTest { + // Lets assume we are from the main timelineId + val timelineId = "timelineId" + // Lets decrypt the original event + aliceSession.cryptoService().decryptEvent(sentEvents[0].root, timelineId) + // Lets decrypt the fake event that will have the same message index + val exception = assertFailsWith { + // An exception should be thrown while the same index would have been used for the previous decryption + aliceSession.cryptoService().decryptEvent(fakeEventWithTheSameIndex.root, timelineId) + } + assertEquals(MXCryptoError.ErrorType.DUPLICATED_MESSAGE_INDEX, exception.errorType) + } + cryptoTestData.cleanUp(testHelper) + } + + @Test + fun replayAttackSameEventTest() { + 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) + + // Alice will send a message + val sentEvents = testHelper.sendTextMessage(aliceRoomPOV, "Hello I will be decrypted twice", 1) + Assert.assertTrue("Message should be sent", sentEvents.size == 1) + + testHelper.runBlockingTest { + // Lets assume we are from the main timelineId + val timelineId = "timelineId" + // Lets decrypt the original event + aliceSession.cryptoService().decryptEvent(sentEvents[0].root, timelineId) + // Lets try to decrypt the same event + aliceSession.cryptoService().decryptEvent(sentEvents[0].root, timelineId) + } + cryptoTestData.cleanUp(testHelper) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt index a3be8b56a1..879bde1862 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.isThread import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.initsync.InitSyncStep @@ -520,9 +521,10 @@ internal class RoomSyncHandler @Inject constructor( private fun decryptIfNeeded(event: Event, roomId: String) { try { + val timelineId = generateTimelineId(roomId, event) // Event from sync does not have roomId, so add it to the event first // note: runBlocking should be used here while we are in realm single thread executor, to avoid thread switching - val result = runBlocking { cryptoService.decryptEvent(event.copy(roomId = roomId), "") } + val result = runBlocking { cryptoService.decryptEvent(event.copy(roomId = roomId), timelineId) } event.mxDecryptionResult = OlmDecryptionResult( payload = result.clearEvent, senderKey = result.senderCurve25519Key, @@ -537,6 +539,11 @@ internal class RoomSyncHandler @Inject constructor( } } + private fun generateTimelineId(roomId: String, event: Event): String { + val threadIndicator = if (event.isThread()) "_thread_" else "_" + return "${RoomSyncHandler::class.java.simpleName}$threadIndicator$roomId" + } + data class EphemeralResult( val typingUserIds: List = emptyList() )