401 lines
16 KiB
Kotlin
401 lines
16 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
|
||
)
|
||
|
||
/**
|
||
* 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: Encrypt with ChaCha20
|
||
val engine = ChaCha7539Engine()
|
||
engine.init(true, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))
|
||
|
||
val ciphertext = ByteArray(plaintext.size)
|
||
engine.processBytes(plaintext, 0, plaintext.size, ciphertext, 0)
|
||
|
||
// Step 4: Generate Poly1305 tag
|
||
// For AEAD, we need to compute Poly1305 over AAD + ciphertext with proper padding
|
||
val poly1305Key = ByteArray(32)
|
||
val zeros = ByteArray(32)
|
||
val polyKeyEngine = ChaCha7539Engine()
|
||
polyKeyEngine.init(true, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))
|
||
polyKeyEngine.processBytes(zeros, 0, 32, poly1305Key, 0)
|
||
|
||
val mac = Poly1305()
|
||
mac.init(KeyParameter(poly1305Key))
|
||
|
||
// 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: Verify Poly1305 tag
|
||
val poly1305Key = ByteArray(32)
|
||
val zeros = ByteArray(32)
|
||
val polyKeyEngine = ChaCha7539Engine()
|
||
polyKeyEngine.init(true, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))
|
||
polyKeyEngine.processBytes(zeros, 0, 32, poly1305Key, 0)
|
||
|
||
val mac = Poly1305()
|
||
mac.init(KeyParameter(poly1305Key))
|
||
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 4: Decrypt with ChaCha20
|
||
val engine = ChaCha7539Engine()
|
||
engine.init(false, ParametersWithIV(KeyParameter(subkey), chacha20Nonce))
|
||
|
||
val plaintext = ByteArray(ciphertext.size)
|
||
engine.processBytes(ciphertext, 0, ciphertext.size, plaintext, 0)
|
||
|
||
return plaintext
|
||
}
|
||
|
||
/**
|
||
* ECDH шифрование ключа для получателя
|
||
* Использует secp256k1 + AES как в RN версии
|
||
*/
|
||
fun encryptKeyForRecipient(keyAndNonce: ByteArray, recipientPublicKeyHex: String): String {
|
||
val secureRandom = SecureRandom()
|
||
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||
|
||
// Генерируем эфемерный приватный ключ
|
||
val ephemeralPrivateKeyBytes = ByteArray(32)
|
||
secureRandom.nextBytes(ephemeralPrivateKeyBytes)
|
||
val ephemeralPrivateKey = BigInteger(1, ephemeralPrivateKeyBytes)
|
||
|
||
// Получаем эфемерный публичный ключ
|
||
val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey)
|
||
val ephemeralPublicKeyHex = ephemeralPublicKey.getEncoded(false).toHex()
|
||
|
||
// Парсим публичный ключ получателя
|
||
val recipientPublicKeyBytes = recipientPublicKeyHex.hexToBytes()
|
||
val recipientPublicKey = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
|
||
|
||
// ECDH: получаем общий секрет
|
||
val sharedPoint = recipientPublicKey.multiply(ephemeralPrivateKey)
|
||
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
||
|
||
// Derive AES key from shared secret
|
||
val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret)
|
||
|
||
// Генерируем IV для AES
|
||
val iv = ByteArray(16)
|
||
secureRandom.nextBytes(iv)
|
||
|
||
// Шифруем keyAndNonce с AES-CBC
|
||
val secretKey = SecretKeySpec(aesKey, "AES")
|
||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(iv))
|
||
val encryptedKey = cipher.doFinal(keyAndNonce)
|
||
|
||
// Формат: iv:ciphertext:ephemeralPublicKey (Base64)
|
||
val result = ByteArray(iv.size + encryptedKey.size + recipientPublicKeyBytes.size)
|
||
System.arraycopy(iv, 0, result, 0, iv.size)
|
||
System.arraycopy(encryptedKey, 0, result, iv.size, encryptedKey.size)
|
||
|
||
// Возвращаем как Base64 с ephemeral public key в конце
|
||
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" +
|
||
Base64.encodeToString(encryptedKey, Base64.NO_WRAP) + ":" +
|
||
ephemeralPublicKeyHex
|
||
}
|
||
|
||
/**
|
||
* ECDH расшифровка ключа
|
||
*/
|
||
fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray {
|
||
val parts = encryptedKeyBase64.split(":")
|
||
if (parts.size != 3) throw IllegalArgumentException("Invalid encrypted key format")
|
||
|
||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||
val encryptedKey = Base64.decode(parts[1], Base64.NO_WRAP)
|
||
val ephemeralPublicKeyHex = parts[2]
|
||
|
||
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||
|
||
// Парсим мой приватный ключ
|
||
val myPrivateKey = BigInteger(myPrivateKeyHex, 16)
|
||
|
||
// Парсим эфемерный публичный ключ отправителя
|
||
val ephemeralPublicKeyBytes = ephemeralPublicKeyHex.hexToBytes()
|
||
val ephemeralPublicKey = ecSpec.curve.decodePoint(ephemeralPublicKeyBytes)
|
||
|
||
// ECDH: получаем общий секрет
|
||
val sharedPoint = ephemeralPublicKey.multiply(myPrivateKey)
|
||
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
||
|
||
// Derive AES key from shared secret
|
||
val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret)
|
||
|
||
// Расшифровываем
|
||
val secretKey = SecretKeySpec(aesKey, "AES")
|
||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||
cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv))
|
||
|
||
return cipher.doFinal(encryptedKey)
|
||
}
|
||
|
||
/**
|
||
* Полное шифрование сообщения для отправки
|
||
*/
|
||
fun encryptForSending(plaintext: String, recipientPublicKey: String): Pair<String, String> {
|
||
// 1. Шифруем текст
|
||
val encrypted = encryptMessage(plaintext)
|
||
|
||
// 2. Собираем key + nonce
|
||
val keyAndNonce = encrypted.key.hexToBytes() + encrypted.nonce.hexToBytes()
|
||
|
||
// 3. Шифруем ключ для получателя
|
||
val encryptedKey = encryptKeyForRecipient(keyAndNonce, recipientPublicKey)
|
||
|
||
return Pair(encrypted.ciphertext, encryptedKey)
|
||
}
|
||
|
||
/**
|
||
* Полная расшифровка входящего сообщения
|
||
*/
|
||
fun decryptIncoming(
|
||
ciphertext: String,
|
||
encryptedKey: String,
|
||
myPrivateKey: String
|
||
): String {
|
||
// 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. Расшифровываем сообщение
|
||
return decryptMessage(ciphertext, key.toHex(), nonce.toHex())
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|