From 849262a79de7adec1db4ddc836def7328f9d6643 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 28 Mar 2026 21:49:21 +0300 Subject: [PATCH] Add safeguard against corrupted messages, fix file encryption Signed-off-by: denis0001-dev --- .../api/AttachmentUploadQueue.android.kt | 48 ++++++----- .../ru/fromchat/crypto/dm/DmCrypto.android.kt | 14 +++- .../transport/TransportCrypto.android.kt | 47 +++++------ .../ru/fromchat/ui/debug/DebugApiScreen.kt | 34 +++++--- .../kotlin/ru/fromchat/api/ApiClient.kt | 16 ++-- .../kotlin/ru/fromchat/api/Models.kt | 4 +- .../crypto/DmCiphertextCorruptedException.kt | 7 ++ .../kotlin/ru/fromchat/crypto/DmCrypto.kt | 12 +-- .../ru/fromchat/crypto/IdentityKeyManager.kt | 7 +- .../crypto/transport/TransportCrypto.kt | 20 +++-- .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 14 +++- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 2 +- .../kotlin/ru/fromchat/ui/dm/DmPanel.kt | 82 +++++++++++-------- .../ru/fromchat/crypto/dm/DmCrypto.ios.kt | 48 ++++++----- .../crypto/transport/TransportCrypto.ios.kt | 44 +++++++--- 15 files changed, 248 insertions(+), 151 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCiphertextCorruptedException.kt diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt index 05d97f5..89e2c4e 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt @@ -17,6 +17,7 @@ import com.pr0gramm3r101.utils.settings.settings import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import ru.fromchat.crypto.transport.TransportCiphertext import ru.fromchat.crypto.transport.TransportCrypto private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024 @@ -100,12 +101,27 @@ class DmAttachmentUploadWorker( return runCatching { emitProgress(jobId, 0) - val encryptedBlob = encryptFileBlob(fileUri) + val transportKey = ApiClient.getTransportPublicKey() + val (msgCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret( + plaintext = plaintext, + transportPublicKeyB64 = transportKey.publicKeyB64 + ) + try { + val bytes = applicationContext.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() } + ?: error("Failed to read file from URI") + val encryptedBlob = TransportCrypto.encryptFileForTransport( + fileBytes = bytes, + transportPublicKeyB64 = transportKey.publicKeyB64, + ephemeralSecretKey = ephemeralSecret + ) - if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { - sendInline(jobId, recipientId, plaintext, filename, encryptedBlob) - } else { - sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob) + if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { + sendInline(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) + } else { + sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) + } + } finally { + ephemeralSecret.fill(0) } clearResumableState(jobId) @@ -122,22 +138,13 @@ class DmAttachmentUploadWorker( } } - private suspend fun encryptFileBlob(fileUri: String): ByteArray { - val bytes = applicationContext.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() } - ?: error("Failed to read file from URI") - val transportKey = ApiClient.getTransportPublicKey() - return TransportCrypto.encryptFileForTransport( - fileBytes = bytes, - transportPublicKeyB64 = transportKey.publicKeyB64 - ) - } - private suspend fun sendInline( jobId: String, recipientId: Int, plaintext: String, filename: String, - encryptedBlob: ByteArray + encryptedBlob: ByteArray, + msgCipher: TransportCiphertext ) { val file = SendDmFile( encryptedFileDataB64 = Base64.encode(encryptedBlob), @@ -148,7 +155,8 @@ class DmAttachmentUploadWorker( recipientId = recipientId, plaintext = plaintext, clientMessageId = jobId, - transportFiles = listOf(file) + transportFiles = listOf(file), + preparedTransport = msgCipher ) } @@ -157,7 +165,8 @@ class DmAttachmentUploadWorker( recipientId: Int, plaintext: String, filename: String, - encryptedBlob: ByteArray + encryptedBlob: ByteArray, + msgCipher: TransportCiphertext ) { val uploadId = settings.getString(uploadIdKey(jobId), "").ifBlank { val init = ApiClient.initDmUpload( @@ -188,7 +197,8 @@ class DmAttachmentUploadWorker( recipientId = recipientId, plaintext = plaintext, clientMessageId = jobId, - uploadedFileIds = listOf(completed.fileId) + uploadedFileIds = listOf(completed.fileId), + preparedTransport = msgCipher ) } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt index 0d07df5..a7a32b6 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt @@ -3,7 +3,9 @@ package ru.fromchat.crypto.dm import com.pr0gramm3r101.utils.crypto.Base64 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import ru.fromchat.crypto.DmCiphertextCorruptedException import ru.fromchat.crypto.backup.BackupCryptoPlatform +import java.security.GeneralSecurityException actual object DmCrypto { private const val AES_KEY_SIZE = 32 @@ -22,7 +24,11 @@ actual object DmCrypto { val iv = wrapped.sliceArray(0 until GCM_IV_SIZE) val ciphertext = wrapped.sliceArray(GCM_IV_SIZE until wrapped.size) - BackupCryptoPlatform.aesGcmDecrypt(wrappingKey, iv, ciphertext) + try { + BackupCryptoPlatform.aesGcmDecrypt(wrappingKey, iv, ciphertext) + } catch (e: GeneralSecurityException) { + throw DmCiphertextCorruptedException(cause = e) + } } actual suspend fun decryptEnvelope( @@ -38,7 +44,11 @@ actual object DmCrypto { require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" } require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" } - BackupCryptoPlatform.aesGcmDecrypt(mek, iv, ciphertext) + try { + BackupCryptoPlatform.aesGcmDecrypt(mek, iv, ciphertext) + } catch (e: GeneralSecurityException) { + throw DmCiphertextCorruptedException(cause = e) + } } } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt index df4e94e..cb2353e 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.security.SecureRandom import java.util.Base64 -import ru.fromchat.crypto.IdentityKeyManager actual object TransportCrypto { private val random = SecureRandom() @@ -14,54 +13,50 @@ actual object TransportCrypto { plaintext: String, transportPublicKeyB64: String ): TransportCiphertext = withContext(Dispatchers.Default) { - // Decode server-provided transport public key + val (cipher, secret) = encryptWithTransportKeyWithEphemeralSecretInner(plaintext, transportPublicKeyB64) + secret.fill(0) + cipher + } + + actual suspend fun encryptWithTransportKeyWithEphemeralSecret( + plaintext: String, + transportPublicKeyB64: String + ): Pair = withContext(Dispatchers.Default) { + encryptWithTransportKeyWithEphemeralSecretInner(plaintext, transportPublicKeyB64) + } + + private fun encryptWithTransportKeyWithEphemeralSecretInner( + plaintext: String, + transportPublicKeyB64: String + ): Pair { val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64) - - // Ephemeral X25519 keypair for this message val keyPair = TweetNaclFast.Box.keyPair() - - // NaCl box with (server transport public key, our ephemeral secret key) val box = TweetNaclFast.Box(transportPublicKey, keyPair.secretKey) - - // 24-byte nonce as required by NaCl box val nonce = ByteArray(TweetNaclFast.Box.nonceLength) random.nextBytes(nonce) - val ciphertext = box.box(plaintext.encodeToByteArray(), nonce) - val encoder = Base64.getEncoder() - TransportCiphertext( + val cipher = TransportCiphertext( clientPublicKeyB64 = encoder.encodeToString(keyPair.publicKey), nonceB64 = encoder.encodeToString(nonce), ciphertextB64 = encoder.encodeToString(ciphertext) ) + return cipher to keyPair.secretKey.copyOf() } actual suspend fun encryptFileForTransport( fileBytes: ByteArray, - transportPublicKeyB64: String + transportPublicKeyB64: String, + ephemeralSecretKey: ByteArray ): ByteArray = withContext(Dispatchers.Default) { - val keys = IdentityKeyManager.getCurrentKeys() - ?: IdentityKeyManager.restoreFromLocal() - ?: error("Identity keys not initialized. Please log in again.") - - // Decode server-provided transport public key val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64) - - // NaCl box with (server transport public key, our long-term identity secret key) - val box = TweetNaclFast.Box(transportPublicKey, keys.privateKey) - - // 24-byte nonce as required by NaCl box + val box = TweetNaclFast.Box(transportPublicKey, ephemeralSecretKey) val nonce = ByteArray(TweetNaclFast.Box.nonceLength) random.nextBytes(nonce) - val ciphertext = box.box(fileBytes, nonce) - - // Files are sent as nonce || ciphertext, base64-encoded by the caller val result = ByteArray(nonce.size + ciphertext.size) System.arraycopy(nonce, 0, result, 0, nonce.size) System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size) result } } - diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt index 4732134..1f1f916 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt @@ -243,20 +243,30 @@ actual fun DebugApiScreen() { runCatching { val fileBytes = "test file".encodeToByteArray() val transportKey = ApiClient.getTransportPublicKey() - val transportBlob = TransportCrypto.encryptFileForTransport( - fileBytes = fileBytes, + val (msgCipher, secret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret( + plaintext = "test file", transportPublicKeyB64 = transportKey.publicKeyB64 ) - val sendFile = SendDmFile( - encryptedFileDataB64 = Base64.encode(transportBlob), - filename = "test.txt", - fileSize = fileBytes.size.toLong() - ) - ApiClient.sendDm( - recipientId = 2, - plaintext = "test file", - transportFiles = listOf(sendFile) - ) + try { + val transportBlob = TransportCrypto.encryptFileForTransport( + fileBytes = fileBytes, + transportPublicKeyB64 = transportKey.publicKeyB64, + ephemeralSecretKey = secret + ) + val sendFile = SendDmFile( + encryptedFileDataB64 = Base64.encode(transportBlob), + filename = "test.txt", + fileSize = fileBytes.size.toLong() + ) + ApiClient.sendDm( + recipientId = 2, + plaintext = "test file", + transportFiles = listOf(sendFile), + preparedTransport = msgCipher + ) + } finally { + secret.fill(0) + } }.onSuccess { statusMessage = "Protocol test file sent to user 2" }.onFailure { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index ac84d9d..dca4297 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -35,6 +35,7 @@ import kotlin.concurrent.Volatile import kotlin.time.Duration.Companion.milliseconds import com.pr0gramm3r101.utils.crypto.Base64 import ru.fromchat.crypto.IdentityKeyManager +import ru.fromchat.crypto.transport.TransportCiphertext import ru.fromchat.crypto.transport.TransportCrypto /** @@ -254,7 +255,8 @@ object ApiClient { clientMessageId: String? = null, replyToId: Int? = null, transportFiles: List = emptyList(), - uploadedFileIds: List = emptyList() + uploadedFileIds: List = emptyList(), + preparedTransport: TransportCiphertext? = null ) { val keys = IdentityKeyManager.getCurrentKeys() ?: IdentityKeyManager.restoreFromLocal() @@ -262,12 +264,14 @@ object ApiClient { val recipientPublicKey = getUserPublicKey(recipientId).publicKey ?: error("Recipient public key not found") - val transportKey = getTransportPublicKey() - val transportCipher = TransportCrypto.encryptWithTransportKey( - plaintext = plaintext, - transportPublicKeyB64 = transportKey.publicKeyB64 - ) + val transportCipher = preparedTransport ?: run { + val transportKey = getTransportPublicKey() + TransportCrypto.encryptWithTransportKey( + plaintext = plaintext, + transportPublicKeyB64 = transportKey.publicKeyB64 + ) + } val senderPublicKeyB64 = Base64.encode(keys.publicKey) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index aef3997..247ab60 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -113,7 +113,9 @@ data class Message( /** File sizes in bytes (by index); from decrypted message JSON. */ @kotlinx.serialization.Transient val fileSizes: List? = null, /** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */ - @kotlinx.serialization.Transient val fileDimensions: List>? = null + @kotlinx.serialization.Transient val fileDimensions: List>? = null, + /** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */ + @kotlinx.serialization.Transient val isContentCorrupted: Boolean = false ) @Serializable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCiphertextCorruptedException.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCiphertextCorruptedException.kt new file mode 100644 index 0000000..e7e4dde --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCiphertextCorruptedException.kt @@ -0,0 +1,7 @@ +package ru.fromchat.crypto + +/** + * AES-GCM authentication failed or ciphertext is unrecoverable (wrong key, truncated, or tampered). + * UI may show [CorruptedDmMessagePlaceholder] when this is caught. + */ +class DmCiphertextCorruptedException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt index 4c261be..ae98dcf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt @@ -4,6 +4,9 @@ import com.pr0gramm3r101.utils.crypto.PasswordHash import ru.fromchat.api.DmEnvelope import ru.fromchat.crypto.dm.DmCrypto +/** Shown in the UI when [DmCiphertextCorruptedException] is caught while decrypting a DM. */ +const val CorruptedDmMessagePlaceholder = "This message is corrupted and can't be displayed." + /** * Unwrap a MEK (Message Encryption Key) using the appropriate wrapping key * Matches Web implementation: derive wrapping key from our public key using HKDF @@ -33,16 +36,15 @@ suspend fun unwrapMek(wrappedMekB64: String, envelope: DmEnvelope, currentUserId } /** - * Decrypt a DM envelope to plaintext + * Decrypt a DM envelope to plaintext. + * @throws DmCiphertextCorruptedException if unwrap or body decrypt fails authentication (see platform [DmCrypto]) + * @throws IllegalArgumentException if the envelope has no wrapped MEK + * @throws IllegalStateException if identity keys are not available */ suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String { val wrappedMekB64 = envelope.wrappedMekB64 ?: throw IllegalArgumentException("No wrapped MEK available for decryption") - - // Unwrap the MEK val mek = unwrapMek(wrappedMekB64, envelope, currentUserId) - - // Decrypt the message using the unwrapped MEK val plaintext = DmCrypto.decryptEnvelope(envelope.ivB64, envelope.ciphertextB64, mek) return plaintext.decodeToString() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt index 533c421..883694b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt @@ -2,11 +2,12 @@ package ru.fromchat.crypto import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.settings.secureSettings -import com.pr0gramm3r101.utils.settings.settings import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import ru.fromchat.api.ApiClient @@ -14,9 +15,9 @@ import ru.fromchat.api.PublicKeyResponse import ru.fromchat.core.Logger import ru.fromchat.core.config.Config import ru.fromchat.crypto.backup.BackupCrypto +import ru.fromchat.crypto.backup.PrivateKeyBundle import ru.fromchat.crypto.backup.decodeBlob import ru.fromchat.crypto.backup.encodeBlob -import ru.fromchat.crypto.backup.PrivateKeyBundle import kotlin.concurrent.Volatile /** @@ -168,6 +169,7 @@ object IdentityKeyManager { private suspend fun uploadBackupBlob(blobJson: String, token: String) { val payload = BackupBlobRequest(blob = blobJson) ApiClient.http.post("${Config.apiBaseUrl}/crypto/backup") { + contentType(ContentType.Application.Json) setBody(payload) } } @@ -185,6 +187,7 @@ object IdentityKeyManager { private suspend fun uploadPublicKey(publicKey: ByteArray, token: String) { val payload = UploadPublicKeyRequest(publicKey = Base64.encode(publicKey)) ApiClient.http.post("${Config.apiBaseUrl}/crypto/public-key") { + contentType(ContentType.Application.Json) setBody(payload) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt index 61544e6..3f77857 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt @@ -25,17 +25,23 @@ expect object TransportCrypto { transportPublicKeyB64: String ): TransportCiphertext + /** + * Same transport encryption as [encryptWithTransportKey], but also returns the ephemeral + * secret key so file blobs can be encrypted for the same [client_public_key_b64]. + * Caller must zero [Pair.second] after use. + */ + suspend fun encryptWithTransportKeyWithEphemeralSecret( + plaintext: String, + transportPublicKeyB64: String + ): Pair + /** * Encrypt raw file bytes for transport using the server's transport public key - * and the client's long-term identity private key. - * - * This mirrors the Web client's behaviour for `transport_files`: - * - Uses NaCl box with (server transport public key, identity private key) - * - Returns a blob formatted as `nonce || ciphertext` + * and the same ephemeral secret used for the message (nonce || ciphertext). */ suspend fun encryptFileForTransport( fileBytes: ByteArray, - transportPublicKeyB64: String + transportPublicKeyB64: String, + ephemeralSecretKey: ByteArray ): ByteArray } - diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index 081c06e..57bd15d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -276,19 +276,29 @@ fun ChatInput( ) ) { AnimatedPreviewBar(replyTo) { replyTo -> + val replySubtitle = if (replyTo.isContentCorrupted) { + "Corrupted message" + } else { + replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "" + } PreviewBar( icon = Icons.AutoMirrored.Filled.Reply, title = "Replying to ${replyTo.username}", - subtitle = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "", + subtitle = replySubtitle, onClose = { onClearReply() } ) } AnimatedPreviewBar(editingMessage) { message -> + val subtitle = if (message.isContentCorrupted) { + "Corrupted message" + } else { + message.content.take(50) + if (message.content.length > 50) "..." else "" + } PreviewBar( icon = Icons.Filled.Edit, title = "Editing message", - subtitle = message.content.take(50) + if (message.content.length > 50) "..." else "", + subtitle = subtitle, onClose = { onClearEdit() } ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 9737036..6e24af4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -633,7 +633,7 @@ fun ChatScreen( }, onEdit = { message -> editingMessage = message - inputText = message.content + inputText = if (message.isContentCorrupted) "" else message.content replyTo = null }, onDelete = { message -> diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index b4d9eaf..20d4f83 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -18,6 +18,8 @@ import ru.fromchat.api.Message import ru.fromchat.api.ProfileCache import ru.fromchat.api.WebSocketMessage import ru.fromchat.core.Logger +import ru.fromchat.crypto.CorruptedDmMessagePlaceholder +import ru.fromchat.crypto.DmCiphertextCorruptedException import ru.fromchat.crypto.decryptEnvelope import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.ChatPanel @@ -40,6 +42,21 @@ class DmPanel( private var otherProfilePicture: String? = null private val dmEnvelopeMutex = Mutex() + private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean) + + /** + * Decrypt for display; only [DmCiphertextCorruptedException] yields the placeholder and [DmDecryptOutcome.isCorrupted]. + * Other errors (e.g. missing identity keys) propagate. + */ + private suspend fun decryptDmEnvelopeForUi(envelope: DmEnvelope): DmDecryptOutcome { + return try { + DmDecryptOutcome(decryptEnvelope(envelope, currentUserId), false) + } catch (e: DmCiphertextCorruptedException) { + Logger.e("DmPanel", "DM ciphertext corrupted (id=${envelope.id})", e) + DmDecryptOutcome(CorruptedDmMessagePlaceholder, true) + } + } + init { updateState { it.copy(title = "Direct message", profileUserId = otherUserId) } coroutineScope.launch { @@ -86,10 +103,9 @@ class DmPanel( clearMessages() val decryptedForLog = mutableListOf>() val messages = response.messages.map { envelope -> - decryptEnvelope(envelope, currentUserId).let { plaintext -> - decryptedForLog.add(envelope.id to plaintext) - createMessage(envelope, plaintext) - } + val outcome = decryptDmEnvelopeForUi(envelope) + decryptedForLog.add(envelope.id to outcome.plaintext) + createMessage(envelope, outcome.plaintext, outcome.isCorrupted) } decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) -> Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json") @@ -162,27 +178,25 @@ class DmPanel( scope.launch(Dispatchers.Default) { dmEnvelopeMutex.withLock { val alreadyExists = _state.messages.any { it.id == envelope.id } - val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() + val outcome = decryptDmEnvelopeForUi(envelope) if (alreadyExists) return@withLock - if (plaintext != null) { - if (envelope.senderId == currentUserId) { - mergeConfirmedOwnMessage(envelope, plaintext) - } else { - addMessage(createMessage(envelope, plaintext)) - } - if (envelope.replyToId != null) { - val replyTo = _state.messages.find { it.id == envelope.replyToId } - updateMessage(envelope.id) { it.copy(reply_to = replyTo) } - } + if (envelope.senderId == currentUserId) { + mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) + } else { + addMessage(createMessage(envelope, outcome.plaintext, outcome.isCorrupted)) + } + if (envelope.replyToId != null) { + val replyTo = _state.messages.find { it.id == envelope.replyToId } + updateMessage(envelope.id) { it.copy(reply_to = replyTo) } } } } } - private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String) { - val confirmed = createMessage(envelope, plaintext) + private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean) { + val confirmed = createMessage(envelope, plaintext, isContentCorrupted) val hasAttachments = !envelope.files.isNullOrEmpty() updateState { currentState -> @@ -247,21 +261,18 @@ class DmPanel( if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return scope.launch(Dispatchers.Default) { DecryptedImageCache.invalidateForMessage(envelope.id) - val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() - if (plaintext != null) { - val dec = parseDecryptedContent(plaintext) - updateMessage(envelope.id) { - it.copy( - content = dec.text, - is_edited = true, - fileThumbnails = dec.thumbnails ?: it.fileThumbnails, - fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios, - fileSizes = dec.fileSizes ?: it.fileSizes, - fileDimensions = dec.fileDimensions ?: it.fileDimensions - ) - } - } else { - updateMessage(envelope.id) { it.copy(is_edited = true) } + val outcome = decryptDmEnvelopeForUi(envelope) + val dec = parseDecryptedContent(outcome.plaintext) + updateMessage(envelope.id) { + it.copy( + content = dec.text, + is_edited = true, + fileThumbnails = dec.thumbnails ?: it.fileThumbnails, + fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios, + fileSizes = dec.fileSizes ?: it.fileSizes, + fileDimensions = dec.fileDimensions ?: it.fileDimensions, + isContentCorrupted = outcome.isCorrupted + ) } } } @@ -301,7 +312,7 @@ class DmPanel( } } - private fun createMessage(envelope: DmEnvelope, plaintext: String): Message { + private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message { val dec = parseDecryptedContent(plaintext) val username = if (envelope.senderId == currentUserId) { "You" @@ -326,7 +337,8 @@ class DmPanel( fileThumbnails = dec.thumbnails, fileAspectRatios = dec.aspectRatios, fileSizes = dec.fileSizes, - fileDimensions = dec.fileDimensions + fileDimensions = dec.fileDimensions, + isContentCorrupted = isContentCorrupted ) } @@ -335,7 +347,7 @@ class DmPanel( ApiClient.editDm(messageId = messageId, recipientId = otherUserId, plaintext = content) }.onSuccess { updateMessage(messageId) { msg -> - msg.copy(content = content, is_edited = true) + msg.copy(content = content, is_edited = true, isContentCorrupted = false) } } } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt index f0aa704..7b490bd 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt @@ -7,6 +7,7 @@ import dev.whyoleg.cryptography.CryptographyProvider import dev.whyoleg.cryptography.algorithms.AES import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import ru.fromchat.crypto.DmCiphertextCorruptedException private const val AES_KEY_SIZE = 32 private const val GCM_IV_SIZE = 12 @@ -25,11 +26,15 @@ actual object DmCrypto { val wrapped = Base64.decode(wrappedMekB64) require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" } - aesGcmDecrypt( - wrappingKey, - wrapped.sliceArray(0 until GCM_IV_SIZE), - wrapped.sliceArray(GCM_IV_SIZE until wrapped.size) - ) + try { + aesGcmDecrypt( + wrappingKey, + wrapped.sliceArray(0 until GCM_IV_SIZE), + wrapped.sliceArray(GCM_IV_SIZE until wrapped.size) + ) + } catch (e: Throwable) { + throw DmCiphertextCorruptedException(cause = e) + } } actual suspend fun decryptEnvelope( @@ -37,21 +42,24 @@ actual object DmCrypto { ciphertextB64: String, mek: ByteArray ) = withContext(Dispatchers.Default) { - aesGcmDecrypt( - mek.require("MEK must be 32 bytes") { - it.size == AES_KEY_SIZE - }, - Base64 - .decode(ivB64) - .require("IV must be 12 bytes") { - it.size == GCM_IV_SIZE - }, - Base64 - .decode(ciphertextB64) - .require("Ciphertext too short") { - it.size >= GCM_TAG_SIZE - } - ) + val key = mek.require("MEK must be 32 bytes") { + it.size == AES_KEY_SIZE + } + val iv = Base64 + .decode(ivB64) + .require("IV must be 12 bytes") { + it.size == GCM_IV_SIZE + } + val ciphertext = Base64 + .decode(ciphertextB64) + .require("Ciphertext too short") { + it.size >= GCM_TAG_SIZE + } + try { + aesGcmDecrypt(key, iv, ciphertext) + } catch (e: Throwable) { + throw DmCiphertextCorruptedException(cause = e) + } } private suspend fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray { diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt index b6a89af..0914f7d 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt @@ -7,7 +7,6 @@ import com.ionspin.kotlin.crypto.util.LibsodiumRandom import com.pr0gramm3r101.utils.crypto.Base64 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.crypto.IdentityKeyManager actual object TransportCrypto { actual suspend fun encryptWithTransportKey( @@ -17,7 +16,6 @@ actual object TransportCrypto { if (!LibsodiumInitializer.isInitialized()) { LibsodiumInitializer.initialize() } - val keyPair = Box.keypair() val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES) val ciphertext = Box.easy( @@ -26,35 +24,55 @@ actual object TransportCrypto { Base64.decode(transportPublicKeyB64).toUByteArray(), keyPair.secretKey ) - - TransportCiphertext( + val cipher = TransportCiphertext( clientPublicKeyB64 = Base64.encode(keyPair.publicKey.toByteArray()), nonceB64 = Base64.encode(nonce.toByteArray()), ciphertextB64 = Base64.encode(ciphertext.toByteArray()) ) + keyPair.secretKey.fill(0u) + cipher + } + + actual suspend fun encryptWithTransportKeyWithEphemeralSecret( + plaintext: String, + transportPublicKeyB64: String + ): Pair = withContext(Dispatchers.Default) { + if (!LibsodiumInitializer.isInitialized()) { + LibsodiumInitializer.initialize() + } + val keyPair = Box.keypair() + val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES) + val ciphertext = Box.easy( + plaintext.encodeToByteArray().toUByteArray(), + nonce, + Base64.decode(transportPublicKeyB64).toUByteArray(), + keyPair.secretKey + ) + val cipher = TransportCiphertext( + clientPublicKeyB64 = Base64.encode(keyPair.publicKey.toByteArray()), + nonceB64 = Base64.encode(nonce.toByteArray()), + ciphertextB64 = Base64.encode(ciphertext.toByteArray()) + ) + val secretCopy = keyPair.secretKey.toByteArray().copyOf() + keyPair.secretKey.fill(0u) + cipher to secretCopy } actual suspend fun encryptFileForTransport( fileBytes: ByteArray, - transportPublicKeyB64: String + transportPublicKeyB64: String, + ephemeralSecretKey: ByteArray ): ByteArray = withContext(Dispatchers.Default) { if (!LibsodiumInitializer.isInitialized()) { LibsodiumInitializer.initialize() } - - val keys = IdentityKeyManager.getCurrentKeys() - ?: IdentityKeyManager.restoreFromLocal() - ?: error("Identity keys not initialized. Please log in again.") - val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES) val ciphertext = Box.easy( fileBytes.toUByteArray(), nonce, Base64.decode(transportPublicKeyB64).toUByteArray(), - keys.privateKey.toUByteArray() + ephemeralSecretKey.toUByteArray() ) - - // Files are sent as nonce || ciphertext, base64-encoded by the caller val nonceBytes = nonce.toByteArray() val cipherBytes = ciphertext.toByteArray() val result = ByteArray(nonceBytes.size + cipherBytes.size)