mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Add safeguard against corrupted messages, fix file encryption
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
+27
-17
@@ -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)
|
||||
sendInline(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher)
|
||||
} else {
|
||||
sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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" }
|
||||
|
||||
try {
|
||||
BackupCryptoPlatform.aesGcmDecrypt(mek, iv, ciphertext)
|
||||
} catch (e: GeneralSecurityException) {
|
||||
throw DmCiphertextCorruptedException(cause = e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-26
@@ -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<TransportCiphertext, ByteArray> = withContext(Dispatchers.Default) {
|
||||
encryptWithTransportKeyWithEphemeralSecretInner(plaintext, transportPublicKeyB64)
|
||||
}
|
||||
|
||||
private fun encryptWithTransportKeyWithEphemeralSecretInner(
|
||||
plaintext: String,
|
||||
transportPublicKeyB64: String
|
||||
): Pair<TransportCiphertext, ByteArray> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,9 +243,15 @@ actual fun DebugApiScreen() {
|
||||
runCatching {
|
||||
val fileBytes = "test file".encodeToByteArray()
|
||||
val transportKey = ApiClient.getTransportPublicKey()
|
||||
val (msgCipher, secret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret(
|
||||
plaintext = "test file",
|
||||
transportPublicKeyB64 = transportKey.publicKeyB64
|
||||
)
|
||||
try {
|
||||
val transportBlob = TransportCrypto.encryptFileForTransport(
|
||||
fileBytes = fileBytes,
|
||||
transportPublicKeyB64 = transportKey.publicKeyB64
|
||||
transportPublicKeyB64 = transportKey.publicKeyB64,
|
||||
ephemeralSecretKey = secret
|
||||
)
|
||||
val sendFile = SendDmFile(
|
||||
encryptedFileDataB64 = Base64.encode(transportBlob),
|
||||
@@ -255,8 +261,12 @@ actual fun DebugApiScreen() {
|
||||
ApiClient.sendDm(
|
||||
recipientId = 2,
|
||||
plaintext = "test file",
|
||||
transportFiles = listOf(sendFile)
|
||||
transportFiles = listOf(sendFile),
|
||||
preparedTransport = msgCipher
|
||||
)
|
||||
} finally {
|
||||
secret.fill(0)
|
||||
}
|
||||
}.onSuccess {
|
||||
statusMessage = "Protocol test file sent to user 2"
|
||||
}.onFailure {
|
||||
|
||||
@@ -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<SendDmFile> = emptyList(),
|
||||
uploadedFileIds: List<String> = emptyList()
|
||||
uploadedFileIds: List<String> = 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(
|
||||
val transportCipher = preparedTransport ?: run {
|
||||
val transportKey = getTransportPublicKey()
|
||||
TransportCrypto.encryptWithTransportKey(
|
||||
plaintext = plaintext,
|
||||
transportPublicKeyB64 = transportKey.publicKeyB64
|
||||
)
|
||||
}
|
||||
|
||||
val senderPublicKeyB64 = Base64.encode(keys.publicKey)
|
||||
|
||||
|
||||
@@ -113,7 +113,9 @@ data class Message(
|
||||
/** File sizes in bytes (by index); from decrypted message JSON. */
|
||||
@kotlinx.serialization.Transient val fileSizes: List<Long>? = null,
|
||||
/** 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
|
||||
|
||||
@@ -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.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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TransportCiphertext, ByteArray>
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -633,7 +633,7 @@ fun ChatScreen(
|
||||
},
|
||||
onEdit = { message ->
|
||||
editingMessage = message
|
||||
inputText = message.content
|
||||
inputText = if (message.isContentCorrupted) "" else message.content
|
||||
replyTo = null
|
||||
},
|
||||
onDelete = { message ->
|
||||
|
||||
@@ -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<Pair<Int, String>>()
|
||||
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,15 +178,14 @@ 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)
|
||||
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||
} else {
|
||||
addMessage(createMessage(envelope, plaintext))
|
||||
addMessage(createMessage(envelope, outcome.plaintext, outcome.isCorrupted))
|
||||
}
|
||||
if (envelope.replyToId != null) {
|
||||
val replyTo = _state.messages.find { it.id == envelope.replyToId }
|
||||
@@ -179,10 +194,9 @@ class DmPanel(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,9 +261,8 @@ 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)
|
||||
val outcome = decryptDmEnvelopeForUi(envelope)
|
||||
val dec = parseDecryptedContent(outcome.plaintext)
|
||||
updateMessage(envelope.id) {
|
||||
it.copy(
|
||||
content = dec.text,
|
||||
@@ -257,12 +270,10 @@ class DmPanel(
|
||||
fileThumbnails = dec.thumbnails ?: it.fileThumbnails,
|
||||
fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios,
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
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") {
|
||||
val key = mek.require("MEK must be 32 bytes") {
|
||||
it.size == AES_KEY_SIZE
|
||||
},
|
||||
Base64
|
||||
}
|
||||
val iv = Base64
|
||||
.decode(ivB64)
|
||||
.require("IV must be 12 bytes") {
|
||||
it.size == GCM_IV_SIZE
|
||||
},
|
||||
Base64
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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<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(
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user