feat: implement device verification flow with new UI components and protocol handling

This commit is contained in:
2026-02-18 04:40:22 +05:00
parent edff3b32c3
commit cacd6dc029
24 changed files with 1645 additions and 195 deletions

View File

@@ -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
}
/**

View File

@@ -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