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 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)
}
}
}
@@ -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)