Files
mobile-android/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt
k1ngsterr1 3a595c02b3
Some checks failed
Android Kernel Build / build (push) Failing after 14m42s
убран коммент
2026-03-21 21:55:58 +05:00

1723 lines
72 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
private const val SYNC_KEY_PREFIX = "sync:"
// Кэш PBKDF2-SHA256 ключей: password → derived key bytes
// PBKDF2 с 1000 итерациями ~50-100ms, кэш убирает повторные вычисления
private val pbkdf2Cache = java.util.concurrent.ConcurrentHashMap<String, ByteArray>()
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<String>
)
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<ByteArray> {
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<String, ByteArray>()
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<String>()
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 (<28>)
*
* @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<String> {
val candidates = LinkedHashSet<String>(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<Int>(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()
}