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 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-26
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user