feat: implement device verification flow with new UI components and protocol handling
This commit is contained in:
@@ -29,6 +29,8 @@ 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>()
|
||||
@@ -57,8 +59,13 @@ object CryptoManager {
|
||||
* (чтобы кэш был горячий к моменту дешифровки)
|
||||
*/
|
||||
fun getPbkdf2Key(password: String): SecretKeySpec {
|
||||
return pbkdf2KeyCache.getOrPut(password) {
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
||||
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(),
|
||||
@@ -207,8 +214,8 @@ object CryptoManager {
|
||||
/**
|
||||
* Encrypt data with password using PBKDF2 + AES
|
||||
*
|
||||
* ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts):
|
||||
* - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию
|
||||
* ⚠️ ВАЖНО: Совместимость с Desktop (crypto-js 4.x):
|
||||
* - PBKDF2WithHmacSHA256
|
||||
* - Salt: "rosetta"
|
||||
* - Iterations: 1000
|
||||
* - Key size: 256 bit
|
||||
@@ -231,17 +238,8 @@ object CryptoManager {
|
||||
val encryptedChunks = mutableListOf<String>()
|
||||
|
||||
for (chunk in chunks) {
|
||||
// Derive key using PBKDF2-HMAC-SHA1
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
||||
val spec =
|
||||
PBEKeySpec(
|
||||
password.toCharArray(),
|
||||
SALT.toByteArray(Charsets.UTF_8),
|
||||
PBKDF2_ITERATIONS,
|
||||
KEY_SIZE
|
||||
)
|
||||
val secretKey = factory.generateSecret(spec)
|
||||
val key = SecretKeySpec(secretKey.encoded, "AES")
|
||||
// Desktop parity: PBKDF2-HMAC-SHA256
|
||||
val key = getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
|
||||
|
||||
// Generate random IV
|
||||
val iv = ByteArray(16)
|
||||
@@ -262,19 +260,8 @@ object CryptoManager {
|
||||
// Return chunked format: "CHNK:" + chunks joined by "::"
|
||||
return "CHNK:" + encryptedChunks.joinToString("::")
|
||||
} else {
|
||||
// Single chunk (original behavior)
|
||||
// Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!)
|
||||
// crypto-js по умолчанию использует SHA1 для PBKDF2
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
||||
val spec =
|
||||
PBEKeySpec(
|
||||
password.toCharArray(),
|
||||
SALT.toByteArray(Charsets.UTF_8),
|
||||
PBKDF2_ITERATIONS,
|
||||
KEY_SIZE
|
||||
)
|
||||
val secretKey = factory.generateSecret(spec)
|
||||
val key = SecretKeySpec(secretKey.encoded, "AES")
|
||||
// Single chunk (desktop parity)
|
||||
val key = getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
|
||||
|
||||
// Generate random IV
|
||||
val iv = ByteArray(16)
|
||||
@@ -297,8 +284,8 @@ object CryptoManager {
|
||||
/**
|
||||
* Decrypt data with password
|
||||
*
|
||||
* ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts):
|
||||
* - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию
|
||||
* ⚠️ ВАЖНО: Desktop использует PBKDF2-SHA256.
|
||||
* Для обратной совместимости с legacy Android данными пробуем также SHA1.
|
||||
* - Salt: "rosetta"
|
||||
* - Iterations: 1000
|
||||
* - Key size: 256 bit
|
||||
@@ -339,71 +326,83 @@ object CryptoManager {
|
||||
|
||||
/** 🔐 Внутренняя функция расшифровки (без кэширования результата) */
|
||||
private fun decryptWithPasswordInternal(encryptedData: String, password: String): String? {
|
||||
return try {
|
||||
// 🚀 Получаем кэшированный PBKDF2 ключ
|
||||
val key = getPbkdf2Key(password)
|
||||
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)) {
|
||||
val decoded = String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
|
||||
val parts = decoded.split(":")
|
||||
if (parts.size != 2) return null
|
||||
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 iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
// 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)
|
||||
|
||||
return String(decrypted, Charsets.UTF_8)
|
||||
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:")) {
|
||||
val chunkStrings = encryptedData.substring(5).split("::")
|
||||
val decompressedParts = mutableListOf<ByteArray>()
|
||||
try {
|
||||
val chunkStrings = encryptedData.substring(5).split("::")
|
||||
val decompressedParts = mutableListOf<ByteArray>()
|
||||
|
||||
for (chunkString in chunkStrings) {
|
||||
val parts = chunkString.split(":")
|
||||
if (parts.size != 2) return null
|
||||
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 iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
|
||||
// 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)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
val decrypted = cipher.doFinal(ciphertext)
|
||||
|
||||
decompressedParts.add(decrypted)
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// New format: base64 "iv:ciphertext"
|
||||
val parts = encryptedData.split(":")
|
||||
if (parts.size != 2) return null
|
||||
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 iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
|
||||
// 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)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
val decrypted = cipher.doFinal(ciphertext)
|
||||
|
||||
// Decompress (zlib inflate - совместимо с pako.inflate в JS)
|
||||
String(decompress(decrypted), Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
// 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 ":") */
|
||||
@@ -425,16 +424,13 @@ object CryptoManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* RAW Deflate сжатие (без zlib header)
|
||||
* Сжатие данных для encodeWithPassword.
|
||||
*
|
||||
* ⚠️ ВАЖНО: nowrap=true для совместимости с pako.deflate() в JS!
|
||||
* - pako.deflate() создаёт RAW deflate поток (без 2-byte zlib header)
|
||||
* - Java Deflater() по умолчанию создаёт zlib поток (с header 78 9C)
|
||||
* - Поэтому используем Deflater(level, true) где true = nowrap
|
||||
* Десктоп использует pako.deflate (zlib wrapper), поэтому тут должен быть обычный
|
||||
* Deflater без nowrap=true.
|
||||
*/
|
||||
private fun compress(data: ByteArray): ByteArray {
|
||||
// nowrap=true = RAW deflate (совместимо с pako.deflate)
|
||||
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true)
|
||||
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION)
|
||||
deflater.setInput(data)
|
||||
deflater.finish()
|
||||
|
||||
@@ -450,27 +446,38 @@ object CryptoManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* RAW Inflate декомпрессия (без zlib header)
|
||||
*
|
||||
* ⚠️ ВАЖНО: nowrap=true для совместимости с pako.inflate() в JS!
|
||||
* - pako.inflate() ожидает RAW deflate поток
|
||||
* - Java Inflater() по умолчанию ожидает zlib поток (с header)
|
||||
* - Поэтому используем Inflater(true) где true = nowrap
|
||||
* Декомпрессия с обратной совместимостью:
|
||||
* 1) сначала zlib (desktop/new android),
|
||||
* 2) затем raw deflate (legacy android данные).
|
||||
*/
|
||||
private fun decompress(data: ByteArray): ByteArray {
|
||||
// nowrap=true = RAW inflate (совместимо с pako.inflate)
|
||||
val inflater = Inflater(true)
|
||||
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()
|
||||
return outputStream.toByteArray()
|
||||
val result = outputStream.toByteArray()
|
||||
if (result.isEmpty()) throw IllegalStateException("Decompression produced empty output")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -511,6 +511,23 @@ object MessageCrypto {
|
||||
encryptedKey: String,
|
||||
myPrivateKey: String
|
||||
): String = decryptIncomingFull(ciphertext, encryptedKey, myPrivateKey).plaintext
|
||||
|
||||
fun decryptIncomingFullWithPlainKey(
|
||||
ciphertext: String,
|
||||
plainKeyAndNonce: ByteArray
|
||||
): DecryptedIncoming {
|
||||
require(plainKeyAndNonce.size >= 56) { "Invalid plainKeyAndNonce size: ${plainKeyAndNonce.size}" }
|
||||
|
||||
val key = plainKeyAndNonce.copyOfRange(0, 32)
|
||||
val nonce = plainKeyAndNonce.copyOfRange(32, 56)
|
||||
val plaintext = decryptMessage(ciphertext, key.toHex(), nonce.toHex())
|
||||
return DecryptedIncoming(plaintext, plainKeyAndNonce)
|
||||
}
|
||||
|
||||
fun decryptIncomingWithPlainKey(
|
||||
ciphertext: String,
|
||||
plainKeyAndNonce: ByteArray
|
||||
): String = decryptIncomingFullWithPlainKey(ciphertext, plainKeyAndNonce).plaintext
|
||||
|
||||
/**
|
||||
* Расшифровка MESSAGES attachment blob
|
||||
|
||||
Reference in New Issue
Block a user