feat: Extract replyToMessageId from attachments and implement custom PBKDF2 key derivation
This commit is contained in:
@@ -610,11 +610,13 @@ object MessageCrypto {
|
|||||||
android.util.Log.d("ReplyDebug", " - plainKeyAndNonce size: ${plainKeyAndNonce.size}")
|
android.util.Log.d("ReplyDebug", " - plainKeyAndNonce size: ${plainKeyAndNonce.size}")
|
||||||
android.util.Log.d("ReplyDebug", " - plainKeyAndNonce hex: ${plainKeyAndNonce.joinToString("") { "%02x".format(it) }}")
|
android.util.Log.d("ReplyDebug", " - plainKeyAndNonce hex: ${plainKeyAndNonce.joinToString("") { "%02x".format(it) }}")
|
||||||
|
|
||||||
// Convert plainKeyAndNonce to string - simulate JS Buffer.toString('utf-8') behavior
|
// Convert plainKeyAndNonce to string then back to bytes - simulate JS behavior:
|
||||||
// which replaces invalid UTF-8 sequences with U+FFFD
|
// Buffer.from(key.toString('utf-8'), 'utf8')
|
||||||
val password = bytesToJsUtf8String(plainKeyAndNonce)
|
val password = bytesToJsUtf8String(plainKeyAndNonce)
|
||||||
android.util.Log.d("ReplyDebug", " - password length: ${password.length}")
|
val passwordBytes = password.toByteArray(Charsets.UTF_8)
|
||||||
android.util.Log.d("ReplyDebug", " - password bytes (first 20): ${password.toByteArray(Charsets.UTF_8).take(20).joinToString(",")}")
|
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)
|
// Compress with pako (deflate)
|
||||||
val deflater = java.util.zip.Deflater()
|
val deflater = java.util.zip.Deflater()
|
||||||
@@ -625,17 +627,12 @@ object MessageCrypto {
|
|||||||
deflater.end()
|
deflater.end()
|
||||||
val compressed = compressedBuffer.copyOf(compressedSize)
|
val compressed = compressedBuffer.copyOf(compressedSize)
|
||||||
|
|
||||||
// PBKDF2 key derivation (matching RN: crypto.PBKDF2(password, 'rosetta', {keySize: 256/32, iterations: 1000}))
|
// PBKDF2 key derivation using custom implementation with raw bytes
|
||||||
// CRITICAL: Must use SHA256 to match React Native (not SHA1!)
|
// This matches JavaScript's pbkdf2Sync(Buffer.from(password, 'utf8'), ...) exactly
|
||||||
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
val salt = "rosetta".toByteArray(Charsets.UTF_8)
|
||||||
val spec = javax.crypto.spec.PBEKeySpec(
|
val keyBytes = pbkdf2WithBytes(passwordBytes, salt, 1000, 32)
|
||||||
password.toCharArray(),
|
|
||||||
"rosetta".toByteArray(Charsets.UTF_8),
|
android.util.Log.d("ReplyDebug", " - Derived key hex: ${keyBytes.joinToString("") { "%02x".format(it) }}")
|
||||||
1000,
|
|
||||||
256
|
|
||||||
)
|
|
||||||
val secretKey = factory.generateSecret(spec)
|
|
||||||
val keyBytes = secretKey.encoded
|
|
||||||
|
|
||||||
// Generate random IV (16 bytes)
|
// Generate random IV (16 bytes)
|
||||||
val iv = ByteArray(16)
|
val iv = ByteArray(16)
|
||||||
@@ -679,12 +676,53 @@ object MessageCrypto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 полученного по сети
|
* Расшифровка reply blob полученного по сети
|
||||||
*
|
*
|
||||||
* Совместим с React Native:
|
* Совместим с React Native:
|
||||||
* 1. Parse "ivBase64:ciphertextBase64" format
|
* 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
|
* 3. AES-256-CBC decryption
|
||||||
* 4. Decompress with pako (inflate)
|
* 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 IV size: ${iv.size}")
|
||||||
android.util.Log.d("ReplyDebug", " - Decoded ciphertext size: ${ciphertext.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)
|
val password = bytesToJsUtf8String(plainKeyAndNonce)
|
||||||
android.util.Log.d("ReplyDebug", " - Password length: ${password.length}")
|
val passwordBytes = password.toByteArray(Charsets.UTF_8)
|
||||||
android.util.Log.d("ReplyDebug", " - Password bytes hex: ${password.toByteArray(Charsets.UTF_8).joinToString("") { "%02x".format(it) }}")
|
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
|
// PBKDF2 key derivation using custom implementation with raw bytes
|
||||||
// CRITICAL: Must use SHA256 to match React Native (not SHA1!)
|
// This matches JavaScript's pbkdf2Sync(Buffer.from(password, 'utf8'), ...) exactly
|
||||||
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
val salt = "rosetta".toByteArray(Charsets.UTF_8)
|
||||||
val spec = javax.crypto.spec.PBEKeySpec(
|
val keyBytes = pbkdf2WithBytes(passwordBytes, salt, 1000, 32)
|
||||||
password.toCharArray(),
|
|
||||||
"rosetta".toByteArray(Charsets.UTF_8),
|
android.util.Log.d("ReplyDebug", " - Derived key size: ${keyBytes.size}")
|
||||||
1000,
|
android.util.Log.d("ReplyDebug", " - Derived key hex: ${keyBytes.joinToString("") { "%02x".format(it) }}")
|
||||||
256
|
|
||||||
)
|
|
||||||
val secretKey = factory.generateSecret(spec)
|
|
||||||
val keyBytes = secretKey.encoded
|
|
||||||
|
|
||||||
android.util.Log.d("ReplyDebug", " - Derived key size: ${keyBytes.size}")
|
android.util.Log.d("ReplyDebug", " - Derived key size: ${keyBytes.size}")
|
||||||
|
|
||||||
|
|||||||
@@ -284,6 +284,9 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Извлекаем replyToMessageId из attachments
|
||||||
|
val replyToMessageId = extractReplyToMessageId(packet.attachments)
|
||||||
|
|
||||||
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||||
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
||||||
|
|
||||||
@@ -301,6 +304,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
||||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
|
replyToMessageId = replyToMessageId, // 🔥 Добавляем replyToMessageId из attachments!
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -552,6 +556,32 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
plainMessage
|
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(
|
return Message(
|
||||||
id = id,
|
id = id,
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
@@ -562,6 +592,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
isFromMe = fromMe == 1,
|
isFromMe = fromMe == 1,
|
||||||
isRead = read == 1,
|
isRead = read == 1,
|
||||||
deliveryStatus = DeliveryStatus.fromInt(delivered),
|
deliveryStatus = DeliveryStatus.fromInt(delivered),
|
||||||
|
attachments = attachmentsList, // Десериализованные attachments
|
||||||
replyToMessageId = replyToMessageId
|
replyToMessageId = replyToMessageId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -599,6 +630,39 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
return jsonArray.toString()
|
return jsonArray.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлечение replyToMessageId из attachments
|
||||||
|
* Ищет MESSAGES (type=1) attachment и парсит message_id
|
||||||
|
*/
|
||||||
|
private fun extractReplyToMessageId(attachments: List<MessageAttachment>): 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
|
* Сериализация attachments в JSON с расшифровкой MESSAGES blob
|
||||||
* Для MESSAGES типа blob расшифровывается и сохраняется в preview (как в RN)
|
* Для MESSAGES типа blob расшифровывается и сохраняется в preview (как в RN)
|
||||||
|
|||||||
@@ -2485,9 +2485,16 @@ private fun MessageInputBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(
|
// 🔥 Box с clickable вместо IconButton - убираем задержку ripple
|
||||||
onClick = onCloseReply,
|
Box(
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null, // Убираем ripple индикацию для мгновенного клика
|
||||||
|
onClick = onCloseReply
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Close,
|
Icons.Default.Close,
|
||||||
|
|||||||
Reference in New Issue
Block a user