From 4e17c9b18822d778001154ae1c2f2956f547d9f6 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 29 Mar 2026 21:28:28 +0500 Subject: [PATCH] =?UTF-8?q?Forward=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=D0=BB=D0=B8=D0=B2=D0=BA=D0=B8:=20chach?= =?UTF-8?q?a=5Fkey=5Fplain=20+=20HEX=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20(De?= =?UTF-8?q?sktop=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta/Core/Crypto/MessageCrypto.swift | 10 +- .../Network/Protocol/Packets/Packet.swift | 62 ++++ Rosetta/Core/Services/SessionManager.swift | 164 ++--------- .../Chats/ChatDetail/ChatDetailView.swift | 266 ++---------------- 4 files changed, 113 insertions(+), 389 deletions(-) diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift index ae6a274..95282a6 100644 --- a/Rosetta/Core/Crypto/MessageCrypto.swift +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -154,18 +154,22 @@ enum MessageCrypto { // MARK: - Attachment Password Helpers /// Returns password candidates from a stored attachment password string. - /// New format: `"rawkey:"` → derives Android (`bytesToJsUtf8String`) + WHATWG passwords. + /// New format: `"rawkey:"` → 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 diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift index e2df592..405b189 100644 --- a/Rosetta/Core/Network/Protocol/Packets/Packet.swift +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -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 diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index b2cb1d2..5239530 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -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("", "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. diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 3750a04..a226b61 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -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:" 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 ) }