Merge pull request #155 from ouchadam/feature/encrypted-images
Encrypted images
This commit is contained in:
commit
a78b99fe50
|
@ -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))!!
|
||||
}
|
|
@ -3,3 +3,7 @@ plugins { id 'kotlin' }
|
|||
dependencies {
|
||||
implementation project(':core')
|
||||
}
|
||||
|
||||
|
||||
task generateReleaseSources {}
|
||||
task compileReleaseSources {}
|
|
@ -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")
|
||||
|
|
|
@ -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<RoomEvent.Image> {
|
||||
|
||||
private val mediaDecrypter = MediaDecrypter(base64)
|
||||
|
||||
class DecryptingFetcherFactory(private val context: Context) : Fetcher.Factory<RoomEvent.Image> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<DecryptingFetcherFactory> { throw IllegalAccessError() }
|
||||
|
||||
class MessengerActivity : DapkActivity() {
|
||||
|
||||
private val viewModel by viewModel { module<MessengerModule>().messengerViewModel() }
|
||||
private val module by unsafeLazy { module<MessengerModule>() }
|
||||
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<MessagerActivityPayload>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -228,7 +228,6 @@ private fun <T : RoomEvent> LazyItemScope.AlignedBubble(
|
|||
@Composable
|
||||
private fun MessageImage(content: BubbleContent<RoomEvent.Image>) {
|
||||
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<RoomEvent.Image>) {
|
|||
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<RoomEvent.Reply>) {
|
|||
.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<RoomEvent.Reply>) {
|
|||
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<RoomEvent.Reply>) {
|
|||
painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(content.message)
|
||||
.fetcherFactory(fetcherFactory)
|
||||
.fetcherFactory(LocalDecyptingFetcherFactory.current)
|
||||
.build()
|
||||
),
|
||||
contentDescription = null,
|
||||
|
|
|
@ -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<SharedRoomKey>)
|
||||
|
@ -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<String>,
|
||||
val kty: String,
|
||||
val k: String,
|
||||
val iv: String,
|
||||
val hashes: Map<String, String>,
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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("=", "")
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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")))
|
||||
|
|
|
@ -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<String>,
|
||||
val kty: String,
|
||||
val k: String,
|
||||
val iv: String,
|
||||
val hashes: Map<String, String>,
|
||||
val v: String,
|
||||
) {
|
||||
|
||||
fun openStream() = File(uri).inputStream()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal object MissingMediaEncrypter : MediaEncrypter {
|
||||
override suspend fun encrypt(input: InputStream) = throw IllegalStateException("No encrypter instance set")
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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<MessageEncrypter> = ServiceDepFactory { MissingMessageEncrypter },
|
||||
mediaEncrypter: ServiceDepFactory<MediaEncrypter> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<String, String>,
|
||||
@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<String>,
|
||||
@SerialName("kty") val kty: String,
|
||||
@SerialName("k") val k: String
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ApiMessageContent
|
|
@ -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}")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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<ApiSendResponse> {
|
||||
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<ApiSendResponse> {
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
|
|
|
@ -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<ApiSendResponse>(
|
||||
internal fun sendRequest(roomId: RoomId, eventType: EventType, txId: String, content: ApiMessageContent) = httpRequest<ApiSendResponse>(
|
||||
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<ApiUploadResponse>(
|
||||
internal fun uploadRequest(stream: InputStream, contentLength: Long, filename: String, contentType: String) = httpRequest<ApiUploadResponse>(
|
||||
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()}"
|
|
@ -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 }
|
||||
|
|
|
@ -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<RoomEvent.Image>().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)
|
||||
|
|
|
@ -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()
|
||||
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 58 KiB |
Loading…
Reference in New Issue