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