1723 lines
72 KiB
Kotlin
1723 lines
72 KiB
Kotlin
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()
|
||
}
|