diff --git a/changelog.d/4082.bugfix b/changelog.d/4082.bugfix new file mode 100644 index 0000000000..9ec8d4db97 --- /dev/null +++ b/changelog.d/4082.bugfix @@ -0,0 +1 @@ +Verifying exported E2E keys to provide user feedback when the output is malformed \ No newline at end of file diff --git a/vector/build.gradle b/vector/build.gradle index 69339cf214..b3658acb00 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -314,6 +314,11 @@ android { } } +configurations { + // videocache includes a sl4j logger which causes mockk to attempt to call the static android Log + testImplementation.exclude group: 'org.slf4j', module: 'slf4j-android' +} + dependencies { implementation project(":matrix-sdk-android") @@ -490,6 +495,7 @@ dependencies { // TESTS testImplementation libs.tests.junit testImplementation libs.tests.kluent + testImplementation libs.mockk.mockk // Plant Timber tree for test testImplementation libs.tests.timberJunitRule diff --git a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt index ca26c99a15..a8bf128367 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt @@ -26,6 +26,7 @@ import im.vector.app.EmojiCompatFontProvider import im.vector.app.EmojiCompatWrapper import im.vector.app.VectorApplication import im.vector.app.core.dialogs.UnrecognizedCertificateDialog +import im.vector.app.core.dispatchers.CoroutineDispatchers import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.network.WifiDetector import im.vector.app.core.pushers.PushersManager @@ -171,6 +172,8 @@ interface VectorComponent { fun appCoroutineScope(): CoroutineScope + fun coroutineDispatchers(): CoroutineDispatchers + fun jitsiActiveConferenceHolder(): JitsiActiveConferenceHolder @Component.Factory diff --git a/vector/src/main/java/im/vector/app/core/di/VectorModule.kt b/vector/src/main/java/im/vector/app/core/di/VectorModule.kt index dd1ffee8ec..ddb765cef8 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorModule.kt @@ -23,6 +23,7 @@ import android.content.res.Resources import dagger.Binds import dagger.Module import dagger.Provides +import im.vector.app.core.dispatchers.CoroutineDispatchers import im.vector.app.core.error.DefaultErrorFormatter import im.vector.app.core.error.ErrorFormatter import im.vector.app.features.invite.AutoAcceptInvites @@ -105,6 +106,12 @@ abstract class VectorModule { fun providesApplicationCoroutineScope(): CoroutineScope { return CoroutineScope(SupervisorJob() + Dispatchers.Main) } + + @Provides + @JvmStatic + fun providesCoroutineDispatchers(): CoroutineDispatchers { + return CoroutineDispatchers(io = Dispatchers.IO) + } } @Binds diff --git a/vector/src/main/java/im/vector/app/core/dispatchers/CoroutineDispatchers.kt b/vector/src/main/java/im/vector/app/core/dispatchers/CoroutineDispatchers.kt new file mode 100644 index 0000000000..c489290a55 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/dispatchers/CoroutineDispatchers.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2021 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 im.vector.app.core.dispatchers + +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +data class CoroutineDispatchers @Inject constructor(val io: CoroutineDispatcher) diff --git a/vector/src/main/java/im/vector/app/features/crypto/keys/KeysExporter.kt b/vector/src/main/java/im/vector/app/features/crypto/keys/KeysExporter.kt index 2c66a14cb4..3db67df8e1 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keys/KeysExporter.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keys/KeysExporter.kt @@ -18,24 +18,43 @@ package im.vector.app.features.crypto.keys import android.content.Context import android.net.Uri -import kotlinx.coroutines.Dispatchers +import im.vector.app.core.dispatchers.CoroutineDispatchers import kotlinx.coroutines.withContext import org.matrix.android.sdk.api.session.Session import javax.inject.Inject class KeysExporter @Inject constructor( private val session: Session, - private val context: Context + private val context: Context, + private val dispatchers: CoroutineDispatchers ) { /** * Export keys and write them to the provided uri */ suspend fun export(password: String, uri: Uri) { - return withContext(Dispatchers.IO) { + withContext(dispatchers.io) { val data = session.cryptoService().exportRoomKeys(password) context.contentResolver.openOutputStream(uri) ?.use { it.write(data) } - ?: throw IllegalStateException("Unable to open file for writting") + ?: throw IllegalStateException("Unable to open file for writing") + verifyExportedKeysOutputFileSize(uri, expectedSize = data.size.toLong()) + } + } + + private fun verifyExportedKeysOutputFileSize(uri: Uri, expectedSize: Long) { + val output = context.contentResolver.openFileDescriptor(uri, "r", null) + when { + output == null -> throw IllegalStateException("Exported file not found") + output.statSize != expectedSize -> { + throw UnexpectedExportKeysFileSizeException( + expectedFileSize = expectedSize, + actualFileSize = output.statSize + ) + } } } } + +class UnexpectedExportKeysFileSizeException(expectedFileSize: Long, actualFileSize: Long) : IllegalStateException( + "Exported Keys file has unexpected file size, got: $actualFileSize but expected: $expectedFileSize" +) diff --git a/vector/src/test/java/im/vector/app/features/crypto/keys/KeysExporterTest.kt b/vector/src/test/java/im/vector/app/features/crypto/keys/KeysExporterTest.kt new file mode 100644 index 0000000000..a8997db855 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/crypto/keys/KeysExporterTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.crypto.keys + +import android.net.Uri +import android.os.ParcelFileDescriptor +import im.vector.app.core.dispatchers.CoroutineDispatchers +import im.vector.app.test.fakes.FakeContext +import im.vector.app.test.fakes.FakeCryptoService +import im.vector.app.test.fakes.FakeSession +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.amshove.kluent.internal.assertFailsWith +import org.junit.Before +import org.junit.Test + +private val A_URI = mockk() +private val A_ROOM_KEYS_EXPORT = ByteArray(size = 111) +private const val A_PASSWORD = "a password" + +class KeysExporterTest { + + private val cryptoService = FakeCryptoService() + private val context = FakeContext() + private val keysExporter = KeysExporter( + session = FakeSession(cryptoService = cryptoService), + context = context.instance, + dispatchers = CoroutineDispatchers(Dispatchers.Unconfined) + ) + + @Before + fun setUp() { + cryptoService.roomKeysExport = A_ROOM_KEYS_EXPORT + } + + @Test + fun `when exporting then writes exported keys to context output stream`() { + givenFileDescriptorWithSize(size = A_ROOM_KEYS_EXPORT.size.toLong()) + val outputStream = context.givenOutputStreamFor(A_URI) + + runBlocking { keysExporter.export(A_PASSWORD, A_URI) } + + verify { outputStream.write(A_ROOM_KEYS_EXPORT) } + } + + @Test + fun `given different file size returned for export when exporting then throws UnexpectedExportKeysFileSizeException`() { + givenFileDescriptorWithSize(size = 110) + context.givenOutputStreamFor(A_URI) + + assertFailsWith { + runBlocking { keysExporter.export(A_PASSWORD, A_URI) } + } + } + + @Test + fun `given output stream is unavailable for exporting to when exporting then throws IllegalStateException`() { + context.givenMissingOutputStreamFor(A_URI) + + assertFailsWith(message = "Unable to open file for writing") { + runBlocking { keysExporter.export(A_PASSWORD, A_URI) } + } + } + + @Test + fun `given exported file is missing after export when exporting then throws IllegalStateException`() { + context.givenFileDescriptor(A_URI, mode = "r") { null } + context.givenOutputStreamFor(A_URI) + + assertFailsWith(message = "Exported file not found") { + runBlocking { keysExporter.export(A_PASSWORD, A_URI) } + } + } + + private fun givenFileDescriptorWithSize(size: Long) { + context.givenFileDescriptor(A_URI, mode = "r") { + mockk().also { every { it.statSize } returns size } + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt new file mode 100644 index 0000000000..8dec510f5a --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021 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 im.vector.app.test.fakes + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import io.mockk.every +import io.mockk.mockk +import java.io.OutputStream + +class FakeContext { + + private val contentResolver = mockk() + val instance = mockk() + + init { + every { instance.contentResolver } returns contentResolver + } + + fun givenFileDescriptor(uri: Uri, mode: String, factory: () -> ParcelFileDescriptor?) { + val fileDescriptor = factory() + every { contentResolver.openFileDescriptor(uri, mode, null) } returns fileDescriptor + } + + fun givenOutputStreamFor(uri: Uri): OutputStream { + val outputStream = mockk(relaxed = true) + every { contentResolver.openOutputStream(uri) } returns outputStream + return outputStream + } + + fun givenMissingOutputStreamFor(uri: Uri) { + every { contentResolver.openOutputStream(uri) } returns null + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt new file mode 100644 index 0000000000..735af4ea11 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021 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 im.vector.app.test.fakes + +import io.mockk.mockk +import org.matrix.android.sdk.api.session.crypto.CryptoService + +class FakeCryptoService : CryptoService by mockk() { + + var roomKeysExport = ByteArray(size = 1) + + override suspend fun exportRoomKeys(password: String) = roomKeysExport +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt new file mode 100644 index 0000000000..3400436705 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021 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 im.vector.app.test.fakes + +import io.mockk.mockk +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.crypto.CryptoService + +class FakeSession( + private val cryptoService: CryptoService = FakeCryptoService() +) : Session by mockk(relaxed = true) { + override fun cryptoService() = cryptoService +}