From 14641c139fb207a46eaec7ddb835a75bb96346c0 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 12:29:46 +0200 Subject: [PATCH] Correlate private media delivery receipts --- bitchat/Protocols/BitchatFilePacket.swift | 87 ++++++++ .../Services/BLE/BLEFileTransferHandler.swift | 94 +++++++++ bitchat/Services/BLE/BLEService.swift | 21 ++ .../ViewModels/ChatLiveVoiceCoordinator.swift | 41 +++- .../ChatMediaTransferCoordinator.swift | 27 ++- bitchat/ViewModels/ChatViewModel.swift | 6 + .../ChatLiveVoiceCoordinatorTests.swift | 53 ++++- ...MediaTransferCoordinatorContextTests.swift | 117 +++++++++-- ...ransportEventCoordinatorContextTests.swift | 50 ++++- .../EndToEnd/PrivateMediaEndToEndTests.swift | 13 +- .../Protocols/BitchatFilePacketTests.swift | 82 ++++++++ .../BLEFileTransferHandlerTests.swift | 190 +++++++++++++++++- 12 files changed, 747 insertions(+), 34 deletions(-) diff --git a/bitchat/Protocols/BitchatFilePacket.swift b/bitchat/Protocols/BitchatFilePacket.swift index 6002744d..7745b6c4 100644 --- a/bitchat/Protocols/BitchatFilePacket.swift +++ b/bitchat/Protocols/BitchatFilePacket.swift @@ -154,3 +154,90 @@ struct BitchatFilePacket { ) } } + +/// Wire-compatible identity for private media exchanged by clients using the +/// current iOS entropy-bearing filenames, without extending the deployed file +/// TLV. Android clients reject unknown file tags, so eligible senders and +/// receivers derive the receipt key from fields already on the wire. +/// +/// Locally-created image and voice-note filenames contain a UUID or live-voice +/// burst ID. Including the normalized direction keeps a reused filename +/// distinct across chats while allowing short and full Noise-key peer IDs to +/// converge. Android and older-iOS timestamp-only names remain ineligible and +/// retain their legacy random local IDs (transfer-compatible, no receipts). +enum PrivateMediaMessageIdentity { + private static let domain = Data("bitchat-private-media-message-v1".utf8) + private static let idPrefix = "media-" + private static let digestHexLength = 32 + + static func isStableID(_ candidate: String) -> Bool { + guard candidate.hasPrefix(idPrefix) else { return false } + let digest = candidate.dropFirst(idPrefix.count) + guard digest.utf8.count == digestHexLength else { return false } + return digest.utf8.allSatisfy { byte in + (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte) + || (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte) + } + } + + static func stableID( + senderPeerID: PeerID, + recipientPeerID: PeerID, + fileName: String? + ) -> String? { + guard let fileName, !fileName.isEmpty else { return nil } + let leafName = (fileName as NSString).lastPathComponent + guard leafName == fileName else { return nil } + + let path = leafName as NSString + let stem = path.deletingPathExtension + let fileExtension = path.pathExtension.lowercased() + switch true { + case stem.hasPrefix("img_"): + guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil } + case stem.hasPrefix("voice_"): + guard fileExtension == "m4a" else { return nil } + default: + return nil + } + let entropyToken = stem.split(separator: "_").last.map(String.init) + let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil + let voiceBurstID = stem.hasPrefix("voice_") + ? String(stem.dropFirst("voice_".count)) + : "" + let hasBurstEntropy = voiceBurstID.count == 16 + && voiceBurstID.allSatisfy(\.isHexDigit) + guard hasUUIDEntropy || hasBurstEntropy else { + return nil + } + + let fields = [ + Data(senderPeerID.toShort().bare.utf8), + Data(recipientPeerID.toShort().bare.utf8), + Data(leafName.utf8) + ] + var input = domain + for field in fields { + guard let length = UInt32(exactly: field.count) else { return nil } + var bigEndianLength = length.bigEndian + withUnsafeBytes(of: &bigEndianLength) { + input.append(contentsOf: $0) + } + input.append(field) + } + + return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))" + } + + static func stableID( + for packet: BitchatFilePacket, + senderPeerID: PeerID, + recipientPeerID: PeerID + ) -> String? { + stableID( + senderPeerID: senderPeerID, + recipientPeerID: recipientPeerID, + fileName: packet.fileName + ) + } +} diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index e254e3b1..799d9892 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -32,17 +32,63 @@ struct BLEFileTransferHandlerEnvironment { _ fallbackExtension: String?, _ defaultPrefix: String ) -> URL? + /// Checks the authenticated sender before any private-media disk work. + let isPrivateMediaSenderBlocked: (PeerID) -> Bool /// Updates the registry last-seen timestamp for the peer (async barrier write). let updatePeerLastSeen: (PeerID) -> Void + /// Re-acknowledges a stable private-media duplicate without saving or + /// re-delivering it. This lets a sender recover from a lost ACK. + let acknowledgePrivateMediaDuplicate: (_ messageID: String, _ peerID: PeerID) -> Void /// Delivers `.messageReceived` to the UI as one main-actor hop. let deliverMessage: (BitchatMessage) -> Void } +/// Process-lifetime reservation cache for stable private-media IDs. +/// +/// The first arrival reserves its ID before quota enforcement and commits it +/// only after durable save. Concurrent/retried arrivals are rejected before +/// they can create uniquified orphan files or churn the incoming-media quota. +private final class PrivateMediaArrivalDeduplicator { + enum Reservation { + case reserved + case pending + case accepted + } + + private let lock = NSLock() + private var accepted = BoundedIDSet(capacity: 4_096) + private var pending: Set = [] + + func reserve(_ messageID: String) -> Reservation { + lock.lock() + defer { lock.unlock() } + if accepted.contains(messageID) { + return .accepted + } + if pending.contains(messageID) { + return .pending + } + + pending.insert(messageID) + return .reserved + } + + func finish(_ messageID: String, accepted didAccept: Bool) { + lock.lock() + defer { lock.unlock() } + pending.remove(messageID) + if didAccept { + accepted.insert(messageID) + } + } +} + /// Orchestrates inbound file transfers: self-echo policy, sender display-name /// resolution, delivery planning, payload validation, quota-checked storage, /// and UI delivery. final class BLEFileTransferHandler { private let environment: BLEFileTransferHandlerEnvironment + private let privateMediaArrivals = PrivateMediaArrivalDeduplicator() init(environment: BLEFileTransferHandlerEnvironment) { self.environment = environment @@ -129,6 +175,7 @@ final class BLEFileTransferHandler { env: BLEFileTransferHandlerEnvironment ) -> Bool { + let localPeerID = env.localPeerID() let filePacket: BitchatFilePacket let mime: MimeType switch BLEIncomingFileValidator.validate(payload: payload) { @@ -149,6 +196,51 @@ final class BLEFileTransferHandler { return false } + if isPrivate, env.isPrivateMediaSenderBlocked(peerID) { + SecureLogger.debug( + "🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write", + category: .security + ) + return true + } + + let messageID = isPrivate + ? PrivateMediaMessageIdentity.stableID( + for: filePacket, + senderPeerID: peerID, + recipientPeerID: localPeerID + ) + : nil + if let messageID { + switch privateMediaArrivals.reserve(messageID) { + case .reserved: + break + case .pending: + // The first arrival has not reached durable storage yet. + // Coalesce this retry without ACKing so a failed first save + // remains retryable by the sender. + SecureLogger.debug( + "📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…", + category: .session + ) + return true + case .accepted: + env.updatePeerLastSeen(peerID) + env.acknowledgePrivateMediaDuplicate(messageID, peerID) + SecureLogger.debug( + "📁 Ignored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…", + category: .session + ) + return true + } + } + var acceptedStableMedia = false + defer { + if let messageID { + privateMediaArrivals.finish(messageID, accepted: acceptedStableMedia) + } + } + // BCH-01-002: Enforce storage quota before saving env.enforceStorageQuota(filePacket.content.count) @@ -167,6 +259,7 @@ final class BLEFileTransferHandler { } let message = BitchatMessage( + id: messageID, sender: senderNickname, content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", timestamp: timestamp, @@ -185,6 +278,7 @@ final class BLEFileTransferHandler { SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) + acceptedStableMedia = messageID != nil env.deliverMessage(message) return true } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 0c051b6d..c87f7e51 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2342,9 +2342,30 @@ final class BLEService: NSObject { defaultPrefix: defaultPrefix ) }, + isPrivateMediaSenderBlocked: { [weak self] peerID in + guard let self else { return false } + let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID) + ?? self.collectionsQueue.sync { + self.peerRegistry.info(for: peerID)?.noisePublicKey + } + guard let senderStaticKey else { return false } + return self.identityManager.isBlocked( + fingerprint: senderStaticKey.sha256Fingerprint() + ) + }, updatePeerLastSeen: { [weak self] peerID in self?.updatePeerLastSeen(peerID) }, + acknowledgePrivateMediaDuplicate: { [weak self] messageID, peerID in + guard let self, + let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID), + !self.identityManager.isBlocked( + fingerprint: senderStaticKey.sha256Fingerprint() + ) else { + return + } + self.sendDeliveryAck(for: messageID, to: peerID) + }, deliverMessage: { [weak self] message in // Single main-actor hop delivering `.messageReceived`. self?.emitTransportEvent(.messageReceived(message)) diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift index 6c4c483c..3cedbdfb 100644 --- a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift +++ b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift @@ -10,6 +10,7 @@ import Foundation @MainActor protocol ChatLiveVoiceContext: AnyObject { var nickname: String { get } + var myPeerID: PeerID { get } var selectedPrivateChatPeer: PeerID? { get } /// Whether the public mesh timeline is what's on screen (autoplay gate /// for public bursts). @@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject { func upsertPublicMeshMessage(_ message: BitchatMessage) @discardableResult func removePrivateMessage(withID messageID: String) -> BitchatMessage? + /// Records and sends the finalized note's read receipt after a live + /// bubble adopts its wire-derivable message ID. + func hasSentReadReceipt(_ messageID: String) -> Bool + @discardableResult + func markReadReceiptSent(_ messageID: String) -> Bool + func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) /// Removes a message from whichever conversation holds it. func removeMessage(withID messageID: String, cleanupFile: Bool) /// Publishes who is currently talking live in the public mesh channel @@ -257,8 +264,16 @@ final class ChatLiveVoiceCoordinator { guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false } let finished = entry.value + // A DM live bubble starts before the finalized file exists and + // therefore has a receiver-local random ID. Adopt the finalized + // message's deterministic ID so delivery/read ACKs address the same + // row as the sender's media placeholder. Public notes retain their + // live-bubble ID because public transfers have no private receipts. + let replacementID = finished.scope == .directMessage + ? message.id + : finished.messageID let replacement = BitchatMessage( - id: finished.messageID, + id: replacementID, sender: message.sender, content: message.content, timestamp: finished.messageTimestamp, @@ -272,7 +287,31 @@ final class ChatLiveVoiceCoordinator { ) switch finished.scope { case .directMessage: + // Capture read state before rekeying. The user may have read the + // live bubble and navigated away before the finalized .m4a lands. + let shouldSendAdoptedReadReceipt = + context.hasSentReadReceipt(finished.messageID) + || context.selectedPrivateChatPeer == finished.peerID + + // Insert first so replacing the only row in a DM never + // transiently deletes its conversation, unread state, or current + // selection. Then remove the receiver-local live-bubble alias. context.upsertPrivateMessage(replacement, in: finished.peerID) + if replacementID != finished.messageID { + context.removePrivateMessage(withID: finished.messageID) + } + // The live bubble may already have emitted a receiver-local READ + // before the sender created its finalized media row. Re-emit once + // for the adopted stable ID now that the file has arrived. + if shouldSendAdoptedReadReceipt, + context.markReadReceiptSent(replacementID) { + let receipt = ReadReceipt( + originalMessageID: replacementID, + readerID: context.myPeerID, + readerNickname: context.nickname + ) + context.sendMeshReadReceipt(receipt, to: finished.peerID) + } case .publicMesh: context.upsertPublicMeshMessage(replacement) } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 2f7d4e05..04bc2c5d 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -171,9 +171,17 @@ final class ChatMediaTransferCoordinator { } let targetPeer = context.selectedPrivateChatPeer + let privateMessageID = targetPeer.flatMap { peerID in + PrivateMediaMessageIdentity.stableID( + senderPeerID: context.myPeerID, + recipientPeerID: peerID, + fileName: url.lastPathComponent + ) + } let message = enqueueMediaMessage( content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", - targetPeer: targetPeer + targetPeer: targetPeer, + messageID: privateMessageID ) let messageID = message.id let transferId = makeTransferID(messageID: messageID) @@ -282,9 +290,17 @@ final class ChatMediaTransferCoordinator { await MainActor.run { [weak self] in guard let self else { return } + let privateMessageID = targetPeer.flatMap { peerID in + PrivateMediaMessageIdentity.stableID( + for: prepared.packet, + senderPeerID: self.context.myPeerID, + recipientPeerID: peerID + ) + } let message = self.enqueueMediaMessage( content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)", - targetPeer: targetPeer + targetPeer: targetPeer, + messageID: privateMessageID ) let messageID = message.id let transferId = self.makeTransferID(messageID: messageID) @@ -316,12 +332,17 @@ final class ChatMediaTransferCoordinator { } } - func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage { + func enqueueMediaMessage( + content: String, + targetPeer: PeerID?, + messageID: String? = nil + ) -> BitchatMessage { let timestamp = Date() let message: BitchatMessage if let peerID = targetPeer { message = BitchatMessage( + id: messageID, sender: context.nickname, content: content, timestamp: timestamp, diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 5ef1415c..2f7a1ea0 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -467,6 +467,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage } } + /// Whether a read receipt has already been recorded for `messageID`. + @MainActor + func hasSentReadReceipt(_ messageID: String) -> Bool { + sentReadReceipts.contains(messageID) + } + /// Records that a read receipt is being sent for `messageID`. /// Returns `false` when one was already recorded — the caller must skip sending. @MainActor diff --git a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift index 18e25ef4..2e5ca317 100644 --- a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift +++ b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift @@ -14,6 +14,7 @@ import BitFoundation @MainActor private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { var nickname = "me" + var myPeerID = PeerID(str: "0102030405060708") var selectedPrivateChatPeer: PeerID? var isViewingPublicMeshTimeline = false var blockedPeers: Set = [] @@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = [] private(set) var upsertedPublicMessages: [BitchatMessage] = [] private(set) var removedMessageIDs: [String] = [] + private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = [] private(set) var talkerUpdates: [String?] = [] + private(set) var privateMutationLog: [String] = [] + private var readReceiptMessageIDs: Set = [] func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } func resolveNickname(for peerID: PeerID) -> String { "alice" } @@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) } func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { upsertedMessages.append((message, peerID)) + privateMutationLog.append("upsert:\(message.id)") } func upsertPublicMeshMessage(_ message: BitchatMessage) { upsertedPublicMessages.append(message) @@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { @discardableResult func removePrivateMessage(withID messageID: String) -> BitchatMessage? { removedMessageIDs.append(messageID) + privateMutationLog.append("remove:\(messageID)") return nil } + func hasSentReadReceipt(_ messageID: String) -> Bool { + readReceiptMessageIDs.contains(messageID) + } + func markReadReceiptSent(_ messageID: String) -> Bool { + readReceiptMessageIDs.insert(messageID).inserted + } + func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + sentReadReceipts.append((receipt, peerID)) + } func removeMessage(withID messageID: String, cleanupFile: Bool) { removedMessageIDs.append(messageID) } @@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests { @Test func absorbsFinalizedNoteIntoLiveBubble() throws { let context = MockChatLiveVoiceContext() + context.selectedPrivateChatPeer = peer let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) let burstID = makeBurstID(0xB2) let hex = burstID.hexEncodedString() + let fileName = "voice_\(hex).m4a" + let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID( + senderPeerID: peer, + recipientPeerID: context.myPeerID, + fileName: fileName + )) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer) let bubble = try #require(context.handledPrivateMessages.first) + // The user read the live bubble, then left before the finalized file + // arrived. Stable-ID adoption must preserve that read state. + #expect(context.markReadReceiptSent(bubble.id)) + context.selectedPrivateChatPeer = nil let note = BitchatMessage( + id: stableMessageID, sender: "alice", - content: "[voice] voice_\(hex).m4a", + content: "[voice] \(fileName)", timestamp: Date(), isRelay: false, isPrivate: true, @@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests { ) #expect(coordinator.absorbFinalizedVoiceNote(note)) - // The note replaced the live bubble in place: same message ID, new - // content, partial capture deleted. + // The finalized note adopts the sender-correlatable ID, removes the + // receiver-local live ID, and emits a fresh READ now that the sender + // has created its finalized media row. let replacement = try #require(context.upsertedMessages.last) - #expect(replacement.message.id == bubble.id) + #expect(replacement.message.id == stableMessageID) #expect(replacement.message.content == note.content) #expect(replacement.peerID == peer) + #expect(context.removedMessageIDs.contains(bubble.id)) + #expect(Array(context.privateMutationLog.suffix(2)) == [ + "upsert:\(stableMessageID)", + "remove:\(bubble.id)" + ]) + #expect(context.sentReadReceipts.count == 1) + #expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID) + #expect(context.sentReadReceipts.first?.peerID == peer) // The promoted partial capture is deleted in favor of the note. let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer)) #expect(!FileManager.default.fileExists(atPath: url.path)) @@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests { isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer ) #expect(coordinator.absorbFinalizedVoiceNote(dmNote)) - #expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id) + #expect(try #require(context.upsertedMessages.last).message.id == dmNote.id) + #expect(context.removedMessageIDs.contains(dmBubble.id)) } @Test func finalizedNoteBindsToItsAuthenticatedSender() throws { @@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests { ) #expect(coordinator.absorbFinalizedVoiceNote(note)) let replacement = try #require(context.upsertedMessages.last) - #expect(replacement.message.id == victimBubble.id) + #expect(replacement.message.id == note.id) #expect(replacement.peerID == peer) + #expect(context.removedMessageIDs.contains(victimBubble.id)) // The attacker's note can only ever claim the attacker's own bubble. let attackerNote = BitchatMessage( @@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests { ) #expect(coordinator.absorbFinalizedVoiceNote(attackerNote)) let attackerReplacement = try #require(context.upsertedMessages.last) - #expect(attackerReplacement.message.id == attackerBubble.id) + #expect(attackerReplacement.message.id == attackerNote.id) #expect(attackerReplacement.peerID == attacker) + #expect(context.removedMessageIDs.contains(attackerBubble.id)) // Both registry entries are consumed — nothing left to hijack. #expect(!coordinator.absorbFinalizedVoiceNote(note)) diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index 3be90cf5..f694e0ae 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -15,6 +15,11 @@ import Testing import Foundation import BitFoundation +#if os(iOS) +import UIKit +#else +import AppKit +#endif @testable import bitchat // MARK: - Mock Context @@ -84,7 +89,11 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { } // Mesh file transfer - private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = [] + private(set) var privateFileSends: [( + packet: BitchatFilePacket, + peerID: PeerID, + transferId: String + )] = [] private(set) var privateFileLegacyAllowances: [Bool] = [] private(set) var broadcastFileSends: [String] = [] private(set) var cancelledTransfers: [String] = [] @@ -149,7 +158,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { transferId: String, allowLegacyFallback: Bool ) { - privateFileSends.append((peerID, transferId)) + privateFileSends.append((packet, peerID, transferId)) privateFileLegacyAllowances.append(allowLegacyFallback) } @@ -167,19 +176,8 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable { private var started = false private var released = false private var finished = false - private let packet: BitchatFilePacket - init() { - let content = Data("voice".utf8) - packet = BitchatFilePacket( - fileName: "paused.m4a", - fileSize: UInt64(content.count), - mimeType: "audio/mp4", - content: content - ) - } - - func prepare(_: URL) throws -> BitchatFilePacket { + func prepare(_ url: URL) throws -> BitchatFilePacket { condition.lock() started = true condition.broadcast() @@ -189,7 +187,13 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable { finished = true condition.broadcast() condition.unlock() - return packet + let content = Data("voice".utf8) + return BitchatFilePacket( + fileName: url.lastPathComponent, + fileSize: UInt64(content.count), + mimeType: "audio/mp4", + content: content + ) } var hasStarted: Bool { @@ -339,6 +343,57 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(coordinator.transferIdToMessageIDs.isEmpty) } + @Test @MainActor + func privateVoiceNoteUsesWireDerivableMessageID() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let message = try #require(context.privateChats[peerID]?.first) + let sentPacket = try #require(context.privateFileSends.first?.packet) + #expect(message.id == PrivateMediaMessageIdentity.stableID( + for: sentPacket, + senderPeerID: context.myPeerID, + recipientPeerID: peerID + )) + } + + @Test @MainActor + func privateImageUsesWireDerivableMessageID() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "99aabbccddeeff00") + context.selectedPrivateChatPeer = peerID + let sourceURL = try makeCoordinatorTestImageURL() + defer { try? FileManager.default.removeItem(at: sourceURL) } + + coordinator.sendImage(from: sourceURL) + + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let message = try #require(context.privateChats[peerID]?.first) + let sentPacket = try #require(context.privateFileSends.first?.packet) + #expect(message.id == PrivateMediaMessageIdentity.stableID( + for: sentPacket, + senderPeerID: context.myPeerID, + recipientPeerID: peerID + )) + coordinator.cleanupLocalFile(forMessage: message) + } + @Test @MainActor func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws { let context = MockChatMediaTransferContext() @@ -584,3 +639,35 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(context.privateFileSends.isEmpty) } } + +private func makeCoordinatorTestImageURL() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("media-receipt-\(UUID().uuidString).png") + #if os(iOS) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32)) + let image = renderer.image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 32, height: 32)) + } + guard let data = image.pngData() else { + throw CoordinatorMediaTestError.imageEncodingFailed + } + #else + let image = NSImage(size: NSSize(width: 32, height: 32)) + image.lockFocus() + NSColor.systemBlue.setFill() + NSRect(x: 0, y: 0, width: 32, height: 32).fill() + image.unlockFocus() + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let data = bitmap.representation(using: .png, properties: [:]) else { + throw CoordinatorMediaTestError.imageEncodingFailed + } + #endif + try data.write(to: url, options: .atomic) + return url +} + +private enum CoordinatorMediaTestError: Error { + case imageEncodingFailed +} diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 46bf79d7..1663e039 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -220,26 +220,64 @@ struct ChatTransportEventCoordinatorContextTests { func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async { let context = MockChatTransportEventContext() let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") // Blocked messages are dropped before any handling. - context.blockedMessageIDs = ["blocked"] + context.blockedMessageIDs = ["blocked", "blocked-private"] coordinator.didReceiveMessage(makeMessage(id: "blocked")) + coordinator.didReceiveMessage(makeMessage( + id: "blocked-private", + isPrivate: true, + senderPeerID: peerID + )) // Empty public content is dropped too. coordinator.didReceiveMessage(makeMessage(id: "empty", content: " ")) await drainMainActorTasks() #expect(context.handledPublicMessages.isEmpty) #expect(context.handledPrivateMessages.isEmpty) #expect(context.mentionCheckedMessageIDs.isEmpty) + #expect(context.meshDeliveryAcks.isEmpty) // Private goes to the private handler, public to the public handler; - // both get mention checks and haptics. - coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true)) + // both get mention checks and haptics. Only current-iOS media with a + // cross-device stable ID and authenticated sender gets the + // durable-arrival acknowledgement. Legacy/Android-style random IDs + // remain transfer-compatible but cannot correlate receipts. + let stableMediaID = "media-\(String(repeating: "a", count: 32))" + coordinator.didReceiveMessage(makeMessage( + id: stableMediaID, + isPrivate: true, + senderPeerID: peerID + )) + coordinator.didReceiveMessage(makeMessage( + id: "legacy-media", + isPrivate: true, + senderPeerID: peerID + )) + coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true)) coordinator.didReceiveMessage(makeMessage(id: "pub")) await drainMainActorTasks() - #expect(context.handledPrivateMessages.map(\.id) == ["pm"]) + #expect(context.handledPrivateMessages.map(\.id) == [ + stableMediaID, + "legacy-media", + "pm-missing-sender" + ]) #expect(context.handledPublicMessages.map(\.id) == ["pub"]) - #expect(context.mentionCheckedMessageIDs == ["pm", "pub"]) - #expect(context.hapticMessageIDs == ["pm", "pub"]) + #expect(context.mentionCheckedMessageIDs == [ + stableMediaID, + "legacy-media", + "pm-missing-sender", + "pub" + ]) + #expect(context.hapticMessageIDs == [ + stableMediaID, + "legacy-media", + "pm-missing-sender", + "pub" + ]) + #expect(context.meshDeliveryAcks.count == 1) + #expect(context.meshDeliveryAcks.first?.messageID == stableMediaID) + #expect(context.meshDeliveryAcks.first?.peerID == peerID) } @Test @MainActor diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index 8a8a4737..0cb28f22 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -736,7 +736,7 @@ struct PrivateMediaEndToEndTests { + marker + Data(repeating: 0x4A, count: 6 * 1024) try await assertPrivateMediaRoundTrip( - fileName: "private.jpg", + fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg", mimeType: "image/jpeg", content: content, marker: marker, @@ -1007,6 +1007,17 @@ struct PrivateMediaEndToEndTests { #expect(message.isPrivate) #expect(message.senderPeerID == alice.myPeerID) #expect(message.content.hasPrefix(expectedMessagePrefix)) + if let stableMessageID = PrivateMediaMessageIdentity.stableID( + for: file, + senderPeerID: alice.myPeerID, + recipientPeerID: bob.myPeerID + ) { + #expect(message.id == stableMessageID) + } else { + // Generic/legacy filenames retain random per-arrival IDs so two + // unrelated "photo.jpg" transfers are never deduplicated. + #expect(!message.id.hasPrefix("media-")) + } let stored = recursivelyStoredFiles(under: bobRoot) #expect(stored.count == 1) diff --git a/bitchatTests/Protocols/BitchatFilePacketTests.swift b/bitchatTests/Protocols/BitchatFilePacketTests.swift index 849eda47..2476647f 100644 --- a/bitchatTests/Protocols/BitchatFilePacketTests.swift +++ b/bitchatTests/Protocols/BitchatFilePacketTests.swift @@ -1,3 +1,4 @@ +import BitFoundation import XCTest @testable import bitchat @@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase { XCTAssertEqual(decoded.fileSize, UInt64(content.count)) XCTAssertEqual(decoded.content, content) } + + func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws { + let senderKey = Data(repeating: 0x11, count: 32) + let recipientKey = Data(repeating: 0x22, count: 32) + let senderStable = PeerID(hexData: senderKey) + let recipientStable = PeerID(hexData: recipientKey) + let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + + let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID( + senderPeerID: senderStable.toShort(), + recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"), + fileName: fileName + )) + let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID( + senderPeerID: senderStable, + recipientPeerID: recipientStable.toShort(), + fileName: fileName + )) + + XCTAssertEqual(senderID, receiverID) + XCTAssertTrue(senderID.hasPrefix("media-")) + XCTAssertEqual(senderID.count, 38) + XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID)) + XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))")) + XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))")) + XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString)) + } + + func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws { + let alice = PeerID(str: "0011223344556677") + let bob = PeerID(str: "8899aabbccddeeff") + let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a" + let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a" + let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: firstName + )) + + XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID( + senderPeerID: bob, + recipientPeerID: alice, + fileName: firstName + )) + XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: secondName + )) + XCTAssertNil(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: nil + )) + XCTAssertNil(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: "photo.jpg" + )) + XCTAssertNil(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: "img_11111111-1111-1111-1111-111111111111.pdf" + )) + XCTAssertNotNil(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: "voice_0011223344556677.m4a" + )) + } + + func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() { + XCTAssertEqual( + PrivateMediaMessageIdentity.stableID( + senderPeerID: PeerID(str: "0011223344556677"), + recipientPeerID: PeerID(str: "8899aabbccddeeff"), + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + ), + "media-910bd42c65060ab76bb6406f220c4516" + ) + } } diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index a0f642db..c6fbacba 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -13,11 +13,20 @@ struct BLEFileTransferHandlerTests { var signatureVerifyCount = 0 var signedNameQueries: [PeerID] = [] + var blockedPeers: Set = [] var trackedPackets: [BitchatPacket] = [] var quotaReservations: [Int] = [] var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = [] var lastSeenUpdates: [PeerID] = [] + var duplicateDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] var deliveredMessages: [BitchatMessage] = [] + var saveOverride: (( + _ data: Data, + _ preferredName: String?, + _ subdirectory: String, + _ fallbackExtension: String?, + _ defaultPrefix: String + ) -> URL?)? } private let localPeerID = PeerID(str: "0102030405060708") @@ -46,11 +55,20 @@ struct BLEFileTransferHandlerTests { }, saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix)) + if let saveOverride = recorder.saveOverride { + return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix) + } return recorder.saveResult }, + isPrivateMediaSenderBlocked: { peerID in + recorder.blockedPeers.contains(peerID) + }, updatePeerLastSeen: { peerID in recorder.lastSeenUpdates.append(peerID) }, + acknowledgePrivateMediaDuplicate: { messageID, peerID in + recorder.duplicateDeliveryAcks.append((messageID, peerID)) + }, deliverMessage: { message in recorder.deliveredMessages.append(message) } @@ -284,6 +302,7 @@ struct BLEFileTransferHandlerTests { #expect(recorder.lastSeenUpdates == [remotePeerID]) #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.isPrivate == true) + #expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false) // Must be explicit: BitchatMessage defaults private messages to // .sending, which the media views render as an in-flight send // (empty reveal mask, disabled reveal tap). @@ -296,8 +315,9 @@ struct BLEFileTransferHandlerTests { recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] let handler = makeHandler(recorder: recorder) let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" let file = BitchatFilePacket( - fileName: "secret.jpg", + fileName: fileName, fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content @@ -316,6 +336,173 @@ struct BLEFileTransferHandlerTests { #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.isPrivate == true) #expect(recorder.deliveredMessages.first?.timestamp == timestamp) + #expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID( + senderPeerID: remotePeerID, + recipientPeerID: localPeerID, + fileName: fileName + )) + } + + @Test + func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let file = BitchatFilePacket( + fileName: "photo.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + + #expect(recorder.deliveredMessages.count == 2) + #expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id) + #expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") }) + } + + @Test + func repeatedStablePrivateMediaIsAcknowledgedWithoutQuotaOrDiskWork() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + let file = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + let expectedID = try #require(PrivateMediaMessageIdentity.stableID( + senderPeerID: remotePeerID, + recipientPeerID: localPeerID, + fileName: fileName + )) + + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + + #expect(recorder.quotaReservations == [content.count]) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID]) + #expect(recorder.duplicateDeliveryAcks.count == 1) + #expect(recorder.duplicateDeliveryAcks.first?.messageID == expectedID) + #expect(recorder.duplicateDeliveryAcks.first?.peerID == remotePeerID) + } + + @Test + func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let file = BitchatFilePacket( + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + var handler: BLEFileTransferHandler! + var nestedResult: Bool? + var failFirstSave = true + recorder.saveOverride = { _, _, _, _, _ in + if failFirstSave { + failFirstSave = false + nestedResult = handler.handlePrivatePayload( + payload, + from: self.remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + ) + return nil + } + return recorder.saveResult + } + handler = makeHandler(recorder: recorder) + + // The nested arrival sees the first reservation as pending. It is + // coalesced without an ACK; then the first durable save fails. + #expect(!handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(nestedResult == true) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.duplicateDeliveryAcks.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + + // Failure released the reservation, so the sender's later retry can + // persist and deliver normally. + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_236) + )) + #expect(recorder.saveCalls.count == 2) + #expect(recorder.duplicateDeliveryAcks.isEmpty) + #expect(recorder.deliveredMessages.count == 1) + } + + @Test + func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws { + let recorder = Recorder() + recorder.blockedPeers = [remotePeerID] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let file = BitchatFilePacket( + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.duplicateDeliveryAcks.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + + // Unblocking must allow a retry through; the blocked attempt cannot + // poison the stable-ID dedup reservation. + recorder.blockedPeers = [] + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.deliveredMessages.count == 1) } @Test @@ -337,6 +524,7 @@ struct BLEFileTransferHandlerTests { #expect(recorder.quotaReservations.isEmpty) #expect(recorder.saveCalls.isEmpty) #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.duplicateDeliveryAcks.isEmpty) #expect(recorder.deliveredMessages.isEmpty) }