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 private const val SYNC_KEY_PREFIX = "sync:" // Кэш PBKDF2-SHA256 ключей: password → derived key bytes // PBKDF2 с 1000 итерациями ~50-100ms, кэш убирает повторные вычисления private val pbkdf2Cache = java.util.concurrent.ConcurrentHashMap() 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 ) data class AttachmentDecryptDebugResult( val decrypted: String?, val trace: List ) private data class AttachmentDecryptAttemptResult( val decrypted: String?, val reason: String ) /** * 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 candidates = decryptKeyFromSenderCandidates(encryptedKeyBase64, myPrivateKeyHex) return candidates.firstOrNull() ?: throw IllegalArgumentException("Failed to decrypt key: no valid candidates") } fun decryptKeyFromSenderCandidates( encryptedKeyBase64: String, myPrivateKeyHex: String ): List { if (encryptedKeyBase64.startsWith(SYNC_KEY_PREFIX)) { val aesChachaKey = encryptedKeyBase64.removePrefix(SYNC_KEY_PREFIX) if (aesChachaKey.isBlank()) { throw IllegalArgumentException("Invalid sync key format: empty aesChachaKey") } val decoded = CryptoManager.decryptWithPassword(aesChachaKey, myPrivateKeyHex) ?: throw IllegalArgumentException("Failed to decrypt sync chacha key") return listOf(decoded.toByteArray(Charsets.ISO_8859_1)) } 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) // Desktop parity: noble/secp256k1 getSharedSecret(...).slice(1, 33) // => ровно 32 байта X-координаты С сохранением ведущих нулей. // Для обратной совместимости пробуем и старый Android-вариант (trim leading zeros). val xCoordBigInt = sharedPoint.normalize().xCoord.toBigInteger() val sharedSecretExact = bigIntegerToFixed32Bytes(xCoordBigInt) // Legacy (старый Android): BN.toString(16) + hex parse (теряет ведущие нули) var sharedSecretHexLegacy = xCoordBigInt.toString(16) if (sharedSecretHexLegacy.length % 2 != 0) { sharedSecretHexLegacy = "0$sharedSecretHexLegacy" } val sharedSecretLegacy = sharedSecretHexLegacy.hexToBytes() val candidateMap = LinkedHashMap() val decryptedVariants = listOf( decryptKeyAesPayload(encryptedKey, iv, sharedSecretExact), decryptKeyAesPayload(encryptedKey, iv, sharedSecretLegacy) ) for (decryptedUtf8Bytes in decryptedVariants) { if (decryptedUtf8Bytes == null) continue // ⚠️ КРИТИЧНО: Обратная конвертация 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) // В протоколе это key(32)+nonce(24). Фильтруем мусор после ложнопозитивной PKCS5 padding. if (originalBytes.size < 56) continue val fp = shortSha256(originalBytes) candidateMap.putIfAbsent(fp, originalBytes) } return candidateMap.values.toList() } private fun decryptKeyAesPayload( encryptedKey: ByteArray, iv: ByteArray, sharedSecret: ByteArray ): ByteArray? { return try { val aesKey = SecretKeySpec(sharedSecret, "AES") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv)) cipher.doFinal(encryptedKey) } catch (_: Exception) { null } } private fun bigIntegerToFixed32Bytes(value: BigInteger): ByteArray { val raw = value.toByteArray() return when { raw.size == 32 -> raw raw.size > 32 -> raw.copyOfRange(raw.size - 32, raw.size) else -> ByteArray(32 - raw.size) + raw } } /** * Результат шифрования для отправки */ 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 { val keyCandidates = decryptKeyFromSenderCandidates(encryptedKey, myPrivateKey) if (keyCandidates.isEmpty()) { throw IllegalArgumentException("Failed to decrypt message key: no candidates") } var lastError: Exception? = null for (keyAndNonce in keyCandidates) { if (keyAndNonce.size < 56) continue val key = keyAndNonce.copyOfRange(0, 32) val nonce = keyAndNonce.copyOfRange(32, 56) try { val plaintext = decryptMessage(ciphertext, key.toHex(), nonce.toHex()) return DecryptedIncoming(plaintext, keyAndNonce) } catch (e: Exception) { lastError = e } } throw (lastError ?: IllegalArgumentException("Failed to decrypt message content with all key candidates")) } /** * Совместимая версия decryptIncoming (возвращает только текст) */ fun decryptIncoming( ciphertext: String, encryptedKey: String, myPrivateKey: String ): String = decryptIncomingFull(ciphertext, encryptedKey, myPrivateKey).plaintext fun decryptIncomingFullWithPlainKey( ciphertext: String, plainKeyAndNonce: ByteArray ): DecryptedIncoming { require(plainKeyAndNonce.size >= 56) { "Invalid plainKeyAndNonce size: ${plainKeyAndNonce.size}" } val key = plainKeyAndNonce.copyOfRange(0, 32) val nonce = plainKeyAndNonce.copyOfRange(32, 56) val plaintext = decryptMessage(ciphertext, key.toHex(), nonce.toHex()) return DecryptedIncoming(plaintext, plainKeyAndNonce) } fun decryptIncomingWithPlainKey( ciphertext: String, plainKeyAndNonce: ByteArray ): String = decryptIncomingFullWithPlainKey(ciphertext, plainKeyAndNonce).plaintext /** * Расшифровка MESSAGES attachment blob * Формат: ivBase64:ciphertextBase64 * Использует PBKDF2 + AES-256-CBC + zlib decompression * * КРИТИЧНО: Desktop использует ВЕСЬ keyAndNonce (56 bytes) как password! * Desktop: chachaDecryptedKey.toString('utf-8') - конвертирует все 56 байт в UTF-8 строку * * @param encryptedData зашифрованный контент (ivBase64:ciphertextBase64) * @param chachaKeyPlain Уже расшифрованный ChaCha ключ+nonce (56 bytes: 32 key + 24 nonce) */ /** * Расшифровка аттачмента зашифрованного через encodeWithPassword (desktop parity) * * Десктоп: decodeWithPassword(keyPlain, data) * 1. keyPlain = chachaDecryptedKey.toString('utf-8') — JS Buffer → UTF-8 string * 2. PBKDF2(keyPlain, 'rosetta', {keySize: 256/32, iterations: 1000}) — SHA256 * 3. AES-CBC decrypt * 4. pako.inflate → string * * Ровно то же самое делаем здесь. */ fun decryptAttachmentBlobWithPlainKey( encryptedData: String, chachaKeyPlain: ByteArray ): String? { return decryptAttachmentBlobWithPlainKeyDebug(encryptedData, chachaKeyPlain).decrypted } fun decryptAttachmentBlobWithPlainKeyDebug( encryptedData: String, chachaKeyPlain: ByteArray ): AttachmentDecryptDebugResult { val trace = mutableListOf() return try { trace += "payload=${describeAttachmentPayload(encryptedData)}" trace += "key=size${chachaKeyPlain.size},fp=${shortSha256(chachaKeyPlain)}" val candidates = buildAttachmentPasswordCandidates(chachaKeyPlain) trace += "candidates=${candidates.size}" for ((index, password) in candidates.withIndex()) { val passwordBytesUtf8 = password.toByteArray(Charsets.UTF_8) trace += "cand[$index]=chars${password.length},utf8${passwordBytesUtf8.size},fp=${shortSha256(passwordBytesUtf8)}" val pbkdf2KeySha256 = generatePBKDF2Key(password) trace += "cand[$index]=pbkdf2-sha256:${shortSha256(pbkdf2KeySha256)}" val sha256Attempt = decryptWithPBKDF2KeyDebug(encryptedData, pbkdf2KeySha256) if (sha256Attempt.decrypted != null) { trace += "cand[$index]=SUCCESS:sha256,len${sha256Attempt.decrypted.length}" return AttachmentDecryptDebugResult(sha256Attempt.decrypted, trace) } trace += "cand[$index]=fail:sha256:${sha256Attempt.reason}" val pbkdf2KeySha1 = generatePBKDF2KeySha1FromBytes(passwordBytesUtf8) trace += "cand[$index]=pbkdf2-sha1:${shortSha256(pbkdf2KeySha1)}" val sha1Attempt = decryptWithPBKDF2KeyDebug(encryptedData, pbkdf2KeySha1) if (sha1Attempt.decrypted != null) { trace += "cand[$index]=SUCCESS:sha1,len${sha1Attempt.decrypted.length}" return AttachmentDecryptDebugResult(sha1Attempt.decrypted, trace) } trace += "cand[$index]=fail:sha1:${sha1Attempt.reason}" } trace += "result=failed_all_candidates" AttachmentDecryptDebugResult(null, trace) } catch (e: Exception) { trace += "exception=${e.javaClass.simpleName}:${e.message?.take(120)}" AttachmentDecryptDebugResult(null, trace) } catch (_: OutOfMemoryError) { System.gc() trace += "exception=OutOfMemoryError" AttachmentDecryptDebugResult(null, trace) } } /** * Streaming decrypt: зашифрованный файл → расшифрованный файл на диске. * Не загружает весь контент в память — использует потоковый пайплайн: * File → Base64Decode → AES-CBC → Inflate → Base64Decode → outputFile * Пиковое потребление памяти: ~128KB вместо ~200MB для 30МБ файла. * * Поддерживает форматы: * - ivBase64:ciphertextBase64 (обычный) * - CHNK:iv1:ct1::iv2:ct2::... (чанкованный, Desktop >10MB) * * @param inputFile temp file с зашифрованным контентом (с CDN) * @param chachaKeyPlain расшифрованный ChaCha ключ (56 bytes) * @param outputFile куда записать результат (raw bytes файла) * @return true если успешно */ fun decryptAttachmentFileStreaming( inputFile: java.io.File, chachaKeyPlain: ByteArray, outputFile: java.io.File ): Boolean { return try { val passwordCandidates = buildAttachmentPasswordCandidates(chachaKeyPlain) if (passwordCandidates.isEmpty()) return false // Проверяем формат: CHNK: или обычный ivBase64:ciphertextBase64 val header = ByteArray(5) var headerLen = 0 java.io.FileInputStream(inputFile).use { fis -> while (headerLen < 5) { val n = fis.read(header, headerLen, 5 - headerLen) if (n == -1) break headerLen += n } } val isChunked = headerLen == 5 && String(header, Charsets.US_ASCII) == "CHNK:" for (password in passwordCandidates) { val passwordBytesUtf8 = password.toByteArray(Charsets.UTF_8) val pbkdf2Sha256 = generatePBKDF2KeyFromBytes(passwordBytesUtf8) val pbkdf2Sha1 = generatePBKDF2KeySha1FromBytes(passwordBytesUtf8) val pbkdf2Candidates = linkedSetOf(pbkdf2Sha256, pbkdf2Sha1) for (pbkdf2Key in pbkdf2Candidates) { outputFile.delete() val ok = if (isChunked) { decryptChunkedFileStreaming(inputFile, pbkdf2Key, outputFile) } else { decryptSingleFileStreaming(inputFile, pbkdf2Key, outputFile) } if (ok) { return true } } } false } catch (e: Exception) { android.util.Log.e("MessageCrypto", "Streaming decrypt failed", e) outputFile.delete() false } catch (_: OutOfMemoryError) { System.gc() outputFile.delete() false } } /** * Streaming decrypt обычного формата: ivBase64:ciphertextBase64 * Пайплайн: File → Base64Decode → AES-CBC → Inflate → strip data URL → Base64Decode → file */ private fun decryptSingleFileStreaming( inputFile: java.io.File, pbkdf2Key: ByteArray, outputFile: java.io.File ): Boolean { // 1. Считываем IV (всё до первого ':') var colonOffset = 0L val ivBuf = java.io.ByteArrayOutputStream(64) java.io.FileInputStream(inputFile).use { fis -> while (true) { val b = fis.read() if (b == -1) return false colonOffset++ if (b.toChar() == ':') break ivBuf.write(b) } } val iv = Base64.decode(ivBuf.toByteArray(), Base64.DEFAULT) // 2. AES-256-CBC cipher val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init( Cipher.DECRYPT_MODE, SecretKeySpec(pbkdf2Key, "AES"), IvParameterSpec(iv) ) // 3. Streaming pipeline: File[после ':'] → Base64 → AES → Inflate val fis = java.io.FileInputStream(inputFile) // Skip IV and ':' var skipped = 0L while (skipped < colonOffset) { val s = fis.skip(colonOffset - skipped) if (s <= 0) { fis.read(); skipped++ } else skipped += s } val pipeline = java.util.zip.InflaterInputStream( javax.crypto.CipherInputStream( android.util.Base64InputStream(fis, Base64.DEFAULT), cipher ) ) pipeline.use { inflated -> // 4. Результат — base64 текст файла, возможно с data URL prefix // Читаем первый кусок чтобы определить и пропустить prefix val headerBuf = ByteArray(256) var headerReadLen = 0 while (headerReadLen < headerBuf.size) { val n = inflated.read(headerBuf, headerReadLen, headerBuf.size - headerReadLen) if (n == -1) break headerReadLen += n } if (headerReadLen == 0) return false // Ищем "base64," чтобы пропустить data URL prefix val headerStr = String(headerBuf, 0, headerReadLen, Charsets.ISO_8859_1) val marker = "base64," val markerIdx = headerStr.indexOf(marker) val skip = if (markerIdx in 0..199) markerIdx + marker.length else 0 // 5. Объединяем остаток header + остаток inflated stream → Base64 decode → файл val remaining = java.io.ByteArrayInputStream(headerBuf, skip, headerReadLen - skip) val bodyStream = java.io.SequenceInputStream(remaining, inflated) android.util.Base64InputStream(bodyStream, Base64.DEFAULT).use { decoded -> outputFile.outputStream().use { out -> val buf = ByteArray(64 * 1024) while (true) { val n = decoded.read(buf) if (n == -1) break out.write(buf, 0, n) } } } } return outputFile.length() > 0 } /** * Streaming decrypt CHNK формата (Desktop >10MB). * Формат: CHNK:iv1Base64:ct1Base64::iv2Base64:ct2Base64::... * Каждый чанк — отдельный AES-CBC шифротекст (до 10MB compressed). * Все чанки после расшифровки + конкатенации → inflate → base64 файла. */ private fun decryptChunkedFileStreaming( inputFile: java.io.File, pbkdf2Key: ByteArray, outputFile: java.io.File ): Boolean { // Для CHNK: читаем весь файл, разбиваем на чанки, расшифровываем каждый, // конкатенируем compressed bytes → inflate → strip data URL → base64 decode → file // // Оптимизация: обрабатываем чанки по одному, записываем decrypted bytes // во временный файл, потом inflate оттуда. val cacheDir = inputFile.parentFile ?: return false val decryptedTmp = java.io.File(cacheDir, "chnk_dec_${System.currentTimeMillis()}.tmp") try { // Читаем содержимое файла после "CHNK:" и разбиваем на чанки по "::" val content = inputFile.readText(Charsets.UTF_8) val chunksStr = content.removePrefix("CHNK:") val chunks = chunksStr.split("::") // Расшифровываем каждый чанк и записываем compressed bytes в tmp файл decryptedTmp.outputStream().use { tmpOut -> for (chunk in chunks) { if (chunk.isBlank()) continue val parts = chunk.split(":") if (parts.size != 2) continue val chunkIv = Base64.decode(parts[0], Base64.DEFAULT) val chunkCt = Base64.decode(parts[1], Base64.DEFAULT) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init( Cipher.DECRYPT_MODE, SecretKeySpec(pbkdf2Key, "AES"), IvParameterSpec(chunkIv) ) val decrypted = cipher.doFinal(chunkCt) tmpOut.write(decrypted) } } // Inflate compressed concatenated data → base64 текст файла java.util.zip.InflaterInputStream( java.io.FileInputStream(decryptedTmp) ).use { inflated -> // Читаем header для data URL prefix val headerBuf = ByteArray(256) var headerReadLen = 0 while (headerReadLen < headerBuf.size) { val n = inflated.read(headerBuf, headerReadLen, headerBuf.size - headerReadLen) if (n == -1) break headerReadLen += n } if (headerReadLen == 0) return false val headerStr = String(headerBuf, 0, headerReadLen, Charsets.ISO_8859_1) val marker = "base64," val markerIdx = headerStr.indexOf(marker) val skip = if (markerIdx in 0..199) markerIdx + marker.length else 0 val remaining = java.io.ByteArrayInputStream(headerBuf, skip, headerReadLen - skip) val bodyStream = java.io.SequenceInputStream(remaining, inflated) android.util.Base64InputStream(bodyStream, Base64.DEFAULT).use { decoded -> outputFile.outputStream().use { out -> val buf = ByteArray(64 * 1024) while (true) { val n = decoded.read(buf) if (n == -1) break out.write(buf, 0, n) } } } } return outputFile.length() > 0 } finally { decryptedTmp.delete() } } /** * Расшифровка attachment blob с уже готовым паролем (Latin1 string) * Используется когда chachaKey сохранён в БД как Latin1 string (raw bytes) * * КРИТИЧНО: Desktop делает Buffer.from(str, 'binary').toString('utf-8') * Это эквивалентно взятию charCode каждого символа (0-255) как байт, * потом UTF-8 decode этих байтов с заменой невалидных на U+FFFD (�) * * @param encryptedData зашифрованный контент (ivBase64:ciphertextBase64) * @param passwordLatin1 Latin1 string (56 chars, каждый char = один байт 0-255) */ fun decryptAttachmentBlobWithPassword( encryptedData: String, passwordLatin1: String ): String? { return try { // Конвертируем Latin1 string → bytes → UTF-8 string (эмулируем Desktop) // Desktop: Buffer.from(str, 'binary').toString('utf-8') val passwordBytes = passwordLatin1.toByteArray(Charsets.ISO_8859_1) val passwordCandidates = linkedSetOf( bytesToBufferPolyfillUtf8String(passwordBytes), bytesToJsUtf8String(passwordBytes) ) for (passwordUtf8 in passwordCandidates) { // Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations) val pbkdf2Key = generatePBKDF2Key(passwordUtf8) decryptWithPBKDF2Key(encryptedData, pbkdf2Key)?.let { return it } // Legacy fallback (sha1) val pbkdf2KeySha1 = generatePBKDF2KeySha1FromBytes(passwordUtf8.toByteArray(Charsets.UTF_8)) decryptWithPBKDF2Key(encryptedData, pbkdf2KeySha1)?.let { return it } } null } catch (e: Exception) { null } catch (_: OutOfMemoryError) { System.gc() 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 { val keyCandidates = decryptKeyFromSenderCandidates(encryptedKey, myPrivateKey) for (keyAndNonce in keyCandidates) { decryptAttachmentBlobWithPlainKey(encryptedData, keyAndNonce)?.let { return it } } null } catch (e: Exception) { null } catch (_: OutOfMemoryError) { System.gc() null } } /** * Генерация PBKDF2 ключа (совместимо с crypto-js) * ВАЖНО: crypto-js использует PBKDF2 с SHA256 по умолчанию (НЕ SHA1!) * * КРИТИЧНО: crypto-js конвертирует password через UTF-8 encoding, * но PBEKeySpec в Java использует UTF-16! Поэтому используем ручную реализацию. */ private fun generatePBKDF2Key(password: String, salt: String = "rosetta", iterations: Int = 1000): ByteArray { // Кэшируем только для дефолтных salt/iterations (99% вызовов) if (salt == "rosetta" && iterations == 1000) { return pbkdf2Cache.getOrPut(password) { val passwordBytes = password.toByteArray(Charsets.UTF_8) generatePBKDF2KeyFromBytes(passwordBytes, salt.toByteArray(Charsets.UTF_8), iterations) } } // Crypto-js: WordArray.create(password) использует UTF-8 val passwordBytes = password.toByteArray(Charsets.UTF_8) return generatePBKDF2KeyFromBytes(passwordBytes, salt.toByteArray(Charsets.UTF_8), iterations) } /** * Генерация PBKDF2 ключа из raw bytes (без string conversion) */ private fun generatePBKDF2KeyFromBytes(passwordBytes: ByteArray, saltBytes: ByteArray = "rosetta".toByteArray(Charsets.UTF_8), iterations: Int = 1000): ByteArray { // PBKDF2-HMAC-SHA256 ручная реализация для совместимости с crypto-js // ВАЖНО: crypto-js PBKDF2 по умолчанию использует SHA256, НЕ SHA1! return generatePBKDF2KeyFromBytesWithHmac( passwordBytes = passwordBytes, saltBytes = saltBytes, iterations = iterations, hmacAlgo = "HmacSHA256" ) } /** * Генерация PBKDF2-SHA1 ключа из raw bytes. * Нужна как fallback для legacy сообщений. */ private fun generatePBKDF2KeySha1FromBytes( passwordBytes: ByteArray, saltBytes: ByteArray = "rosetta".toByteArray(Charsets.UTF_8), iterations: Int = 1000 ): ByteArray { return generatePBKDF2KeyFromBytesWithHmac( passwordBytes = passwordBytes, saltBytes = saltBytes, iterations = iterations, hmacAlgo = "HmacSHA1" ) } private fun generatePBKDF2KeyFromBytesWithHmac( passwordBytes: ByteArray, saltBytes: ByteArray, iterations: Int, hmacAlgo: String ): ByteArray { val keyLength = 32 // 256 bits val mac = javax.crypto.Mac.getInstance(hmacAlgo) val keySpec = javax.crypto.spec.SecretKeySpec(passwordBytes, hmacAlgo) mac.init(keySpec) // PBKDF2 алгоритм val hLen = mac.macLength val dkLen = keyLength val l = (dkLen + hLen - 1) / hLen val r = dkLen - (l - 1) * hLen val derivedKey = ByteArray(dkLen) var offset = 0 for (i in 1..l) { val block = pbkdf2Block(mac, saltBytes, iterations, i) val copyLen = if (i < l) hLen else r System.arraycopy(block, 0, derivedKey, offset, copyLen) offset += copyLen } return derivedKey } /** * Генерация PBKDF2 ключа через стандартный Java SecretKeyFactory * Это работает по-другому - использует char[] и UTF-16 encoding! * Используем SHA256 для совместимости с crypto-js */ private fun generatePBKDF2KeyJava(password: String): ByteArray { val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") val spec = javax.crypto.spec.PBEKeySpec( password.toCharArray(), "rosetta".toByteArray(Charsets.UTF_8), 1000, 256 ) return factory.generateSecret(spec).encoded } /** * PBKDF2 block функция (для совместимости с crypto-js) */ private fun pbkdf2Block(mac: javax.crypto.Mac, salt: ByteArray, iterations: Int, blockIndex: Int): ByteArray { // U1 = PRF(Password, Salt || INT_32_BE(i)) mac.reset() mac.update(salt) mac.update(byteArrayOf( (blockIndex shr 24).toByte(), (blockIndex shr 16).toByte(), (blockIndex shr 8).toByte(), blockIndex.toByte() )) var u = mac.doFinal() val result = u.clone() // U2 = PRF(Password, U1), ... , Uc = PRF(Password, Uc-1) for (j in 2..iterations) { mac.reset() u = mac.doFinal(u) for (k in result.indices) { result[k] = (result[k].toInt() xor u[k].toInt()).toByte() } } return result } /** * Расшифровка с PBKDF2 ключом (AES-256-CBC + zlib) * Формат: ivBase64:ciphertextBase64 */ private fun decryptWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? { return decryptWithPBKDF2KeyDebug(encryptedData, pbkdf2Key).decrypted } private fun decryptWithPBKDF2KeyDebug( encryptedData: String, pbkdf2Key: ByteArray ): AttachmentDecryptAttemptResult { return try { if (encryptedData.startsWith("CHNK:")) { val chunked = decryptChunkedWithPBKDF2KeyDebug(encryptedData, pbkdf2Key) return AttachmentDecryptAttemptResult(chunked, if (chunked != null) "ok:chunked" else "chunked:failed") } val parts = encryptedData.split(":", limit = 2) if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) { // Legacy desktop format: base64(ivHex:cipherHex) val old = decryptOldFormatWithPBKDF2KeyDebug(encryptedData, pbkdf2Key) return AttachmentDecryptAttemptResult(old.decrypted, "legacy:${old.reason}") } val iv = decodeBase64Compat(parts[0]) val ciphertext = decodeBase64Compat(parts[1]) if (iv == null || ciphertext == null) { val old = decryptOldFormatWithPBKDF2KeyDebug(encryptedData, pbkdf2Key) return AttachmentDecryptAttemptResult(old.decrypted, "new_base64_invalid->legacy:${old.reason}") } // 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) val inflated = inflateToUtf8(decrypted) AttachmentDecryptAttemptResult(inflated, "ok:new") } catch (e: javax.crypto.BadPaddingException) { AttachmentDecryptAttemptResult(null, "aes_bad_padding") } catch (e: java.util.zip.DataFormatException) { AttachmentDecryptAttemptResult(null, "inflate_data_format") } catch (e: Exception) { AttachmentDecryptAttemptResult(null, "${e.javaClass.simpleName}:${e.message?.take(80)}") } catch (_: OutOfMemoryError) { System.gc() AttachmentDecryptAttemptResult(null, "OutOfMemoryError") } } private fun decryptChunkedWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? { return decryptChunkedWithPBKDF2KeyDebug(encryptedData, pbkdf2Key) } private fun decryptChunkedWithPBKDF2KeyDebug(encryptedData: String, pbkdf2Key: ByteArray): String? { return try { val raw = encryptedData.removePrefix("CHNK:") if (raw.isBlank()) return null val encryptedChunks = raw.split("::").filter { it.isNotBlank() } if (encryptedChunks.isEmpty()) return null val cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding") val secretKey = javax.crypto.spec.SecretKeySpec(pbkdf2Key, "AES") val compressedOutput = java.io.ByteArrayOutputStream() for (chunk in encryptedChunks) { val parts = chunk.split(":", limit = 2) if (parts.size != 2) return null val iv = decodeBase64Compat(parts[0]) ?: return null val ciphertext = decodeBase64Compat(parts[1]) ?: return null val ivSpec = javax.crypto.spec.IvParameterSpec(iv) cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, ivSpec) val decryptedChunk = cipher.doFinal(ciphertext) compressedOutput.write(decryptedChunk) } inflateToUtf8(compressedOutput.toByteArray()) } catch (e: Exception) { android.util.Log.w( "MessageCrypto", "decryptChunkedWithPBKDF2Key: ${e.javaClass.simpleName}: ${e.message}" ) null } catch (_: OutOfMemoryError) { System.gc() null } } private fun decryptWithPBKDF2KeySha1(encryptedData: String, passwordBytes: ByteArray): String? { return try { val keyBytesSha1 = generatePBKDF2KeySha1FromBytes(passwordBytes) decryptWithPBKDF2Key(encryptedData, keyBytesSha1) } catch (_: Exception) { null } catch (_: OutOfMemoryError) { System.gc() null } } /** * Desktop legacy decodeWithPassword support: * data = base64("ivHex:cipherHex") */ private fun decryptOldFormatWithPBKDF2Key( encryptedData: String, pbkdf2Key: ByteArray ): String? { return decryptOldFormatWithPBKDF2KeyDebug(encryptedData, pbkdf2Key).decrypted } private fun decryptOldFormatWithPBKDF2KeyDebug( encryptedData: String, pbkdf2Key: ByteArray ): AttachmentDecryptAttemptResult { return try { val decoded = decodeBase64Compat(encryptedData) ?: return AttachmentDecryptAttemptResult(null, "base64_decode_failed") val decodedText = String(decoded, Charsets.UTF_8) val parts = decodedText.split(":", limit = 2) if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) { return AttachmentDecryptAttemptResult(null, "decoded_not_ivhex_cthex") } val iv = parts[0].hexToBytes() val ciphertext = parts[1].hexToBytes() 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 decryptedUtf8 = cipher.doFinal(ciphertext) val text = String(decryptedUtf8, Charsets.UTF_8) if (text.isEmpty()) { AttachmentDecryptAttemptResult(null, "decrypted_empty") } else { AttachmentDecryptAttemptResult(text, "ok") } } catch (e: javax.crypto.BadPaddingException) { AttachmentDecryptAttemptResult(null, "aes_bad_padding") } catch (e: Exception) { AttachmentDecryptAttemptResult(null, "${e.javaClass.simpleName}:${e.message?.take(80)}") } } private fun decodeBase64Compat(raw: String): ByteArray? { val cleaned = raw.trim().replace("\n", "").replace("\r", "") if (cleaned.isEmpty()) return null return try { Base64.decode(cleaned, Base64.DEFAULT) } catch (_: IllegalArgumentException) { try { val pad = (4 - (cleaned.length % 4)) % 4 Base64.decode(cleaned + "=".repeat(pad), Base64.DEFAULT) } catch (_: IllegalArgumentException) { null } } } private fun shortSha256(bytes: ByteArray): String { return try { val digest = MessageDigest.getInstance("SHA-256").digest(bytes) digest.copyOfRange(0, 6).joinToString("") { "%02x".format(it) } } catch (_: Exception) { "shaerr" } } private fun describeAttachmentPayload(encryptedData: String): String { return try { val colonCount = encryptedData.count { it == ':' } if (encryptedData.startsWith("CHNK:")) { val raw = encryptedData.removePrefix("CHNK:") val chunks = raw.split("::").filter { it.isNotBlank() } val firstChunk = chunks.firstOrNull().orEmpty() val firstParts = firstChunk.split(":", limit = 2) val firstIvB64Len = firstParts.getOrNull(0)?.length ?: 0 val firstCtB64Len = firstParts.getOrNull(1)?.length ?: 0 val firstIvBytes = firstParts.getOrNull(0)?.let { decodeBase64Compat(it)?.size } ?: -1 return "chunked,len=${encryptedData.length},colons=$colonCount,chunks=${chunks.size},firstIvB64=$firstIvB64Len,firstCtB64=$firstCtB64Len,firstIvBytes=$firstIvBytes" } val parts = encryptedData.split(":", limit = 2) if (parts.size == 2) { val ivBytes = decodeBase64Compat(parts[0])?.size ?: -1 val ctBytes = decodeBase64Compat(parts[1])?.size ?: -1 return "new-or-legacy2,len=${encryptedData.length},colons=$colonCount,ivB64=${parts[0].length},ctB64=${parts[1].length},ivBytes=$ivBytes,ctBytes=$ctBytes" } val decoded = decodeBase64Compat(encryptedData) if (decoded != null) { val decodedText = String(decoded, Charsets.UTF_8) val legacyParts = decodedText.split(":", limit = 2) if (legacyParts.size == 2) { return "legacy-base64,len=${encryptedData.length},decoded=${decoded.size},ivHex=${legacyParts[0].length},ctHex=${legacyParts[1].length}" } return "base64-unknown,len=${encryptedData.length},decoded=${decoded.size},colons=$colonCount" } "unknown,len=${encryptedData.length},colons=$colonCount" } catch (_: Exception) { "inspect-error,len=${encryptedData.length}" } } /** * Собираем пароль-кандидаты для полной desktop совместимости: * - full key+nonce (56 bytes) и legacy key-only (32 bytes) * - Buffer polyfill UTF-8 decode (desktop runtime parity: window.Buffer from "buffer") * - WHATWG/Node UTF-8 decode * - JVM UTF-8 / Latin1 fallback */ private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List { val candidates = LinkedHashSet(12) fun addVariants(bytes: ByteArray) { if (bytes.isEmpty()) return candidates.add(bytesToBufferPolyfillUtf8String(bytes)) candidates.add(bytesToJsUtf8String(bytes)) candidates.add(String(bytes, Charsets.UTF_8)) candidates.add(String(bytes, Charsets.ISO_8859_1)) } addVariants(chachaKeyPlain) addVariants(chachaKeyPlain.copyOfRange(0, minOf(chachaKeyPlain.size, 32))) return candidates.toList() } /** * Шифрование 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 { // Desktop runtime parity: App sets window.Buffer from "buffer" polyfill. // Use the same UTF-8 decoding semantics for password derivation. val password = bytesToBufferPolyfillUtf8String(plainKeyAndNonce) // Compress with pako (deflate) val deflater = java.util.zip.Deflater() deflater.setInput(replyJson.toByteArray(Charsets.UTF_8)) deflater.finish() val outputStream = java.io.ByteArrayOutputStream() val buffer = ByteArray(8192) while (!deflater.finished()) { val count = deflater.deflate(buffer) outputStream.write(buffer, 0, count) } deflater.end() val compressed = outputStream.toByteArray() // PBKDF2 key derivation (matching crypto-js: crypto.PBKDF2(password, 'rosetta', {keySize: 256/32, iterations: 1000})) // Используем generatePBKDF2Key() для совместимости с crypto-js (UTF-8 encoding) val keyBytes = generatePBKDF2Key(password) // 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) { android.util.Log.e("MessageCrypto", "encryptReplyBlob failed", e) throw e } } /** * Desktop runtime parity: Buffer is from npm package "buffer" (feross), * not Node native decoder in all execution contexts. * * This implementation mirrors utf8Slice() from buffer@6: * - determines bytesPerSequence from first byte * - on invalid sequence emits U+FFFD and consumes exactly 1 byte * - produces surrogate pairs for code points > U+FFFF */ private fun bytesToBufferPolyfillUtf8String(bytes: ByteArray): String { val codePoints = ArrayList(bytes.size * 2) var index = 0 val end = bytes.size while (index < end) { val firstByte = bytes[index].toInt() and 0xff var codePoint: Int? = null var bytesPerSequence = when { firstByte > 0xef -> 4 firstByte > 0xdf -> 3 firstByte > 0xbf -> 2 else -> 1 } if (index + bytesPerSequence <= end) { when (bytesPerSequence) { 1 -> { if (firstByte < 0x80) { codePoint = firstByte } } 2 -> { val secondByte = bytes[index + 1].toInt() and 0xff if ((secondByte and 0xc0) == 0x80) { val tempCodePoint = ((firstByte and 0x1f) shl 6) or (secondByte and 0x3f) if (tempCodePoint > 0x7f) { codePoint = tempCodePoint } } } 3 -> { val secondByte = bytes[index + 1].toInt() and 0xff val thirdByte = bytes[index + 2].toInt() and 0xff if ((secondByte and 0xc0) == 0x80 && (thirdByte and 0xc0) == 0x80) { val tempCodePoint = ((firstByte and 0x0f) shl 12) or ((secondByte and 0x3f) shl 6) or (thirdByte and 0x3f) if (tempCodePoint > 0x7ff && (tempCodePoint < 0xd800 || tempCodePoint > 0xdfff) ) { codePoint = tempCodePoint } } } 4 -> { val secondByte = bytes[index + 1].toInt() and 0xff val thirdByte = bytes[index + 2].toInt() and 0xff val fourthByte = bytes[index + 3].toInt() and 0xff if ((secondByte and 0xc0) == 0x80 && (thirdByte and 0xc0) == 0x80 && (fourthByte and 0xc0) == 0x80 ) { val tempCodePoint = ((firstByte and 0x0f) shl 18) or ((secondByte and 0x3f) shl 12) or ((thirdByte and 0x3f) shl 6) or (fourthByte and 0x3f) if (tempCodePoint > 0xffff && tempCodePoint < 0x110000) { codePoint = tempCodePoint } } } } } if (codePoint == null) { codePoint = 0xfffd bytesPerSequence = 1 } else if (codePoint > 0xffff) { val adjusted = codePoint - 0x10000 codePoints.add(((adjusted ushr 10) and 0x3ff) or 0xd800) codePoint = 0xdc00 or (adjusted and 0x3ff) } codePoints.add(codePoint) index += bytesPerSequence } val builder = StringBuilder(codePoints.size) codePoints.forEach { builder.append(it.toChar()) } return builder.toString() } /** * WHATWG/Node-like UTF-8 decoder fallback. * Kept for backwards compatibility with already persisted payloads. * * Public wrapper for use in MessageRepository */ fun bytesToJsUtf8StringPublic(bytes: ByteArray): String = bytesToJsUtf8String(bytes) /** * Converts bytes to string mimicking JavaScript's Buffer.toString('utf-8') behavior. * Implements the WHATWG Encoding Standard UTF-8 decoder algorithm EXACTLY: * https://encoding.spec.whatwg.org/#utf-8-decoder * * CRITICAL RULES (differ from naive implementations): * 1. Failed multi-byte sequence → emit exactly ONE U+FFFD, reprocess the bad byte * 2. Truncated sequence at end of input → emit exactly ONE U+FFFD * 3. Overlong / surrogate rejection via lower/upper continuation bounds (not post-hoc check) * 4. Bytes 0x80-0xC1, 0xF5-0xFF as starters → ONE U+FFFD per byte * * This is critical for cross-platform compatibility with desktop (Node.js)! */ private fun bytesToJsUtf8String(bytes: ByteArray): String { val result = StringBuilder() // WHATWG state variables var codePoint = 0 var bytesNeeded = 0 var bytesSeen = 0 var lowerBoundary = 0x80 var upperBoundary = 0xBF var i = 0 // Process all bytes + one extra iteration for end-of-input handling while (i <= bytes.size) { if (i == bytes.size) { // End of input if (bytesNeeded > 0) { // Truncated multi-byte sequence → exactly ONE U+FFFD result.append('\uFFFD') } break } val b = bytes[i].toInt() and 0xFF if (bytesNeeded == 0) { // Initial state — expecting a starter byte when { b <= 0x7F -> { result.append(b.toChar()) i++ } b in 0xC2..0xDF -> { bytesNeeded = 1 codePoint = b and 0x1F i++ } b in 0xE0..0xEF -> { bytesNeeded = 2 codePoint = b and 0x0F // WHATWG: tighter bounds to reject overlong & surrogates early when (b) { 0xE0 -> lowerBoundary = 0xA0 // reject overlong < U+0800 0xED -> upperBoundary = 0x9F // reject surrogates U+D800..U+DFFF } i++ } b in 0xF0..0xF4 -> { bytesNeeded = 3 codePoint = b and 0x07 when (b) { 0xF0 -> lowerBoundary = 0x90 // reject overlong < U+10000 0xF4 -> upperBoundary = 0x8F // reject > U+10FFFF } i++ } else -> { // 0x80-0xC1, 0xF5-0xFF → invalid starter → ONE U+FFFD per byte result.append('\uFFFD') i++ } } } else { // Continuation state — expecting byte in [lowerBoundary, upperBoundary] if (b in lowerBoundary..upperBoundary) { // Valid continuation codePoint = (codePoint shl 6) or (b and 0x3F) bytesSeen++ // Reset bounds to default after first continuation lowerBoundary = 0x80 upperBoundary = 0xBF if (bytesSeen == bytesNeeded) { // Sequence complete — emit code point if (codePoint <= 0xFFFF) { result.append(codePoint.toChar()) } else { // Supplementary: surrogate pair val adjusted = codePoint - 0x10000 result.append((0xD800 + (adjusted shr 10)).toChar()) result.append((0xDC00 + (adjusted and 0x3FF)).toChar()) } // Reset state codePoint = 0 bytesNeeded = 0 bytesSeen = 0 } i++ } else { // Invalid continuation → emit exactly ONE U+FFFD for the whole // failed sequence, then REPROCESS this byte (don't consume it) result.append('\uFFFD') // Reset state codePoint = 0 bytesNeeded = 0 bytesSeen = 0 lowerBoundary = 0x80 upperBoundary = 0xBF // Do NOT increment i — reprocess this byte as a potential starter } } } 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(':', limit = 2) if (parts.size != 2) { return encryptedBlob } val iv = decodeBase64Compat(parts[0]) ?: return encryptedBlob val ciphertext = decodeBase64Compat(parts[1]) ?: return encryptedBlob val passwordCandidates = buildAttachmentPasswordCandidates(plainKeyAndNonce) for (password in passwordCandidates) { val passwordBytes = password.toByteArray(Charsets.UTF_8) val keyCandidates = listOf( generatePBKDF2KeyFromBytes(passwordBytes), generatePBKDF2KeySha1FromBytes(passwordBytes) ) for (keyBytes in keyCandidates) { try { val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val keySpec = SecretKeySpec(keyBytes, "AES") val ivSpec = IvParameterSpec(iv) cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) val compressedBytes = cipher.doFinal(ciphertext) return inflateToUtf8(compressedBytes) } catch (_: Exception) { } } } encryptedBlob } catch (e: Exception) { // Return as-is, might be plain JSON encryptedBlob } } private fun inflateToUtf8(compressedBytes: ByteArray): String { val inflater = java.util.zip.Inflater() return try { inflater.setInput(compressedBytes) val output = java.io.ByteArrayOutputStream() val buffer = ByteArray(8 * 1024) while (!inflater.finished()) { val count = inflater.inflate(buffer) if (count > 0) { output.write(buffer, 0, count) continue } if (inflater.needsInput()) { break } if (inflater.needsDictionary()) { throw java.util.zip.DataFormatException("Inflater requires dictionary") } throw java.util.zip.DataFormatException("Inflater stalled") } if (!inflater.finished()) { throw java.util.zip.DataFormatException("Inflater did not finish") } String(output.toByteArray(), Charsets.UTF_8) } finally { inflater.end() } } } // 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() }