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() private val privateKeyHashCache = mutableMapOf() // 🚀 КРИТИЧЕСКАЯ ОПТИМИЗАЦИЯ: Кэш для PBKDF2 производных ключей // PBKDF2 с 1000 итерациями - очень тяжелая операция! // Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз private val pbkdf2KeyCache = mutableMapOf() // 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений // ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной // расшифровке private const val DECRYPTION_CACHE_SIZE = 2000 private val decryptionCache = ConcurrentHashMap(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 { 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): 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 { // 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): 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() 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() 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)