mirror of
https://github.com/element-hq/element-android
synced 2024-11-27 20:06:51 +03:00
Enhance reply attack to prevent DUPLICATED_MESSAGE_INDEX while decrypting the same event
This commit is contained in:
parent
2e08c07dad
commit
a0a7d3e7f6
3 changed files with 117 additions and 161 deletions
|
@ -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<MessageContent>()?.body?.startsWith(text) == true }
|
|
||||||
sentEventId = decryptedMsg?.eventId
|
|
||||||
decryptedMsg != null
|
|
||||||
}
|
|
||||||
|
|
||||||
timeline.dispose()
|
|
||||||
}
|
|
||||||
return sentEventId
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<MXCryptoError.Base> {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.crypto.model.OlmDecryptionResult
|
||||||
import org.matrix.android.sdk.api.session.events.model.Event
|
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.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.events.model.toModel
|
||||||
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
|
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
|
||||||
import org.matrix.android.sdk.api.session.initsync.InitSyncStep
|
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) {
|
private fun decryptIfNeeded(event: Event, roomId: String) {
|
||||||
try {
|
try {
|
||||||
|
val timelineId = generateTimelineId(roomId, event)
|
||||||
// Event from sync does not have roomId, so add it to the event first
|
// 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
|
// 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(
|
event.mxDecryptionResult = OlmDecryptionResult(
|
||||||
payload = result.clearEvent,
|
payload = result.clearEvent,
|
||||||
senderKey = result.senderCurve25519Key,
|
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(
|
data class EphemeralResult(
|
||||||
val typingUserIds: List<String> = emptyList()
|
val typingUserIds: List<String> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue