feat: Implement JSON serialization for message attachments with decryption support
This commit is contained in:
@@ -637,6 +637,88 @@ object MessageCrypto {
|
|||||||
|
|
||||||
return plaintext
|
return plaintext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расшифровка MESSAGES attachment blob
|
||||||
|
* Формат: ivBase64:ciphertextBase64
|
||||||
|
* Использует PBKDF2 + AES-256-CBC + zlib decompression
|
||||||
|
*/
|
||||||
|
fun decryptAttachmentBlob(
|
||||||
|
encryptedData: String,
|
||||||
|
encryptedKey: String,
|
||||||
|
myPrivateKey: String
|
||||||
|
): String? {
|
||||||
|
return try {
|
||||||
|
// 1. Расшифровываем ChaCha ключ (как для сообщений)
|
||||||
|
val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey)
|
||||||
|
|
||||||
|
// 2. Конвертируем key+nonce в строку (как в RN: key.toString('utf-8'))
|
||||||
|
val chachaKeyString = String(keyAndNonce, Charsets.UTF_8)
|
||||||
|
|
||||||
|
// 3. Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations, sha256)
|
||||||
|
val pbkdf2Key = generatePBKDF2Key(chachaKeyString)
|
||||||
|
|
||||||
|
// 4. Расшифровываем AES-256-CBC
|
||||||
|
decryptWithPBKDF2Key(encryptedData, pbkdf2Key)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MessageCrypto", "❌ Failed to decrypt attachment blob", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерация PBKDF2 ключа (совместимо с RN)
|
||||||
|
*/
|
||||||
|
private fun generatePBKDF2Key(password: String, salt: String = "rosetta", iterations: Int = 1000): ByteArray {
|
||||||
|
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||||
|
val spec = javax.crypto.spec.PBEKeySpec(
|
||||||
|
password.toCharArray(),
|
||||||
|
salt.toByteArray(Charsets.UTF_8),
|
||||||
|
iterations,
|
||||||
|
256 // 32 bytes
|
||||||
|
)
|
||||||
|
return factory.generateSecret(spec).encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расшифровка с PBKDF2 ключом (AES-256-CBC + zlib)
|
||||||
|
* Формат: ivBase64:ciphertextBase64
|
||||||
|
*/
|
||||||
|
private fun decryptWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? {
|
||||||
|
return try {
|
||||||
|
val parts = encryptedData.split(":")
|
||||||
|
if (parts.size != 2) {
|
||||||
|
android.util.Log.e("MessageCrypto", "Invalid encrypted data format")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val iv = android.util.Base64.decode(parts[0], android.util.Base64.DEFAULT)
|
||||||
|
val ciphertext = android.util.Base64.decode(parts[1], android.util.Base64.DEFAULT)
|
||||||
|
|
||||||
|
// AES-256-CBC расшифровка
|
||||||
|
val cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
val secretKey = javax.crypto.spec.SecretKeySpec(pbkdf2Key, "AES")
|
||||||
|
val ivSpec = javax.crypto.spec.IvParameterSpec(iv)
|
||||||
|
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, ivSpec)
|
||||||
|
val decrypted = cipher.doFinal(ciphertext)
|
||||||
|
|
||||||
|
// Zlib декомпрессия
|
||||||
|
val inflater = java.util.zip.Inflater()
|
||||||
|
inflater.setInput(decrypted)
|
||||||
|
val outputStream = java.io.ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(1024)
|
||||||
|
while (!inflater.finished()) {
|
||||||
|
val count = inflater.inflate(buffer)
|
||||||
|
outputStream.write(buffer, 0, count)
|
||||||
|
}
|
||||||
|
inflater.end()
|
||||||
|
|
||||||
|
String(outputStream.toByteArray(), Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MessageCrypto", "❌ Failed to decrypt with PBKDF2 key", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extension functions для конвертации
|
// Extension functions для конвертации
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import com.rosetta.messenger.database.*
|
|||||||
import com.rosetta.messenger.network.*
|
import com.rosetta.messenger.network.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +154,9 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
toPublicKey
|
toPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Сериализуем attachments в JSON
|
||||||
|
val attachmentsJson = serializeAttachments(attachments)
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
val entity = MessageEntity(
|
val entity = MessageEntity(
|
||||||
account = account,
|
account = account,
|
||||||
@@ -165,7 +170,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
delivered = DeliveryStatus.WAITING.value,
|
delivered = DeliveryStatus.WAITING.value,
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
plainMessage = text.trim(),
|
plainMessage = text.trim(),
|
||||||
attachments = "[]", // TODO: JSON serialize
|
attachments = attachmentsJson,
|
||||||
replyToMessageId = replyToMessageId,
|
replyToMessageId = replyToMessageId,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
@@ -218,6 +223,13 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob
|
||||||
|
val attachmentsJson = serializeAttachmentsWithDecryption(
|
||||||
|
packet.attachments,
|
||||||
|
packet.chachaKey,
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
val entity = MessageEntity(
|
val entity = MessageEntity(
|
||||||
account = account,
|
account = account,
|
||||||
@@ -231,7 +243,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
delivered = DeliveryStatus.DELIVERED.value,
|
delivered = DeliveryStatus.DELIVERED.value,
|
||||||
messageId = packet.messageId,
|
messageId = packet.messageId,
|
||||||
plainMessage = plainText,
|
plainMessage = plainText,
|
||||||
attachments = "[]", // TODO
|
attachments = attachmentsJson,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
messageDao.insertMessage(entity)
|
messageDao.insertMessage(entity)
|
||||||
@@ -417,4 +429,92 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
lastSeen = lastSeen,
|
lastSeen = lastSeen,
|
||||||
verified = verified == 1
|
verified = verified == 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сериализация attachments в JSON
|
||||||
|
*/
|
||||||
|
private fun serializeAttachments(attachments: List<MessageAttachment>): String {
|
||||||
|
if (attachments.isEmpty()) return "[]"
|
||||||
|
|
||||||
|
val jsonArray = JSONArray()
|
||||||
|
for (attachment in attachments) {
|
||||||
|
val jsonObj = JSONObject().apply {
|
||||||
|
put("id", attachment.id)
|
||||||
|
put("blob", attachment.blob)
|
||||||
|
put("type", attachment.type.value)
|
||||||
|
put("preview", attachment.preview)
|
||||||
|
put("width", attachment.width)
|
||||||
|
put("height", attachment.height)
|
||||||
|
}
|
||||||
|
jsonArray.put(jsonObj)
|
||||||
|
}
|
||||||
|
return jsonArray.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сериализация attachments в JSON с расшифровкой MESSAGES blob
|
||||||
|
* Для MESSAGES типа blob расшифровывается и сохраняется в preview (как в RN)
|
||||||
|
*/
|
||||||
|
private fun serializeAttachmentsWithDecryption(
|
||||||
|
attachments: List<MessageAttachment>,
|
||||||
|
encryptedKey: String,
|
||||||
|
privateKey: String
|
||||||
|
): String {
|
||||||
|
if (attachments.isEmpty()) return "[]"
|
||||||
|
|
||||||
|
val jsonArray = JSONArray()
|
||||||
|
for (attachment in attachments) {
|
||||||
|
val jsonObj = JSONObject()
|
||||||
|
|
||||||
|
// Для MESSAGES типа расшифровываем blob
|
||||||
|
if (attachment.type == AttachmentType.MESSAGES && attachment.blob.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
|
||||||
|
attachment.blob,
|
||||||
|
encryptedKey,
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
|
||||||
|
if (decryptedBlob != null) {
|
||||||
|
// Сохраняем расшифрованный JSON в preview (как в RN)
|
||||||
|
jsonObj.put("id", attachment.id)
|
||||||
|
jsonObj.put("blob", decryptedBlob) // Расшифрованный JSON
|
||||||
|
jsonObj.put("type", attachment.type.value)
|
||||||
|
jsonObj.put("preview", decryptedBlob) // Для совместимости
|
||||||
|
jsonObj.put("width", attachment.width)
|
||||||
|
jsonObj.put("height", attachment.height)
|
||||||
|
android.util.Log.d("MessageRepository", "✅ Decrypted MESSAGES blob: ${decryptedBlob.take(100)}")
|
||||||
|
} else {
|
||||||
|
// Fallback - сохраняем как есть
|
||||||
|
jsonObj.put("id", attachment.id)
|
||||||
|
jsonObj.put("blob", attachment.blob)
|
||||||
|
jsonObj.put("type", attachment.type.value)
|
||||||
|
jsonObj.put("preview", attachment.preview)
|
||||||
|
jsonObj.put("width", attachment.width)
|
||||||
|
jsonObj.put("height", attachment.height)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MessageRepository", "❌ Failed to decrypt MESSAGES blob", e)
|
||||||
|
// Fallback - сохраняем как есть
|
||||||
|
jsonObj.put("id", attachment.id)
|
||||||
|
jsonObj.put("blob", attachment.blob)
|
||||||
|
jsonObj.put("type", attachment.type.value)
|
||||||
|
jsonObj.put("preview", attachment.preview)
|
||||||
|
jsonObj.put("width", attachment.width)
|
||||||
|
jsonObj.put("height", attachment.height)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Для других типов сохраняем как есть
|
||||||
|
jsonObj.put("id", attachment.id)
|
||||||
|
jsonObj.put("blob", attachment.blob)
|
||||||
|
jsonObj.put("type", attachment.type.value)
|
||||||
|
jsonObj.put("preview", attachment.preview)
|
||||||
|
jsonObj.put("width", attachment.width)
|
||||||
|
jsonObj.put("height", attachment.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonArray.put(jsonObj)
|
||||||
|
}
|
||||||
|
return jsonArray.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,11 +422,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
*/
|
*/
|
||||||
private fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
private fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
||||||
// Парсим attachments для поиска MESSAGES (цитата)
|
// Парсим attachments для поиска MESSAGES (цитата)
|
||||||
val replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1)
|
var replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1)
|
||||||
|
|
||||||
|
// Текст сообщения и возможный fallback reply из текста
|
||||||
|
var displayText = entity.plainMessage
|
||||||
|
|
||||||
|
// Если не нашли reply в attachments, пробуем распарсить из текста
|
||||||
|
// Формат: "🇵 Reply: "текст цитаты"\n\nоснователь текст" или подобный
|
||||||
|
if (replyData == null) {
|
||||||
|
val parseResult = parseReplyFromText(entity.plainMessage)
|
||||||
|
if (parseResult != null) {
|
||||||
|
replyData = parseResult.first
|
||||||
|
displayText = parseResult.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ChatMessage(
|
return ChatMessage(
|
||||||
id = entity.messageId,
|
id = entity.messageId,
|
||||||
text = entity.plainMessage, // Уже расшифровано при сохранении
|
text = displayText, // Уже расшифровано при сохранении
|
||||||
isOutgoing = entity.fromMe == 1,
|
isOutgoing = entity.fromMe == 1,
|
||||||
timestamp = Date(entity.timestamp),
|
timestamp = Date(entity.timestamp),
|
||||||
status = when (entity.delivered) {
|
status = when (entity.delivered) {
|
||||||
@@ -440,6 +453,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсинг reply из текста сообщения (fallback формат)
|
||||||
|
* Формат: "🇵 Reply: "текст цитаты"\n\nосновной текст"
|
||||||
|
* Возвращает Pair(ReplyData, очищенный текст) или null
|
||||||
|
*/
|
||||||
|
private fun parseReplyFromText(text: String): Pair<ReplyData, String>? {
|
||||||
|
// Паттерн: начинается с эмодзи флага + "Reply:" + текст в кавычках + перенос строки
|
||||||
|
val replyPattern = Regex("^[🇵🔁↩️]\\s*Reply:\\s*\"(.+?)\"\\s*\\n+(.*)$", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
val match = replyPattern.find(text) ?: return null
|
||||||
|
|
||||||
|
val replyText = match.groupValues[1]
|
||||||
|
val mainText = match.groupValues[2].trim()
|
||||||
|
|
||||||
|
return Pair(
|
||||||
|
ReplyData(
|
||||||
|
messageId = "",
|
||||||
|
senderName = opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } },
|
||||||
|
text = replyText,
|
||||||
|
isFromMe = false // Мы не знаем кто автор из fallback формата
|
||||||
|
),
|
||||||
|
mainText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Парсинг MESSAGES attachment для извлечения данных цитаты
|
* Парсинг MESSAGES attachment для извлечения данных цитаты
|
||||||
* Формат: [{"message_id": "...", "publicKey": "...", "message": "..."}]
|
* Формат: [{"message_id": "...", "publicKey": "...", "message": "..."}]
|
||||||
|
|||||||
Reference in New Issue
Block a user