666 lines
27 KiB
Kotlin
666 lines
27 KiB
Kotlin
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)
|