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..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 @@ -31,14 +32,9 @@ 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.http.MatrixHttpClient 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.* @@ -64,6 +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.io.InputStream import java.time.Clock internal class AppModule(context: Application, logger: MatrixLogger) { @@ -80,6 +77,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( @@ -94,7 +92,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( @@ -139,6 +137,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { deviceMeta, coroutineDispatchers, clock, + base64, ) } @@ -154,6 +153,7 @@ internal class FeatureModules internal constructor( deviceMeta: DeviceMeta, coroutineDispatchers: CoroutineDispatchers, clock: Clock, + base64: Base64, ) { val directoryModule by unsafeLazy { @@ -181,7 +181,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) } @@ -232,6 +234,7 @@ internal class MatrixModules( private val logger: MatrixLogger, private val coroutineDispatchers: CoroutineDispatchers, private val contentResolver: ContentResolver, + private val base64: Base64, private val buildMeta: BuildMeta, ) { @@ -249,7 +252,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( @@ -274,40 +276,47 @@ internal class MatrixModules( coroutineDispatchers = coroutineDispatchers, ) val imageContentReader = AndroidImageContentReader(contentResolver) - 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 - }, - 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 - ) - ) - ) + 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, + ) - is MessageService.Message.ImageMessage -> TODO() - } - ) - - 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, + contentLength = result.contentLength, + 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( @@ -475,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", - content = output - ) + 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/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 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 0c89d90..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 @@ -1,7 +1,8 @@ package app.dapk.st.messenger import android.content.Context -import android.util.Base64 +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 @@ -14,25 +15,23 @@ 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, base64: Base64) : Fetcher.Factory { + + private val mediaDecrypter = MediaDecrypter(base64) -class DecryptingFetcherFactory(private val context: Context) : Fetcher.Factory { 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 { +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() @@ -44,32 +43,11 @@ 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 response.body?.byteStream()?.let { byteStream -> + Buffer().also { buffer -> + mediaDecrypter.decrypt(byteStream, keys.k, keys.iv).collect { buffer.write(it) } } - } - return outputStream + } ?: Buffer() } -} \ No newline at end of file +} + 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/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..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 @@ -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,19 @@ interface Crypto { val deviceId: DeviceId ) + data class MediaEncryptionResult( + val uri: URI, + val contentLength: Long, + 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 +166,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 +183,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/MediaDecrypter.kt b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt new file mode 100644 index 0000000..df513d2 --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/MediaDecrypter.kt @@ -0,0 +1,50 @@ +package app.dapk.st.matrix.crypto + +import app.dapk.st.core.Base64 +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(private val base64: Base64) { + + fun decrypt(input: InputStream, k: String, iv: String): Collector { + 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) + 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 + + 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) + } + } + } + } + +} + + +fun interface Collector { + fun collect(partial: (ByteArray) -> Unit) +} 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/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 new file mode 100644 index 0000000..f8c2eea --- /dev/null +++ b/matrix/services/crypto/src/main/kotlin/app/dapk/st/matrix/crypto/internal/MediaEncrypter.kt @@ -0,0 +1,89 @@ +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 + +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): Crypto.MediaEncryptionResult { + 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-${UUID.randomUUID()}", ".png") + + outputFile.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 Crypto.MediaEncryptionResult( + uri = outputFile.toURI(), + contentLength = outputFile.length(), + 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" + ) + } +} + +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("=", "") +} \ No newline at end of file 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/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/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/MediaEncrypter.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt new file mode 100644 index 0000000..10fa046 --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MediaEncrypter.kt @@ -0,0 +1,31 @@ +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 contentLength: Long, + 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).inputStream() + } + +} + +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 94300cd..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 @@ -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") -} \ No newline at end of file + override suspend fun encrypt(message: MessageEncrypter.ClearMessagePayload) = throw IllegalStateException("No encrypter instance set") +} 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..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 @@ -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, - ) - } } } @@ -142,9 +131,17 @@ fun MatrixServiceInstaller.installMessageService( backgroundScheduler: BackgroundScheduler, imageContentReader: ImageContentReader, messageEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMessageEncrypter }, + mediaEncrypter: ServiceDepFactory = ServiceDepFactory { MissingMediaEncrypter }, ) { this.install { (httpClient, _, installedServices) -> - SERVICE_KEY to DefaultMessageService(httpClient, localEchoStore, backgroundScheduler, messageEncrypter.create(installedServices), imageContentReader) + SERVICE_KEY to DefaultMessageService( + httpClient, + localEchoStore, + backgroundScheduler, + messageEncrypter.create(installedServices), + mediaEncrypter.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 new file mode 100644 index 0000000..9af2353 --- /dev/null +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ApiMessage.kt @@ -0,0 +1,72 @@ +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 + +@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, + ) : ApiMessageContent + } + + @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..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 @@ -3,10 +3,7 @@ package app.dapk.st.matrix.message.internal 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,16 +17,17 @@ internal class DefaultMessageService( private val localEchoStore: LocalEchoStore, private val backgroundScheduler: BackgroundScheduler, messageEncrypter: MessageEncrypter, + mediaEncrypter: MediaEncrypter, imageContentReader: ImageContentReader, ) : MessageService, MatrixTaskRunner { - private val sendMessageUseCase = SendMessageUseCase(httpClient, messageEncrypter, imageContentReader) + 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 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/ImageContentReader.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/ImageContentReader.kt index 8395092..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,7 +1,10 @@ package app.dapk.st.matrix.message.internal +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, @@ -9,28 +12,5 @@ interface ImageContentReader { val size: Long, val fileName: String, val mimeType: String, - val content: ByteArray - ) { - 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 - } - } + ) } \ 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..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 @@ -1,62 +1,176 @@ 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.* 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 +import app.dapk.st.matrix.message.MessageService.Message internal class SendMessageUseCase( private val httpClient: MatrixHttpClient, private val messageEncrypter: MessageEncrypter, + private val mediaEncrypter: MediaEncrypter, private val imageContentReader: ImageContentReader, ) { - suspend fun sendMessage(message: MessageService.Message): EventId { - return when (message) { - is MessageService.Message.TextMessage -> { - val request = when (message.sendEncrypted) { - true -> { - sendRequest( - roomId = message.roomId, - eventType = EventType.ENCRYPTED, - txId = message.localId, - content = messageEncrypter.encrypt(message), - ) - } + private val mapper = ApiMessageMapper() - 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 imageMeta = imageContentReader.meta(message.content.uri) + + return when (message.sendEncrypted) { + true -> { + 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 = imageMeta.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 = imageMeta.height, + width = imageMeta.width, + size = imageMeta.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 imageContent = imageContentReader.read(message.content.uri) - val uri = httpClient.execute(uploadRequest(imageContent.content, imageContent.fileName, imageContent.mimeType)).contentUri - val request = sendRequest( + false -> { + 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 = MessageService.Message.Content.ImageContent( + content = ApiMessage.ImageMessage.ImageContent( url = uri, - filename = imageContent.fileName, - MessageService.Message.Content.ImageContent.Info( - height = imageContent.height, - width = imageContent.width, - size = imageContent.size + filename = imageMeta.fileName, + ApiMessage.ImageMessage.ImageContent.Info( + height = imageMeta.height, + width = imageMeta.width, + size = imageMeta.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..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 @@ -9,18 +9,20 @@ 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 io.ktor.content.* +import app.dapk.st.matrix.message.internal.ApiMessage.ImageMessage +import app.dapk.st.matrix.message.internal.ApiMessage.TextMessage 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: 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) } ) @@ -38,12 +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 d692c96..cccb5c8 100644 --- a/test-harness/src/test/kotlin/SmokeTest.kt +++ b/test-harness/src/test/kotlin/SmokeTest.kt @@ -81,6 +81,14 @@ class SmokeTest { @Test @Order(7) + fun `can send and receive encrypted image messages`() = testAfterInitialSync { alice, bob -> + val testImage = loadResourceFile("test-image2.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..08b4e4c 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,20 @@ class MatrixTestScope(private val testScope: TestScope) { this.client.syncService().room(roomId) .map { it.events.filterIsInstance().map { - println("found: ${it.imageMeta.url}") 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 411f8b6..023a3b1 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -16,14 +16,9 @@ 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.http.MatrixHttpClient 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 @@ -35,7 +30,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 @@ -88,6 +82,7 @@ class TestMatrix( }, coroutineDispatchers = coroutineDispatchers ) + val base64 = JavaBase64() val client = MatrixClient( KtorMatrixHttpClientFactory( @@ -100,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, @@ -124,39 +118,47 @@ class TestMatrix( coroutineDispatchers = coroutineDispatchers, ) - 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 - }, - 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 - ) - ) - ) + 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, + ) - is MessageService.Message.ImageMessage -> TODO() - } - ) - - 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, + contentLength = result.contentLength, + 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(), @@ -338,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) @@ -348,8 +350,9 @@ class JavaImageContentReader : ImageContentReader { size = size, mimeType = "image/${file.extension}", fileName = file.name, - content = file.readBytes() ) } + override fun inputStream(uri: String) = File(uri).inputStream() + } \ No newline at end of file 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 0000000..56b0161 Binary files /dev/null and b/test-harness/src/test/resources/test-image2.png differ