From 99c028ec01ecd6fc058d3fd84a5f6e525db39642 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 20 Sep 2022 20:54:35 +0100 Subject: [PATCH 01/13] extracting a dedicated media decrypter --- .../dapk/st/messenger/DecryptingFetcher.kt | 43 ++--------------- .../app/dapk/st/messenger/MediaDecrypter.kt | 47 +++++++++++++++++++ .../message/internal/SendMessageUseCase.kt | 39 +++++++++------ 3 files changed, 76 insertions(+), 53 deletions(-) create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt index 0c89d90..4f27103 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt @@ -1,7 +1,6 @@ package app.dapk.st.messenger import android.content.Context -import android.util.Base64 import app.dapk.st.matrix.sync.RoomEvent import coil.ImageLoader import coil.decode.DataSource @@ -14,15 +13,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okio.Buffer -import java.security.MessageDigest -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -private const val CRYPTO_BUFFER_SIZE = 32 * 1024 -private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding" -private const val SECRET_KEY_SPEC_ALGORITHM = "AES" -private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" class DecryptingFetcherFactory(private val context: Context) : Fetcher.Factory { override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher { @@ -34,6 +24,8 @@ private val http = OkHttpClient() class DecryptingFetcher(private val data: RoomEvent.Image, private val context: Context) : Fetcher { + private val mediaDecrypter = MediaDecrypter() + override suspend fun fetch(): FetchResult { val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute() val outputStream = when { @@ -44,32 +36,7 @@ class DecryptingFetcher(private val data: RoomEvent.Image, private val context: } private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { - val key = Base64.decode(keys.k.replace('-', '+').replace('_', '/'), Base64.DEFAULT) - val initVectorBytes = Base64.decode(keys.iv, Base64.DEFAULT) - - val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) - val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) - val ivParameterSpec = IvParameterSpec(initVectorBytes) - decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) - - val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) - - var read: Int - val d = ByteArray(CRYPTO_BUFFER_SIZE) - var decodedBytes: ByteArray - - val outputStream = Buffer() - response.body?.let { - it.byteStream().use { - read = it.read(d) - while (read != -1) { - messageDigest.update(d, 0, read) - decodedBytes = decryptCipher.update(d, 0, read) - outputStream.write(decodedBytes) - read = it.read(d) - } - } - } - return outputStream + return response.body?.byteStream()?.let { mediaDecrypter.decrypt(it, keys) } ?: Buffer() } -} \ No newline at end of file +} + diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt new file mode 100644 index 0000000..9e18c76 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt @@ -0,0 +1,47 @@ +package app.dapk.st.messenger + +import android.util.Base64 +import app.dapk.st.matrix.sync.RoomEvent +import okio.Buffer +import java.io.InputStream +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +private const val CRYPTO_BUFFER_SIZE = 32 * 1024 +private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding" +private const val SECRET_KEY_SPEC_ALGORITHM = "AES" +private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" + +class MediaDecrypter { + + fun decrypt(input: InputStream, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { + val key = Base64.decode(keys.k.replace('-', '+').replace('_', '/'), Base64.DEFAULT) + val initVectorBytes = Base64.decode(keys.iv, Base64.DEFAULT) + + val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) + val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) + val ivParameterSpec = IvParameterSpec(initVectorBytes) + decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) + + val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + + var read: Int + val d = ByteArray(CRYPTO_BUFFER_SIZE) + var decodedBytes: ByteArray + + val outputStream = Buffer() + input.use { + read = it.read(d) + while (read != -1) { + messageDigest.update(d, 0, read) + decodedBytes = decryptCipher.update(d, 0, read) + outputStream.write(decodedBytes) + read = it.read(d) + } + } + return outputStream + } + +} \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index b094e4f..646b716 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -38,22 +38,31 @@ internal class SendMessageUseCase( } is MessageService.Message.ImageMessage -> { - val imageContent = imageContentReader.read(message.content.uri) - val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri - val request = sendRequest( - roomId = message.roomId, - eventType = EventType.ROOM_MESSAGE, - txId = message.localId, - content = MessageService.Message.Content.ImageContent( - url = uri, - filename = imageContent.fileName, - MessageService.Message.Content.ImageContent.Info( - height = imageContent.height, - width = imageContent.width, - size = imageContent.size + val request = when (message.sendEncrypted) { + true -> { + throw IllegalStateException() + } + + false -> { + val imageContent = imageContentReader.read(message.content.uri) + val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri + sendRequest( + roomId = message.roomId, + eventType = EventType.ROOM_MESSAGE, + txId = message.localId, + content = MessageService.Message.Content.ImageContent( + url = uri, + filename = imageContent.fileName, + MessageService.Message.Content.ImageContent.Info( + height = imageContent.height, + width = imageContent.width, + size = imageContent.size + ) + ), ) - ), - ) + } + } + httpClient.execute(request).eventId } } From 9fb81cb2779b5f557f1cc1b46d9820a696353f08 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 20 Sep 2022 20:56:59 +0100 Subject: [PATCH 02/13] adding dummy tasks to noop module to allow the ide to assemble --- domains/firebase/crashlytics-noop/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/domains/firebase/crashlytics-noop/build.gradle b/domains/firebase/crashlytics-noop/build.gradle index 5ab1042..132fe20 100644 --- a/domains/firebase/crashlytics-noop/build.gradle +++ b/domains/firebase/crashlytics-noop/build.gradle @@ -3,3 +3,7 @@ plugins { id 'kotlin' } dependencies { implementation project(':core') } + + +task generateReleaseSources {} +task compileReleaseSources {} \ No newline at end of file From 4f7a36abd7451ae68745216cd4b669e06f3e6cee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Sep 2022 05:34:39 +0000 Subject: [PATCH 03/13] Bump junit-jupiter-engine from 5.9.0 to 5.9.1 Bumps [junit-jupiter-engine](https://github.com/junit-team/junit5) from 5.9.0 to 5.9.1. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.9.0...r5.9.1) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-engine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 66dbbba..5209c14 100644 --- a/build.gradle +++ b/build.gradle @@ -136,7 +136,7 @@ ext.kotlinTest = { dependencies -> dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' - dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0' + dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.1' } ext.kotlinFixtures = { dependencies -> From b2ff6cba563e31e02850662cf86214300145f76e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Sep 2022 17:20:53 +0000 Subject: [PATCH 04/13] Bump junit-jupiter-api from 5.9.0 to 5.9.1 Bumps [junit-jupiter-api](https://github.com/junit-team/junit5) from 5.9.0 to 5.9.1. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.9.0...r5.9.1) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-api dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5209c14..a379ec5 100644 --- a/build.gradle +++ b/build.gradle @@ -135,7 +135,7 @@ ext.kotlinTest = { dependencies -> dependencies.testImplementation 'io.mockk:mockk:1.12.8' dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' - dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' + dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1' dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.1' } From 49ae369e3897cd4b12e2e8fc5abab28c26a89d28 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 19:55:41 +0100 Subject: [PATCH 05/13] moving the ApiMessage to the message module --- .../kotlin/app/dapk/st/graph/AppModule.kt | 2 +- .../device/internal/DefaultDeviceService.kt | 21 --------------- .../st/matrix/message/internal/ApiMessage.kt | 26 +++++++++++++++++++ .../src/test/kotlin/test/TestMatrix.kt | 2 +- 4 files changed, 28 insertions(+), 23 deletions(-) create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 9917e86..4bbca82 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -31,7 +31,7 @@ import app.dapk.st.matrix.crypto.cryptoService import app.dapk.st.matrix.crypto.installCryptoService import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.installEncryptionService -import app.dapk.st.matrix.device.internal.ApiMessage +import app.dapk.st.matrix.message.internal.ApiMessage import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.message.MessageEncrypter diff --git a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt index 1bfbe86..ad9fbdf 100644 --- a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt +++ b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/DefaultDeviceService.kt @@ -6,8 +6,6 @@ import app.dapk.st.matrix.device.DeviceService.OneTimeKeys.Key.SignedCurve.Ed255 import app.dapk.st.matrix.device.KnownDeviceStore import app.dapk.st.matrix.device.ToDevicePayload import app.dapk.st.matrix.http.MatrixHttpClient -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import java.util.* @@ -141,22 +139,3 @@ internal class DefaultDeviceService( } } -@Serializable -sealed class ApiMessage { - - @Serializable - @SerialName("text_message") - data class TextMessage( - @SerialName("content") val content: TextContent, - @SerialName("room_id") val roomId: RoomId, - @SerialName("type") val type: String, - ) : ApiMessage() { - - @Serializable - data class TextContent( - @SerialName("body") val body: String, - @SerialName("msgtype") val type: String = MessageType.TEXT.value, - ) - } - -} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt new file mode 100644 index 0000000..7d4cbc7 --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt @@ -0,0 +1,26 @@ +package app.dapk.st.matrix.message.internal + +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class ApiMessage { + + @Serializable + @SerialName("text_message") + data class TextMessage( + @SerialName("content") val content: TextContent, + @SerialName("room_id") val roomId: RoomId, + @SerialName("type") val type: String, + ) : ApiMessage() { + + @Serializable + data class TextContent( + @SerialName("body") val body: String, + @SerialName("msgtype") val type: String = MessageType.TEXT.value, + ) + } + +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 411f8b6..55206ac 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -16,7 +16,7 @@ import app.dapk.st.matrix.crypto.cryptoService import app.dapk.st.matrix.crypto.installCryptoService import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.installEncryptionService -import app.dapk.st.matrix.device.internal.ApiMessage +import app.dapk.st.matrix.message.internal.ApiMessage import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.message.MessageEncrypter From c0d2f1445da7cdcadc2a2bbe7f2bddbbeb8aadac Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 20:07:03 +0100 Subject: [PATCH 06/13] lifting the message contents jsonising to the message usecase --- .../kotlin/app/dapk/st/graph/AppModule.kt | 23 ++----------------- .../st/matrix/message/MessageEncrypter.kt | 14 ++++++----- .../message/internal/SendMessageUseCase.kt | 17 +++++++++++++- .../src/test/kotlin/test/TestMatrix.kt | 23 ++----------------- 4 files changed, 28 insertions(+), 49 deletions(-) diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 4bbca82..b44cf68 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -31,8 +31,6 @@ import app.dapk.st.matrix.crypto.cryptoService import app.dapk.st.matrix.crypto.installCryptoService import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.installEncryptionService -import app.dapk.st.matrix.message.internal.ApiMessage -import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService @@ -277,26 +275,9 @@ internal class MatrixModules( installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( - roomId = when (message) { - is MessageService.Message.TextMessage -> message.roomId - is MessageService.Message.ImageMessage -> message.roomId - }, + roomId = message.roomId, credentials = credentialsStore.credentials()!!, - when (message) { - is MessageService.Message.TextMessage -> JsonString( - MatrixHttpClient.jsonWithDefaults.encodeToString( - ApiMessage.TextMessage.serializer(), - ApiMessage.TextMessage( - ApiMessage.TextMessage.TextContent( - message.content.body, - message.content.type, - ), message.roomId, type = EventType.ROOM_MESSAGE.value - ) - ) - ) - - is MessageService.Message.ImageMessage -> TODO() - } + messageJson = message.contents, ) MessageEncrypter.EncryptedMessagePayload( diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt index 94300cd..13b67c2 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt @@ -1,15 +1,12 @@ package app.dapk.st.matrix.message -import app.dapk.st.matrix.common.AlgorithmName -import app.dapk.st.matrix.common.CipherText -import app.dapk.st.matrix.common.DeviceId -import app.dapk.st.matrix.common.SessionId +import app.dapk.st.matrix.common.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable fun interface MessageEncrypter { - suspend fun encrypt(message: MessageService.Message): EncryptedMessagePayload + suspend fun encrypt(message: ClearMessagePayload): EncryptedMessagePayload @Serializable data class EncryptedMessagePayload( @@ -19,8 +16,13 @@ fun interface MessageEncrypter { @SerialName("session_id") val sessionId: SessionId, @SerialName("device_id") val deviceId: DeviceId ) + + data class ClearMessagePayload( + val roomId: RoomId, + val contents: JsonString, + ) } internal object MissingMessageEncrypter : MessageEncrypter { - override suspend fun encrypt(message: MessageService.Message) = throw IllegalStateException("No encrypter instance set") + override suspend fun encrypt(message: MessageEncrypter.ClearMessagePayload) = throw IllegalStateException("No encrypter instance set") } \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index 646b716..b0a91a2 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -2,6 +2,7 @@ package app.dapk.st.matrix.message.internal import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.EventType +import app.dapk.st.matrix.common.JsonString import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService @@ -17,11 +18,25 @@ internal class SendMessageUseCase( is MessageService.Message.TextMessage -> { val request = when (message.sendEncrypted) { true -> { + val content = JsonString( + MatrixHttpClient.jsonWithDefaults.encodeToString( + ApiMessage.TextMessage.serializer(), + ApiMessage.TextMessage( + content = ApiMessage.TextMessage.TextContent( + message.content.body, + message.content.type, + ), + roomId = message.roomId, + type = EventType.ROOM_MESSAGE.value + ) + ) + ) + sendRequest( roomId = message.roomId, eventType = EventType.ENCRYPTED, txId = message.localId, - content = messageEncrypter.encrypt(message), + content = messageEncrypter.encrypt(MessageEncrypter.ClearMessagePayload(message.roomId, content)), ) } diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 55206ac..4e89e7b 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -16,8 +16,6 @@ import app.dapk.st.matrix.crypto.cryptoService import app.dapk.st.matrix.crypto.installCryptoService import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.installEncryptionService -import app.dapk.st.matrix.message.internal.ApiMessage -import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService @@ -35,7 +33,6 @@ import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.OlmPersistenceWrapper import app.dapk.st.olm.OlmWrapper import kotlinx.coroutines.* -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.amshove.kluent.fail import test.impl.InMemoryDatabase @@ -127,25 +124,9 @@ class TestMatrix( installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader()) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( - roomId = when (message) { - is MessageService.Message.TextMessage -> message.roomId - is MessageService.Message.ImageMessage -> message.roomId - }, + roomId = message.roomId, credentials = storeModule.credentialsStore().credentials()!!, - when (message) { - is MessageService.Message.TextMessage -> JsonString( - MatrixHttpClient.jsonWithDefaults.encodeToString( - ApiMessage.TextMessage( - ApiMessage.TextMessage.TextContent( - message.content.body, - message.content.type, - ), message.roomId, type = EventType.ROOM_MESSAGE.value - ) - ) - ) - - is MessageService.Message.ImageMessage -> TODO() - } + messageJson = message.contents, ) MessageEncrypter.EncryptedMessagePayload( From c97e402c1ac706533b9a29b5c03cebc07e6b114f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 20:35:39 +0100 Subject: [PATCH 07/13] adding support for sending encrypted images --- .../kotlin/app/dapk/st/graph/AppModule.kt | 2 +- .../st/matrix/device/internal/ApiMessage.kt | 26 +++ matrix/services/message/build.gradle | 2 + .../dapk/st/matrix/message/MessageService.kt | 31 ++- .../st/matrix/message/internal/ApiMessage.kt | 50 ++++- .../message/internal/DefaultMessageService.kt | 6 +- .../matrix/message/internal/MediaEncrypter.kt | 100 +++++++++ .../message/internal/SendMessageUseCase.kt | 211 ++++++++++++------ .../st/matrix/message/internal/SendRequest.kt | 11 +- test-harness/src/test/kotlin/SmokeTest.kt | 2 +- .../src/test/kotlin/test/TestMatrix.kt | 2 +- 11 files changed, 348 insertions(+), 95 deletions(-) create mode 100644 matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/ApiMessage.kt create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index b44cf68..d80f132 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -272,7 +272,7 @@ internal class MatrixModules( coroutineDispatchers = coroutineDispatchers, ) val imageContentReader = AndroidImageContentReader(contentResolver) - installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader) { serviceProvider -> + installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader, base64) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( roomId = message.roomId, diff --git a/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/ApiMessage.kt b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/ApiMessage.kt new file mode 100644 index 0000000..a2b32ad --- /dev/null +++ b/matrix/services/device/src/main/kotlin/app/dapk/st/matrix/device/internal/ApiMessage.kt @@ -0,0 +1,26 @@ +package app.dapk.st.matrix.device.internal + +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class ApiMessage { + + @Serializable + @SerialName("text_message") + data class TextMessage( + @SerialName("content") val content: TextContent, + @SerialName("room_id") val roomId: RoomId, + @SerialName("type") val type: String, + ) : ApiMessage() { + + @Serializable + data class TextContent( + @SerialName("body") val body: String, + @SerialName("msgtype") val type: String = MessageType.TEXT.value, + ) + } + +} \ No newline at end of file diff --git a/matrix/services/message/build.gradle b/matrix/services/message/build.gradle index 60041a2..9143c32 100644 --- a/matrix/services/message/build.gradle +++ b/matrix/services/message/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java-test-fixtures' } applyMatrixServiceModule(project) dependencies { + implementation project(":core") + kotlinFixtures(it) testFixturesImplementation(testFixtures(project(":core"))) testFixturesImplementation(testFixtures(project(":matrix:common"))) diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt index 2684194..1fb003a 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt @@ -1,10 +1,14 @@ package app.dapk.st.matrix.message +import app.dapk.st.core.Base64 import app.dapk.st.matrix.MatrixService import app.dapk.st.matrix.MatrixServiceInstaller import app.dapk.st.matrix.MatrixServiceProvider import app.dapk.st.matrix.ServiceDepFactory -import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.common.AlgorithmName +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.message.internal.DefaultMessageService import app.dapk.st.matrix.message.internal.ImageContentReader import kotlinx.coroutines.flow.Flow @@ -67,21 +71,6 @@ interface MessageService : MatrixService { @SerialName("uri") val uri: String, ) : Content() - @Serializable - data class ImageContent( - @SerialName("url") val url: MxUrl, - @SerialName("body") val filename: String, - @SerialName("info") val info: Info, - @SerialName("msgtype") val type: String = MessageType.IMAGE.value, - ) : Content() { - - @Serializable - data class Info( - @SerialName("h") val height: Int, - @SerialName("w") val width: Int, - @SerialName("size") val size: Long, - ) - } } } @@ -141,10 +130,18 @@ fun MatrixServiceInstaller.installMessageService( localEchoStore: LocalEchoStore, backgroundScheduler: BackgroundScheduler, imageContentReader: ImageContentReader, + base64: Base64, messageEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMessageEncrypter }, ) { this.install { (httpClient, _, installedServices) -> - SERVICE_KEY to DefaultMessageService(httpClient, localEchoStore, backgroundScheduler, messageEncrypter.create(installedServices), imageContentReader) + SERVICE_KEY to DefaultMessageService( + httpClient, + localEchoStore, + backgroundScheduler, + base64, + messageEncrypter.create(installedServices), + imageContentReader + ) } } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt index 7d4cbc7..9af2353 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt @@ -1,6 +1,7 @@ package app.dapk.st.matrix.message.internal import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.MxUrl import app.dapk.st.matrix.common.RoomId import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -20,7 +21,52 @@ sealed class ApiMessage { data class TextContent( @SerialName("body") val body: String, @SerialName("msgtype") val type: String = MessageType.TEXT.value, - ) + ) : ApiMessageContent } -} \ No newline at end of file + @Serializable + @SerialName("image_message") + data class ImageMessage( + @SerialName("content") val content: ImageContent, + @SerialName("room_id") val roomId: RoomId, + @SerialName("type") val type: String, + ) : ApiMessage() { + + @Serializable + data class ImageContent( + @SerialName("url") val url: MxUrl?, + @SerialName("body") val filename: String, + @SerialName("info") val info: Info, + @SerialName("msgtype") val type: String = MessageType.IMAGE.value, + @SerialName("file") val file: File? = null, + ) : ApiMessageContent { + + @Serializable + data class Info( + @SerialName("h") val height: Int, + @SerialName("w") val width: Int, + @SerialName("size") val size: Long, + ) + + @Serializable + data class File( + @SerialName("url") val url: MxUrl, + @SerialName("key") val key: EncryptionMeta, + @SerialName("iv") val iv: String, + @SerialName("hashes") val hashes: Map, + @SerialName("v") val v: String + ) { + @Serializable + data class EncryptionMeta( + @SerialName("alg") val algorithm: String, + @SerialName("ext") val ext: Boolean, + @SerialName("key_ops") val keyOperations: List, + @SerialName("kty") val kty: String, + @SerialName("k") val k: String + ) + } + } + } +} + +sealed interface ApiMessageContent diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt index 14d4f08..c51dc37 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt @@ -1,5 +1,6 @@ package app.dapk.st.matrix.message.internal +import app.dapk.st.core.Base64 import app.dapk.st.matrix.MatrixTaskRunner import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.http.MatrixHttpClient @@ -19,17 +20,18 @@ internal class DefaultMessageService( httpClient: MatrixHttpClient, private val localEchoStore: LocalEchoStore, private val backgroundScheduler: BackgroundScheduler, + base64: Base64, messageEncrypter: MessageEncrypter, imageContentReader: ImageContentReader, ) : MessageService, MatrixTaskRunner { - private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader) + private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader, base64) private val sendEventMessageUseCase = SendEventMessageUseCase(httpClient) override suspend fun canRun(task: MatrixTaskRunner.MatrixTask) = task.type == MATRIX_MESSAGE_TASK_TYPE || task.type == MATRIX_IMAGE_MESSAGE_TASK_TYPE override suspend fun run(task: MatrixTaskRunner.MatrixTask): MatrixTaskRunner.TaskResult { - val message = when(task.type) { + val message = when (task.type) { MATRIX_MESSAGE_TASK_TYPE -> Json.decodeFromString(MessageService.Message.TextMessage.serializer(), task.jsonPayload) MATRIX_IMAGE_MESSAGE_TASK_TYPE -> Json.decodeFromString(MessageService.Message.ImageMessage.serializer(), task.jsonPayload) else -> throw IllegalStateException("Unhandled task type: ${task.type}") diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt new file mode 100644 index 0000000..181ec2d --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt @@ -0,0 +1,100 @@ +package app.dapk.st.matrix.message.internal + +import app.dapk.st.core.Base64 +import java.io.File +import java.io.InputStream +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +private const val CRYPTO_BUFFER_SIZE = 32 * 1024 +private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding" +private const val SECRET_KEY_SPEC_ALGORITHM = "AES" +private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" + +class MediaEncrypter(private val base64: Base64) { + + fun encrypt(input: InputStream, name: String): Result { + val secureRandom = SecureRandom() + val initVectorBytes = ByteArray(16) { 0.toByte() } + + val ivRandomPart = ByteArray(8) + secureRandom.nextBytes(ivRandomPart) + + System.arraycopy(ivRandomPart, 0, initVectorBytes, 0, ivRandomPart.size) + + val key = ByteArray(32) + secureRandom.nextBytes(key) + + val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + + val outputFile = File.createTempFile("_encrypt-${name.hashCode()}", ".png") + + val outputStream = outputFile.outputStream() + outputStream.use { s -> + val encryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) + val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) + val ivParameterSpec = IvParameterSpec(initVectorBytes) + encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec) + + val data = ByteArray(CRYPTO_BUFFER_SIZE) + var read: Int + var encodedBytes: ByteArray + + input.use { inputStream -> + read = inputStream.read(data) + var totalRead = read + while (read != -1) { + encodedBytes = encryptCipher.update(data, 0, read) + messageDigest.update(encodedBytes, 0, encodedBytes.size) + s.write(encodedBytes) + read = inputStream.read(data) + totalRead += read + } + } + + encodedBytes = encryptCipher.doFinal() + messageDigest.update(encodedBytes, 0, encodedBytes.size) + s.write(encodedBytes) + } + + return Result( + contents = outputFile.readBytes(), + algorithm = "A256CTR", + ext = true, + keyOperations = listOf("encrypt", "decrypt"), + kty = "oct", + k = base64ToBase64Url(base64.encode(key)), + iv = base64.encode(initVectorBytes).replace("\n", "").replace("=", ""), + hashes = mapOf("sha256" to base64ToUnpaddedBase64(base64.encode(messageDigest.digest()))), + v = "v2" + ) + } + + data class Result( + val contents: ByteArray, + val algorithm: String, + val ext: Boolean, + val keyOperations: List, + val kty: String, + val k: String, + val iv: String, + val hashes: Map, + val v: String, + ) + +} + +private fun base64ToBase64Url(base64: String): String { + return base64.replace("\n".toRegex(), "") + .replace("\\+".toRegex(), "-") + .replace('/', '_') + .replace("=", "") +} + +private fun base64ToUnpaddedBase64(base64: String): String { + return base64.replace("\n".toRegex(), "") + .replace("=", "") +} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index b0a91a2..d3799e1 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -1,86 +1,167 @@ package app.dapk.st.matrix.message.internal -import app.dapk.st.matrix.common.EventId -import app.dapk.st.matrix.common.EventType -import app.dapk.st.matrix.common.JsonString +import app.dapk.st.core.Base64 +import app.dapk.st.matrix.common.* import app.dapk.st.matrix.http.MatrixHttpClient +import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest +import app.dapk.st.matrix.message.ApiSendResponse import app.dapk.st.matrix.message.MessageEncrypter -import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.message.MessageService.Message +import java.io.ByteArrayInputStream internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, private val messageEncrypter: MessageEncrypter, private val imageContentReader: ImageContentReader, + private val base64: Base64, ) { - suspend fun sendMessage(message: MessageService.Message): EventId { - return when (message) { - is MessageService.Message.TextMessage -> { - val request = when (message.sendEncrypted) { - true -> { - val content = JsonString( - MatrixHttpClient.jsonWithDefaults.encodeToString( - ApiMessage.TextMessage.serializer(), - ApiMessage.TextMessage( - content = ApiMessage.TextMessage.TextContent( - message.content.body, - message.content.type, - ), - roomId = message.roomId, - type = EventType.ROOM_MESSAGE.value - ) - ) - ) + private val mapper = ApiMessageMapper() - sendRequest( - roomId = message.roomId, - eventType = EventType.ENCRYPTED, - txId = message.localId, - content = messageEncrypter.encrypt(MessageEncrypter.ClearMessagePayload(message.roomId, content)), - ) - } - - false -> { - sendRequest( - roomId = message.roomId, - eventType = EventType.ROOM_MESSAGE, - txId = message.localId, - content = message.content, - ) - } + suspend fun sendMessage(message: Message): EventId { + return with(mapper) { + when (message) { + is Message.TextMessage -> { + val request = textMessageRequest(message) + httpClient.execute(request).eventId } - httpClient.execute(request).eventId + + is Message.ImageMessage -> { + val request = imageMessageRequest(message) + httpClient.execute(request).eventId + } + } + } + } + + private suspend fun ApiMessageMapper.textMessageRequest(message: Message.TextMessage): HttpRequest { + val contents = message.toContents() + return when (message.sendEncrypted) { + true -> sendRequest( + roomId = message.roomId, + eventType = EventType.ENCRYPTED, + txId = message.localId, + content = messageEncrypter.encrypt( + MessageEncrypter.ClearMessagePayload( + message.roomId, + contents.toMessageJson(message.roomId) + ) + ), + ) + + false -> sendRequest( + roomId = message.roomId, + eventType = EventType.ROOM_MESSAGE, + txId = message.localId, + content = contents, + ) + } + } + + private suspend fun ApiMessageMapper.imageMessageRequest(message: Message.ImageMessage): HttpRequest { + val imageContent = imageContentReader.read(message.content.uri) + + return when (message.sendEncrypted) { + true -> { + val result = MediaEncrypter(base64).encrypt( + ByteArrayInputStream(imageContent.content), + imageContent.fileName, + ) + + val uri = httpClient.execute(uploadRequest(result.contents, imageContent.fileName, "application/octet-stream")).contentUri + + val content = ApiMessage.ImageMessage.ImageContent( + url = null, + filename = imageContent.fileName, + file = ApiMessage.ImageMessage.ImageContent.File( + url = uri, + key = ApiMessage.ImageMessage.ImageContent.File.EncryptionMeta( + algorithm = result.algorithm, + ext = result.ext, + keyOperations = result.keyOperations, + kty = result.kty, + k = result.k, + ), + iv = result.iv, + hashes = result.hashes, + v = result.v, + ), + info = ApiMessage.ImageMessage.ImageContent.Info( + height = imageContent.height, + width = imageContent.width, + size = imageContent.size + ) + ) + + + val json = JsonString( + MatrixHttpClient.jsonWithDefaults.encodeToString( + ApiMessage.ImageMessage.serializer(), + ApiMessage.ImageMessage( + content = content, + roomId = message.roomId, + type = EventType.ROOM_MESSAGE.value, + ) + ) + ) + + sendRequest( + roomId = message.roomId, + eventType = EventType.ENCRYPTED, + txId = message.localId, + content = messageEncrypter.encrypt(MessageEncrypter.ClearMessagePayload(message.roomId, json)), + ) } - is MessageService.Message.ImageMessage -> { - val request = when (message.sendEncrypted) { - true -> { - throw IllegalStateException() - } - - false -> { - val imageContent = imageContentReader.read(message.content.uri) - val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri - sendRequest( - roomId = message.roomId, - eventType = EventType.ROOM_MESSAGE, - txId = message.localId, - content = MessageService.Message.Content.ImageContent( - url = uri, - filename = imageContent.fileName, - MessageService.Message.Content.ImageContent.Info( - height = imageContent.height, - width = imageContent.width, - size = imageContent.size - ) - ), + false -> { + val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri + sendRequest( + roomId = message.roomId, + eventType = EventType.ROOM_MESSAGE, + txId = message.localId, + content = ApiMessage.ImageMessage.ImageContent( + url = uri, + filename = imageContent.fileName, + ApiMessage.ImageMessage.ImageContent.Info( + height = imageContent.height, + width = imageContent.width, + size = imageContent.size ) - } - } - - httpClient.execute(request).eventId + ), + ) } } } } + + +class ApiMessageMapper { + + fun Message.TextMessage.toContents() = ApiMessage.TextMessage.TextContent( + this.content.body, + this.content.type, + ) + + fun ApiMessage.TextMessage.TextContent.toMessageJson(roomId: RoomId) = JsonString( + MatrixHttpClient.jsonWithDefaults.encodeToString( + ApiMessage.TextMessage.serializer(), + ApiMessage.TextMessage( + content = this, + roomId = roomId, + type = EventType.ROOM_MESSAGE.value + ) + ) + ) + + fun Message.ImageMessage.toContents(uri: MxUrl, image: ImageContentReader.ImageContent) = ApiMessage.ImageMessage.ImageContent( + url = uri, + filename = image.fileName, + ApiMessage.ImageMessage.ImageContent.Info( + height = image.height, + width = image.width, + size = image.size + ) + ) + +} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt index 5cf1d64..d3cc3d3 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt @@ -9,18 +9,18 @@ import app.dapk.st.matrix.message.ApiSendResponse import app.dapk.st.matrix.message.ApiUploadResponse import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.EventMessage -import app.dapk.st.matrix.message.MessageService.Message +import app.dapk.st.matrix.message.internal.ApiMessage.ImageMessage +import app.dapk.st.matrix.message.internal.ApiMessage.TextMessage import io.ktor.content.* import io.ktor.http.* import java.util.* -internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: Message.Content) = httpRequest( +internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: ApiMessageContent) = httpRequest( path = "_matrix/client/r0/rooms/${roomId.value}/send/${eventType.value}/${txId}", method = MatrixHttpClient.Method.PUT, body = when (content) { - is Message.Content.TextContent -> jsonBody(Message.Content.TextContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) - is Message.Content.ImageContent -> jsonBody(Message.Content.ImageContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) - is Message.Content.ApiImageContent -> throw IllegalArgumentException() + is TextMessage.TextContent -> jsonBody(TextMessage.TextContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) + is ImageMessage.ImageContent -> jsonBody(ImageMessage.ImageContent.serializer(), content, MatrixHttpClient.jsonWithDefaults) } ) @@ -45,5 +45,4 @@ internal fun uploadRequest(body: ByteArray, filename: String, contentType: Strin body = ByteArrayContent(body, ContentType.parse(contentType)), ) - fun txId() = "local.${UUID.randomUUID()}" \ No newline at end of file diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index d692c96..b610072 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -75,7 +75,7 @@ class SmokeTest { @Order(6) fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> val testImage = loadResourceFile("test-image.png") - alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) + alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = true) bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) } diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 4e89e7b..3da7355 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -121,7 +121,7 @@ class TestMatrix( coroutineDispatchers = coroutineDispatchers, ) - installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader()) { serviceProvider -> + installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader(), base64) { serviceProvider -> MessageEncrypter { message -> val result = serviceProvider.cryptoService().encrypt( roomId = message.roomId, From 065eeef5a0703f27d28c238acdd09f993ce705ee Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 21:24:45 +0100 Subject: [PATCH 08/13] moving the media encrypting to the crypto module and exposing as part of the service --- .../kotlin/app/dapk/st/graph/AppModule.kt | 60 ++++++++++++------- .../dapk/st/matrix/crypto/CryptoService.kt | 27 +++++++-- .../crypto/internal/DefaultCryptoService.kt | 6 ++ .../matrix/crypto}/internal/MediaEncrypter.kt | 30 +++------- .../dapk/st/matrix/message/MediaEncrypter.kt | 30 ++++++++++ .../st/matrix/message/MessageEncrypter.kt | 2 +- .../dapk/st/matrix/message/MessageService.kt | 4 +- .../message/internal/DefaultMessageService.kt | 10 +--- .../message/internal/ImageContentReader.kt | 6 ++ .../message/internal/SendMessageUseCase.kt | 16 ++--- test-harness/src/test/kotlin/SmokeTest.kt | 2 +- .../src/test/kotlin/test/TestMatrix.kt | 60 ++++++++++++------- 12 files changed, 168 insertions(+), 85 deletions(-) rename matrix/services/{message/src/main/kotlin/app/dapk/st/matrix/message => crypto/src/main/kotlin/app/dapk/st/matrix/crypto}/internal/MediaEncrypter.kt (81%) create mode 100644 matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index d80f132..d16eb64 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -32,11 +32,8 @@ import app.dapk.st.matrix.crypto.installCryptoService import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.installEncryptionService import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory -import app.dapk.st.matrix.message.MessageEncrypter -import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.message.installMessageService +import app.dapk.st.matrix.message.* import app.dapk.st.matrix.message.internal.ImageContentReader -import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.push.pushService import app.dapk.st.matrix.room.* @@ -272,23 +269,46 @@ internal class MatrixModules( coroutineDispatchers = coroutineDispatchers, ) val imageContentReader = AndroidImageContentReader(contentResolver) - installMessageService(store.localEchoStore, BackgroundWorkAdapter(workModule.workScheduler()), imageContentReader, base64) { serviceProvider -> - MessageEncrypter { message -> - val result = serviceProvider.cryptoService().encrypt( - roomId = message.roomId, - credentials = credentialsStore.credentials()!!, - messageJson = message.contents, - ) + installMessageService( + store.localEchoStore, + BackgroundWorkAdapter(workModule.workScheduler()), + imageContentReader, + messageEncrypter = { + val cryptoService = it.cryptoService() + MessageEncrypter { message -> + val result = cryptoService.encrypt( + roomId = message.roomId, + credentials = credentialsStore.credentials()!!, + messageJson = message.contents, + ) - MessageEncrypter.EncryptedMessagePayload( - result.algorithmName, - result.senderKey, - result.cipherText, - result.sessionId, - result.deviceId, - ) - } - } + MessageEncrypter.EncryptedMessagePayload( + result.algorithmName, + result.senderKey, + result.cipherText, + result.sessionId, + result.deviceId, + ) + } + }, + mediaEncrypter = { + val cryptoService = it.cryptoService() + MediaEncrypter { input -> + val result = cryptoService.encrypt(input) + MediaEncrypter.Result( + uri = result.uri, + algorithm = result.algorithm, + ext = result.ext, + keyOperations = result.keyOperations, + kty = result.kty, + k = result.k, + iv = result.iv, + hashes = result.hashes, + v = result.v, + ) + } + }, + ) val overviewStore = store.overviewStore() installRoomService( diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt index adb85c9..405a718 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt @@ -11,10 +11,12 @@ import app.dapk.st.matrix.crypto.internal.* import app.dapk.st.matrix.device.deviceService import kotlinx.coroutines.flow.Flow import java.io.InputStream +import java.net.URI private val SERVICE_KEY = CryptoService::class interface CryptoService : MatrixService { + suspend fun encrypt(input: InputStream): Crypto.MediaEncryptionResult suspend fun encrypt(roomId: RoomId, credentials: DeviceCredentials, messageJson: JsonString): Crypto.EncryptionResult suspend fun decrypt(encryptedPayload: EncryptedMessageContent): DecryptionResult suspend fun importRoomKeys(keys: List) @@ -38,6 +40,18 @@ interface Crypto { val deviceId: DeviceId ) + data class MediaEncryptionResult( + val uri: URI, + val algorithm: String, + val ext: Boolean, + val keyOperations: List, + val kty: String, + val k: String, + val iv: String, + val hashes: Map, + val v: String, + ) + } @@ -151,7 +165,9 @@ fun MatrixServiceInstaller.installCryptoService( ) val verificationHandler = VerificationHandler(deviceService, credentialsStore, logger, JsonCanonicalizer(), olm) val roomKeyImporter = RoomKeyImporter(base64, coroutineDispatchers) - SERVICE_KEY to DefaultCryptoService(olmCrypto, verificationHandler, roomKeyImporter, logger) + val mediaEncrypter = MediaEncrypter(base64) + + SERVICE_KEY to DefaultCryptoService(olmCrypto, verificationHandler, roomKeyImporter, mediaEncrypter, logger) } } @@ -166,12 +182,13 @@ sealed interface ImportResult { data class Error(val cause: Type) : ImportResult { sealed interface Type { - data class Unknown(val cause: Throwable): Type - object NoKeysFound: Type - object UnexpectedDecryptionOutput: Type - object UnableToOpenFile: Type + data class Unknown(val cause: Throwable) : Type + object NoKeysFound : Type + object UnexpectedDecryptionOutput : Type + object UnableToOpenFile : Type } } + data class Update(val importedKeysCount: Long) : ImportResult } diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt index f5a64fe..c23cebc 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/DefaultCryptoService.kt @@ -13,8 +13,14 @@ internal class DefaultCryptoService( private val olmCrypto: OlmCrypto, private val verificationHandler: VerificationHandler, private val roomKeyImporter: RoomKeyImporter, + private val mediaEncrypter: MediaEncrypter, private val logger: MatrixLogger, ) : CryptoService { + + override suspend fun encrypt(input: InputStream): Crypto.MediaEncryptionResult { + return mediaEncrypter.encrypt(input) + } + override suspend fun encrypt(roomId: RoomId, credentials: DeviceCredentials, messageJson: JsonString): Crypto.EncryptionResult { return olmCrypto.encryptMessage(roomId, credentials, messageJson) } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt similarity index 81% rename from matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt rename to matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt index 181ec2d..17ca412 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/MediaEncrypter.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt @@ -1,10 +1,12 @@ -package app.dapk.st.matrix.message.internal +package app.dapk.st.matrix.crypto.internal import app.dapk.st.core.Base64 +import app.dapk.st.matrix.crypto.Crypto import java.io.File import java.io.InputStream import java.security.MessageDigest import java.security.SecureRandom +import java.util.* import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -16,7 +18,7 @@ private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" class MediaEncrypter(private val base64: Base64) { - fun encrypt(input: InputStream, name: String): Result { + fun encrypt(input: InputStream): Crypto.MediaEncryptionResult { val secureRandom = SecureRandom() val initVectorBytes = ByteArray(16) { 0.toByte() } @@ -30,10 +32,9 @@ class MediaEncrypter(private val base64: Base64) { val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) - val outputFile = File.createTempFile("_encrypt-${name.hashCode()}", ".png") + val outputFile = File.createTempFile("_encrypt-${UUID.randomUUID()}", ".png") - val outputStream = outputFile.outputStream() - outputStream.use { s -> + outputFile.outputStream().use { s -> val encryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) val ivParameterSpec = IvParameterSpec(initVectorBytes) @@ -60,8 +61,8 @@ class MediaEncrypter(private val base64: Base64) { s.write(encodedBytes) } - return Result( - contents = outputFile.readBytes(), + return Crypto.MediaEncryptionResult( + uri = outputFile.toURI(), algorithm = "A256CTR", ext = true, keyOperations = listOf("encrypt", "decrypt"), @@ -72,19 +73,6 @@ class MediaEncrypter(private val base64: Base64) { v = "v2" ) } - - data class Result( - val contents: ByteArray, - val algorithm: String, - val ext: Boolean, - val keyOperations: List, - val kty: String, - val k: String, - val iv: String, - val hashes: Map, - val v: String, - ) - } private fun base64ToBase64Url(base64: String): String { @@ -97,4 +85,4 @@ private fun base64ToBase64Url(base64: String): String { private fun base64ToUnpaddedBase64(base64: String): String { return base64.replace("\n".toRegex(), "") .replace("=", "") -} +} \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt new file mode 100644 index 0000000..2f6711a --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt @@ -0,0 +1,30 @@ +package app.dapk.st.matrix.message + +import java.io.File +import java.io.InputStream +import java.net.URI + +fun interface MediaEncrypter { + + suspend fun encrypt(input: InputStream): Result + + data class Result( + val uri: URI, + val algorithm: String, + val ext: Boolean, + val keyOperations: List, + val kty: String, + val k: String, + val iv: String, + val hashes: Map, + val v: String, + ) { + + fun openStream() = File(uri).outputStream() + } + +} + +internal object MissingMediaEncrypter : MediaEncrypter { + override suspend fun encrypt(input: InputStream) = throw IllegalStateException("No encrypter instance set") +} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt index 13b67c2..d2387e8 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageEncrypter.kt @@ -25,4 +25,4 @@ fun interface MessageEncrypter { internal object MissingMessageEncrypter : MessageEncrypter { override suspend fun encrypt(message: MessageEncrypter.ClearMessagePayload) = throw IllegalStateException("No encrypter instance set") -} \ No newline at end of file +} diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt index 1fb003a..23fdb98 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt @@ -130,16 +130,16 @@ fun MatrixServiceInstaller.installMessageService( localEchoStore: LocalEchoStore, backgroundScheduler: BackgroundScheduler, imageContentReader: ImageContentReader, - base64: Base64, messageEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMessageEncrypter }, + mediaEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMediaEncrypter }, ) { this.install { (httpClient, _, installedServices) -> SERVICE_KEY to DefaultMessageService( httpClient, localEchoStore, backgroundScheduler, - base64, messageEncrypter.create(installedServices), + mediaEncrypter.create(installedServices), imageContentReader ) } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt index c51dc37..c6b7374 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/DefaultMessageService.kt @@ -1,13 +1,9 @@ package app.dapk.st.matrix.message.internal -import app.dapk.st.core.Base64 import app.dapk.st.matrix.MatrixTaskRunner import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.http.MatrixHttpClient -import app.dapk.st.matrix.message.BackgroundScheduler -import app.dapk.st.matrix.message.LocalEchoStore -import app.dapk.st.matrix.message.MessageEncrypter -import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.message.* import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json import java.net.SocketException @@ -20,12 +16,12 @@ internal class DefaultMessageService( httpClient: MatrixHttpClient, private val localEchoStore: LocalEchoStore, private val backgroundScheduler: BackgroundScheduler, - base64: Base64, messageEncrypter: MessageEncrypter, + mediaEncrypter: MediaEncrypter, imageContentReader: ImageContentReader, ) : MessageService, MatrixTaskRunner { - private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader, base64) + private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, mediaEncrypter, imageContentReader) private val sendEventMessageUseCase = SendEventMessageUseCase(httpClient) override suspend fun canRun(task: MatrixTaskRunner.MatrixTask) = task.type == MATRIX_MESSAGE_TASK_TYPE || task.type == MATRIX_IMAGE_MESSAGE_TASK_TYPE diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt index 8395092..d4ff899 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt @@ -1,5 +1,7 @@ package app.dapk.st.matrix.message.internal +import java.io.InputStream + interface ImageContentReader { fun read(uri: String): ImageContent @@ -32,5 +34,9 @@ interface ImageContentReader { result = 31 * result + content.contentHashCode() return result } + + fun stream(): InputStream { + TODO("Not yet implemented") + } } } \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index d3799e1..16d8678 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -1,19 +1,19 @@ package app.dapk.st.matrix.message.internal -import app.dapk.st.core.Base64 import app.dapk.st.matrix.common.* import app.dapk.st.matrix.http.MatrixHttpClient import app.dapk.st.matrix.http.MatrixHttpClient.HttpRequest import app.dapk.st.matrix.message.ApiSendResponse +import app.dapk.st.matrix.message.MediaEncrypter import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.Message -import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, private val messageEncrypter: MessageEncrypter, + private val mediaEncrypter: MediaEncrypter, private val imageContentReader: ImageContentReader, - private val base64: Base64, ) { private val mapper = ApiMessageMapper() @@ -63,12 +63,12 @@ internal class SendMessageUseCase( return when (message.sendEncrypted) { true -> { - val result = MediaEncrypter(base64).encrypt( - ByteArrayInputStream(imageContent.content), - imageContent.fileName, - ) + val result = mediaEncrypter.encrypt(imageContent.stream()) + val bytes = ByteArrayOutputStream().also { + it.writeTo(result.openStream()) + }.toByteArray() - val uri = httpClient.execute(uploadRequest(result.contents, imageContent.fileName, "application/octet-stream")).contentUri + val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, "application/octet-stream")).contentUri val content = ApiMessage.ImageMessage.ImageContent( url = null, diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index b610072..d692c96 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -75,7 +75,7 @@ class SmokeTest { @Order(6) fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> val testImage = loadResourceFile("test-image.png") - alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = true) + alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) } diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 3da7355..797547c 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -17,11 +17,8 @@ import app.dapk.st.matrix.crypto.installCryptoService import app.dapk.st.matrix.device.deviceService import app.dapk.st.matrix.device.installEncryptionService import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory -import app.dapk.st.matrix.message.MessageEncrypter -import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.message.installMessageService +import app.dapk.st.matrix.message.* import app.dapk.st.matrix.message.internal.ImageContentReader -import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.room.RoomMessenger import app.dapk.st.matrix.room.installRoomService @@ -121,23 +118,46 @@ class TestMatrix( coroutineDispatchers = coroutineDispatchers, ) - installMessageService(storeModule.localEchoStore, InstantScheduler(it), JavaImageContentReader(), base64) { serviceProvider -> - MessageEncrypter { message -> - val result = serviceProvider.cryptoService().encrypt( - roomId = message.roomId, - credentials = storeModule.credentialsStore().credentials()!!, - messageJson = message.contents, - ) + installMessageService( + localEchoStore = storeModule.localEchoStore, + backgroundScheduler = InstantScheduler(it), + imageContentReader = JavaImageContentReader(), + messageEncrypter = { + val cryptoService = it.cryptoService() + MessageEncrypter { message -> + val result = cryptoService.encrypt( + roomId = message.roomId, + credentials = storeModule.credentialsStore().credentials()!!, + messageJson = message.contents, + ) - MessageEncrypter.EncryptedMessagePayload( - result.algorithmName, - result.senderKey, - result.cipherText, - result.sessionId, - result.deviceId, - ) - } - } + MessageEncrypter.EncryptedMessagePayload( + result.algorithmName, + result.senderKey, + result.cipherText, + result.sessionId, + result.deviceId, + ) + } + }, + mediaEncrypter = { + val cryptoService = it.cryptoService() + MediaEncrypter { input -> + val result = cryptoService.encrypt(input) + MediaEncrypter.Result( + uri = result.uri, + algorithm = result.algorithm, + ext = result.ext, + keyOperations = result.keyOperations, + kty = result.kty, + k = result.k, + iv = result.iv, + hashes = result.hashes, + v = result.v, + ) + } + }, + ) installRoomService( storeModule.memberStore(), From 854a4c17cef819b61db79cd8c2434acad4cf18b1 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 22:11:35 +0100 Subject: [PATCH 09/13] making the image fectching factory part of the dagger graph --- .../kotlin/app/dapk/st/graph/AppModule.kt | 14 +++++--- .../dapk/st/messenger/DecryptingFetcher.kt | 18 +++++++---- .../app/dapk/st/messenger/MediaDecrypter.kt | 11 +++---- .../dapk/st/messenger/MessengerActivity.kt | 20 ++++++++---- .../app/dapk/st/messenger/MessengerModule.kt | 6 ++++ .../app/dapk/st/messenger/MessengerScreen.kt | 8 ++--- .../message/internal/ImageContentReader.kt | 32 +++---------------- .../message/internal/SendMessageUseCase.kt | 14 +++++--- 8 files changed, 64 insertions(+), 59 deletions(-) diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index d16eb64..677c984 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -59,6 +59,7 @@ import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.WorkModule import com.squareup.sqldelight.android.AndroidSqliteDriver import kotlinx.coroutines.Dispatchers +import java.net.URI import java.time.Clock internal class AppModule(context: Application, logger: MatrixLogger) { @@ -75,6 +76,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { private val database = DapkDb(driver) private val clock = Clock.systemUTC() val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO) + val base64 = AndroidBase64() val storeModule = unsafeLazy { StoreModule( @@ -89,7 +91,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { private val workModule = WorkModule(context) private val imageLoaderModule = ImageLoaderModule(context) - private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver, buildMeta) + private val matrixModules = MatrixModules(storeModule, trackingModule, workModule, logger, coroutineDispatchers, context.contentResolver, base64, buildMeta) val domainModules = DomainModules(matrixModules, trackingModule.errorTracker, workModule, storeModule, context, coroutineDispatchers) val coreAndroidModule = CoreAndroidModule( @@ -134,6 +136,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { deviceMeta, coroutineDispatchers, clock, + base64, ) } @@ -149,6 +152,7 @@ internal class FeatureModules internal constructor( deviceMeta: DeviceMeta, coroutineDispatchers: CoroutineDispatchers, clock: Clock, + base64: Base64, ) { val directoryModule by unsafeLazy { @@ -176,7 +180,9 @@ internal class FeatureModules internal constructor( matrixModules.room, storeModule.value.credentialsStore(), storeModule.value.roomStore(), - clock + clock, + context, + base64, ) } val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile, matrixModules.sync, buildMeta) } @@ -227,6 +233,7 @@ internal class MatrixModules( private val logger: MatrixLogger, private val coroutineDispatchers: CoroutineDispatchers, private val contentResolver: ContentResolver, + private val base64: Base64, private val buildMeta: BuildMeta, ) { @@ -244,7 +251,6 @@ internal class MatrixModules( installAuthService(credentialsStore) installEncryptionService(store.knownDevicesStore()) - val base64 = AndroidBase64() val olmAccountStore = OlmPersistenceWrapper(store.olmStore(), base64) val singletonFlows = SingletonFlows(coroutineDispatchers) val olm = OlmWrapper( @@ -491,7 +497,7 @@ internal class AndroidImageContentReader(private val contentResolver: ContentRes size = output.size.toLong(), mimeType = options.outMimeType, fileName = androidUri.lastPathSegment ?: "file", - content = output + uri = URI.create(uri) ) } ?: throw IllegalArgumentException("Could not process $uri") } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt index 4f27103..71cfb4c 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt @@ -1,6 +1,7 @@ package app.dapk.st.messenger import android.content.Context +import app.dapk.st.core.Base64 import app.dapk.st.matrix.sync.RoomEvent import coil.ImageLoader import coil.decode.DataSource @@ -14,17 +15,22 @@ import okhttp3.Request import okhttp3.Response import okio.Buffer -class DecryptingFetcherFactory(private val context: Context) : Fetcher.Factory { +class DecryptingFetcherFactory(private val context: Context, base64: Base64) : Fetcher.Factory { + + private val mediaDecrypter = MediaDecrypter(base64) + override fun create(data: RoomEvent.Image, options: Options, imageLoader: ImageLoader): Fetcher { - return DecryptingFetcher(data, context) + return DecryptingFetcher(data, context, mediaDecrypter) } } private val http = OkHttpClient() -class DecryptingFetcher(private val data: RoomEvent.Image, private val context: Context) : Fetcher { - - private val mediaDecrypter = MediaDecrypter() +class DecryptingFetcher( + private val data: RoomEvent.Image, + private val context: Context, + private val mediaDecrypter: MediaDecrypter, +) : Fetcher { override suspend fun fetch(): FetchResult { val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute() @@ -36,7 +42,7 @@ class DecryptingFetcher(private val data: RoomEvent.Image, private val context: } private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { - return response.body?.byteStream()?.let { mediaDecrypter.decrypt(it, keys) } ?: Buffer() + return response.body?.byteStream()?.let { mediaDecrypter.decrypt(it, keys.k, keys.iv) } ?: Buffer() } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt index 9e18c76..230c542 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt @@ -1,7 +1,6 @@ package app.dapk.st.messenger -import android.util.Base64 -import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.core.Base64 import okio.Buffer import java.io.InputStream import java.security.MessageDigest @@ -14,11 +13,11 @@ private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding" private const val SECRET_KEY_SPEC_ALGORITHM = "AES" private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" -class MediaDecrypter { +class MediaDecrypter(private val base64: Base64) { - fun decrypt(input: InputStream, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { - val key = Base64.decode(keys.k.replace('-', '+').replace('_', '/'), Base64.DEFAULT) - val initVectorBytes = Base64.decode(keys.iv, Base64.DEFAULT) + fun decrypt(input: InputStream, k: String, iv: String): Buffer { + val key = base64.decode(k.replace('-', '+').replace('_', '/')) + val initVectorBytes = base64.decode(iv) val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt index 3697b26..01cb2e6 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt @@ -5,19 +5,25 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Parcelable -import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier -import app.dapk.st.core.* -import app.dapk.st.design.components.SmallTalkTheme +import app.dapk.st.core.DapkActivity +import app.dapk.st.core.extensions.unsafeLazy +import app.dapk.st.core.module +import app.dapk.st.core.viewModel import app.dapk.st.matrix.common.RoomId import app.dapk.st.navigator.MessageAttachment import kotlinx.parcelize.Parcelize +val LocalDecyptingFetcherFactory = staticCompositionLocalOf { throw IllegalAccessError() } + class MessengerActivity : DapkActivity() { - private val viewModel by viewModel { module().messengerViewModel() } + private val module by unsafeLazy { module() } + private val viewModel by viewModel { module.messengerViewModel() } companion object { @@ -44,11 +50,13 @@ class MessengerActivity : DapkActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val payload = readPayload() - log(AppLogTag.ERROR_NON_FATAL, payload) + val factory = module.decryptingFetcherFactory() setContent { - Surface(Modifier.fillMaxSize()) { + Surface(Modifier.fillMaxSize()) { + CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) { MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator) } + } } } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt index 622019c..f34013f 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt @@ -1,5 +1,7 @@ package app.dapk.st.messenger +import android.content.Context +import app.dapk.st.core.Base64 import app.dapk.st.core.ProvidableModule import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.message.MessageService @@ -15,6 +17,8 @@ class MessengerModule( private val credentialsStore: CredentialsStore, private val roomStore: RoomStore, private val clock: Clock, + private val context: Context, + private val base64: Base64, ) : ProvidableModule { internal fun messengerViewModel(): MessengerViewModel { @@ -25,4 +29,6 @@ class MessengerModule( val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(LocalEchoMapper(MetaMapper())) return TimelineUseCaseImpl(syncService, messageService, roomService, mergeWithLocalEchosUseCase) } + + internal fun decryptingFetcherFactory() = DecryptingFetcherFactory(context, base64) } \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 93e0a62..1730b34 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -228,7 +228,6 @@ private fun LazyItemScope.AlignedBubble( @Composable private fun MessageImage(content: BubbleContent) { val context = LocalContext.current - val fetcherFactory = remember { DecryptingFetcherFactory(context) } Box(modifier = Modifier.padding(start = 6.dp)) { Box( @@ -258,7 +257,7 @@ private fun MessageImage(content: BubbleContent) { modifier = Modifier.size(content.message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) - .fetcherFactory(fetcherFactory) + .fetcherFactory(LocalDecyptingFetcherFactory.current) .data(content.message) .build() ), @@ -407,7 +406,6 @@ private fun ReplyBubbleContent(content: BubbleContent) { .defaultMinSize(minWidth = 50.dp) ) { val context = LocalContext.current - val fetcherFactory = remember { DecryptingFetcherFactory(context) } Column( Modifier .background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground) @@ -438,7 +436,7 @@ private fun ReplyBubbleContent(content: BubbleContent) { modifier = Modifier.size(replyingTo.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) - .fetcherFactory(fetcherFactory) + .fetcherFactory(LocalDecyptingFetcherFactory.current) .data(replyingTo) .build() ), @@ -481,7 +479,7 @@ private fun ReplyBubbleContent(content: BubbleContent) { painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .data(content.message) - .fetcherFactory(fetcherFactory) + .fetcherFactory(LocalDecyptingFetcherFactory.current) .build() ), contentDescription = null, diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt index d4ff899..dd8ecfa 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt @@ -1,6 +1,7 @@ package app.dapk.st.matrix.message.internal -import java.io.InputStream +import java.io.File +import java.net.URI interface ImageContentReader { fun read(uri: String): ImageContent @@ -11,32 +12,9 @@ interface ImageContentReader { val size: Long, val fileName: String, val mimeType: String, - val content: ByteArray + val uri: URI ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ImageContent - - if (height != other.height) return false - if (width != other.width) return false - if (size != other.size) return false - if (!content.contentEquals(other.content)) return false - - return true - } - - override fun hashCode(): Int { - var result = height - result = 31 * result + width - result = 31 * result + size.hashCode() - result = 31 * result + content.contentHashCode() - return result - } - - fun stream(): InputStream { - TODO("Not yet implemented") - } + fun inputStream() = File(uri).inputStream() + fun outputStream() = File(uri).outputStream() } } \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index 16d8678..4dbfa5e 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -8,6 +8,7 @@ import app.dapk.st.matrix.message.MediaEncrypter import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.Message import java.io.ByteArrayOutputStream +import java.io.File internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, @@ -63,10 +64,9 @@ internal class SendMessageUseCase( return when (message.sendEncrypted) { true -> { - val result = mediaEncrypter.encrypt(imageContent.stream()) - val bytes = ByteArrayOutputStream().also { - it.writeTo(result.openStream()) - }.toByteArray() + val result = mediaEncrypter.encrypt(imageContent.inputStream()) + val bytes = File(result.uri).readBytes() + println("!!! ${bytes.size}") val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, "application/octet-stream")).contentUri @@ -114,7 +114,11 @@ internal class SendMessageUseCase( } false -> { - val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri + val bytes = ByteArrayOutputStream().also { + it.writeTo(imageContent.outputStream()) + }.toByteArray() + + val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, imageContent.mimeType)).contentUri sendRequest( roomId = message.roomId, eventType = EventType.ROOM_MESSAGE, From e70ed9f6e5b113dfdcc944a8b7f8461a8fc7024a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 22:42:04 +0100 Subject: [PATCH 10/13] improving image decrypting pipeline to use one less copy and adding smoke test to sending encrypted images --- features/messenger/build.gradle | 1 + .../dapk/st/messenger/DecryptingFetcher.kt | 7 ++++- .../dapk/st/matrix/crypto}/MediaDecrypter.kt | 28 +++++++++++-------- test-harness/src/test/kotlin/SmokeTest.kt | 23 ++++++++++----- test-harness/src/test/kotlin/test/Test.kt | 17 +++++++++-- .../src/test/kotlin/test/TestMatrix.kt | 4 +-- 6 files changed, 56 insertions(+), 24 deletions(-) rename {features/messenger/src/main/kotlin/app/dapk/st/messenger => matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto}/MediaDecrypter.kt (69%) diff --git a/features/messenger/build.gradle b/features/messenger/build.gradle index 4603773..e5686f7 100644 --- a/features/messenger/build.gradle +++ b/features/messenger/build.gradle @@ -4,6 +4,7 @@ apply plugin: 'kotlin-parcelize' dependencies { implementation project(":matrix:services:sync") implementation project(":matrix:services:message") + implementation project(":matrix:services:crypto") implementation project(":matrix:services:room") implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt index 71cfb4c..d54dc06 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/DecryptingFetcher.kt @@ -2,6 +2,7 @@ package app.dapk.st.messenger import android.content.Context import app.dapk.st.core.Base64 +import app.dapk.st.matrix.crypto.MediaDecrypter import app.dapk.st.matrix.sync.RoomEvent import coil.ImageLoader import coil.decode.DataSource @@ -42,7 +43,11 @@ class DecryptingFetcher( } private fun handleEncrypted(response: Response, keys: RoomEvent.Image.ImageMeta.Keys): Buffer { - return response.body?.byteStream()?.let { mediaDecrypter.decrypt(it, keys.k, keys.iv) } ?: Buffer() + return response.body?.byteStream()?.let { byteStream -> + Buffer().also { buffer -> + mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { buffer.write(it) } + } + } ?: Buffer() } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt similarity index 69% rename from features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt rename to matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt index 230c542..df513d2 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MediaDecrypter.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt @@ -1,7 +1,6 @@ -package app.dapk.st.messenger +package app.dapk.st.matrix.crypto import app.dapk.st.core.Base64 -import okio.Buffer import java.io.InputStream import java.security.MessageDigest import javax.crypto.Cipher @@ -15,7 +14,7 @@ private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" class MediaDecrypter(private val base64: Base64) { - fun decrypt(input: InputStream, k: String, iv: String): Buffer { + fun decrypt(input: InputStream, k: String, iv: String): Collector { val key = base64.decode(k.replace('-', '+').replace('_', '/')) val initVectorBytes = base64.decode(iv) @@ -30,17 +29,22 @@ class MediaDecrypter(private val base64: Base64) { val d = ByteArray(CRYPTO_BUFFER_SIZE) var decodedBytes: ByteArray - val outputStream = Buffer() - input.use { - read = it.read(d) - while (read != -1) { - messageDigest.update(d, 0, read) - decodedBytes = decryptCipher.update(d, 0, read) - outputStream.write(decodedBytes) + return Collector { partial -> + input.use { read = it.read(d) + while (read != -1) { + messageDigest.update(d, 0, read) + decodedBytes = decryptCipher.update(d, 0, read) + partial(decodedBytes) + read = it.read(d) + } } } - return outputStream } -} \ No newline at end of file +} + + +fun interface Collector { + fun collect(partial: (ByteArray) -> Unit) +} diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index d692c96..8779851 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -71,16 +71,25 @@ class SmokeTest { @Order(5) fun `can send and receive encrypted text messages`() = testTextMessaging(isEncrypted = true) - @Test - @Order(6) - fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> - val testImage = loadResourceFile("test-image.png") - alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) - bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) - } +// @Test +// @Order(6) +// fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> +// val testImage = loadResourceFile("test-image.png") +// alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) +// bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember, isEncrypted = false) +// } @Test @Order(7) + fun `can send and receive encrypted image messages`() = testAfterInitialSync { alice, bob -> + val testImage = loadResourceFile("test-image.png") + alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = true) + bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) + } + + + @Test + @Order(8) fun `can request and verify devices`() = testAfterInitialSync { alice, bob -> alice.client.cryptoService().verificationAction(Verification.Action.Request(bob.userId(), bob.deviceId())) alice.client.cryptoService().verificationState().automaticVerification(alice).expectAsync { it == Verification.State.Done } diff --git a/test-harness/src/test/kotlin/test/Test.kt b/test-harness/src/test/kotlin/test/Test.kt index 83cdc01..562a863 100644 --- a/test-harness/src/test/kotlin/test/Test.kt +++ b/test-harness/src/test/kotlin/test/Test.kt @@ -7,6 +7,7 @@ import TestUser import app.dapk.st.core.extensions.ifNull import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.crypto.MediaDecrypter import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.sync.RoomEvent @@ -22,6 +23,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.amshove.kluent.fail import org.amshove.kluent.shouldBeEqualTo +import java.io.ByteArrayOutputStream import java.io.File import java.math.BigInteger import java.security.MessageDigest @@ -145,10 +147,21 @@ class MatrixTestScope(private val testScope: TestScope) { this.client.syncService().room(roomId) .map { it.events.filterIsInstance().map { - println("found: ${it.imageMeta.url}") + println("found: ${it}") val output = File(image.parentFile.absolutePath, "output.png") HttpClient().request(it.imageMeta.url).bodyAsChannel().copyAndClose(output.writeChannel()) - output.readBytes().md5Hash() to it.author + val md5Hash = when (val keys = it.imageMeta.keys) { + null -> output.readBytes().md5Hash() + else -> { + val byteStream = ByteArrayOutputStream() + MediaDecrypter(this.base64).decrypt(output.inputStream(), keys.k, keys.iv).collect { + byteStream.write(it) + } + byteStream.toByteArray().md5Hash() + } + } + + md5Hash to it.author }.firstOrNull() } .assert(image.readBytes().md5Hash() to author) diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 797547c..853ac60 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -82,6 +82,7 @@ class TestMatrix( }, coroutineDispatchers = coroutineDispatchers ) + val base64 = JavaBase64() val client = MatrixClient( KtorMatrixHttpClientFactory( @@ -94,7 +95,6 @@ class TestMatrix( installAuthService(storeModule.credentialsStore()) installEncryptionService(storeModule.knownDevicesStore()) - val base64 = JavaBase64() val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore(), base64) val olm = OlmWrapper( olmStore = olmAccountStore, @@ -349,7 +349,7 @@ class JavaImageContentReader : ImageContentReader { size = size, mimeType = "image/${file.extension}", fileName = file.name, - content = file.readBytes() + uri = file.toURI(), ) } From 5472b41d7387f5176b667bfc1b1f5cdd8f38c0c2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 22:51:00 +0100 Subject: [PATCH 11/13] adding separate image for encrypted image test and re-enabling clear image test --- .../message/internal/SendMessageUseCase.kt | 5 +---- test-harness/src/test/kotlin/SmokeTest.kt | 16 ++++++++-------- test-harness/src/test/kotlin/test/Test.kt | 1 - test-harness/src/test/resources/test-image2.png | Bin 0 -> 59109 bytes 4 files changed, 9 insertions(+), 13 deletions(-) create mode 100644 test-harness/src/test/resources/test-image2.png diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index 4dbfa5e..c5e52be 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -66,7 +66,6 @@ internal class SendMessageUseCase( true -> { val result = mediaEncrypter.encrypt(imageContent.inputStream()) val bytes = File(result.uri).readBytes() - println("!!! ${bytes.size}") val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, "application/octet-stream")).contentUri @@ -114,9 +113,7 @@ internal class SendMessageUseCase( } false -> { - val bytes = ByteArrayOutputStream().also { - it.writeTo(imageContent.outputStream()) - }.toByteArray() + val bytes = File(imageContent.uri).readBytes() val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, imageContent.mimeType)).contentUri sendRequest( diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index 8779851..87be394 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -71,18 +71,18 @@ class SmokeTest { @Order(5) fun `can send and receive encrypted text messages`() = testTextMessaging(isEncrypted = true) -// @Test -// @Order(6) -// fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> -// val testImage = loadResourceFile("test-image.png") -// alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) -// bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember, isEncrypted = false) -// } + @Test + @Order(6) + fun `can send and receive clear image messages`() = testAfterInitialSync { alice, bob -> + val testImage = loadResourceFile("test-image.png") + alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = false) + bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) + } @Test @Order(7) fun `can send and receive encrypted image messages`() = testAfterInitialSync { alice, bob -> - val testImage = loadResourceFile("test-image.png") + val testImage = loadResourceFile("test-image2.png") alice.sendImageMessage(SharedState.sharedRoom, testImage, isEncrypted = true) bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) } diff --git a/test-harness/src/test/kotlin/test/Test.kt b/test-harness/src/test/kotlin/test/Test.kt index 562a863..08b4e4c 100644 --- a/test-harness/src/test/kotlin/test/Test.kt +++ b/test-harness/src/test/kotlin/test/Test.kt @@ -147,7 +147,6 @@ class MatrixTestScope(private val testScope: TestScope) { this.client.syncService().room(roomId) .map { it.events.filterIsInstance().map { - println("found: ${it}") val output = File(image.parentFile.absolutePath, "output.png") HttpClient().request(it.imageMeta.url).bodyAsChannel().copyAndClose(output.writeChannel()) val md5Hash = when (val keys = it.imageMeta.keys) { diff --git a/test-harness/src/test/resources/test-image2.png b/test-harness/src/test/resources/test-image2.png new file mode 100644 index 0000000000000000000000000000000000000000..56b0161429c02e7bcb71779417fc4306088ccfe1 GIT binary patch literal 59109 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 VDk-2=TFwAGH;k=}>aV)T{tw-48|(l8 literal 0 HcmV?d00001 From b4878aa2c62f63c28e6af1a974ff93ad15095fe3 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 21 Sep 2022 23:29:01 +0100 Subject: [PATCH 12/13] using separate content provider query to find image file size rather than collect the content of the image itself into memory, should be much more effecient! --- .../kotlin/app/dapk/st/graph/AppModule.kt | 30 ++++++++----- .../dapk/st/matrix/crypto/CryptoService.kt | 1 + .../matrix/crypto/internal/MediaEncrypter.kt | 1 + .../dapk/st/matrix/message/MediaEncrypter.kt | 3 +- .../message/internal/ImageContentReader.kt | 12 ++--- .../message/internal/SendMessageUseCase.kt | 44 +++++++++++-------- .../st/matrix/message/internal/SendRequest.kt | 12 +++-- test-harness/src/test/kotlin/SmokeTest.kt | 1 - .../src/test/kotlin/test/TestMatrix.kt | 6 ++- 9 files changed, 65 insertions(+), 45 deletions(-) diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 677c984..296ee1f 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -8,6 +8,7 @@ import android.content.Intent import android.graphics.BitmapFactory import android.net.Uri import android.os.Build +import android.provider.OpenableColumns import app.dapk.db.DapkDb import app.dapk.st.BuildConfig import app.dapk.st.SharedPreferencesDelegate @@ -59,7 +60,7 @@ import app.dapk.st.work.TaskRunnerModule import app.dapk.st.work.WorkModule import com.squareup.sqldelight.android.AndroidSqliteDriver import kotlinx.coroutines.Dispatchers -import java.net.URI +import java.io.InputStream import java.time.Clock internal class AppModule(context: Application, logger: MatrixLogger) { @@ -303,6 +304,7 @@ internal class MatrixModules( val result = cryptoService.encrypt(input) MediaEncrypter.Result( uri = result.uri, + contentLength = result.contentLength, algorithm = result.algorithm, ext = result.ext, keyOperations = result.keyOperations, @@ -482,23 +484,27 @@ internal class DomainModules( } internal class AndroidImageContentReader(private val contentResolver: ContentResolver) : ImageContentReader { - override fun read(uri: String): ImageContentReader.ImageContent { + override fun meta(uri: String): ImageContentReader.ImageContent { val androidUri = Uri.parse(uri) val fileStream = contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri") val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeStream(fileStream, null, options) - return contentResolver.openInputStream(androidUri)?.use { stream -> - val output = stream.readBytes() - ImageContentReader.ImageContent( - height = options.outHeight, - width = options.outWidth, - size = output.size.toLong(), - mimeType = options.outMimeType, - fileName = androidUri.lastPathSegment ?: "file", - uri = URI.create(uri) - ) + val fileSize = contentResolver.query(androidUri, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + val columnIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.getLong(columnIndex) } ?: throw IllegalArgumentException("Could not process $uri") + + return ImageContentReader.ImageContent( + height = options.outHeight, + width = options.outWidth, + size = fileSize, + mimeType = options.outMimeType, + fileName = androidUri.lastPathSegment ?: "file", + ) } + + override fun inputStream(uri: String): InputStream = contentResolver.openInputStream(Uri.parse(uri))!! } \ No newline at end of file diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt index 405a718..becaf3b 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/CryptoService.kt @@ -42,6 +42,7 @@ interface Crypto { data class MediaEncryptionResult( val uri: URI, + val contentLength: Long, val algorithm: String, val ext: Boolean, val keyOperations: List, diff --git a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt index 17ca412..f8c2eea 100644 --- a/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt @@ -63,6 +63,7 @@ class MediaEncrypter(private val base64: Base64) { return Crypto.MediaEncryptionResult( uri = outputFile.toURI(), + contentLength = outputFile.length(), algorithm = "A256CTR", ext = true, keyOperations = listOf("encrypt", "decrypt"), diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt index 2f6711a..10fa046 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt @@ -10,6 +10,7 @@ fun interface MediaEncrypter { data class Result( val uri: URI, + val contentLength: Long, val algorithm: String, val ext: Boolean, val keyOperations: List, @@ -20,7 +21,7 @@ fun interface MediaEncrypter { val v: String, ) { - fun openStream() = File(uri).outputStream() + fun openStream() = File(uri).inputStream() } } diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt index dd8ecfa..ffce257 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt @@ -1,10 +1,10 @@ package app.dapk.st.matrix.message.internal -import java.io.File -import java.net.URI +import java.io.InputStream interface ImageContentReader { - fun read(uri: String): ImageContent + fun meta(uri: String): ImageContent + fun inputStream(uri: String): InputStream data class ImageContent( val height: Int, @@ -12,9 +12,5 @@ interface ImageContentReader { val size: Long, val fileName: String, val mimeType: String, - val uri: URI - ) { - fun inputStream() = File(uri).inputStream() - fun outputStream() = File(uri).outputStream() - } + ) } \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index c5e52be..8398148 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -7,8 +7,6 @@ import app.dapk.st.matrix.message.ApiSendResponse import app.dapk.st.matrix.message.MediaEncrypter import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.Message -import java.io.ByteArrayOutputStream -import java.io.File internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, @@ -60,18 +58,23 @@ internal class SendMessageUseCase( } private suspend fun ApiMessageMapper.imageMessageRequest(message: Message.ImageMessage): HttpRequest { - val imageContent = imageContentReader.read(message.content.uri) + val imageMeta = imageContentReader.meta(message.content.uri) return when (message.sendEncrypted) { true -> { - val result = mediaEncrypter.encrypt(imageContent.inputStream()) - val bytes = File(result.uri).readBytes() - - val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, "application/octet-stream")).contentUri + val result = mediaEncrypter.encrypt(imageContentReader.inputStream(message.content.uri)) + val uri = httpClient.execute( + uploadRequest( + result.openStream(), + result.contentLength, + imageMeta.fileName, + "application/octet-stream" + ) + ).contentUri val content = ApiMessage.ImageMessage.ImageContent( url = null, - filename = imageContent.fileName, + filename = imageMeta.fileName, file = ApiMessage.ImageMessage.ImageContent.File( url = uri, key = ApiMessage.ImageMessage.ImageContent.File.EncryptionMeta( @@ -86,9 +89,9 @@ internal class SendMessageUseCase( v = result.v, ), info = ApiMessage.ImageMessage.ImageContent.Info( - height = imageContent.height, - width = imageContent.width, - size = imageContent.size + height = imageMeta.height, + width = imageMeta.width, + size = imageMeta.size ) ) @@ -113,20 +116,25 @@ internal class SendMessageUseCase( } false -> { - val bytes = File(imageContent.uri).readBytes() - - val uri = httpClient.execute(uploadRequest(bytes, imageContent.fileName, imageContent.mimeType)).contentUri + val uri = httpClient.execute( + uploadRequest( + imageContentReader.inputStream(message.content.uri), + imageMeta.size, + imageMeta.fileName, + imageMeta.mimeType + ) + ).contentUri sendRequest( roomId = message.roomId, eventType = EventType.ROOM_MESSAGE, txId = message.localId, content = ApiMessage.ImageMessage.ImageContent( url = uri, - filename = imageContent.fileName, + filename = imageMeta.fileName, ApiMessage.ImageMessage.ImageContent.Info( - height = imageContent.height, - width = imageContent.width, - size = imageContent.size + height = imageMeta.height, + width = imageMeta.width, + size = imageMeta.size ) ), ) diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt index d3cc3d3..2057084 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendRequest.kt @@ -11,8 +11,10 @@ import app.dapk.st.matrix.message.MessageEncrypter import app.dapk.st.matrix.message.MessageService.EventMessage import app.dapk.st.matrix.message.internal.ApiMessage.ImageMessage import app.dapk.st.matrix.message.internal.ApiMessage.TextMessage -import io.ktor.content.* import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.utils.io.jvm.javaio.* +import java.io.InputStream import java.util.* internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: ApiMessageContent) = httpRequest( @@ -38,11 +40,15 @@ internal fun sendRequest(roomId: RoomId, eventType: EventType, content: EventMes } ) -internal fun uploadRequest(body: ByteArray, filename: String, contentType: String) = httpRequest( +internal fun uploadRequest(stream: InputStream, contentLength: Long, filename: String, contentType: String) = httpRequest( path = "_matrix/media/r0/upload/?filename=$filename", headers = listOf("Content-Type" to contentType), method = MatrixHttpClient.Method.POST, - body = ByteArrayContent(body, ContentType.parse(contentType)), + body = ChannelWriterContent( + body = { stream.copyTo(this) }, + contentType = ContentType.parse(contentType), + contentLength = contentLength, + ), ) fun txId() = "local.${UUID.randomUUID()}" \ No newline at end of file diff --git a/test-harness/src/test/kotlin/SmokeTest.kt b/test-harness/src/test/kotlin/SmokeTest.kt index 87be394..cccb5c8 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -87,7 +87,6 @@ class SmokeTest { bob.expectImageMessage(SharedState.sharedRoom, testImage, SharedState.alice.roomMember) } - @Test @Order(8) fun `can request and verify devices`() = testAfterInitialSync { alice, bob -> diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index 853ac60..023a3b1 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -146,6 +146,7 @@ class TestMatrix( val result = cryptoService.encrypt(input) MediaEncrypter.Result( uri = result.uri, + contentLength = result.contentLength, algorithm = result.algorithm, ext = result.ext, keyOperations = result.keyOperations, @@ -339,7 +340,7 @@ class JavaBase64 : Base64 { class JavaImageContentReader : ImageContentReader { - override fun read(uri: String): ImageContentReader.ImageContent { + override fun meta(uri: String): ImageContentReader.ImageContent { val file = File(uri) val size = file.length() val image = ImageIO.read(file) @@ -349,8 +350,9 @@ class JavaImageContentReader : ImageContentReader { size = size, mimeType = "image/${file.extension}", fileName = file.name, - uri = file.toURI(), ) } + override fun inputStream(uri: String) = File(uri).inputStream() + } \ No newline at end of file From 2eba0babe19a21bd0cd8c8cac568c89e191de761 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 22 Sep 2022 20:04:15 +0100 Subject: [PATCH 13/13] updating version for release --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index 5f46553..9e572b7 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "code": 17, - "name": "19/09/2022-V1" + "code": 18, + "name": "22/09/2022-V1" } \ No newline at end of file