Forward без перезаливки: chacha_key_plain + HEX пароль вложений (Desktop parity)
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user