diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 57eaf0b..354495d 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -610,11 +610,13 @@ object MessageCrypto { android.util.Log.d("ReplyDebug", " - plainKeyAndNonce size: ${plainKeyAndNonce.size}") android.util.Log.d("ReplyDebug", " - plainKeyAndNonce hex: ${plainKeyAndNonce.joinToString("") { "%02x".format(it) }}") - // Convert plainKeyAndNonce to string - simulate JS Buffer.toString('utf-8') behavior - // which replaces invalid UTF-8 sequences with U+FFFD + // Convert plainKeyAndNonce to string then back to bytes - simulate JS behavior: + // Buffer.from(key.toString('utf-8'), 'utf8') val password = bytesToJsUtf8String(plainKeyAndNonce) - android.util.Log.d("ReplyDebug", " - password length: ${password.length}") - android.util.Log.d("ReplyDebug", " - password bytes (first 20): ${password.toByteArray(Charsets.UTF_8).take(20).joinToString(",")}") + val passwordBytes = password.toByteArray(Charsets.UTF_8) + android.util.Log.d("ReplyDebug", " - password string length: ${password.length}") + android.util.Log.d("ReplyDebug", " - password bytes length: ${passwordBytes.size}") + android.util.Log.d("ReplyDebug", " - password bytes hex: ${passwordBytes.joinToString("") { "%02x".format(it) }}") // Compress with pako (deflate) val deflater = java.util.zip.Deflater() @@ -625,17 +627,12 @@ object MessageCrypto { deflater.end() val compressed = compressedBuffer.copyOf(compressedSize) - // PBKDF2 key derivation (matching RN: crypto.PBKDF2(password, 'rosetta', {keySize: 256/32, iterations: 1000})) - // CRITICAL: Must use SHA256 to match React Native (not SHA1!) - val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - val spec = javax.crypto.spec.PBEKeySpec( - password.toCharArray(), - "rosetta".toByteArray(Charsets.UTF_8), - 1000, - 256 - ) - val secretKey = factory.generateSecret(spec) - val keyBytes = secretKey.encoded + // PBKDF2 key derivation using custom implementation with raw bytes + // This matches JavaScript's pbkdf2Sync(Buffer.from(password, 'utf8'), ...) exactly + val salt = "rosetta".toByteArray(Charsets.UTF_8) + val keyBytes = pbkdf2WithBytes(passwordBytes, salt, 1000, 32) + + android.util.Log.d("ReplyDebug", " - Derived key hex: ${keyBytes.joinToString("") { "%02x".format(it) }}") // Generate random IV (16 bytes) val iv = ByteArray(16) @@ -678,13 +675,54 @@ object MessageCrypto { String(bytes, Charsets.UTF_8) } } + + /** + * Custom PBKDF2-HMAC-SHA256 implementation that takes raw bytes as password. + * This matches JavaScript's pbkdf2Sync(Buffer.from(password, 'utf8'), ...) behavior exactly. + */ + private fun pbkdf2WithBytes(passwordBytes: ByteArray, salt: ByteArray, iterations: Int, keyLength: Int): ByteArray { + val mac = javax.crypto.Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(passwordBytes, "HmacSHA256")) + + val hashLength = 32 // SHA256 produces 32 bytes + val numBlocks = (keyLength + hashLength - 1) / hashLength + val result = ByteArray(numBlocks * hashLength) + + for (blockNum in 1..numBlocks) { + // U1 = PRF(Password, Salt || INT_32_BE(i)) + val blockBytes = ByteArray(4) + blockBytes[0] = (blockNum shr 24).toByte() + blockBytes[1] = (blockNum shr 16).toByte() + blockBytes[2] = (blockNum shr 8).toByte() + blockBytes[3] = blockNum.toByte() + + mac.reset() + mac.update(salt) + mac.update(blockBytes) + var u = mac.doFinal() + var block = u.copyOf() + + // U2...Uc + for (j in 2..iterations) { + mac.reset() + u = mac.doFinal(u) + for (k in block.indices) { + block[k] = (block[k].toInt() xor u[k].toInt()).toByte() + } + } + + System.arraycopy(block, 0, result, (blockNum - 1) * hashLength, hashLength) + } + + return result.copyOf(keyLength) + } /** * Расшифровка reply blob полученного по сети * * Совместим с React Native: * 1. Parse "ivBase64:ciphertextBase64" format - * 2. Generate PBKDF2 key from ChaCha key (32 bytes) as string via Buffer.toString('utf-8') + * 2. Generate PBKDF2 key from ChaCha key as bytes via Buffer.from(password, 'utf8') * 3. AES-256-CBC decryption * 4. Decompress with pako (inflate) * @@ -720,22 +758,21 @@ object MessageCrypto { android.util.Log.d("ReplyDebug", " - Decoded IV size: ${iv.size}") android.util.Log.d("ReplyDebug", " - Decoded ciphertext size: ${ciphertext.size}") - // Password from plainKeyAndNonce - use same JS-like UTF-8 conversion + // Password from plainKeyAndNonce - convert to string then back to bytes (like JS does) + // This simulates: Buffer.from(key.toString('utf-8'), 'utf8') val password = bytesToJsUtf8String(plainKeyAndNonce) - android.util.Log.d("ReplyDebug", " - Password length: ${password.length}") - android.util.Log.d("ReplyDebug", " - Password bytes hex: ${password.toByteArray(Charsets.UTF_8).joinToString("") { "%02x".format(it) }}") + val passwordBytes = password.toByteArray(Charsets.UTF_8) + android.util.Log.d("ReplyDebug", " - Password string length: ${password.length}") + android.util.Log.d("ReplyDebug", " - Password bytes length: ${passwordBytes.size}") + android.util.Log.d("ReplyDebug", " - Password bytes hex: ${passwordBytes.joinToString("") { "%02x".format(it) }}") - // PBKDF2 key derivation - // CRITICAL: Must use SHA256 to match React Native (not SHA1!) - val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - val spec = javax.crypto.spec.PBEKeySpec( - password.toCharArray(), - "rosetta".toByteArray(Charsets.UTF_8), - 1000, - 256 - ) - val secretKey = factory.generateSecret(spec) - val keyBytes = secretKey.encoded + // PBKDF2 key derivation using custom implementation with raw bytes + // This matches JavaScript's pbkdf2Sync(Buffer.from(password, 'utf8'), ...) exactly + val salt = "rosetta".toByteArray(Charsets.UTF_8) + val keyBytes = pbkdf2WithBytes(passwordBytes, salt, 1000, 32) + + android.util.Log.d("ReplyDebug", " - Derived key size: ${keyBytes.size}") + android.util.Log.d("ReplyDebug", " - Derived key hex: ${keyBytes.joinToString("") { "%02x".format(it) }}") android.util.Log.d("ReplyDebug", " - Derived key size: ${keyBytes.size}") diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 40bdcb5..de616d5 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -284,6 +284,9 @@ class MessageRepository private constructor(private val context: Context) { privateKey ) + // Извлекаем replyToMessageId из attachments + val replyToMessageId = extractReplyToMessageId(packet.attachments) + // 🔒 Шифруем plainMessage с использованием приватного ключа val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey) @@ -301,6 +304,7 @@ class MessageRepository private constructor(private val context: Context) { messageId = messageId, // 🔥 Используем сгенерированный messageId! plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст attachments = attachmentsJson, + replyToMessageId = replyToMessageId, // 🔥 Добавляем replyToMessageId из attachments! dialogKey = dialogKey ) @@ -552,6 +556,32 @@ class MessageRepository private constructor(private val context: Context) { plainMessage } + // Десериализуем attachments из JSON + val attachmentsList = try { + if (attachments.isNotEmpty() && attachments != "[]") { + val jsonArray = JSONArray(attachments) + (0 until jsonArray.length()).mapNotNull { i -> + try { + val obj = jsonArray.getJSONObject(i) + MessageAttachment( + id = obj.optString("id", ""), + blob = obj.optString("blob", ""), + type = AttachmentType.fromInt(obj.optInt("type", 0)), + preview = obj.optString("preview", ""), + width = obj.optInt("width", 0), + height = obj.optInt("height", 0) + ) + } catch (e: Exception) { + null + } + } + } else { + emptyList() + } + } catch (e: Exception) { + emptyList() + } + return Message( id = id, messageId = messageId, @@ -562,6 +592,7 @@ class MessageRepository private constructor(private val context: Context) { isFromMe = fromMe == 1, isRead = read == 1, deliveryStatus = DeliveryStatus.fromInt(delivered), + attachments = attachmentsList, // Десериализованные attachments replyToMessageId = replyToMessageId ) } @@ -599,6 +630,39 @@ class MessageRepository private constructor(private val context: Context) { return jsonArray.toString() } + /** + * Извлечение replyToMessageId из attachments + * Ищет MESSAGES (type=1) attachment и парсит message_id + */ + private fun extractReplyToMessageId(attachments: List): String? { + if (attachments.isEmpty()) return null + + for (attachment in attachments) { + if (attachment.type == AttachmentType.MESSAGES) { + try { + // Берем blob или preview + val dataJson = attachment.blob.ifEmpty { attachment.preview } + if (dataJson.isEmpty() || dataJson.contains(":")) { + // Пропускаем зашифрованные старые сообщения + continue + } + + val messagesArray = JSONArray(dataJson) + if (messagesArray.length() > 0) { + val replyMessage = messagesArray.getJSONObject(0) + val replyMessageId = replyMessage.optString("message_id", "") + if (replyMessageId.isNotEmpty()) { + return replyMessageId + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + return null + } + /** * Сериализация attachments в JSON с расшифровкой MESSAGES blob * Для MESSAGES типа blob расшифровывается и сохраняется в preview (как в RN) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index c2b6c0f..14ffc66 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -2485,9 +2485,16 @@ private fun MessageInputBar( ) } } - IconButton( - onClick = onCloseReply, - modifier = Modifier.size(32.dp) + // 🔥 Box с clickable вместо IconButton - убираем задержку ripple + Box( + modifier = Modifier + .size(32.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, // Убираем ripple индикацию для мгновенного клика + onClick = onCloseReply + ), + contentAlignment = Alignment.Center ) { Icon( Icons.Default.Close,