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