Add safeguard against corrupted messages, fix file encryption

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-03-28 21:49:21 +03:00
Unverified
parent c09d605458
commit 849262a79d
15 changed files with 248 additions and 151 deletions
@@ -17,6 +17,7 @@ import com.pr0gramm3r101.utils.settings.settings
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import ru.fromchat.crypto.transport.TransportCiphertext
import ru.fromchat.crypto.transport.TransportCrypto import ru.fromchat.crypto.transport.TransportCrypto
private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024 private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024
@@ -100,12 +101,27 @@ class DmAttachmentUploadWorker(
return runCatching { return runCatching {
emitProgress(jobId, 0) 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) { if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) {
sendInline(jobId, recipientId, plaintext, filename, encryptedBlob) sendInline(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher)
} else { } else {
sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob) sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher)
}
} finally {
ephemeralSecret.fill(0)
} }
clearResumableState(jobId) 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( private suspend fun sendInline(
jobId: String, jobId: String,
recipientId: Int, recipientId: Int,
plaintext: String, plaintext: String,
filename: String, filename: String,
encryptedBlob: ByteArray encryptedBlob: ByteArray,
msgCipher: TransportCiphertext
) { ) {
val file = SendDmFile( val file = SendDmFile(
encryptedFileDataB64 = Base64.encode(encryptedBlob), encryptedFileDataB64 = Base64.encode(encryptedBlob),
@@ -148,7 +155,8 @@ class DmAttachmentUploadWorker(
recipientId = recipientId, recipientId = recipientId,
plaintext = plaintext, plaintext = plaintext,
clientMessageId = jobId, clientMessageId = jobId,
transportFiles = listOf(file) transportFiles = listOf(file),
preparedTransport = msgCipher
) )
} }
@@ -157,7 +165,8 @@ class DmAttachmentUploadWorker(
recipientId: Int, recipientId: Int,
plaintext: String, plaintext: String,
filename: String, filename: String,
encryptedBlob: ByteArray encryptedBlob: ByteArray,
msgCipher: TransportCiphertext
) { ) {
val uploadId = settings.getString(uploadIdKey(jobId), "").ifBlank { val uploadId = settings.getString(uploadIdKey(jobId), "").ifBlank {
val init = ApiClient.initDmUpload( val init = ApiClient.initDmUpload(
@@ -188,7 +197,8 @@ class DmAttachmentUploadWorker(
recipientId = recipientId, recipientId = recipientId,
plaintext = plaintext, plaintext = plaintext,
clientMessageId = jobId, clientMessageId = jobId,
uploadedFileIds = listOf(completed.fileId) uploadedFileIds = listOf(completed.fileId),
preparedTransport = msgCipher
) )
} }
@@ -3,7 +3,9 @@ package ru.fromchat.crypto.dm
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.backup.BackupCryptoPlatform import ru.fromchat.crypto.backup.BackupCryptoPlatform
import java.security.GeneralSecurityException
actual object DmCrypto { actual object DmCrypto {
private const val AES_KEY_SIZE = 32 private const val AES_KEY_SIZE = 32
@@ -22,7 +24,11 @@ actual object DmCrypto {
val iv = wrapped.sliceArray(0 until GCM_IV_SIZE) val iv = wrapped.sliceArray(0 until GCM_IV_SIZE)
val ciphertext = wrapped.sliceArray(GCM_IV_SIZE until wrapped.size) val ciphertext = wrapped.sliceArray(GCM_IV_SIZE until wrapped.size)
try {
BackupCryptoPlatform.aesGcmDecrypt(wrappingKey, iv, ciphertext) BackupCryptoPlatform.aesGcmDecrypt(wrappingKey, iv, ciphertext)
} catch (e: GeneralSecurityException) {
throw DmCiphertextCorruptedException(cause = e)
}
} }
actual suspend fun decryptEnvelope( actual suspend fun decryptEnvelope(
@@ -38,7 +44,11 @@ actual object DmCrypto {
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" } require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" } require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" }
try {
BackupCryptoPlatform.aesGcmDecrypt(mek, iv, ciphertext) BackupCryptoPlatform.aesGcmDecrypt(mek, iv, ciphertext)
} catch (e: GeneralSecurityException) {
throw DmCiphertextCorruptedException(cause = e)
}
} }
} }
@@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.security.SecureRandom import java.security.SecureRandom
import java.util.Base64 import java.util.Base64
import ru.fromchat.crypto.IdentityKeyManager
actual object TransportCrypto { actual object TransportCrypto {
private val random = SecureRandom() private val random = SecureRandom()
@@ -14,54 +13,50 @@ actual object TransportCrypto {
plaintext: String, plaintext: String,
transportPublicKeyB64: String transportPublicKeyB64: String
): TransportCiphertext = withContext(Dispatchers.Default) { ): 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<TransportCiphertext, ByteArray> = withContext(Dispatchers.Default) {
encryptWithTransportKeyWithEphemeralSecretInner(plaintext, transportPublicKeyB64)
}
private fun encryptWithTransportKeyWithEphemeralSecretInner(
plaintext: String,
transportPublicKeyB64: String
): Pair<TransportCiphertext, ByteArray> {
val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64) val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64)
// Ephemeral X25519 keypair for this message
val keyPair = TweetNaclFast.Box.keyPair() val keyPair = TweetNaclFast.Box.keyPair()
// NaCl box with (server transport public key, our ephemeral secret key)
val box = TweetNaclFast.Box(transportPublicKey, keyPair.secretKey) val box = TweetNaclFast.Box(transportPublicKey, keyPair.secretKey)
// 24-byte nonce as required by NaCl box
val nonce = ByteArray(TweetNaclFast.Box.nonceLength) val nonce = ByteArray(TweetNaclFast.Box.nonceLength)
random.nextBytes(nonce) random.nextBytes(nonce)
val ciphertext = box.box(plaintext.encodeToByteArray(), nonce) val ciphertext = box.box(plaintext.encodeToByteArray(), nonce)
val encoder = Base64.getEncoder() val encoder = Base64.getEncoder()
TransportCiphertext( val cipher = TransportCiphertext(
clientPublicKeyB64 = encoder.encodeToString(keyPair.publicKey), clientPublicKeyB64 = encoder.encodeToString(keyPair.publicKey),
nonceB64 = encoder.encodeToString(nonce), nonceB64 = encoder.encodeToString(nonce),
ciphertextB64 = encoder.encodeToString(ciphertext) ciphertextB64 = encoder.encodeToString(ciphertext)
) )
return cipher to keyPair.secretKey.copyOf()
} }
actual suspend fun encryptFileForTransport( actual suspend fun encryptFileForTransport(
fileBytes: ByteArray, fileBytes: ByteArray,
transportPublicKeyB64: String transportPublicKeyB64: String,
ephemeralSecretKey: ByteArray
): ByteArray = withContext(Dispatchers.Default) { ): 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) val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64)
val box = TweetNaclFast.Box(transportPublicKey, ephemeralSecretKey)
// 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 nonce = ByteArray(TweetNaclFast.Box.nonceLength) val nonce = ByteArray(TweetNaclFast.Box.nonceLength)
random.nextBytes(nonce) random.nextBytes(nonce)
val ciphertext = box.box(fileBytes, 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) val result = ByteArray(nonce.size + ciphertext.size)
System.arraycopy(nonce, 0, result, 0, nonce.size) System.arraycopy(nonce, 0, result, 0, nonce.size)
System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size) System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size)
result result
} }
} }
@@ -243,9 +243,15 @@ actual fun DebugApiScreen() {
runCatching { runCatching {
val fileBytes = "test file".encodeToByteArray() val fileBytes = "test file".encodeToByteArray()
val transportKey = ApiClient.getTransportPublicKey() val transportKey = ApiClient.getTransportPublicKey()
val (msgCipher, secret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret(
plaintext = "test file",
transportPublicKeyB64 = transportKey.publicKeyB64
)
try {
val transportBlob = TransportCrypto.encryptFileForTransport( val transportBlob = TransportCrypto.encryptFileForTransport(
fileBytes = fileBytes, fileBytes = fileBytes,
transportPublicKeyB64 = transportKey.publicKeyB64 transportPublicKeyB64 = transportKey.publicKeyB64,
ephemeralSecretKey = secret
) )
val sendFile = SendDmFile( val sendFile = SendDmFile(
encryptedFileDataB64 = Base64.encode(transportBlob), encryptedFileDataB64 = Base64.encode(transportBlob),
@@ -255,8 +261,12 @@ actual fun DebugApiScreen() {
ApiClient.sendDm( ApiClient.sendDm(
recipientId = 2, recipientId = 2,
plaintext = "test file", plaintext = "test file",
transportFiles = listOf(sendFile) transportFiles = listOf(sendFile),
preparedTransport = msgCipher
) )
} finally {
secret.fill(0)
}
}.onSuccess { }.onSuccess {
statusMessage = "Protocol test file sent to user 2" statusMessage = "Protocol test file sent to user 2"
}.onFailure { }.onFailure {
@@ -35,6 +35,7 @@ import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import ru.fromchat.crypto.IdentityKeyManager import ru.fromchat.crypto.IdentityKeyManager
import ru.fromchat.crypto.transport.TransportCiphertext
import ru.fromchat.crypto.transport.TransportCrypto import ru.fromchat.crypto.transport.TransportCrypto
/** /**
@@ -254,7 +255,8 @@ object ApiClient {
clientMessageId: String? = null, clientMessageId: String? = null,
replyToId: Int? = null, replyToId: Int? = null,
transportFiles: List<SendDmFile> = emptyList(), transportFiles: List<SendDmFile> = emptyList(),
uploadedFileIds: List<String> = emptyList() uploadedFileIds: List<String> = emptyList(),
preparedTransport: TransportCiphertext? = null
) { ) {
val keys = IdentityKeyManager.getCurrentKeys() val keys = IdentityKeyManager.getCurrentKeys()
?: IdentityKeyManager.restoreFromLocal() ?: IdentityKeyManager.restoreFromLocal()
@@ -262,12 +264,14 @@ object ApiClient {
val recipientPublicKey = getUserPublicKey(recipientId).publicKey val recipientPublicKey = getUserPublicKey(recipientId).publicKey
?: error("Recipient public key not found") ?: error("Recipient public key not found")
val transportKey = getTransportPublicKey()
val transportCipher = TransportCrypto.encryptWithTransportKey( val transportCipher = preparedTransport ?: run {
val transportKey = getTransportPublicKey()
TransportCrypto.encryptWithTransportKey(
plaintext = plaintext, plaintext = plaintext,
transportPublicKeyB64 = transportKey.publicKeyB64 transportPublicKeyB64 = transportKey.publicKeyB64
) )
}
val senderPublicKeyB64 = Base64.encode(keys.publicKey) val senderPublicKeyB64 = Base64.encode(keys.publicKey)
@@ -113,7 +113,9 @@ data class Message(
/** File sizes in bytes (by index); from decrypted message JSON. */ /** File sizes in bytes (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileSizes: List<Long>? = null, @kotlinx.serialization.Transient val fileSizes: List<Long>? = null,
/** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */ /** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileDimensions: List<Pair<Int, Int>>? = null @kotlinx.serialization.Transient val fileDimensions: List<Pair<Int, Int>>? = null,
/** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */
@kotlinx.serialization.Transient val isContentCorrupted: Boolean = false
) )
@Serializable @Serializable
@@ -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)
@@ -4,6 +4,9 @@ import com.pr0gramm3r101.utils.crypto.PasswordHash
import ru.fromchat.api.DmEnvelope import ru.fromchat.api.DmEnvelope
import ru.fromchat.crypto.dm.DmCrypto 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 * Unwrap a MEK (Message Encryption Key) using the appropriate wrapping key
* Matches Web implementation: derive wrapping key from our public key using HKDF * 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 { suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String {
val wrappedMekB64 = envelope.wrappedMekB64 val wrappedMekB64 = envelope.wrappedMekB64
?: throw IllegalArgumentException("No wrapped MEK available for decryption") ?: throw IllegalArgumentException("No wrapped MEK available for decryption")
// Unwrap the MEK
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId) val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
// Decrypt the message using the unwrapped MEK
val plaintext = DmCrypto.decryptEnvelope(envelope.ivB64, envelope.ciphertextB64, mek) val plaintext = DmCrypto.decryptEnvelope(envelope.ivB64, envelope.ciphertextB64, mek)
return plaintext.decodeToString() return plaintext.decodeToString()
} }
@@ -2,11 +2,12 @@ package ru.fromchat.crypto
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import com.pr0gramm3r101.utils.settings.secureSettings import com.pr0gramm3r101.utils.settings.secureSettings
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
@@ -14,9 +15,9 @@ import ru.fromchat.api.PublicKeyResponse
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.crypto.backup.BackupCrypto import ru.fromchat.crypto.backup.BackupCrypto
import ru.fromchat.crypto.backup.PrivateKeyBundle
import ru.fromchat.crypto.backup.decodeBlob import ru.fromchat.crypto.backup.decodeBlob
import ru.fromchat.crypto.backup.encodeBlob import ru.fromchat.crypto.backup.encodeBlob
import ru.fromchat.crypto.backup.PrivateKeyBundle
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
/** /**
@@ -168,6 +169,7 @@ object IdentityKeyManager {
private suspend fun uploadBackupBlob(blobJson: String, token: String) { private suspend fun uploadBackupBlob(blobJson: String, token: String) {
val payload = BackupBlobRequest(blob = blobJson) val payload = BackupBlobRequest(blob = blobJson)
ApiClient.http.post("${Config.apiBaseUrl}/crypto/backup") { ApiClient.http.post("${Config.apiBaseUrl}/crypto/backup") {
contentType(ContentType.Application.Json)
setBody(payload) setBody(payload)
} }
} }
@@ -185,6 +187,7 @@ object IdentityKeyManager {
private suspend fun uploadPublicKey(publicKey: ByteArray, token: String) { private suspend fun uploadPublicKey(publicKey: ByteArray, token: String) {
val payload = UploadPublicKeyRequest(publicKey = Base64.encode(publicKey)) val payload = UploadPublicKeyRequest(publicKey = Base64.encode(publicKey))
ApiClient.http.post("${Config.apiBaseUrl}/crypto/public-key") { ApiClient.http.post("${Config.apiBaseUrl}/crypto/public-key") {
contentType(ContentType.Application.Json)
setBody(payload) setBody(payload)
} }
} }
@@ -25,17 +25,23 @@ expect object TransportCrypto {
transportPublicKeyB64: String transportPublicKeyB64: String
): TransportCiphertext ): 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<TransportCiphertext, ByteArray>
/** /**
* Encrypt raw file bytes for transport using the server's transport public key * Encrypt raw file bytes for transport using the server's transport public key
* and the client's long-term identity private key. * and the same ephemeral secret used for the message (nonce || ciphertext).
*
* 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`
*/ */
suspend fun encryptFileForTransport( suspend fun encryptFileForTransport(
fileBytes: ByteArray, fileBytes: ByteArray,
transportPublicKeyB64: String transportPublicKeyB64: String,
ephemeralSecretKey: ByteArray
): ByteArray ): ByteArray
} }
@@ -276,19 +276,29 @@ fun ChatInput(
) )
) { ) {
AnimatedPreviewBar(replyTo) { replyTo -> AnimatedPreviewBar(replyTo) { replyTo ->
val replySubtitle = if (replyTo.isContentCorrupted) {
"Corrupted message"
} else {
replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else ""
}
PreviewBar( PreviewBar(
icon = Icons.AutoMirrored.Filled.Reply, icon = Icons.AutoMirrored.Filled.Reply,
title = "Replying to ${replyTo.username}", title = "Replying to ${replyTo.username}",
subtitle = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "", subtitle = replySubtitle,
onClose = { onClearReply() } onClose = { onClearReply() }
) )
} }
AnimatedPreviewBar(editingMessage) { message -> AnimatedPreviewBar(editingMessage) { message ->
val subtitle = if (message.isContentCorrupted) {
"Corrupted message"
} else {
message.content.take(50) + if (message.content.length > 50) "..." else ""
}
PreviewBar( PreviewBar(
icon = Icons.Filled.Edit, icon = Icons.Filled.Edit,
title = "Editing message", title = "Editing message",
subtitle = message.content.take(50) + if (message.content.length > 50) "..." else "", subtitle = subtitle,
onClose = { onClearEdit() } onClose = { onClearEdit() }
) )
} }
@@ -633,7 +633,7 @@ fun ChatScreen(
}, },
onEdit = { message -> onEdit = { message ->
editingMessage = message editingMessage = message
inputText = message.content inputText = if (message.isContentCorrupted) "" else message.content
replyTo = null replyTo = null
}, },
onDelete = { message -> onDelete = { message ->
@@ -18,6 +18,8 @@ import ru.fromchat.api.Message
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketMessage
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.crypto.CorruptedDmMessagePlaceholder
import ru.fromchat.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.decryptEnvelope import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.AvatarInfo
import ru.fromchat.ui.chat.ChatPanel import ru.fromchat.ui.chat.ChatPanel
@@ -40,6 +42,21 @@ class DmPanel(
private var otherProfilePicture: String? = null private var otherProfilePicture: String? = null
private val dmEnvelopeMutex = Mutex() 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 { init {
updateState { it.copy(title = "Direct message", profileUserId = otherUserId) } updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
coroutineScope.launch { coroutineScope.launch {
@@ -86,10 +103,9 @@ class DmPanel(
clearMessages() clearMessages()
val decryptedForLog = mutableListOf<Pair<Int, String>>() val decryptedForLog = mutableListOf<Pair<Int, String>>()
val messages = response.messages.map { envelope -> val messages = response.messages.map { envelope ->
decryptEnvelope(envelope, currentUserId).let { plaintext -> val outcome = decryptDmEnvelopeForUi(envelope)
decryptedForLog.add(envelope.id to plaintext) decryptedForLog.add(envelope.id to outcome.plaintext)
createMessage(envelope, plaintext) createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
}
} }
decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) -> decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) ->
Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json") Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json")
@@ -162,15 +178,14 @@ class DmPanel(
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
dmEnvelopeMutex.withLock { dmEnvelopeMutex.withLock {
val alreadyExists = _state.messages.any { it.id == envelope.id } 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 (alreadyExists) return@withLock
if (plaintext != null) {
if (envelope.senderId == currentUserId) { if (envelope.senderId == currentUserId) {
mergeConfirmedOwnMessage(envelope, plaintext) mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
} else { } else {
addMessage(createMessage(envelope, plaintext)) addMessage(createMessage(envelope, outcome.plaintext, outcome.isCorrupted))
} }
if (envelope.replyToId != null) { if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId } val replyTo = _state.messages.find { it.id == envelope.replyToId }
@@ -179,10 +194,9 @@ class DmPanel(
} }
} }
} }
}
private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String) { private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean) {
val confirmed = createMessage(envelope, plaintext) val confirmed = createMessage(envelope, plaintext, isContentCorrupted)
val hasAttachments = !envelope.files.isNullOrEmpty() val hasAttachments = !envelope.files.isNullOrEmpty()
updateState { currentState -> updateState { currentState ->
@@ -247,9 +261,8 @@ class DmPanel(
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
DecryptedImageCache.invalidateForMessage(envelope.id) DecryptedImageCache.invalidateForMessage(envelope.id)
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val outcome = decryptDmEnvelopeForUi(envelope)
if (plaintext != null) { val dec = parseDecryptedContent(outcome.plaintext)
val dec = parseDecryptedContent(plaintext)
updateMessage(envelope.id) { updateMessage(envelope.id) {
it.copy( it.copy(
content = dec.text, content = dec.text,
@@ -257,12 +270,10 @@ class DmPanel(
fileThumbnails = dec.thumbnails ?: it.fileThumbnails, fileThumbnails = dec.thumbnails ?: it.fileThumbnails,
fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios, fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios,
fileSizes = dec.fileSizes ?: it.fileSizes, fileSizes = dec.fileSizes ?: it.fileSizes,
fileDimensions = dec.fileDimensions ?: it.fileDimensions fileDimensions = dec.fileDimensions ?: it.fileDimensions,
isContentCorrupted = outcome.isCorrupted
) )
} }
} else {
updateMessage(envelope.id) { it.copy(is_edited = true) }
}
} }
} }
@@ -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 dec = parseDecryptedContent(plaintext)
val username = if (envelope.senderId == currentUserId) { val username = if (envelope.senderId == currentUserId) {
"You" "You"
@@ -326,7 +337,8 @@ class DmPanel(
fileThumbnails = dec.thumbnails, fileThumbnails = dec.thumbnails,
fileAspectRatios = dec.aspectRatios, fileAspectRatios = dec.aspectRatios,
fileSizes = dec.fileSizes, 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) ApiClient.editDm(messageId = messageId, recipientId = otherUserId, plaintext = content)
}.onSuccess { }.onSuccess {
updateMessage(messageId) { msg -> updateMessage(messageId) { msg ->
msg.copy(content = content, is_edited = true) msg.copy(content = content, is_edited = true, isContentCorrupted = false)
} }
} }
} }
@@ -7,6 +7,7 @@ import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.algorithms.AES import dev.whyoleg.cryptography.algorithms.AES
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.crypto.DmCiphertextCorruptedException
private const val AES_KEY_SIZE = 32 private const val AES_KEY_SIZE = 32
private const val GCM_IV_SIZE = 12 private const val GCM_IV_SIZE = 12
@@ -25,11 +26,15 @@ actual object DmCrypto {
val wrapped = Base64.decode(wrappedMekB64) val wrapped = Base64.decode(wrappedMekB64)
require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" } require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" }
try {
aesGcmDecrypt( aesGcmDecrypt(
wrappingKey, wrappingKey,
wrapped.sliceArray(0 until GCM_IV_SIZE), wrapped.sliceArray(0 until GCM_IV_SIZE),
wrapped.sliceArray(GCM_IV_SIZE until wrapped.size) wrapped.sliceArray(GCM_IV_SIZE until wrapped.size)
) )
} catch (e: Throwable) {
throw DmCiphertextCorruptedException(cause = e)
}
} }
actual suspend fun decryptEnvelope( actual suspend fun decryptEnvelope(
@@ -37,21 +42,24 @@ actual object DmCrypto {
ciphertextB64: String, ciphertextB64: String,
mek: ByteArray mek: ByteArray
) = withContext(Dispatchers.Default) { ) = withContext(Dispatchers.Default) {
aesGcmDecrypt( val key = mek.require("MEK must be 32 bytes") {
mek.require("MEK must be 32 bytes") {
it.size == AES_KEY_SIZE it.size == AES_KEY_SIZE
}, }
Base64 val iv = Base64
.decode(ivB64) .decode(ivB64)
.require("IV must be 12 bytes") { .require("IV must be 12 bytes") {
it.size == GCM_IV_SIZE it.size == GCM_IV_SIZE
}, }
Base64 val ciphertext = Base64
.decode(ciphertextB64) .decode(ciphertextB64)
.require("Ciphertext too short") { .require("Ciphertext too short") {
it.size >= GCM_TAG_SIZE 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 { private suspend fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray {
@@ -7,7 +7,6 @@ import com.ionspin.kotlin.crypto.util.LibsodiumRandom
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.crypto.IdentityKeyManager
actual object TransportCrypto { actual object TransportCrypto {
actual suspend fun encryptWithTransportKey( actual suspend fun encryptWithTransportKey(
@@ -17,7 +16,6 @@ actual object TransportCrypto {
if (!LibsodiumInitializer.isInitialized()) { if (!LibsodiumInitializer.isInitialized()) {
LibsodiumInitializer.initialize() LibsodiumInitializer.initialize()
} }
val keyPair = Box.keypair() val keyPair = Box.keypair()
val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES) val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES)
val ciphertext = Box.easy( val ciphertext = Box.easy(
@@ -26,35 +24,55 @@ actual object TransportCrypto {
Base64.decode(transportPublicKeyB64).toUByteArray(), Base64.decode(transportPublicKeyB64).toUByteArray(),
keyPair.secretKey keyPair.secretKey
) )
val cipher = TransportCiphertext(
TransportCiphertext(
clientPublicKeyB64 = Base64.encode(keyPair.publicKey.toByteArray()), clientPublicKeyB64 = Base64.encode(keyPair.publicKey.toByteArray()),
nonceB64 = Base64.encode(nonce.toByteArray()), nonceB64 = Base64.encode(nonce.toByteArray()),
ciphertextB64 = Base64.encode(ciphertext.toByteArray()) ciphertextB64 = Base64.encode(ciphertext.toByteArray())
) )
keyPair.secretKey.fill(0u)
cipher
}
actual suspend fun encryptWithTransportKeyWithEphemeralSecret(
plaintext: String,
transportPublicKeyB64: String
): Pair<TransportCiphertext, ByteArray> = 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( actual suspend fun encryptFileForTransport(
fileBytes: ByteArray, fileBytes: ByteArray,
transportPublicKeyB64: String transportPublicKeyB64: String,
ephemeralSecretKey: ByteArray
): ByteArray = withContext(Dispatchers.Default) { ): ByteArray = withContext(Dispatchers.Default) {
if (!LibsodiumInitializer.isInitialized()) { if (!LibsodiumInitializer.isInitialized()) {
LibsodiumInitializer.initialize() 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 nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES)
val ciphertext = Box.easy( val ciphertext = Box.easy(
fileBytes.toUByteArray(), fileBytes.toUByteArray(),
nonce, nonce,
Base64.decode(transportPublicKeyB64).toUByteArray(), 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 nonceBytes = nonce.toByteArray()
val cipherBytes = ciphertext.toByteArray() val cipherBytes = ciphertext.toByteArray()
val result = ByteArray(nonceBytes.size + cipherBytes.size) val result = ByteArray(nonceBytes.size + cipherBytes.size)