Files
mobile-android/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt

666 lines
27 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.rosetta.messenger.crypto
import android.util.Base64
import com.google.crypto.tink.subtle.XChaCha20Poly1305
import java.io.ByteArrayOutputStream
import java.math.BigInteger
import java.security.*
import java.util.concurrent.ConcurrentHashMap
import java.util.zip.Deflater
import java.util.zip.Inflater
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
import org.bitcoinj.crypto.MnemonicCode
import org.bitcoinj.crypto.MnemonicException
import org.bouncycastle.jce.ECNamedCurveTable
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.jce.spec.ECPrivateKeySpec
import org.bouncycastle.jce.spec.ECPublicKeySpec
/**
* Cryptography module for Rosetta Messenger Implements BIP39 seed phrase generation and secp256k1
* key derivation
*/
object CryptoManager {
private const val PBKDF2_ITERATIONS = 1000
private const val KEY_SIZE = 256
private const val SALT = "rosetta"
private const val PBKDF2_HMAC_SHA1 = "PBKDF2WithHmacSHA1"
private const val PBKDF2_HMAC_SHA256 = "PBKDF2WithHmacSHA256"
// 🚀 ОПТИМИЗАЦИЯ: Кэш для генерации ключей (seedPhrase -> KeyPair)
private val keyPairCache = mutableMapOf<String, KeyPairData>()
private val privateKeyHashCache = mutableMapOf<String, String>()
// 🚀 КРИТИЧЕСКАЯ ОПТИМИЗАЦИЯ: Кэш для PBKDF2 производных ключей
// PBKDF2 с 1000 итерациями - очень тяжелая операция!
// Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз
private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>()
// 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
// расшифровке
private const val DECRYPTION_CACHE_SIZE = 2000
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
init {
// Add BouncyCastle provider for secp256k1 support
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(BouncyCastleProvider())
}
}
/**
* 🚀 Получить или вычислить PBKDF2 ключ для пароля (кэшируется) Public для pre-warm при логине
* (чтобы кэш был горячий к моменту дешифровки)
*/
fun getPbkdf2Key(password: String): SecretKeySpec {
return getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
}
private fun getPbkdf2Key(password: String, algorithm: String): SecretKeySpec {
val cacheKey = "$algorithm::$password"
return pbkdf2KeyCache.getOrPut(cacheKey) {
val factory = SecretKeyFactory.getInstance(algorithm)
val spec =
PBEKeySpec(
password.toCharArray(),
SALT.toByteArray(Charsets.UTF_8),
PBKDF2_ITERATIONS,
KEY_SIZE
)
val secretKey = factory.generateSecret(spec)
SecretKeySpec(secretKey.encoded, "AES")
}
}
/** 🧹 Очистить кэши при logout */
fun clearCaches() {
pbkdf2KeyCache.clear()
decryptionCache.clear()
keyPairCache.clear()
privateKeyHashCache.clear()
}
/** Generate a new 12-word BIP39 seed phrase */
fun generateSeedPhrase(): List<String> {
val secureRandom = SecureRandom()
val entropy = ByteArray(16) // 128 bits = 12 words
secureRandom.nextBytes(entropy)
val mnemonicCode = MnemonicCode.INSTANCE
return mnemonicCode.toMnemonic(entropy)
}
/** Validate a seed phrase */
fun validateSeedPhrase(words: List<String>): Boolean {
return try {
val mnemonicCode = MnemonicCode.INSTANCE
mnemonicCode.check(words)
true
} catch (e: MnemonicException) {
false
}
}
/**
* Convert seed phrase to private key (32 bytes hex string)
*
* Алгоритм (совместим с Desktop desktop-rosetta):
* 1. BIP39 mnemonicToSeed(phrase, "") → 64 байта (PBKDF2-SHA512, 2048 итераций)
* 2. Конвертация seed в hex-строку (128 символов)
* 3. SHA256(hexSeed) → 32 байта privateKey
*
* Desktop эквивалент (SetPassword.tsx + crypto.ts):
* ```js
* let seed = await mnemonicToSeed(phrase); // BIP39 → 64 bytes
* let hex = Buffer.from(seed).toString('hex'); // → 128-char hex string
* let keypair = await generateKeyPairFromSeed(hex); // SHA256(hex) → privateKey
* ```
*/
fun seedPhraseToPrivateKey(seedPhrase: List<String>): String {
// Step 1: BIP39 mnemonicToSeed — PBKDF2-SHA512 with 2048 iterations
// passphrase = "" (empty), salt = "mnemonic" + passphrase
val bip39Seed = MnemonicCode.toSeed(seedPhrase, "")
// Step 2: Convert to hex string (128 chars for 64 bytes)
val hexSeed = bip39Seed.joinToString("") { "%02x".format(it) }
// Step 3: SHA256(hexSeed) — matches Desktop's sha256.create().update(hex).digest()
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(hexSeed.toByteArray(Charsets.UTF_8))
return hash.joinToString("") { "%02x".format(it) }
}
/**
* Generate key pair from seed phrase using secp256k1 curve
*
* Алгоритм (совместим с Desktop desktop-rosetta):
* - privateKey = SHA256(hex(BIP39_seed(mnemonic))) - 32 байта
* - publicKey = secp256k1.getPublicKey(privateKey, compressed=true) - 33 байта
*
* Desktop эквивалент (crypto.ts):
* ```js
* const privateKey = sha256.create().update(seed).digest().toHex().toString();
* const publicKey = secp256k1.getPublicKey(Buffer.from(privateKey, "hex"), true);
* ```
*
* Кэшируем результаты для избежания повторных вычислений
*/
fun generateKeyPairFromSeed(seedPhrase: List<String>): KeyPairData {
val cacheKey = seedPhrase.joinToString(" ")
// Проверяем кэш
keyPairCache[cacheKey]?.let {
return it
}
// Генерируем приватный ключ через SHA256
val privateKeyHex = seedPhraseToPrivateKey(seedPhrase)
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// Преобразуем hex в bytes (32 байта)
val privateKeyBytes = privateKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
// Генерируем публичный ключ из приватного
val publicKeyPoint = ecSpec.g.multiply(privateKeyBigInt)
// ⚡ ВАЖНО: Используем СЖАТЫЙ формат (compressed=true) - 33 байта вместо 65
// Это совместимо с crypto_new где используется: secp256k1.getPublicKey(..., true)
val publicKeyHex = publicKeyPoint.getEncoded(true).joinToString("") { "%02x".format(it) }
val keyPair = KeyPairData(privateKey = privateKeyHex, publicKey = publicKeyHex)
// Сохраняем в кэш (ограничиваем размер до 5 записей)
keyPairCache[cacheKey] = keyPair
if (keyPairCache.size > 5) {
keyPairCache.remove(keyPairCache.keys.first())
}
return keyPair
}
/**
* Generate private key hash for protocol (SHA256(privateKey + "rosetta")) 🚀 ОПТИМИЗАЦИЯ:
* Кэшируем хэши для избежания повторных вычислений
*/
fun generatePrivateKeyHash(privateKey: String): String {
// Проверяем кэш
privateKeyHashCache[privateKey]?.let {
return it
}
val data = (privateKey + SALT).toByteArray()
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(data)
val hashHex = hash.joinToString("") { "%02x".format(it) }
// Сохраняем в кэш
privateKeyHashCache[privateKey] = hashHex
if (privateKeyHashCache.size > 10) {
privateKeyHashCache.remove(privateKeyHashCache.keys.first())
}
return hashHex
}
/**
* Encrypt data with password using PBKDF2 + AES
*
* ⚠️ ВАЖНО: Совместимость с Desktop (crypto-js 4.x):
* - PBKDF2WithHmacSHA256
* - Salt: "rosetta"
* - Iterations: 1000
* - Key size: 256 bit
* - AES-256-CBC с PKCS5/PKCS7 padding
* - Compression: zlib deflate (pako.deflate в JS)
* - Chunking для данных > 10MB
* - Формат single chunk: base64(iv):base64(ciphertext)
* - Формат chunked: "CHNK:" + chunks joined by "::"
*/
fun encryptWithPassword(data: String, password: String): String {
// Compress data (zlib deflate - совместимо с pako.deflate в JS)
val compressed = compress(data.toByteArray(Charsets.UTF_8))
val CHUNK_SIZE = 10 * 1024 * 1024 // 10MB
// Check if we need chunking
if (compressed.size > CHUNK_SIZE) {
// Chunk the compressed data
val chunks = compressed.toList().chunked(CHUNK_SIZE).map { it.toByteArray() }
val encryptedChunks = mutableListOf<String>()
for (chunk in chunks) {
// Desktop parity: PBKDF2-HMAC-SHA256
val key = getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
// Generate random IV
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val ivSpec = IvParameterSpec(iv)
// Encrypt with AES
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
val encrypted = cipher.doFinal(chunk)
// Store as ivBase64:ctBase64
val ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP)
val ctBase64 = Base64.encodeToString(encrypted, Base64.NO_WRAP)
encryptedChunks.add("$ivBase64:$ctBase64")
}
// Return chunked format: "CHNK:" + chunks joined by "::"
return "CHNK:" + encryptedChunks.joinToString("::")
} else {
// Single chunk (desktop parity)
val key = getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
// Generate random IV
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val ivSpec = IvParameterSpec(iv)
// Encrypt with AES
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
val encrypted = cipher.doFinal(compressed)
// Return iv:ciphertext in Base64
val ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP)
val ctBase64 = Base64.encodeToString(encrypted, Base64.NO_WRAP)
return "$ivBase64:$ctBase64"
}
}
/**
* Decrypt data with password
*
* ⚠️ ВАЖНО: Desktop использует PBKDF2-SHA256.
* Для обратной совместимости с legacy Android данными пробуем также SHA1.
* - Salt: "rosetta"
* - Iterations: 1000
* - Key size: 256 bit
* - AES-256-CBC с PKCS5/PKCS7 padding
* - Decompression: zlib inflate (pako.inflate в JS)
* - Supports old format (base64-encoded hex "iv:ciphertext")
* - Supports new format (base64 "iv:ciphertext")
* - Supports chunked format ("CHNK:" + chunks joined by "::")
*
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
*/
fun decryptWithPassword(encryptedData: String, password: String): String? {
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
val cacheKey = "$password:$encryptedData"
decryptionCache[cacheKey]?.let {
return it
}
return try {
val result = decryptWithPasswordInternal(encryptedData, password)
// 🚀 Сохраняем в кэш (lock-free)
if (result != null) {
// Ограничиваем размер кэша
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
// Удаляем ~10% самых старых записей
val keysToRemove = decryptionCache.keys.take(DECRYPTION_CACHE_SIZE / 10)
keysToRemove.forEach { decryptionCache.remove(it) }
}
decryptionCache[cacheKey] = result
}
result
} catch (e: Exception) {
null
}
}
/** 🔐 Внутренняя функция расшифровки (без кэширования результата) */
private fun decryptWithPasswordInternal(encryptedData: String, password: String): String? {
val keysToTry =
listOf(
getPbkdf2Key(password, PBKDF2_HMAC_SHA256),
getPbkdf2Key(password, PBKDF2_HMAC_SHA1)
)
keysToTry.forEach { key ->
// Check for old format: base64-encoded string containing hex
if (isOldFormat(encryptedData)) {
try {
val decoded =
String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
val parts = decoded.split(":")
if (parts.size != 2) return@forEach
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
return String(decrypted, Charsets.UTF_8)
} catch (_: Exception) {
return@forEach
}
}
// Check for chunked format
if (encryptedData.startsWith("CHNK:")) {
try {
val chunkStrings = encryptedData.substring(5).split("::")
val decompressedParts = mutableListOf<ByteArray>()
for (chunkString in chunkStrings) {
val parts = chunkString.split(":")
if (parts.size != 2) return@forEach
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
decompressedParts.add(decrypted)
}
// Concatenate all decrypted chunks
val allBytes = decompressedParts.fold(ByteArray(0)) { acc, arr -> acc + arr }
// Decompress the concatenated data
return String(decompress(allBytes), Charsets.UTF_8)
} catch (_: Exception) {
return@forEach
}
}
// New format: base64 "iv:ciphertext"
try {
val parts = encryptedData.split(":")
if (parts.size != 2) return@forEach
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
// Decompress (совместимо с desktop + fallback для legacy)
return String(decompress(decrypted), Charsets.UTF_8)
} catch (_: Exception) {
return@forEach
}
}
return null
}
/** Check if data is in old format (base64-encoded hex with ":") */
private fun isOldFormat(data: String): Boolean {
// 🚀 Fast path: new format always contains ':' at plaintext level (iv:ct)
// Old format is a single base64 blob without ':' in the encoded string
if (data.contains(':')) return false
if (data.startsWith("CHNK:")) return false
return try {
val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8)
decoded.contains(":") &&
decoded.split(":").all { part ->
part.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
}
} catch (e: Exception) {
false
}
}
/**
* Сжатие данных для encodeWithPassword.
*
* Десктоп использует pako.deflate (zlib wrapper), поэтому тут должен быть обычный
* Deflater без nowrap=true.
*/
private fun compress(data: ByteArray): ByteArray {
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION)
deflater.setInput(data)
deflater.finish()
val outputStream = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (!deflater.finished()) {
val count = deflater.deflate(buffer)
outputStream.write(buffer, 0, count)
}
deflater.end() // Освобождаем ресурсы
outputStream.close()
return outputStream.toByteArray()
}
/**
* Декомпрессия с обратной совместимостью:
* 1) сначала zlib (desktop/new android),
* 2) затем raw deflate (legacy android данные).
*/
private fun decompress(data: ByteArray): ByteArray {
return try {
inflate(data, nowrap = false)
} catch (_: Exception) {
inflate(data, nowrap = true)
}
}
private fun inflate(data: ByteArray, nowrap: Boolean): ByteArray {
val inflater = Inflater(nowrap)
inflater.setInput(data)
val outputStream = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
if (count == 0) {
if (inflater.needsInput() || inflater.needsDictionary()) {
throw IllegalStateException("Inflate failed: incomplete or unsupported stream")
}
}
outputStream.write(buffer, 0, count)
}
inflater.end() // Освобождаем ресурсы
outputStream.close()
val result = outputStream.toByteArray()
if (result.isEmpty()) throw IllegalStateException("Decompression produced empty output")
return result
}
/**
* Encrypt data using ECDH + AES
*
* Algorithm:
* 1. Generate ephemeral key pair
* 2. Compute shared secret using ECDH (ephemeralPrivateKey × recipientPublicKey)
* 3. Use x-coordinate of shared point as AES key
* 4. Encrypt data with AES-256-CBC
* 5. Return: base64(iv:ciphertext:ephemeralPrivateKey)
*/
fun encrypt(data: String, publicKeyHex: String): String {
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// Generate ephemeral private key (random 32 bytes)
val ephemeralPrivateKeyBytes = ByteArray(32)
SecureRandom().nextBytes(ephemeralPrivateKeyBytes)
val ephemeralPrivateKeyBigInt = BigInteger(1, ephemeralPrivateKeyBytes)
// Generate ephemeral public key from private key
val ephemeralPublicKeyPoint = ecSpec.g.multiply(ephemeralPrivateKeyBigInt)
// Parse recipient's public key
val recipientPublicKeyBytes =
publicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val recipientPublicKeyPoint = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
// Compute shared secret using ECDH (ephemeralPrivateKey × recipientPublicKey)
val sharedPoint = recipientPublicKeyPoint.multiply(ephemeralPrivateKeyBigInt).normalize()
// Use x-coordinate of shared point as AES key (32 bytes)
val sharedKeyBytes = sharedPoint.affineXCoord.encoded
val sharedKey =
if (sharedKeyBytes.size >= 32) {
sharedKeyBytes.copyOfRange(sharedKeyBytes.size - 32, sharedKeyBytes.size)
} else {
// Pad with leading zeros if needed
ByteArray(32 - sharedKeyBytes.size) + sharedKeyBytes
}
val key = SecretKeySpec(sharedKey, "AES")
// Generate random IV
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val ivSpec = IvParameterSpec(iv)
// Encrypt with AES
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
val encrypted = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
// Normalize ephemeral private key to 32 bytes
val normalizedPrivateKey =
if (ephemeralPrivateKeyBytes.size > 32) {
ephemeralPrivateKeyBytes.copyOfRange(
ephemeralPrivateKeyBytes.size - 32,
ephemeralPrivateKeyBytes.size
)
} else {
ephemeralPrivateKeyBytes
}
// Return base64(iv:ciphertext:ephemeralPrivateKey)
val ivHex = iv.joinToString("") { "%02x".format(it) }
val ctHex = encrypted.joinToString("") { "%02x".format(it) }
val ephemeralPrivateKeyHex = normalizedPrivateKey.joinToString("") { "%02x".format(it) }
val combined = "$ivHex:$ctHex:$ephemeralPrivateKeyHex"
return Base64.encodeToString(combined.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
}
/**
* Decrypt data using ECDH + AES
*
* Algorithm:
* 1. Parse iv, ciphertext, and ephemeralPrivateKey from base64
* 2. Compute ephemeral public key from ephemeral private key
* 3. Compute shared secret using ECDH (privateKey × ephemeralPublicKey)
* 4. Use x-coordinate of shared point as AES key
* 5. Decrypt data with AES-256-CBC
*/
fun decrypt(encryptedData: String, privateKeyHex: String): String? {
return try {
// Decode base64
val decoded = String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
val parts = decoded.split(":")
if (parts.size != 3) return null
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ephemeralPrivateKeyBytes =
parts[2].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// Compute ephemeral public key from ephemeral private key
val ephemeralPrivateKeyBigInt = BigInteger(1, ephemeralPrivateKeyBytes)
val ephemeralPublicKeyPoint = ecSpec.g.multiply(ephemeralPrivateKeyBigInt)
val ephemeralPublicKeySpec = ECPublicKeySpec(ephemeralPublicKeyPoint, ecSpec)
val keyFactory = KeyFactory.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME)
val ephemeralPublicKey = keyFactory.generatePublic(ephemeralPublicKeySpec)
// Parse private key
val privateKeyBytes =
privateKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
val privateKeySpec = ECPrivateKeySpec(privateKeyBigInt, ecSpec)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
// Compute shared secret using ECDH
val keyAgreement =
javax.crypto.KeyAgreement.getInstance(
"ECDH",
BouncyCastleProvider.PROVIDER_NAME
)
keyAgreement.init(privateKey)
keyAgreement.doPhase(ephemeralPublicKey, true)
val sharedSecret = keyAgreement.generateSecret()
// Use first 32 bytes (x-coordinate) as AES key
val sharedKey = sharedSecret.copyOfRange(1, 33)
val key = SecretKeySpec(sharedKey, "AES")
// Decrypt with AES-256-CBC
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
String(decrypted, Charsets.UTF_8)
} catch (e: Exception) {
null
}
}
/**
* Encrypt data using XChaCha20-Poly1305
*
* Returns: { ciphertext: hex string, nonce: hex string (24 bytes), key: hex string (32 bytes) }
*/
fun chacha20Encrypt(data: String): ChaCha20Result {
// Generate random key (32 bytes) and nonce (24 bytes)
val key = ByteArray(32)
val nonce = ByteArray(24)
SecureRandom().nextBytes(key)
SecureRandom().nextBytes(nonce)
// Encrypt using XChaCha20-Poly1305
val cipher = XChaCha20Poly1305(key)
val plaintext = data.toByteArray(Charsets.UTF_8)
val ciphertext = cipher.encrypt(nonce, plaintext)
return ChaCha20Result(
ciphertext = ciphertext.joinToString("") { "%02x".format(it) },
nonce = nonce.joinToString("") { "%02x".format(it) },
key = key.joinToString("") { "%02x".format(it) }
)
}
/**
* Decrypt data using XChaCha20-Poly1305
*
* @param ciphertextHex Hex-encoded ciphertext
* @param nonceHex Hex-encoded nonce (24 bytes)
* @param keyHex Hex-encoded key (32 bytes)
*/
fun chacha20Decrypt(ciphertextHex: String, nonceHex: String, keyHex: String): String? {
return try {
val ciphertext = ciphertextHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val nonce = nonceHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val key = keyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val cipher = XChaCha20Poly1305(key)
val decrypted = cipher.decrypt(nonce, ciphertext)
String(decrypted, Charsets.UTF_8)
} catch (e: Exception) {
null
}
}
}
data class KeyPairData(val privateKey: String, val publicKey: String)
data class ChaCha20Result(val ciphertext: String, val nonce: String, val key: String)