Files
mobile-android/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt

925 lines
39 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.rosetta.messenger.crypto
import android.util.Base64
import org.bouncycastle.crypto.engines.ChaCha7539Engine
import org.bouncycastle.crypto.macs.Poly1305
import org.bouncycastle.crypto.params.KeyParameter
import org.bouncycastle.crypto.params.ParametersWithIV
import org.bouncycastle.jce.ECNamedCurveTable
import org.bouncycastle.jce.provider.BouncyCastleProvider
import java.math.BigInteger
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.Security
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Шифрование сообщений как в React Native версии
* XChaCha20-Poly1305 для текста + ECDH + AES для ключа
*/
object MessageCrypto {
private const val CHACHA_KEY_SIZE = 32
private const val XCHACHA_NONCE_SIZE = 24
private const val POLY1305_TAG_SIZE = 16
init {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(BouncyCastleProvider())
}
}
/**
* Результат шифрования сообщения
*/
data class EncryptedMessage(
val ciphertext: String, // Hex-encoded XChaCha20-Poly1305 ciphertext
val key: String, // Hex-encoded 32-byte key
val nonce: String // Hex-encoded 24-byte nonce
)
/**
* Результат расшифровки входящего сообщения
*/
data class DecryptedIncoming(
val plaintext: String, // Расшифрованный текст
val plainKeyAndNonce: ByteArray // Raw key+nonce для расшифровки attachments
)
/**
* XChaCha20-Poly1305 шифрование (совместимо с @noble/ciphers в RN)
*
* XChaCha20-Poly1305 использует 24-byte nonce:
* - Первые 16 байт nonce используются в HChaCha20 для derive subkey
* - Последние 8 байт nonce используются как counter для ChaCha20
*/
fun encryptMessage(plaintext: String): EncryptedMessage {
val secureRandom = SecureRandom()
// Генерируем случайный ключ (32 байта) и nonce (24 байта для XChaCha20)
val key = ByteArray(CHACHA_KEY_SIZE)
val nonce = ByteArray(XCHACHA_NONCE_SIZE)
secureRandom.nextBytes(key)
secureRandom.nextBytes(nonce)
val ciphertext = xchacha20Poly1305Encrypt(plaintext.toByteArray(Charsets.UTF_8), key, nonce)
return EncryptedMessage(
ciphertext = ciphertext.toHex(),
key = key.toHex(),
nonce = nonce.toHex()
)
}
/**
* XChaCha20-Poly1305 encrypt implementation
*/
private fun xchacha20Poly1305Encrypt(plaintext: ByteArray, key: ByteArray, nonce: ByteArray): ByteArray {
// Step 1: Derive subkey using HChaCha20
val subkey = hchacha20(key, nonce.copyOfRange(0, 16))
// Step 2: Create ChaCha20 nonce (4 zeros + last 8 bytes of original nonce)
val chacha20Nonce = ByteArray(12)
// First 4 bytes are 0
System.arraycopy(nonce, 16, chacha20Nonce, 4, 8)
// Step 3: Initialize ChaCha20 engine ONCE
val engine = ChaCha7539Engine()
engine.init(true, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))
// Step 4: Generate Poly1305 key from first 64 bytes of keystream (counter=0)
val poly1305KeyBlock = ByteArray(64)
engine.processBytes(ByteArray(64), 0, 64, poly1305KeyBlock, 0)
// Step 5: Encrypt plaintext (engine continues from counter=1 automatically)
val ciphertext = ByteArray(plaintext.size)
engine.processBytes(plaintext, 0, plaintext.size, ciphertext, 0)
// Step 6: Generate Poly1305 tag
val mac = Poly1305()
mac.init(KeyParameter(poly1305KeyBlock.copyOfRange(0, 32)))
// No AAD in our case, just process ciphertext
mac.update(ciphertext, 0, ciphertext.size)
// Pad to 16 bytes
val padding = (16 - (ciphertext.size % 16)) % 16
if (padding > 0) {
mac.update(ByteArray(padding), 0, padding)
}
// Length of AAD (0) as 64-bit LE
mac.update(ByteArray(8), 0, 8)
// Length of ciphertext as 64-bit LE
val ctLen = longToLittleEndian(ciphertext.size.toLong())
mac.update(ctLen, 0, 8)
val tag = ByteArray(POLY1305_TAG_SIZE)
mac.doFinal(tag, 0)
// Return ciphertext + tag
return ciphertext + tag
}
/**
* HChaCha20 - derives a 256-bit subkey from a 256-bit key and 128-bit nonce
*/
private fun hchacha20(key: ByteArray, nonce: ByteArray): ByteArray {
val state = IntArray(16)
// Constants "expand 32-byte k"
state[0] = 0x61707865
state[1] = 0x3320646e
state[2] = 0x79622d32
state[3] = 0x6b206574
// Key
for (i in 0..7) {
state[4 + i] = littleEndianToInt(key, i * 4)
}
// Nonce
for (i in 0..3) {
state[12 + i] = littleEndianToInt(nonce, i * 4)
}
// 20 rounds
for (i in 0 until 10) {
// Column rounds
quarterRound(state, 0, 4, 8, 12)
quarterRound(state, 1, 5, 9, 13)
quarterRound(state, 2, 6, 10, 14)
quarterRound(state, 3, 7, 11, 15)
// Diagonal rounds
quarterRound(state, 0, 5, 10, 15)
quarterRound(state, 1, 6, 11, 12)
quarterRound(state, 2, 7, 8, 13)
quarterRound(state, 3, 4, 9, 14)
}
// Output first 4 and last 4 words
val subkey = ByteArray(32)
intToLittleEndian(state[0], subkey, 0)
intToLittleEndian(state[1], subkey, 4)
intToLittleEndian(state[2], subkey, 8)
intToLittleEndian(state[3], subkey, 12)
intToLittleEndian(state[12], subkey, 16)
intToLittleEndian(state[13], subkey, 20)
intToLittleEndian(state[14], subkey, 24)
intToLittleEndian(state[15], subkey, 28)
return subkey
}
private fun quarterRound(state: IntArray, a: Int, b: Int, c: Int, d: Int) {
state[a] += state[b]; state[d] = rotl(state[d] xor state[a], 16)
state[c] += state[d]; state[b] = rotl(state[b] xor state[c], 12)
state[a] += state[b]; state[d] = rotl(state[d] xor state[a], 8)
state[c] += state[d]; state[b] = rotl(state[b] xor state[c], 7)
}
private fun rotl(v: Int, c: Int): Int = (v shl c) or (v ushr (32 - c))
private fun littleEndianToInt(bs: ByteArray, off: Int): Int {
return (bs[off].toInt() and 0xff) or
((bs[off + 1].toInt() and 0xff) shl 8) or
((bs[off + 2].toInt() and 0xff) shl 16) or
((bs[off + 3].toInt() and 0xff) shl 24)
}
private fun intToLittleEndian(n: Int, bs: ByteArray, off: Int) {
bs[off] = n.toByte()
bs[off + 1] = (n ushr 8).toByte()
bs[off + 2] = (n ushr 16).toByte()
bs[off + 3] = (n ushr 24).toByte()
}
private fun longToLittleEndian(n: Long): ByteArray {
val bs = ByteArray(8)
bs[0] = n.toByte()
bs[1] = (n ushr 8).toByte()
bs[2] = (n ushr 16).toByte()
bs[3] = (n ushr 24).toByte()
bs[4] = (n ushr 32).toByte()
bs[5] = (n ushr 40).toByte()
bs[6] = (n ushr 48).toByte()
bs[7] = (n ushr 56).toByte()
return bs
}
/**
* Расшифровка текста сообщения (XChaCha20-Poly1305)
*/
fun decryptMessage(ciphertext: String, keyHex: String, nonceHex: String): String {
val key = keyHex.hexToBytes()
val nonce = nonceHex.hexToBytes()
val ciphertextWithTag = ciphertext.hexToBytes()
val plaintext = xchacha20Poly1305Decrypt(ciphertextWithTag, key, nonce)
return String(plaintext, Charsets.UTF_8)
}
/**
* XChaCha20-Poly1305 decrypt implementation
*/
private fun xchacha20Poly1305Decrypt(ciphertextWithTag: ByteArray, key: ByteArray, nonce: ByteArray): ByteArray {
if (ciphertextWithTag.size < POLY1305_TAG_SIZE) {
throw IllegalArgumentException("Ciphertext too short")
}
val ciphertext = ciphertextWithTag.copyOfRange(0, ciphertextWithTag.size - POLY1305_TAG_SIZE)
val tag = ciphertextWithTag.copyOfRange(ciphertextWithTag.size - POLY1305_TAG_SIZE, ciphertextWithTag.size)
// Step 1: Derive subkey using HChaCha20
val subkey = hchacha20(key, nonce.copyOfRange(0, 16))
// Step 2: Create ChaCha20 nonce
val chacha20Nonce = ByteArray(12)
System.arraycopy(nonce, 16, chacha20Nonce, 4, 8)
// Step 3: Initialize ChaCha20 engine ONCE
val engine = ChaCha7539Engine()
engine.init(true, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))
// Step 4: Generate Poly1305 key from first 64 bytes of keystream (counter=0)
val poly1305KeyBlock = ByteArray(64)
engine.processBytes(ByteArray(64), 0, 64, poly1305KeyBlock, 0)
// Step 5: Verify Poly1305 tag
val mac = Poly1305()
mac.init(KeyParameter(poly1305KeyBlock.copyOfRange(0, 32)))
mac.update(ciphertext, 0, ciphertext.size)
val padding = (16 - (ciphertext.size % 16)) % 16
if (padding > 0) {
mac.update(ByteArray(padding), 0, padding)
}
mac.update(ByteArray(8), 0, 8)
val ctLen = longToLittleEndian(ciphertext.size.toLong())
mac.update(ctLen, 0, 8)
val computedTag = ByteArray(POLY1305_TAG_SIZE)
mac.doFinal(computedTag, 0)
if (!tag.contentEquals(computedTag)) {
throw SecurityException("Authentication failed")
}
// Step 6: Decrypt with ChaCha20 (engine continues from where it left off)
// Note: We already consumed 64 bytes for Poly1305 key, now decrypt ciphertext
// BUT: We need a fresh engine for decryption OR reset it
// Actually, for decryption we need to re-init the engine
val decryptEngine = ChaCha7539Engine()
decryptEngine.init(false, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))
// Skip first 64 bytes (Poly1305 key block)
val skipBlock = ByteArray(64)
decryptEngine.processBytes(ByteArray(64), 0, 64, skipBlock, 0)
val plaintext = ByteArray(ciphertext.size)
decryptEngine.processBytes(ciphertext, 0, ciphertext.size, plaintext, 0)
return plaintext
}
/**
* ECDH шифрование ключа для получателя
* Использует secp256k1 + AES как в RN версии
* Формат: Base64(iv:ciphertext:ephemeralPrivateKeyHex)
*
* JS эквивалент:
* const key = Buffer.concat([keyBytes, nonceBytes]);
* const encryptedKey = await encrypt(key.toString('binary'), publicKey);
*
* КРИТИЧНО: ephemeralKey.getPrivate('hex') в JS может быть БЕЗ ведущих нулей!
*/
fun encryptKeyForRecipient(keyAndNonce: ByteArray, recipientPublicKeyHex: String): String {
val secureRandom = SecureRandom()
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// Генерируем эфемерный приватный ключ (32 байта)
val ephemeralPrivateKeyBytes = ByteArray(32)
secureRandom.nextBytes(ephemeralPrivateKeyBytes)
val ephemeralPrivateKey = BigInteger(1, ephemeralPrivateKeyBytes)
// ⚠️ КРИТИЧНО: JS elliptic.js может вернуть hex БЕЗ ведущих нулей!
// ephemeralKey.getPrivate('hex') - это BigInteger.toString(16)
// Не добавляет ведущие нули если первый байт < 0x10
val ephemeralPrivateKeyHex = ephemeralPrivateKey.toString(16).let {
// Но если нечётная длина, добавим ведущий 0 для правильного парсинга
if (it.length % 2 != 0) {
"0$it"
} else it
}
// Получаем эфемерный публичный ключ
val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey)
val ephemeralPublicKeyHex = ephemeralPublicKey.getEncoded(false).toHex()
// Парсим публичный ключ получателя
val recipientPublicKeyBytes = recipientPublicKeyHex.hexToBytes()
val recipientPublicKey = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
// ECDH: ephemeralPrivate * recipientPublic = sharedSecret
val sharedPoint = recipientPublicKey.multiply(ephemeralPrivateKey)
// ⚠️ КРИТИЧНО: Эмулируем JS поведение!
// JS: BN.toString(16) НЕ добавляет ведущие нули
// crypto.enc.Hex.parse(hex) парсит как есть
// Если X coordinate = 0x00abc..., JS получит "abc..." (меньше 32 байт)
val xCoordBigInt = sharedPoint.normalize().xCoord.toBigInteger()
var sharedSecretHex = xCoordBigInt.toString(16)
// JS: если hex нечётной длины, crypto.enc.Hex.parse добавит ведущий 0
if (sharedSecretHex.length % 2 != 0) {
sharedSecretHex = "0$sharedSecretHex"
}
val sharedSecret = sharedSecretHex.hexToBytes()
// Генерируем IV для AES (16 байт)
val iv = ByteArray(16)
secureRandom.nextBytes(iv)
val ivHex = iv.toHex()
// ⚠️ КРИТИЧНО: Эмулируем поведение React Native + crypto-js!
// React Native: Buffer.toString('binary') → строка с символами (включая > 127)
// crypto-js: AES.encrypt(string, ...) → кодирует строку в UTF-8 перед шифрованием
// Итого: байты > 127 превращаются в многобайтовые UTF-8 последовательности
// Шаг 1: Байты → Latin1 строка (как Buffer.toString('binary'))
val latin1String = String(keyAndNonce, Charsets.ISO_8859_1)
// Шаг 2: Latin1 строка → UTF-8 байты (как crypto-js делает внутри)
val utf8Bytes = latin1String.toByteArray(Charsets.UTF_8)
// AES шифрование
val aesKey = SecretKeySpec(sharedSecret, "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, aesKey, IvParameterSpec(iv))
val encryptedKey = cipher.doFinal(utf8Bytes)
val encryptedKeyHex = encryptedKey.toHex()
// Формат как в RN: btoa(ivHex:encryptedHex:ephemeralPrivateHex)
val combined = "$ivHex:$encryptedKeyHex:$ephemeralPrivateKeyHex"
val result = Base64.encodeToString(combined.toByteArray(), Base64.NO_WRAP)
return result
}
/**
* ECDH расшифровка ключа
* Формат: Base64(ivHex:encryptedHex:ephemeralPrivateHex)
*
* КРИТИЧНО: ephemeralPrivateKeyHex может иметь нечётную длину!
*/
fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray {
val combined = String(Base64.decode(encryptedKeyBase64, Base64.NO_WRAP))
val parts = combined.split(":")
if (parts.size != 3) {
throw IllegalArgumentException("Invalid encrypted key format: expected 3 parts, got ${parts.size}")
}
val ivHex = parts[0]
val encryptedKeyHex = parts[1]
var ephemeralPrivateKeyHex = parts[2]
// ⚠️ КРИТИЧНО: JS toString(16) может вернуть hex нечётной длины!
// Добавляем ведущий 0 если нужно для правильного парсинга
if (ephemeralPrivateKeyHex.length % 2 != 0) {
ephemeralPrivateKeyHex = "0$ephemeralPrivateKeyHex"
}
val iv = ivHex.hexToBytes()
val encryptedKey = encryptedKeyHex.hexToBytes()
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// Парсим эфемерный приватный ключ
val ephemeralPrivateKey = BigInteger(ephemeralPrivateKeyHex, 16)
val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey)
val ephemeralPublicKeyHex = ephemeralPublicKey.getEncoded(false).toHex()
// Парсим мой приватный ключ
val myPrivateKey = BigInteger(myPrivateKeyHex, 16)
val myPublicKey = ecSpec.g.multiply(myPrivateKey)
val myPublicKeyHex = myPublicKey.getEncoded(false).toHex()
// ECDH: ephemeralPrivate * myPublic = sharedSecret
val sharedPoint = myPublicKey.multiply(ephemeralPrivateKey)
// ⚠️ КРИТИЧНО: Эмулируем JS поведение!
// JS: BN.toString(16) НЕ добавляет ведущие нули
val xCoordBigInt = sharedPoint.normalize().xCoord.toBigInteger()
var sharedSecretHex = xCoordBigInt.toString(16)
if (sharedSecretHex.length % 2 != 0) {
sharedSecretHex = "0$sharedSecretHex"
}
val sharedSecret = sharedSecretHex.hexToBytes()
// Расшифровываем используя sharedSecret как AES ключ
val aesKey = SecretKeySpec(sharedSecret, "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
val decryptedUtf8Bytes = cipher.doFinal(encryptedKey)
// ⚠️ КРИТИЧНО: Обратная конвертация UTF-8 → Latin1!
// Desktop: decrypted.toString(crypto.enc.Utf8) → Buffer.from(str, 'binary')
// Это декодирует UTF-8 в строку, потом берёт charCode каждого символа
val utf8String = String(decryptedUtf8Bytes, Charsets.UTF_8)
val originalBytes = utf8String.toByteArray(Charsets.ISO_8859_1)
return originalBytes
}
/**
* Результат шифрования для отправки
*/
data class EncryptedForSending(
val ciphertext: String, // Hex-encoded encrypted message
val encryptedKey: String, // ECDH+AES encrypted key (for recipient)
val plainKeyAndNonce: ByteArray // Raw key+nonce for encrypting attachments
)
/**
* Полное шифрование сообщения для отправки
*/
fun encryptForSending(plaintext: String, recipientPublicKey: String): EncryptedForSending {
// 1. Шифруем текст
val encrypted = encryptMessage(plaintext)
// 2. Собираем key + nonce
val keyAndNonce = encrypted.key.hexToBytes() + encrypted.nonce.hexToBytes()
// 3. Шифруем ключ для получателя
val encryptedKey = encryptKeyForRecipient(keyAndNonce, recipientPublicKey)
return EncryptedForSending(encrypted.ciphertext, encryptedKey, keyAndNonce)
}
/**
* Полная расшифровка входящего сообщения
* Возвращает текст и plainKeyAndNonce для расшифровки attachments
*/
fun decryptIncomingFull(
ciphertext: String,
encryptedKey: String,
myPrivateKey: String
): DecryptedIncoming {
// 1. Расшифровываем ключ
val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey)
// 2. Разделяем key и nonce
val key = keyAndNonce.slice(0 until 32).toByteArray()
val nonce = keyAndNonce.slice(32 until keyAndNonce.size).toByteArray()
// 3. Расшифровываем сообщение
val plaintext = decryptMessage(ciphertext, key.toHex(), nonce.toHex())
return DecryptedIncoming(plaintext, keyAndNonce)
}
/**
* Совместимая версия decryptIncoming (возвращает только текст)
*/
fun decryptIncoming(
ciphertext: String,
encryptedKey: String,
myPrivateKey: String
): String = decryptIncomingFull(ciphertext, encryptedKey, myPrivateKey).plaintext
/**
* Расшифровка MESSAGES attachment blob
* Формат: ivBase64:ciphertextBase64
* Использует PBKDF2 + AES-256-CBC + zlib decompression
*
* @param encryptedData зашифрованный контент (ivBase64:ciphertextBase64)
* @param chachaKeyPlain Уже расшифрованный ChaCha ключ (32 bytes)
*/
fun decryptAttachmentBlobWithPlainKey(
encryptedData: String,
chachaKeyPlain: ByteArray
): String? {
return try {
android.util.Log.d("MessageCrypto", "🔐 decryptAttachmentBlobWithPlainKey: data length=${encryptedData.length}, key=${chachaKeyPlain.size} bytes")
// ВАЖНО: Для attachment используем только первые 32 bytes (ChaCha key без nonce)
val keyOnly = chachaKeyPlain.copyOfRange(0, 32)
// 1. Конвертируем key в строку используя bytesToJsUtf8String
// чтобы совпадало с JS Buffer.toString('utf-8') который заменяет
// невалидные UTF-8 последовательности на U+FFFD
val chachaKeyString = bytesToJsUtf8String(keyOnly)
android.util.Log.d("MessageCrypto", "🔑 ChaCha key string length: ${chachaKeyString.length}")
// 2. Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations, sha1)
val pbkdf2Key = generatePBKDF2Key(chachaKeyString)
android.util.Log.d("MessageCrypto", "🔑 PBKDF2 key: ${pbkdf2Key.size} bytes")
// 3. Расшифровываем AES-256-CBC
val result = decryptWithPBKDF2Key(encryptedData, pbkdf2Key)
android.util.Log.d("MessageCrypto", "✅ Decryption result: ${if (result != null) "success (${result.length} chars)" else "null"}")
result
} catch (e: Exception) {
android.util.Log.e("MessageCrypto", "❌ decryptAttachmentBlobWithPlainKey failed: ${e.message}", e)
null
}
}
/**
* Расшифровка MESSAGES attachment blob (Legacy - с RSA расшифровкой ключа)
* Формат: ivBase64:ciphertextBase64
* Использует PBKDF2 + AES-256-CBC + zlib decompression
*/
fun decryptAttachmentBlob(
encryptedData: String,
encryptedKey: String,
myPrivateKey: String
): String? {
return try {
android.util.Log.d("MessageCrypto", "🔐 decryptAttachmentBlob: data length=${encryptedData.length}, key length=${encryptedKey.length}")
// 1. Расшифровываем ChaCha ключ (как для сообщений)
val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey)
android.util.Log.d("MessageCrypto", "🔑 Decrypted keyAndNonce: ${keyAndNonce.size} bytes")
// 2. Используем новую функцию
decryptAttachmentBlobWithPlainKey(encryptedData, keyAndNonce)
} catch (e: Exception) {
android.util.Log.e("MessageCrypto", "❌ decryptAttachmentBlob failed: ${e.message}", e)
null
}
}
/**
* Генерация PBKDF2 ключа (совместимо с crypto-js / RN)
* ВАЖНО: crypto-js использует PBKDF2WithHmacSHA1 по умолчанию!
*/
private fun generatePBKDF2Key(password: String, salt: String = "rosetta", iterations: Int = 1000): ByteArray {
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec = javax.crypto.spec.PBEKeySpec(
password.toCharArray(),
salt.toByteArray(Charsets.UTF_8),
iterations,
256 // 32 bytes
)
return factory.generateSecret(spec).encoded
}
/**
* Расшифровка с PBKDF2 ключом (AES-256-CBC + zlib)
* Формат: ivBase64:ciphertextBase64
*/
private fun decryptWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? {
return try {
android.util.Log.d("MessageCrypto", "🔓 decryptWithPBKDF2Key: data length=${encryptedData.length}")
android.util.Log.d("MessageCrypto", "🔓 First 100 chars: ${encryptedData.take(100)}")
android.util.Log.d("MessageCrypto", "🔓 Contains colon: ${encryptedData.contains(":")}")
val parts = encryptedData.split(":")
android.util.Log.d("MessageCrypto", "🔓 Split parts: ${parts.size}")
if (parts.size != 2) {
android.util.Log.e("MessageCrypto", "❌ Invalid format: expected 2 parts, got ${parts.size}")
return null
}
val iv = android.util.Base64.decode(parts[0], android.util.Base64.DEFAULT)
val ciphertext = android.util.Base64.decode(parts[1], android.util.Base64.DEFAULT)
android.util.Log.d("MessageCrypto", "🔓 IV: ${iv.size} bytes, Ciphertext: ${ciphertext.size} bytes")
// AES-256-CBC расшифровка
val cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding")
val secretKey = javax.crypto.spec.SecretKeySpec(pbkdf2Key, "AES")
val ivSpec = javax.crypto.spec.IvParameterSpec(iv)
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, ivSpec)
val decrypted = cipher.doFinal(ciphertext)
android.util.Log.d("MessageCrypto", "🔓 AES decrypted: ${decrypted.size} bytes")
// Zlib декомпрессия
val inflater = java.util.zip.Inflater()
inflater.setInput(decrypted)
val outputStream = java.io.ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
outputStream.write(buffer, 0, count)
}
inflater.end()
val result = String(outputStream.toByteArray(), Charsets.UTF_8)
android.util.Log.d("MessageCrypto", "🔓 Decompressed: ${result.length} chars")
result
} catch (e: Exception) {
android.util.Log.e("MessageCrypto", "❌ decryptWithPBKDF2Key failed: ${e.message}", e)
null
}
}
/**
* Шифрование reply blob для передачи по сети
*
* Совместим с React Native:
* 1. Compress with pako (deflate)
* 2. Generate PBKDF2 key from ChaCha key (32 bytes) as string via Buffer.toString('utf-8')
* 3. AES-256-CBC encryption
*
* @param replyJson - JSON string to encrypt
* @param plainKeyAndNonce - 56 bytes (32 key + 24 nonce)
*
* Формат выхода: "ivBase64:ciphertextBase64" (совместим с desktop)
*/
fun encryptReplyBlob(replyJson: String, plainKeyAndNonce: ByteArray): String {
return try {
// Convert plainKeyAndNonce to string - simulate JS Buffer.toString('utf-8') behavior
// which replaces invalid UTF-8 sequences with U+FFFD
val password = bytesToJsUtf8String(plainKeyAndNonce)
// Compress with pako (deflate)
val deflater = java.util.zip.Deflater()
deflater.setInput(replyJson.toByteArray(Charsets.UTF_8))
deflater.finish()
val compressedBuffer = ByteArray(replyJson.length * 2 + 100)
val compressedSize = deflater.deflate(compressedBuffer)
deflater.end()
val compressed = compressedBuffer.copyOf(compressedSize)
// PBKDF2 key derivation (matching RN: crypto.PBKDF2(password, 'rosetta', {keySize: 256/32, iterations: 1000}))
// CRITICAL: Must use SHA256 to match React Native (not SHA1!)
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = javax.crypto.spec.PBEKeySpec(
password.toCharArray(),
"rosetta".toByteArray(Charsets.UTF_8),
1000,
256
)
val secretKey = factory.generateSecret(spec)
val keyBytes = secretKey.encoded
// Generate random IV (16 bytes)
val iv = ByteArray(16)
java.security.SecureRandom().nextBytes(iv)
// AES-CBC encryption
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val keySpec = SecretKeySpec(keyBytes, "AES")
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
val ciphertext = cipher.doFinal(compressed)
// Format: "ivBase64:ciphertextBase64" (same as RN new format)
val ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP)
val ctBase64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP)
val result = "$ivBase64:$ctBase64"
result
} catch (e: Exception) {
// Fallback: return plaintext (for backwards compatibility)
replyJson
}
}
/**
* Converts bytes to string mimicking JavaScript's Buffer.toString('utf-8') behavior.
* Uses WHATWG UTF-8 decoder algorithm where each invalid byte produces exactly ONE U+FFFD.
* This is critical for cross-platform compatibility with React Native!
*/
private fun bytesToJsUtf8String(bytes: ByteArray): String {
val result = StringBuilder()
var i = 0
while (i < bytes.size) {
val b0 = bytes[i].toInt() and 0xFF
when {
// ASCII (0x00-0x7F) - single byte
b0 <= 0x7F -> {
result.append(b0.toChar())
i++
}
// Continuation byte without starter (0x80-0xBF) - invalid
b0 <= 0xBF -> {
result.append('\uFFFD')
i++
}
// 2-byte sequence (0xC0-0xDF)
b0 <= 0xDF -> {
if (i + 1 >= bytes.size) {
// Truncated - emit replacement for this byte
result.append('\uFFFD')
i++
} else {
val b1 = bytes[i + 1].toInt() and 0xFF
if (b1 and 0xC0 != 0x80) {
// Invalid continuation - emit replacement for starter only
result.append('\uFFFD')
i++
} else {
val codePoint = ((b0 and 0x1F) shl 6) or (b1 and 0x3F)
// Check for overlong encoding (should be >= 0x80 for 2-byte)
if (codePoint < 0x80 || b0 == 0xC0 || b0 == 0xC1) {
// Overlong - emit replacement for each byte
result.append('\uFFFD')
result.append('\uFFFD')
} else {
result.append(codePoint.toChar())
}
i += 2
}
}
}
// 3-byte sequence (0xE0-0xEF)
b0 <= 0xEF -> {
if (i + 2 >= bytes.size) {
// Truncated
val remaining = bytes.size - i
repeat(remaining) { result.append('\uFFFD') }
i = bytes.size
} else {
val b1 = bytes[i + 1].toInt() and 0xFF
val b2 = bytes[i + 2].toInt() and 0xFF
if (b1 and 0xC0 != 0x80) {
// Invalid first continuation
result.append('\uFFFD')
i++
} else if (b2 and 0xC0 != 0x80) {
// Invalid second continuation - emit for first two bytes
result.append('\uFFFD')
result.append('\uFFFD')
i += 2
} else {
val codePoint = ((b0 and 0x0F) shl 12) or ((b1 and 0x3F) shl 6) or (b2 and 0x3F)
// Check for overlong (should be >= 0x800 for 3-byte) and surrogates
if (codePoint < 0x800 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
// Invalid - emit replacement for each byte
result.append('\uFFFD')
result.append('\uFFFD')
result.append('\uFFFD')
} else {
result.append(codePoint.toChar())
}
i += 3
}
}
}
// 4-byte sequence (0xF0-0xF7)
b0 <= 0xF7 -> {
if (i + 3 >= bytes.size) {
// Truncated
val remaining = bytes.size - i
repeat(remaining) { result.append('\uFFFD') }
i = bytes.size
} else {
val b1 = bytes[i + 1].toInt() and 0xFF
val b2 = bytes[i + 2].toInt() and 0xFF
val b3 = bytes[i + 3].toInt() and 0xFF
if (b1 and 0xC0 != 0x80) {
result.append('\uFFFD')
i++
} else if (b2 and 0xC0 != 0x80) {
result.append('\uFFFD')
result.append('\uFFFD')
i += 2
} else if (b3 and 0xC0 != 0x80) {
result.append('\uFFFD')
result.append('\uFFFD')
result.append('\uFFFD')
i += 3
} else {
val codePoint = ((b0 and 0x07) shl 18) or ((b1 and 0x3F) shl 12) or
((b2 and 0x3F) shl 6) or (b3 and 0x3F)
// Check for overlong (should be >= 0x10000) and max Unicode
if (codePoint < 0x10000 || codePoint > 0x10FFFF) {
result.append('\uFFFD')
result.append('\uFFFD')
result.append('\uFFFD')
result.append('\uFFFD')
} else {
// Encode as surrogate pair
val adjusted = codePoint - 0x10000
result.append((0xD800 + (adjusted shr 10)).toChar())
result.append((0xDC00 + (adjusted and 0x3FF)).toChar())
}
i += 4
}
}
}
// Invalid starter byte (0xF8-0xFF)
else -> {
result.append('\uFFFD')
i++
}
}
}
return result.toString()
}
/**
* Расшифровка reply blob полученного по сети
*
* Совместим с React Native:
* 1. Parse "ivBase64:ciphertextBase64" format
* 2. Generate PBKDF2 key from ChaCha key (32 bytes) as string via Buffer.toString('utf-8')
* 3. AES-256-CBC decryption
* 4. Decompress with pako (inflate)
*
* @param encryptedBlob - "ivBase64:ciphertextBase64" format
* @param plainKeyAndNonce - 56 bytes (32 key + 24 nonce)
*/
fun decryptReplyBlob(encryptedBlob: String, plainKeyAndNonce: ByteArray): String {
return try {
// Check if it's encrypted format (contains ':')
if (!encryptedBlob.contains(':')) {
return encryptedBlob
}
// Parse ivBase64:ciphertextBase64
val parts = encryptedBlob.split(':')
if (parts.size != 2) {
return encryptedBlob
}
val iv = Base64.decode(parts[0], Base64.DEFAULT)
val ciphertext = Base64.decode(parts[1], Base64.DEFAULT)
// Password from plainKeyAndNonce - use same JS-like UTF-8 conversion
val password = bytesToJsUtf8String(plainKeyAndNonce)
// PBKDF2 key derivation
// CRITICAL: Must use SHA256 to match React Native (not SHA1!)
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = javax.crypto.spec.PBEKeySpec(
password.toCharArray(),
"rosetta".toByteArray(Charsets.UTF_8),
1000,
256
)
val secretKey = factory.generateSecret(spec)
val keyBytes = secretKey.encoded
// AES-CBC decryption
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val keySpec = SecretKeySpec(keyBytes, "AES")
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
val decompressed = cipher.doFinal(ciphertext)
// Decompress with inflate
val inflater = java.util.zip.Inflater()
inflater.setInput(decompressed)
val outputBuffer = ByteArray(decompressed.size * 10)
val outputSize = inflater.inflate(outputBuffer)
inflater.end()
val plaintext = String(outputBuffer, 0, outputSize, Charsets.UTF_8)
plaintext
} catch (e: Exception) {
// Return as-is, might be plain JSON
encryptedBlob
}
}
}
// Extension functions для конвертации
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun String.hexToBytes(): ByteArray {
check(length % 2 == 0) { "Hex string must have even length" }
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}