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