Forward без перезаливки: chacha_key_plain + HEX пароль вложений (Desktop parity)

This commit is contained in:
2026-03-29 21:28:28 +05:00
parent 8c64111bd6
commit 4e17c9b188
4 changed files with 113 additions and 389 deletions

View File

@@ -154,18 +154,22 @@ enum MessageCrypto {
// MARK: - Attachment Password Helpers
/// Returns password candidates from a stored attachment password string.
/// New format: `"rawkey:<hex>"` derives Android (`bytesToJsUtf8String`) + WHATWG passwords.
/// New format: `"rawkey:<hex>"` derives HEX (primary) + Android/WHATWG/Latin-1 (backward compat).
/// Legacy format: plain string used as-is (backward compat with persisted messages).
///
/// Desktop commit `61e83bd`: attachment password changed from `Buffer.toString('utf-8')` to
/// `key.toString('hex')`. HEX is now the primary candidate; UTF-8 variants kept for old messages.
nonisolated static func attachmentPasswordCandidates(from stored: String) -> [String] {
if stored.hasPrefix("rawkey:") {
let hex = String(stored.dropFirst("rawkey:".count))
let keyData = Data(hexString: hex)
let hexPwd = hex // HEX: primary candidate (desktop commit 61e83bd)
let androidPwd = bytesToAndroidUtf8String(keyData)
let whatwgPwd = String(decoding: keyData, as: UTF8.self)
// Latin-1 variant: backward compat with iOS builds that used .isoLatin1 encoding
let latin1Pwd = String(bytes: keyData, encoding: .isoLatin1)
var candidates = [androidPwd, whatwgPwd]
if let latin1Pwd, latin1Pwd != androidPwd, latin1Pwd != whatwgPwd {
var candidates = [hexPwd, androidPwd, whatwgPwd]
if let latin1Pwd, latin1Pwd != hexPwd, latin1Pwd != androidPwd, latin1Pwd != whatwgPwd {
candidates.append(latin1Pwd)
}
// Deduplicate while preserving order

View File

@@ -132,14 +132,76 @@ struct ReplyMessageData: Codable {
let message: String
let timestamp: Int64
let attachments: [ReplyAttachmentData]
/// Desktop commit `aaa4b42`: hex-encoded plainKeyAndNonce of the ORIGINAL message.
/// Allows recipients to decrypt forwarded attachments without re-upload.
let chacha_key_plain: String
init(message_id: String, publicKey: String, message: String,
timestamp: Int64, attachments: [ReplyAttachmentData],
chacha_key_plain: String = "") {
self.message_id = message_id
self.publicKey = publicKey
self.message = message
self.timestamp = timestamp
self.attachments = attachments
self.chacha_key_plain = chacha_key_plain
}
// Custom Decodable `decodeIfPresent` for backward compat with old reply blobs
// that don't have `chacha_key_plain`.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
message_id = try c.decode(String.self, forKey: .message_id)
publicKey = try c.decode(String.self, forKey: .publicKey)
message = try c.decode(String.self, forKey: .message)
timestamp = try c.decode(Int64.self, forKey: .timestamp)
attachments = try c.decode([ReplyAttachmentData].self, forKey: .attachments)
chacha_key_plain = try c.decodeIfPresent(String.self, forKey: .chacha_key_plain) ?? ""
}
}
/// Transport info inside a reply/forward attachment.
/// Desktop: nested `transport: { transport_tag, transport_server }` in `Attachment`.
struct ReplyAttachmentTransport: Codable {
let transport_tag: String
let transport_server: String
init(transport_tag: String = "", transport_server: String = "") {
self.transport_tag = transport_tag
self.transport_server = transport_server
}
}
/// Attachment inside a reply/forward blob.
/// Desktop: `Attachment` interface in `packet.message.ts`.
struct ReplyAttachmentData: Codable {
let id: String
let type: Int
let preview: String
let blob: String
/// Desktop commit `aaa4b42`: per-attachment transport for forwarded attachments.
let transport: ReplyAttachmentTransport
init(id: String, type: Int, preview: String, blob: String,
transport: ReplyAttachmentTransport = ReplyAttachmentTransport()) {
self.id = id
self.type = type
self.preview = preview
self.blob = blob
self.transport = transport
}
// Custom Decodable `decodeIfPresent` for backward compat with old reply blobs
// that don't have `transport`.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
type = try c.decode(Int.self, forKey: .type)
preview = try c.decode(String.self, forKey: .preview)
blob = try c.decode(String.self, forKey: .blob)
transport = try c.decodeIfPresent(ReplyAttachmentTransport.self, forKey: .transport)
?? ReplyAttachmentTransport()
}
}
// MARK: - Search User

View File

@@ -331,11 +331,10 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey
)
// Attachment password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8')
// used by Desktop in useDialogFiber.ts and useSynchronize.ts).
// Node.js Buffer.toString('utf-8') feross/buffer polyfill bytesToAndroidUtf8String.
// WHATWG TextDecoder differs for ~47% of random 56-byte keys.
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
// Attachment password: HEX encoding of raw 56-byte key+nonce.
// Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex').
// HEX is lossless for all byte values (no U+FFFD data loss).
let attachmentPassword = encrypted.plainKeyAndNonce.hexString
// aesChachaKey = Latin-1 encoding (matches desktop sync chain:
// Buffer.from(decryptedString, 'binary') takes low byte of each char).
@@ -488,23 +487,18 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey
)
// Attachment password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8')
// used by Desktop in useDialogFiber.ts and useSynchronize.ts).
// Node.js Buffer.toString('utf-8') feross/buffer polyfill bytesToAndroidUtf8String.
// WHATWG TextDecoder differs for ~47% of random 56-byte keys.
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
// Attachment password: HEX encoding of raw 56-byte key+nonce.
// Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex').
// HEX is lossless for all byte values (no U+FFFD data loss).
let attachmentPassword = encrypted.plainKeyAndNonce.hexString
#if DEBUG
// Full diagnostic: log values needed to verify PBKDF2 key matches CryptoJS.
// To verify on desktop, run in dev console:
// CryptoJS.PBKDF2("<password>", "rosetta", {keySize:8, iterations:1000}).toString()
// and compare with the pbkdf2Key logged below.
let pwdUtf8Bytes = Array(attachmentPassword.utf8)
let pbkdf2Key = CryptoPrimitives.pbkdf2(
password: attachmentPassword, salt: "rosetta", iterations: 1000,
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
)
Self.logger.debug("📎 rawKey: \(encrypted.plainKeyAndNonce.hexString)")
Self.logger.debug("📎 rawKey (hex password): \(attachmentPassword)")
Self.logger.debug("📎 pwdUTF8(\(pwdUtf8Bytes.count)b): \(Data(pwdUtf8Bytes).hexString)")
Self.logger.debug("📎 pbkdf2Key: \(pbkdf2Key.hexString)")
#endif
@@ -601,10 +595,10 @@ final class SessionManager {
Self.logger.error("📎 aesChachaKey FAILED — not valid UTF-8")
throw CryptoError.decryptionFailed
}
// Simulate Buffer.from(string, 'binary').toString('utf-8')
// Simulate Buffer.from(string, 'binary').toString('hex')
let rtRawBytes = Data(rtString.unicodeScalars.map { UInt8($0.value & 0xFF) })
let rtPassword = String(decoding: rtRawBytes, as: UTF8.self)
let match = rtPassword == attachmentPassword
let rtHex = rtRawBytes.hexString
let match = rtHex == attachmentPassword
Self.logger.debug("📎 aesChachaKey roundtrip: \(match ? "PASS" : "FAIL") (\(rtRawBytes.count) bytes recovered)")
} catch {
Self.logger.error("📎 aesChachaKey roundtrip FAILED: \(error)")
@@ -745,9 +739,7 @@ final class SessionManager {
replyMessages: [ReplyMessageData],
toPublicKey: String,
opponentTitle: String = "",
opponentUsername: String = "",
forwardedImages: [String: Data] = [:], // [originalAttachmentId: jpegData]
forwardedFiles: [String: (data: Data, fileName: String)] = [:] // [originalAttachmentId: (fileData, fileName)]
opponentUsername: String = ""
) async throws {
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
Self.logger.error("📤 Cannot send reply — missing keys")
@@ -764,132 +756,20 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey
)
// Reply password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8')
// used by Desktop in useDialogFiber.ts and useSynchronize.ts).
let replyPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
// Reply password: HEX encoding of raw 56-byte key+nonce.
// Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex').
let replyPassword = encrypted.plainKeyAndNonce.hexString
#if DEBUG
Self.logger.debug("📤 Reply password rawKey: \(encrypted.plainKeyAndNonce.hexString)")
Self.logger.debug("📤 Reply password WHATWG UTF-8 (\(Array(replyPassword.utf8).count)b): \(Data(replyPassword.utf8).hexString)")
Self.logger.debug("📤 Reply password (hex): \(replyPassword)")
#endif
// Android parity: re-upload forwarded photos to CDN
// Android: ChatViewModel lines 2434-2477 re-encrypts + uploads each photo.
// Desktop: DialogProvider.tsx prepareAttachmentsToSend() same pattern.
// Without this, recipient tries to decrypt CDN blob with the wrong key.
var attachmentIdMap: [String: (newId: String, newPreview: String)] = [:]
if !forwardedImages.isEmpty && toPublicKey != currentPublicKey {
var fwdIndex = 0
for (originalId, jpegData) in forwardedImages {
let newAttId = "fwd_\(timestamp)_\(fwdIndex)"
fwdIndex += 1
let dataURI = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
Data(dataURI.utf8),
password: replyPassword
)
#if DEBUG
Self.logger.debug("📤 Forward re-upload: \(originalId)\(newAttId) (\(jpegData.count) bytes JPEG, \(encryptedBlob.count) encrypted)")
#endif
let upload = try await attachmentFlowTransport.uploadFile(
id: newAttId,
content: Data(encryptedBlob.utf8)
)
// Extract blurhash from original preview (format: "tag::blurhash")
let originalPreview = replyMessages
.flatMap { $0.attachments }
.first(where: { $0.id == originalId })?.preview ?? ""
let blurhash = AttachmentPreviewCodec.blurHash(from: originalPreview)
let newPreview = AttachmentPreviewCodec.compose(downloadTag: upload.tag, payload: blurhash)
attachmentIdMap[originalId] = (newAttId, newPreview)
// Cache locally under new ID for ForwardedImagePreviewCell
if let image = UIImage(data: jpegData) {
AttachmentCache.shared.saveImage(image, forAttachmentId: newAttId)
}
#if DEBUG
Self.logger.debug("📤 Forward re-upload OK: \(newAttId) tag=\(upload.tag) preview=\(newPreview)")
#endif
}
}
// Re-upload forwarded files to CDN (Desktop parity: prepareAttachmentsToSend)
if !forwardedFiles.isEmpty && toPublicKey != currentPublicKey {
var fwdIndex = attachmentIdMap.count // Continue numbering from images
for (originalId, fileInfo) in forwardedFiles {
let newAttId = "fwd_\(timestamp)_\(fwdIndex)"
fwdIndex += 1
let mimeType = mimeTypeForFileName(fileInfo.fileName)
let dataURI = "data:\(mimeType);base64,\(fileInfo.data.base64EncodedString())"
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
Data(dataURI.utf8),
password: replyPassword
)
#if DEBUG
Self.logger.debug("📤 Forward file re-upload: \(originalId)\(newAttId) (\(fileInfo.data.count) bytes, \(fileInfo.fileName))")
#endif
let upload = try await attachmentFlowTransport.uploadFile(
id: newAttId,
content: Data(encryptedBlob.utf8)
)
// Preserve fileSize::fileName from original preview
let originalPreview = replyMessages
.flatMap { $0.attachments }
.first(where: { $0.id == originalId })?.preview ?? ""
let filePreview = AttachmentPreviewCodec.parseFilePreview(
originalPreview,
fallbackFileName: fileInfo.fileName,
fallbackFileSize: fileInfo.data.count
)
let fileMeta = "\(filePreview.fileSize)::\(filePreview.fileName)"
let newPreview = AttachmentPreviewCodec.compose(downloadTag: upload.tag, payload: fileMeta)
attachmentIdMap[originalId] = (newAttId, newPreview)
#if DEBUG
Self.logger.debug("📤 Forward file re-upload OK: \(newAttId) tag=\(upload.tag) preview=\(newPreview)")
#endif
}
}
// Update reply messages with new attachment IDs/previews
let finalReplyMessages: [ReplyMessageData]
if attachmentIdMap.isEmpty {
finalReplyMessages = replyMessages
} else {
finalReplyMessages = replyMessages.map { msg in
let updatedAttachments = msg.attachments.map { att -> ReplyAttachmentData in
if let mapped = attachmentIdMap[att.id] {
return ReplyAttachmentData(
id: mapped.newId,
type: att.type,
preview: mapped.newPreview,
blob: ""
)
}
return att
}
return ReplyMessageData(
message_id: msg.message_id,
publicKey: msg.publicKey,
message: msg.message,
timestamp: msg.timestamp,
attachments: updatedAttachments
)
}
}
// Desktop commit aaa4b42: no re-upload needed for forwards.
// chacha_key_plain in ReplyMessageData carries the original message's key,
// so the recipient can decrypt original CDN blobs directly.
// Build the reply JSON blob
let replyJSON = try JSONEncoder().encode(finalReplyMessages)
let replyJSON = try JSONEncoder().encode(replyMessages)
guard let replyJSONString = String(data: replyJSON, encoding: .utf8) else {
throw CryptoError.encryptionFailed
}
@@ -989,7 +869,7 @@ final class SessionManager {
packetFlowSender.sendPacket(packet)
registerOutgoingRetry(for: packet)
MessageRepository.shared.persistNow()
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s), \(forwardedImages.count) re-uploaded photos")
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)")
}
/// Sends a call event message (AttachmentType.call, type=4) to dialog history.

View File

@@ -1325,200 +1325,20 @@ private extension ChatDetailView {
#endif
}
// Collect attachment password for CDN downloads of uncached images.
let storedPassword = message.attachmentPassword
// Desktop commit aaa4b42: no re-upload needed.
// chacha_key_plain in ReplyMessageData carries the original key,
// so the recipient can decrypt original CDN blobs directly.
let targetKey = targetRoute.publicKey
let targetTitle = targetRoute.title
let targetUsername = targetRoute.username
Task { @MainActor in
// Android parity: collect cached images for re-upload.
// Android re-encrypts + re-uploads each photo with the new message key.
// Without this, Desktop tries to decrypt CDN blob with the wrong key.
var forwardedImages: [String: Data] = [:]
var forwardedFiles: [String: (data: Data, fileName: String)] = [:]
for replyData in forwardDataList {
for att in replyData.attachments {
if att.type == AttachmentType.image.rawValue {
// Image re-upload
if let image = AttachmentCache.shared.cachedImage(forAttachmentId: att.id) {
// JPEG encoding (10-50ms) off main thread
let jpegData = await Task.detached(priority: .userInitiated) {
image.jpegData(compressionQuality: 0.85)
}.value
if let jpegData {
forwardedImages[att.id] = jpegData
#if DEBUG
print("📤 Image \(att.id.prefix(16)): loaded from memory cache (\(jpegData.count) bytes)")
#endif
continue
}
}
// Slow path: disk I/O + decrypt off main thread.
await ImageLoadLimiter.shared.acquire()
let image = await Task.detached(priority: .userInitiated) {
AttachmentCache.shared.loadImage(forAttachmentId: att.id)
}.value
await ImageLoadLimiter.shared.release()
if let image {
// JPEG encoding (10-50ms) off main thread
let jpegData = await Task.detached(priority: .userInitiated) {
image.jpegData(compressionQuality: 0.85)
}.value
if let jpegData {
forwardedImages[att.id] = jpegData
#if DEBUG
print("📤 Image \(att.id.prefix(16)): loaded from disk cache (\(jpegData.count) bytes)")
#endif
continue
}
}
// Not in cache download from CDN, decrypt, then include.
let cdnTag = AttachmentPreviewCodec.downloadTag(from: att.preview)
guard !cdnTag.isEmpty else {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): SKIP — empty CDN tag, preview='\(att.preview.prefix(30))'")
#endif
continue
}
let password = storedPassword ?? ""
guard !password.isEmpty else {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): SKIP — no attachment password")
#endif
continue
}
do {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): downloading from CDN tag=\(cdnTag.prefix(16))...")
#endif
let encryptedData = try await TransportManager.shared.downloadFile(tag: cdnTag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
// Decrypt on background thread PBKDF2 per candidate is 50-100ms.
#if DEBUG
let decryptStart = CFAbsoluteTimeGetCurrent()
print("⚡ PERF_DECRYPT | Image \(att.id.prefix(12)): starting background decrypt (\(passwords.count) candidates)")
#endif
let imgResult = await Task.detached(priority: .userInitiated) {
guard let img = Self.decryptForwardImage(encryptedString: encryptedString, passwords: passwords),
let jpegData = img.jpegData(compressionQuality: 0.85) else { return nil as (UIImage, Data)? }
return (img, jpegData)
}.value
#if DEBUG
let decryptMs = (CFAbsoluteTimeGetCurrent() - decryptStart) * 1000
print("⚡ PERF_DECRYPT | Image \(att.id.prefix(12)): \(imgResult != nil ? "OK" : "FAIL") in \(String(format: "%.0f", decryptMs))ms (BACKGROUND)")
#endif
if let (img, jpegData) = imgResult {
forwardedImages[att.id] = jpegData
AttachmentCache.shared.saveImage(img, forAttachmentId: att.id)
#if DEBUG
print("📤 Image \(att.id.prefix(16)): CDN download+decrypt OK (\(jpegData.count) bytes)")
#endif
} else {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): CDN download OK but DECRYPT FAILED (\(encryptedData.count) bytes, \(passwords.count) candidates)")
#endif
}
} catch {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): CDN download FAILED: \(error)")
#endif
}
} else if att.type == AttachmentType.file.rawValue {
// File re-upload (Desktop parity: prepareAttachmentsToSend)
let parsedFile = AttachmentPreviewCodec.parseFilePreview(
att.preview,
fallbackFileName: "file"
)
let fileName = parsedFile.fileName
// Try local cache first
if let fileData = AttachmentCache.shared.loadFileData(forAttachmentId: att.id, fileName: fileName) {
forwardedFiles[att.id] = (data: fileData, fileName: fileName)
#if DEBUG
print("📤 File \(att.id.prefix(16)): loaded from cache (\(fileData.count) bytes, name=\(fileName))")
#endif
continue
}
// Not in cache download from CDN, decrypt
let cdnTag = parsedFile.downloadTag
guard !cdnTag.isEmpty else {
#if DEBUG
print("📤 File \(att.id.prefix(16)): SKIP — empty CDN tag")
#endif
continue
}
let password = storedPassword ?? ""
guard !password.isEmpty else {
#if DEBUG
print("📤 File \(att.id.prefix(16)): SKIP — no attachment password")
#endif
continue
}
do {
#if DEBUG
print("📤 File \(att.id.prefix(16)): downloading from CDN tag=\(cdnTag.prefix(16))...")
#endif
let encryptedData = try await TransportManager.shared.downloadFile(tag: cdnTag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
// Decrypt on background thread PBKDF2 per candidate is 50-100ms.
#if DEBUG
let fileDecryptStart = CFAbsoluteTimeGetCurrent()
print("⚡ PERF_DECRYPT | File \(att.id.prefix(12)): starting background decrypt (\(passwords.count) candidates)")
#endif
let fileData = await Task.detached(priority: .userInitiated) {
Self.decryptForwardFile(encryptedString: encryptedString, passwords: passwords)
}.value
#if DEBUG
let fileDecryptMs = (CFAbsoluteTimeGetCurrent() - fileDecryptStart) * 1000
print("⚡ PERF_DECRYPT | File \(att.id.prefix(12)): \(fileData != nil ? "OK" : "FAIL") in \(String(format: "%.0f", fileDecryptMs))ms (BACKGROUND)")
#endif
if let fileData {
forwardedFiles[att.id] = (data: fileData, fileName: fileName)
#if DEBUG
print("📤 File \(att.id.prefix(16)): CDN download+decrypt OK (\(fileData.count) bytes, name=\(fileName))")
#endif
} else {
#if DEBUG
print("📤 File \(att.id.prefix(16)): CDN download OK but DECRYPT FAILED (\(encryptedData.count) bytes)")
#endif
}
} catch {
#if DEBUG
print("📤 File \(att.id.prefix(16)): CDN download FAILED: \(error)")
#endif
}
} else {
#if DEBUG
print("📤 Attachment \(att.id.prefix(16)): SKIP — type=\(att.type)")
#endif
}
}
}
#if DEBUG
print("📤 ── SEND SUMMARY ──")
print("📤 forwardDataList: \(forwardDataList.count) message(s)")
for (i, msg) in forwardDataList.enumerated() {
print("📤 msg[\(i)]: text='\(msg.message.prefix(20))' attachments=\(msg.attachments.count) (images: \(msg.attachments.filter { $0.type == 0 }.count), files: \(msg.attachments.filter { $0.type == 2 }.count))")
print("📤 msg[\(i)]: text='\(msg.message.prefix(20))' attachments=\(msg.attachments.count) chacha_key_plain=\(msg.chacha_key_plain.prefix(16))")
}
print("📤 forwardedImages: \(forwardedImages.count) re-uploads")
print("📤 forwardedFiles: \(forwardedFiles.count) re-uploads")
#endif
do {
@@ -1527,9 +1347,7 @@ private extension ChatDetailView {
replyMessages: forwardDataList,
toPublicKey: targetKey,
opponentTitle: targetTitle,
opponentUsername: targetUsername,
forwardedImages: forwardedImages,
forwardedFiles: forwardedFiles
opponentUsername: targetUsername
)
#if DEBUG
print("📤 ✅ FORWARD SENT OK")
@@ -1545,64 +1363,11 @@ private extension ChatDetailView {
}
}
/// Decrypt a CDN-downloaded image blob with multiple password candidates.
/// `nonisolated` safe to call from background (no UI access, only CryptoManager).
nonisolated private static func decryptForwardImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared
for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true),
let img = parseForwardImageData(data) { return img }
}
for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password),
let img = parseForwardImageData(data) { return img }
}
return nil
}
nonisolated private static func parseForwardImageData(_ data: Data) -> UIImage? {
if let str = String(data: data, encoding: .utf8),
str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") {
let base64Part = String(str[str.index(after: commaIndex)...])
if let imageData = Data(base64Encoded: base64Part) {
return UIImage(data: imageData)
}
}
return UIImage(data: data)
}
/// Decrypt a CDN-downloaded file blob with multiple password candidates.
/// `nonisolated` safe to call from background (no UI access, only CryptoManager).
nonisolated private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? {
let crypto = CryptoManager.shared
for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true),
let fileData = parseForwardFileData(data) { return fileData }
}
for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password),
let fileData = parseForwardFileData(data) { return fileData }
}
return nil
}
/// Extract raw file bytes from a data URI (format: "data:{mime};base64,{base64data}").
nonisolated private static func parseForwardFileData(_ data: Data) -> Data? {
if let str = String(data: data, encoding: .utf8),
str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") {
let base64Part = String(str[str.index(after: commaIndex)...])
return Data(base64Encoded: base64Part)
}
// Not a data URI return raw data
return data
}
/// Builds a `ReplyMessageData` from a `ChatMessage` for reply/forward encoding.
/// Desktop parity: `MessageReply` in `useReplyMessages.ts`.
/// Desktop commit `aaa4b42`: includes `chacha_key_plain` (hex key) + per-attachment transport.
private func buildReplyData(from message: ChatMessage) -> ReplyMessageData {
// Convert ChatMessage attachments to ReplyAttachmentData (text-only for now)
// Convert ChatMessage attachments to ReplyAttachmentData with transport info
let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in
// Skip MESSAGES attachments in nested replies (don't nest replies recursively)
guard att.type != .messages else { return nil }
@@ -1610,7 +1375,11 @@ private extension ChatDetailView {
id: att.id,
type: att.type.rawValue,
preview: att.preview,
blob: "" // Blob cleared for reply (desktop parity)
blob: "", // Blob cleared for reply (desktop parity)
transport: ReplyAttachmentTransport(
transport_tag: att.transportTag,
transport_server: att.transportServer
)
)
}
@@ -1630,12 +1399,21 @@ private extension ChatDetailView {
// Filter garbage text (U+FFFD from failed decryption) don't send ciphertext/garbage to recipient.
let cleanText = MessageCellView.isGarbageText(message.text) ? "" : message.text
// Extract hex key from "rawkey:<hex>" format for chacha_key_plain
let hexKey: String
if let password = message.attachmentPassword, password.hasPrefix("rawkey:") {
hexKey = String(password.dropFirst("rawkey:".count))
} else {
hexKey = ""
}
return ReplyMessageData(
message_id: message.id,
publicKey: message.fromPublicKey,
message: cleanText,
timestamp: message.timestamp,
attachments: replyAttachments
attachments: replyAttachments,
chacha_key_plain: hexKey
)
}