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

@@ -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
)
}