From fb451bc6d07f071fb9cb3b9d1a6c299a74f0513f Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:00:21 +0200 Subject: [PATCH] Persist authenticated private-media delivery receipts (#1466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a durable receiver-side ledger (BLEPrivateMediaReceiptStore) mapping a deterministic private-media message ID — hash-bound to sender, recipient, and entropy-bearing filename — to the stored file before UI delivery and before the Noise-encrypted delivery ACK, so sender retries and relaunches cannot create duplicate bubbles or files. Receipts are unforgeable without the Noise session; capability bit 9 rides the existing announce bitfield (no wire-format change; rolling upgrade safe both directions). Includes review fixes: corrupt receipt records are quarantined per-record (bytes preserved at .json.corrupt, only that ID fail-closed — previously one bad record silently disabled ALL inbound private media forever); the panic reset now reaches BLEService's own store instance via completePanicReset with a production-wiring test; and both content.delivery.reason.* strings ship with full 30-locale coverage. --- bitchat/Localizable.xcstrings | 372 +++++++++++ bitchat/Protocols/BitchatFilePacket.swift | 87 +++ .../Protocols/PeerCapabilities+Local.swift | 8 +- .../Services/BLE/BLEFileTransferHandler.swift | 270 +++++++- .../Services/BLE/BLEIncomingFileStore.swift | 58 ++ .../BLE/BLEPrivateMediaReceiptStore.swift | 624 ++++++++++++++++++ bitchat/Services/BLE/BLEService.swift | 113 +++- bitchat/Services/TransportConfig.swift | 7 + .../ViewModels/ChatLiveVoiceCoordinator.swift | 41 +- .../ChatMediaTransferCoordinator.swift | 27 +- bitchat/ViewModels/ChatViewModel.swift | 6 + .../ChatLiveVoiceCoordinatorTests.swift | 53 +- ...MediaTransferCoordinatorContextTests.swift | 134 +++- ...ransportEventCoordinatorContextTests.swift | 47 +- .../EndToEnd/PrivateMediaEndToEndTests.swift | 22 +- .../Protocols/BitchatFilePacketTests.swift | 82 +++ .../BLEFileTransferHandlerTests.swift | 458 ++++++++++++- .../BLENoiseReconnectPolicyTests.swift | 3 + .../BLEPrivateMediaReceiptStoreTests.swift | 291 ++++++++ docs/PRIVATE-MEDIA-MIGRATION.md | 7 + .../BitFoundation/PeerCapabilities.swift | 7 + .../PeerCapabilitiesTests.swift | 22 +- 22 files changed, 2677 insertions(+), 62 deletions(-) create mode 100644 bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift create mode 100644 bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 3a75be91..e25b5b0f 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -30626,6 +30626,378 @@ } } }, + "content.delivery.reason.private_media_capability_unresolved" : { + "comment" : "Failure reason shown when the peer's support for encrypted media could not be confirmed before sending", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر تأكيد دعم الوسائط المشفّرة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এনক্রিপ্ট করা মিডিয়া সমর্থন নিশ্চিত করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unterstützung für verschlüsselte Medien konnte nicht bestätigt werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not confirm encrypted media support" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No se pudo confirmar la compatibilidad con multimedia cifrada" + } + }, + "fa" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پشتیبانی از رسانه رمزنگاری‌شده تأیید نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hindi makumpirma ang suporta sa naka-encrypt na media" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impossible de confirmer la prise en charge des médias chiffrés" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לאמת תמיכה במדיה מוצפנת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्टेड मीडिया समर्थन की पुष्टि नहीं हो सकी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tidak dapat memastikan dukungan media terenkripsi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impossibile confermare il supporto dei contenuti multimediali cifrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暗号化メディアの対応を確認できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "암호화된 미디어 지원을 확인할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tidak dapat mengesahkan sokongan media tersulit" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्टेड मिडिया समर्थन पुष्टि गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ondersteuning voor versleutelde media kon niet worden bevestigd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nie udało się potwierdzić obsługi zaszyfrowanych multimediów" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Não foi possível confirmar o suporte a multimédia cifrada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Não foi possível confirmar o suporte a mídia criptografada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Не удалось подтвердить поддержку зашифрованных медиа" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Det gick inte att bekräfta stöd för krypterade medier" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "மறைகுறியாக்கப்பட்ட மீடியா ஆதரவை உறுதிப்படுத்த முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถยืนยันการรองรับสื่อที่เข้ารหัสได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Şifreli medya desteği doğrulanamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Не вдалося підтвердити підтримку зашифрованих медіа" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خفیہ کردہ میڈیا کی معاونت کی تصدیق نہیں ہو سکی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Không thể xác nhận hỗ trợ phương tiện được mã hóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法确认加密媒体支持" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法確認加密媒體支援" + } + } + } + }, + "content.delivery.reason.private_media_delivery_unconfirmed" : { + "comment" : "Failure reason shown when an encrypted media message was sent but its delivery was never confirmed", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر تأكيد التسليم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ডেলিভারি নিশ্চিত করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zustellung konnte nicht bestätigt werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delivery could not be confirmed" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No se pudo confirmar la entrega" + } + }, + "fa" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تحویل تأیید نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hindi makumpirma ang paghahatid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impossible de confirmer la remise" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לאמת את המסירה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डिलीवरी की पुष्टि नहीं हो सकी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pengiriman tidak dapat dipastikan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impossibile confermare la consegna" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "配信を確認できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전달을 확인할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Penghantaran tidak dapat disahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डेलिभरी पुष्टि गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Bezorging kon niet worden bevestigd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nie udało się potwierdzić dostarczenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Não foi possível confirmar a entrega" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Não foi possível confirmar a entrega" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Не удалось подтвердить доставку" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Leveransen kunde inte bekräftas" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "விநியோகத்தை உறுதிப்படுத்த முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถยืนยันการส่งได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Teslimat doğrulanamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Не вдалося підтвердити доставлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ترسیل کی تصدیق نہیں ہو سکی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Không thể xác nhận việc gửi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法确认送达" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法確認送達" + } + } + } + }, "content.delivery.reason.not_delivered" : { "comment" : "Failure reason shown when the router gave up delivering a message", "extractionState" : "manual", 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/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index 8cae9798..d48891d7 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -3,5 +3,11 @@ import BitFoundation extension PeerCapabilities { /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia] + static let localSupported: PeerCapabilities = [ + .vouch, + .prekeys, + .groups, + .privateMedia, + .privateMediaReceipts + ] } diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index e254e3b1..51e6d373 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -32,10 +32,79 @@ struct BLEFileTransferHandlerEnvironment { _ fallbackExtension: String?, _ defaultPrefix: String ) -> URL? + /// Resolves the durable receiver decision for a stable private-media ID. + let privateMediaReceiptState: ( + _ messageID: String + ) -> BLEPrivateMediaReceiptState + /// Atomically records a stable private-media ID after the payload save. + let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool + /// Rolls back a saved payload when its durable receipt commit fails. + let removeIncomingFile: (_ storedURL: URL) -> Void + /// 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 - /// Delivers `.messageReceived` to the UI as one main-actor hop. - let deliverMessage: (BitchatMessage) -> Void + /// Acknowledges stable private media only after its synchronous + /// conversation delivery has completed. + let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void + /// Delivers `.messageReceived` as one main-actor hop while + /// `shouldDeliver` remains true before and after the synchronous sink. + /// The completion authorizes the stable-media ACK. + let deliverMessage: ( + _ message: BitchatMessage, + _ shouldDeliver: @escaping () -> Bool, + _ completion: @escaping () -> Void + ) -> Void +} + +/// Process-lifetime reservation cache for stable private-media IDs. +/// +/// The first arrival reserves its ID before quota enforcement. Concurrent +/// arrivals remain coalesced in memory, while accepted state is resolved from +/// the durable ID-to-file ledger so it survives relaunch and becomes retryable +/// if quota cleanup removed the file. +private final class PrivateMediaArrivalDeduplicator { + enum Reservation { + case reserved + case pending + case accepted(URL) + case tombstoned + case unavailable + } + + private let lock = NSLock() + private var pending: Set = [] + + func reserve( + _ messageID: String, + receiptState: () -> BLEPrivateMediaReceiptState + ) -> Reservation { + lock.lock() + defer { lock.unlock() } + if pending.contains(messageID) { + return .pending + } + + switch receiptState() { + case .accepted(let existingURL): + return .accepted(existingURL) + case .tombstoned: + return .tombstoned + case .unavailable: + return .unavailable + case .absent: + break + } + + pending.insert(messageID) + return .reserved + } + + func finish(_ messageID: String) { + lock.lock() + defer { lock.unlock() } + pending.remove(messageID) + } } /// Orchestrates inbound file transfers: self-echo policy, sender display-name @@ -43,6 +112,7 @@ struct BLEFileTransferHandlerEnvironment { /// and UI delivery. final class BLEFileTransferHandler { private let environment: BLEFileTransferHandlerEnvironment + private let privateMediaArrivals = PrivateMediaArrivalDeduplicator() init(environment: BLEFileTransferHandlerEnvironment) { self.environment = environment @@ -86,6 +156,7 @@ final class BLEFileTransferHandler { senderNickname: senderNickname, timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000), isPrivate: deliveryPlan.isPrivateMessage, + usesDurableReceipts: false, env: env ) // Once authenticated, a local decode/quota/save failure is not proof @@ -116,6 +187,11 @@ final class BLEFileTransferHandler { senderNickname: senderNickname, timestamp: timestamp, isPrivate: true, + // Every authenticated Noise private-file keeps the stable ID/ACK + // contract introduced with capability bit 8. Bit 9 advertises + // sender-side automatic retry support; it must not downgrade + // prior iOS clients to random IDs or single-check delivery. + usesDurableReceipts: true, env: env ) } @@ -126,9 +202,11 @@ final class BLEFileTransferHandler { senderNickname: String, timestamp: Date, isPrivate: Bool, + usesDurableReceipts: Bool, env: BLEFileTransferHandlerEnvironment ) -> Bool { + let localPeerID = env.localPeerID() let filePacket: BitchatFilePacket let mime: MimeType switch BLEIncomingFileValidator.validate(payload: payload) { @@ -149,6 +227,91 @@ 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 = usesDurableReceipts + ? PrivateMediaMessageIdentity.stableID( + for: filePacket, + senderPeerID: peerID, + recipientPeerID: localPeerID + ) + : nil + if let messageID { + switch privateMediaArrivals.reserve( + messageID, + receiptState: { env.privateMediaReceiptState(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(let existingFile): + env.updatePeerLastSeen(peerID) + let message = incomingMessage( + messageID: messageID, + senderNickname: senderNickname, + timestamp: timestamp, + isPrivate: true, + peerID: peerID, + destination: existingFile, + category: storedMediaCategory( + for: existingFile, + fallback: mime.category + ), + env: env + ) + SecureLogger.debug( + "📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)", + category: .session + ) + deliverStableMessage( + message, + messageID: messageID, + peerID: peerID, + expectedURL: existingFile, + env: env + ) + return true + case .tombstoned: + // Explicit deletion is a durable terminal receiver decision. + env.updatePeerLastSeen(peerID) + env.acknowledgePrivateMedia(messageID, peerID) + SecureLogger.debug( + "📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…", + category: .session + ) + return true + case .unavailable: + // Never turn an unreadable ledger into an empty ledger. A + // directory-level failure clears on retry; a quarantined + // record keeps exactly this ID fail-closed while every other + // payload still flows. + SecureLogger.warning( + "📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable", + category: .session + ) + return true + } + } + defer { + if let messageID { + privateMediaArrivals.finish(messageID) + } + } + // BCH-01-002: Enforce storage quota before saving env.enforceStorageQuota(filePacket.content.count) @@ -162,13 +325,82 @@ final class BLEFileTransferHandler { return false } + if let messageID, + !env.commitPrivateMediaFile(messageID, destination) { + // A payload without its durable ID mapping cannot safely suppress + // a retry after relaunch. Roll it back and withhold UI/ACK. + env.removeIncomingFile(destination) + return false + } + if isPrivate { env.updatePeerLastSeen(peerID) } - let message = BitchatMessage( + let message = incomingMessage( + messageID: messageID, + senderNickname: senderNickname, + timestamp: timestamp, + isPrivate: isPrivate, + peerID: peerID, + destination: destination, + category: mime.category, + env: env + ) + + SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) + + if let messageID { + deliverStableMessage( + message, + messageID: messageID, + peerID: peerID, + expectedURL: destination, + env: env + ) + } else { + env.deliverMessage(message, { true }, {}) + } + return true + } + + private func deliverStableMessage( + _ message: BitchatMessage, + messageID: String, + peerID: PeerID, + expectedURL: URL, + env: BLEFileTransferHandlerEnvironment + ) { + env.deliverMessage( + message, + { + guard case .accepted(let resolvedURL) = + env.privateMediaReceiptState(messageID) else { + return false + } + return resolvedURL.standardizedFileURL + == expectedURL.standardizedFileURL + }, + { + env.acknowledgePrivateMedia(messageID, peerID) + } + ) + } + + private func incomingMessage( + messageID: String?, + senderNickname: String, + timestamp: Date, + isPrivate: Bool, + peerID: PeerID, + destination: URL, + category: MimeType.Category, + env: BLEFileTransferHandlerEnvironment + ) -> BitchatMessage { + BitchatMessage( + id: messageID, sender: senderNickname, - content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", + content: "\(category.messagePrefix)\(destination.lastPathComponent)", timestamp: timestamp, isRelay: false, originalSender: nil, @@ -176,17 +408,35 @@ final class BLEFileTransferHandler { recipientNickname: nil, senderPeerID: peerID, // Received messages need an explicit status: BitchatMessage - // defaults private messages to .sending, which the media views - // render as an in-flight send (empty reveal mask, disabled tap). + // defaults private messages to .sending, which media views render + // as an in-flight send. deliveryStatus: isPrivate ? .delivered(to: env.localNickname(), at: timestamp) : nil ) + } - SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) - - env.deliverMessage(message) - return true + /// The durable URL is authoritative during reconstruction. A sender that + /// reuses a stable filename with a different MIME type must not change how + /// the already-stored payload renders. + private func storedMediaCategory( + for url: URL, + fallback: MimeType.Category + ) -> MimeType.Category { + let mediaDirectory = url + .deletingLastPathComponent() + .deletingLastPathComponent() + .lastPathComponent + switch mediaDirectory { + case MimeType.Category.audio.mediaDir: + return .audio + case MimeType.Category.image.mediaDir: + return .image + case MimeType.Category.file.mediaDir: + return .file + default: + return fallback + } } /// Every remaining raw file transfer is signed, regardless of whether it diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index f7c7703c..9463c739 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -134,6 +134,7 @@ struct BLEIncomingFileStore { private let baseDirectory: URL? private let dateProvider: () -> Date private let panicMarkerWriter: (Data, URL) throws -> Void + private let privateMediaReceipts: BLEPrivateMediaReceiptStore init( fileManager: FileManager = .default, @@ -147,6 +148,11 @@ struct BLEIncomingFileStore { self.baseDirectory = baseDirectory self.dateProvider = dateProvider self.panicMarkerWriter = panicMarkerWriter + self.privateMediaReceipts = BLEPrivateMediaReceiptStore( + fileManager: fileManager, + baseDirectory: baseDirectory, + now: dateProvider + ) } /// Panic-wipe every managed incoming and outgoing media artifact before @@ -159,6 +165,11 @@ struct BLEIncomingFileStore { func panicWipe( hasDurablePendingMarker: Bool = false ) throws { + // The receipt index caches tombstones as well as accepted payloads. + // Always invalidate it on return, including partial-failure paths, so + // no pre-panic receiver decision survives after identity reset. + defer { privateMediaReceipts.resetForPanic() } + let markerError: Error? do { try markPanicRecoveryPending() @@ -257,6 +268,46 @@ struct BLEIncomingFileStore { } } + /// Drops THIS instance's in-memory receipt index after a panic wipe. + /// + /// `panicWipe` already resets the receipt store it runs on, but the + /// production wipe runs on the `PanicRecoveryOperations.live()` file + /// store while receipt lookups are served by `BLEService`'s own + /// `incomingFileStore`. The service's panic path must invalidate its own + /// cache explicitly or pre-panic decisions survive in memory. + func resetPrivateMediaReceiptsForPanic() { + privateMediaReceipts.resetForPanic() + } + + func privateMediaReceiptState( + messageID: String + ) -> BLEPrivateMediaReceiptState { + privateMediaReceipts.state(for: messageID) + } + + func commitPrivateMediaFile( + messageID: String, + storedURL: URL + ) -> Bool { + privateMediaReceipts.commitAccepted( + messageID: messageID, + storedURL: storedURL + ) + } + + /// Best-effort rollback for a payload whose durable receipt commit failed. + func removeIncomingFile(at storedURL: URL) { + guard isURLInsideFilesDirectory(storedURL) else { return } + do { + try fileManager.removeItem(at: storedURL) + } catch { + SecureLogger.warning( + "⚠️ Failed to roll back uncommitted incoming media: \(error)", + category: .session + ) + } + } + /// Frees least-recently-modified incoming files until `reservingBytes` /// fits under the quota. Files named `voice_live_*` (in-flight live /// captures) are never evicted regardless of who triggers enforcement — @@ -349,6 +400,13 @@ struct BLEIncomingFileStore { ] } + private func isURLInsideFilesDirectory(_ url: URL) -> Bool { + guard let filesDirectory = try? filesDirectory().standardizedFileURL else { + return false + } + return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/") + } + private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String { var candidate = (name ?? "") .replacingOccurrences(of: "\0", with: "") diff --git a/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift b/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift new file mode 100644 index 00000000..60e70fea --- /dev/null +++ b/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift @@ -0,0 +1,624 @@ +import BitLogger +import Foundation + +enum BLEPrivateMediaReceiptState: Equatable { + /// No durable receiver decision exists for this stable message ID. + case absent + /// The payload is durably mapped to a file that still exists. + case accepted(URL) + /// The user explicitly deleted the payload; retries must not resurrect it. + case tombstoned + /// Durable state could not be read safely. Callers must fail closed and + /// must not save, deliver, or acknowledge the payload. + case unavailable +} + +/// Durable, per-message receiver decisions for stable private media. +/// +/// Each ID has its own atomic record so one hot lookup never rewrites or +/// decodes the entire ledger. The process-lifetime index is installed only +/// after a complete directory scan. A structural failure of the directory +/// itself (create/enumerate) remains globally fail-closed and retryable. +/// An individual record that cannot be read, decoded, or validated is +/// quarantined instead: the file is moved aside with a `.corrupt` suffix, +/// excluded from future scans, and only that ID stays fail-closed — the rest +/// of the ledger keeps working, so one damaged record can never make every +/// inbound private media payload vanish. +final class BLEPrivateMediaReceiptStore: @unchecked Sendable { + typealias DirectoryReader = (_ directory: URL) throws -> [URL] + typealias DataReader = (_ url: URL) throws -> Data + private static let receiptDirectoryName = ".private-media-receipts" + private static let quarantinePathExtension = "corrupt" + + private struct ReceiptRecord: Codable, Equatable { + enum Kind: String, Codable { + case accepted + case tombstone + } + + let kind: Kind + /// Path below the app's `files/` root. Absolute application-container + /// prefixes are not stable across updates, restores, or reinstalls. + let relativePath: String? + let recordedAt: Date + } + + private final class Runtime: @unchecked Sendable { + let lock = NSLock() + var records: [String: ReceiptRecord]? + /// IDs whose durable record was quarantined as unreadable. Installed + /// together with `records`; these IDs stay fail-closed while every + /// other record keeps serving. + var quarantined: Set = [] + var volatileTombstones: [String: Date] = [:] + } + + private let fileManager: FileManager + private let baseDirectory: URL? + private let capacity: Int + private let ttl: TimeInterval + private let now: () -> Date + private let directoryReader: DirectoryReader? + private let dataReader: DataReader? + private let runtime = Runtime() + + init( + fileManager: FileManager = .default, + baseDirectory: URL? = nil, + capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity, + ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds, + now: @escaping () -> Date = Date.init, + directoryReader: DirectoryReader? = nil, + dataReader: DataReader? = nil + ) { + self.fileManager = fileManager + self.baseDirectory = baseDirectory + self.capacity = max(1, capacity) + self.ttl = max(0, ttl) + self.now = now + self.directoryReader = directoryReader + self.dataReader = dataReader + } + + /// Drops process-lifetime decisions after the enclosing media directory + /// has been panic-wiped. A later lookup must rebuild from the durable + /// ledger instead of retaining an accepted receipt or tombstone whose + /// backing files no longer exist. + func resetForPanic() { + runtime.lock.lock() + runtime.records = nil + runtime.quarantined.removeAll(keepingCapacity: false) + runtime.volatileTombstones.removeAll(keepingCapacity: false) + runtime.lock.unlock() + } + + func state(for messageID: String) -> BLEPrivateMediaReceiptState { + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + return .absent + } + + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + if let tombstonedAt = runtime.volatileTombstones[messageID] { + if !isExpired(tombstonedAt, at: date) { + return .tombstoned + } + runtime.volatileTombstones.removeValue(forKey: messageID) + } + + guard let directory = resolvedReceiptDirectory(), + var records = loadIndexIfNeeded(from: directory, at: date) else { + return .unavailable + } + // A quarantined record could have been an acceptance or a tombstone; + // only this ID fails closed, so a retry can neither resurrect deleted + // media nor double-deliver, while every other payload keeps working. + if runtime.quarantined.contains(messageID) { + return .unavailable + } + guard let record = records[messageID] else { return .absent } + + if isExpired(record.recordedAt, at: date) { + records.removeValue(forKey: messageID) + runtime.records = records + removeRecord(messageID: messageID, from: directory) + return .absent + } + + switch record.kind { + case .tombstone: + removePayloadRecordedByTombstone(record) + return .tombstoned + + case .accepted: + guard let relativePath = record.relativePath, + let existingURL = existingPayload(relativePath: relativePath) else { + // Quota cleanup is not explicit deletion. Remove the stale + // receipt so a sender retry can restore the payload and bubble. + records.removeValue(forKey: messageID) + runtime.records = records + removeRecord(messageID: messageID, from: directory) + return .absent + } + return .accepted(existingURL) + } + } + + /// Records an accepted ID only after the payload is on disk. Callers must + /// roll the payload back and withhold UI delivery/ACK when this returns + /// false. + func commitAccepted(messageID: String, storedURL: URL) -> Bool { + guard PrivateMediaMessageIdentity.isStableID(messageID), + validExistingPayload(storedURL) != nil, + let relativePath = relativePath(for: storedURL) else { + return false + } + + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + if let tombstonedAt = runtime.volatileTombstones[messageID], + !isExpired(tombstonedAt, at: date) { + return false + } + + guard let directory = resolvedReceiptDirectory(), + var records = loadIndexIfNeeded(from: directory, at: date), + !runtime.quarantined.contains(messageID) else { + return false + } + if let existing = records[messageID], + existing.kind == .tombstone, + !isExpired(existing.recordedAt, at: date) { + return false + } + + let victim = capacityVictim( + for: .accepted, + replacing: messageID, + in: records + ) + if records[messageID]?.kind != .accepted, + records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity, + victim == nil { + return false + } + + let record = ReceiptRecord( + kind: .accepted, + relativePath: relativePath, + recordedAt: date + ) + guard persist(record, messageID: messageID, to: directory) else { + return false + } + + records[messageID] = record + if let victim, victim != messageID { + records.removeValue(forKey: victim) + removeRecord(messageID: victim, from: directory) + } + runtime.records = records + return true + } + + /// Foundation for explicit media deletion. This branch does not wire the + /// chat-clear UI; it only makes a tombstone durable and fail closed. + func recordDeleted(messageID: String) -> Bool { + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + return false + } + + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + addVolatileTombstone(messageID, at: date) + + guard let directory = resolvedReceiptDirectory(), + var records = loadIndexIfNeeded(from: directory, at: date), + !runtime.quarantined.contains(messageID) else { + runtime.volatileTombstones.removeValue(forKey: messageID) + return false + } + if let existing = records[messageID], + existing.kind == .tombstone, + !isExpired(existing.recordedAt, at: date) { + runtime.volatileTombstones.removeValue(forKey: messageID) + removePayloadRecordedByTombstone(existing) + return true + } + + let victim = capacityVictim( + for: .tombstone, + replacing: messageID, + in: records + ) + if records[messageID]?.kind != .tombstone, + records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity, + victim == nil { + runtime.volatileTombstones.removeValue(forKey: messageID) + return false + } + + let tombstone = ReceiptRecord( + kind: .tombstone, + // Retain the accepted path so a crash between the atomic record + // write and payload unlink can finish cleanup after relaunch. + relativePath: records[messageID]?.relativePath, + recordedAt: date + ) + guard persist(tombstone, messageID: messageID, to: directory) else { + runtime.volatileTombstones.removeValue(forKey: messageID) + return false + } + + records[messageID] = tombstone + if let victim, victim != messageID { + records.removeValue(forKey: victim) + removeRecord(messageID: victim, from: directory) + } + runtime.records = records + runtime.volatileTombstones.removeValue(forKey: messageID) + removePayloadRecordedByTombstone(tombstone) + return true + } + + private func loadIndexIfNeeded( + from directory: URL, + at date: Date + ) -> [String: ReceiptRecord]? { + if let records = runtime.records { + return records + } + + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + SecureLogger.error( + "❌ Failed to create private-media receipt directory: \(error)", + category: .session + ) + return nil + } + + let urls: [URL] + do { + if let directoryReader { + urls = try directoryReader(directory) + } else { + urls = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [] + ) + } + } catch { + SecureLogger.error( + "❌ Failed to enumerate private-media receipts: \(error)", + category: .session + ) + return nil + } + + var records: [String: ReceiptRecord] = [:] + var quarantined: Set = [] + var expired: [String] = [] + var tombstones: [ReceiptRecord] = [] + for url in urls { + // Records quarantined by an earlier scan stay fail-closed on + // every launch without being re-read: only their ID matters. + if url.pathExtension == Self.quarantinePathExtension { + let messageID = url + .deletingPathExtension() + .deletingPathExtension() + .lastPathComponent + if PrivateMediaMessageIdentity.isStableID(messageID) { + quarantined.insert(messageID) + } + continue + } + guard url.pathExtension == "json" else { continue } + let messageID = url.deletingPathExtension().lastPathComponent + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + continue + } + + let record: ReceiptRecord + do { + let data = try dataReader?(url) ?? Data(contentsOf: url) + record = try JSONDecoder().decode(ReceiptRecord.self, from: data) + } catch { + // Never delete or silently skip an unreadable stable-ID + // record: treating it as absent could resurrect accepted or + // deleted media. But never let it poison the whole ledger + // either — quarantine the file and fail only this ID closed. + quarantine(url, messageID: messageID, reason: "\(error)") + quarantined.insert(messageID) + continue + } + + guard isStructurallyValid(record) else { + quarantine( + url, + messageID: messageID, + reason: "structurally invalid" + ) + quarantined.insert(messageID) + continue + } + if isExpired(record.recordedAt, at: date) { + expired.append(messageID) + continue + } + records[messageID] = record + if record.kind == .tombstone { + tombstones.append(record) + } + } + + // A readable duplicate of a quarantined ID must not override the + // fail-closed decision. + for messageID in quarantined { + records.removeValue(forKey: messageID) + } + + let overflow = overflowVictims(in: records) + for messageID in overflow { + records.removeValue(forKey: messageID) + } + + // Install the index only after the whole directory was scanned. + // Cleanup cannot influence a failed scan. + runtime.records = records + runtime.quarantined = quarantined + + for messageID in expired + overflow { + removeRecord(messageID: messageID, from: directory) + } + for tombstone in tombstones { + removePayloadRecordedByTombstone(tombstone) + } + return records + } + + /// Moves an unreadable record aside so future scans skip it while its ID + /// stays fail-closed. The bytes are preserved for offline inspection — + /// quarantine never deletes receiver decisions. + private func quarantine(_ url: URL, messageID: String, reason: String) { + SecureLogger.error( + "❌ Quarantining unreadable private-media receipt \(messageID.prefix(12))…: \(reason)", + category: .session + ) + let destination = url.appendingPathExtension( + Self.quarantinePathExtension + ) + do { + if fileManager.fileExists(atPath: destination.path) { + // Same ID, already fail-closed; keep the earlier evidence. + try fileManager.removeItem(at: url) + } else { + try fileManager.moveItem(at: url, to: destination) + } + } catch { + // The record stays where it is and will be re-quarantined (in + // memory at minimum) by the next scan. Still fail-closed. + SecureLogger.warning( + "⚠️ Failed to move corrupt private-media receipt aside: \(error)", + category: .session + ) + } + } + + private func isStructurallyValid(_ record: ReceiptRecord) -> Bool { + switch record.kind { + case .tombstone: + guard let relativePath = record.relativePath else { return true } + return candidatePayload(relativePath: relativePath) != nil + case .accepted: + guard let relativePath = record.relativePath else { return false } + return candidatePayload(relativePath: relativePath) != nil + } + } + + private func isExpired(_ recordedAt: Date, at date: Date) -> Bool { + date.timeIntervalSince(recordedAt) > ttl + } + + private func overflowVictims( + in records: [String: ReceiptRecord] + ) -> [String] { + var victims: [String] = [] + for kind in [ReceiptRecord.Kind.accepted, .tombstone] { + let matching = records.filter { $0.value.kind == kind } + let overflow = matching.count - capacity + guard overflow > 0 else { continue } + victims.append(contentsOf: matching.sorted { lhs, rhs in + if lhs.value.recordedAt == rhs.value.recordedAt { + return lhs.key < rhs.key + } + return lhs.value.recordedAt < rhs.value.recordedAt + } + .prefix(overflow) + .map(\.key)) + } + return victims + } + + /// Accepted receipts and tombstones have independent capacity. High media + /// volume cannot evict explicit deletion intent, and vice versa. + private func capacityVictim( + for incomingKind: ReceiptRecord.Kind, + replacing messageID: String, + in records: [String: ReceiptRecord] + ) -> String? { + guard records[messageID]?.kind != incomingKind else { return nil } + let matching = records.filter { + $0.key != messageID && $0.value.kind == incomingKind + } + guard matching.count >= capacity else { return nil } + return matching.min { lhs, rhs in + if lhs.value.recordedAt == rhs.value.recordedAt { + return lhs.key < rhs.key + } + return lhs.value.recordedAt < rhs.value.recordedAt + }?.key + } + + private func persist( + _ record: ReceiptRecord, + messageID: String, + to directory: URL + ) -> Bool { + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + let data = try JSONEncoder().encode(record) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + let url = recordURL(messageID: messageID, in: directory) + try data.write(to: url, options: options) + return true + } catch { + SecureLogger.error( + "❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)", + category: .session + ) + return false + } + } + + private func removeRecord(messageID: String, from directory: URL) { + let url = recordURL(messageID: messageID, in: directory) + guard fileManager.fileExists(atPath: url.path) else { return } + do { + try fileManager.removeItem(at: url) + } catch { + SecureLogger.warning( + "⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)", + category: .session + ) + } + } + + private func recordURL(messageID: String, in directory: URL) -> URL { + directory + .appendingPathComponent(messageID, isDirectory: false) + .appendingPathExtension("json") + } + + private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) { + guard record.kind == .tombstone, + let relativePath = record.relativePath, + let payload = candidatePayload(relativePath: relativePath), + fileManager.fileExists(atPath: payload.path) else { + return + } + do { + try fileManager.removeItem(at: payload) + } catch { + SecureLogger.warning( + "⚠️ Failed to remove explicitly deleted private media: \(error)", + category: .session + ) + } + } + + private func addVolatileTombstone(_ messageID: String, at date: Date) { + runtime.volatileTombstones[messageID] = date + let overflow = runtime.volatileTombstones.count - capacity + guard overflow > 0 else { return } + let oldest = runtime.volatileTombstones.sorted { + if $0.value == $1.value { return $0.key < $1.key } + return $0.value < $1.value + } + for (oldMessageID, _) in oldest.prefix(overflow) { + runtime.volatileTombstones.removeValue(forKey: oldMessageID) + } + } + + private func validExistingPayload(_ url: URL) -> URL? { + let standardized = url.standardizedFileURL + guard isInsideFilesDirectory(standardized) else { return nil } + var isDirectory: ObjCBool = false + guard fileManager.fileExists( + atPath: standardized.path, + isDirectory: &isDirectory + ), !isDirectory.boolValue else { + return nil + } + return standardized + } + + private func relativePath(for url: URL) -> String? { + guard let filesRoot = try? filesDirectory().standardizedFileURL else { + return nil + } + let prefix = filesRoot.path + "/" + let standardized = url.standardizedFileURL + guard standardized.path.hasPrefix(prefix) else { return nil } + let relativePath = String(standardized.path.dropFirst(prefix.count)) + return relativePath.isEmpty ? nil : relativePath + } + + private func existingPayload(relativePath: String) -> URL? { + guard let candidate = candidatePayload(relativePath: relativePath) else { + return nil + } + return validExistingPayload(candidate) + } + + private func candidatePayload(relativePath: String) -> URL? { + guard !relativePath.isEmpty, + let filesRoot = try? filesDirectory().standardizedFileURL else { + return nil + } + let candidate = filesRoot + .appendingPathComponent(relativePath, isDirectory: false) + .standardizedFileURL + guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil } + return candidate + } + + private func isInsideFilesDirectory(_ url: URL) -> Bool { + guard let filesRoot = try? filesDirectory().standardizedFileURL else { + return false + } + return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/") + } + + private func resolvedReceiptDirectory() -> URL? { + return try? filesDirectory().appendingPathComponent( + Self.receiptDirectoryName, + isDirectory: true + ) + } + + private func filesDirectory() throws -> URL { + let root = try baseDirectory ?? fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let files = root.appendingPathComponent("files", isDirectory: true) + try fileManager.createDirectory( + at: files, + withIntermediateDirectories: true, + attributes: nil + ) + return files + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 257140f6..265a4cc1 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -739,6 +739,12 @@ final class BLEService: NSObject { /// Reopen the radio only after media deletion and recovery-marker commit. func completePanicReset(restartServices: Bool) { + // The media wipe ran on the recovery operations' own file store; this + // service's store still caches pre-panic receipt decisions (and a + // callback drained during suspension may have re-read the pre-wipe + // ledger). Drop the cache before admission reopens so the next lookup + // rebuilds from the wiped directory. + incomingFileStore.resetPrivateMediaReceiptsForPanic() setPanicSuspended(false) guard restartServices else { return } startServices() @@ -1139,6 +1145,28 @@ final class BLEService: NSObject { collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } } + private func privateMediaPolicyFingerprint( + for peerID: PeerID, + expectedSessionGeneration: UUID? + ) -> String? { + let normalizedPeerID = peerID.toShort() + if let expectedSessionGeneration, + noiseService.sessionGeneration(for: normalizedPeerID) + == expectedSessionGeneration, + let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID), + noiseService.sessionGeneration(for: normalizedPeerID) + == expectedSessionGeneration { + // The exact authenticated Noise static key is stronger than a + // registry entry populated by a public announce. + return fingerprint + } + return collectionsQueue.sync { + peerRegistry.info(for: normalizedPeerID)? + .noisePublicKey? + .sha256Fingerprint() + } + } + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { let normalizedPeerID = peerID.toShort() let state: ( @@ -1166,7 +1194,10 @@ final class BLEService: NSObject { return .awaitingCapabilityProof } - guard let fingerprint = state.fingerprint else { + guard let fingerprint = privateMediaPolicyFingerprint( + for: normalizedPeerID, + expectedSessionGeneration: state.sessionGeneration + ) ?? state.fingerprint else { // A raw fallback must be bound to the stable Noise key from a // verified registry entry; a routing ID alone can rotate or be // spoofed. Without that key neither proof nor safe migration state @@ -1220,11 +1251,13 @@ final class BLEService: NSObject { return } - let fingerprint: String? = self.collectionsQueue.sync { - self.peerRegistry.info(for: normalizedPeerID)? - .noisePublicKey? - .sha256Fingerprint() + let generation = self.collectionsQueue.sync { + self.privateMediaSessionGenerations[normalizedPeerID] } + let fingerprint = self.privateMediaPolicyFingerprint( + for: normalizedPeerID, + expectedSessionGeneration: generation + ) guard let fingerprint else { self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) return @@ -2558,12 +2591,50 @@ final class BLEService: NSObject { defaultPrefix: defaultPrefix ) }, + privateMediaReceiptState: { [weak self] messageID in + self?.incomingFileStore.privateMediaReceiptState( + messageID: messageID + ) ?? .unavailable + }, + commitPrivateMediaFile: { [weak self] messageID, storedURL in + self?.incomingFileStore.commitPrivateMediaFile( + messageID: messageID, + storedURL: storedURL + ) ?? false + }, + removeIncomingFile: { [weak self] storedURL in + self?.incomingFileStore.removeIncomingFile(at: storedURL) + }, + 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) }, - deliverMessage: { [weak self] message in - // Single main-actor hop delivering `.messageReceived`. - self?.emitTransportEvent(.messageReceived(message)) + acknowledgePrivateMedia: { [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, shouldDeliver, completion in + self?.emitTransportEvent( + .messageReceived(message), + shouldDeliver: shouldDeliver, + completion: completion + ) } ) } @@ -3348,6 +3419,15 @@ extension BLEService { capturePanicLifecycleGeneration() != nil } + /// Queries the receipt store of the service's OWN incoming-file store — + /// the instance production lookups run against — so panic tests exercise + /// the real wiring instead of a same-instance shortcut. + func _test_privateMediaReceiptState( + messageID: String + ) -> BLEPrivateMediaReceiptState { + incomingFileStore.privateMediaReceiptState(messageID: messageID) + } + /// Models a CoreBluetooth delegate callback without requiring a physical /// peripheral. The callback itself runs on `bleQueue`, exactly where the /// panic radio-stop barrier must linearize it. @@ -4301,9 +4381,22 @@ extension BLEService { } } - private func emitTransportEvent(_ event: TransportEvent) { + private func emitTransportEvent( + _ event: TransportEvent, + shouldDeliver: (() -> Bool)? = nil, + completion: (() -> Void)? = nil + ) { notifyUI { [weak self] in - _ = self?.deliverTransportEvent(event) + guard let self, + shouldDeliver?() ?? true, + self.deliverTransportEvent(event), + // Quota cleanup can race the asynchronous main-actor hop or + // the synchronous ConversationStore upsert. ACK only while + // the exact durable mapping and file still resolve. + shouldDeliver?() ?? true else { + return + } + completion?() } } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 7b65a758..98029094 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -15,6 +15,13 @@ enum TransportConfig { static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5 static let privateMediaCapabilityProofPendingPeerCap: Int = 64 static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16 + /// Accepted private-media receipts and explicit-deletion tombstones each + /// receive this independent capacity. + static let privateMediaReceivedLedgerCapacity: Int = 4_096 + /// A bounded retry horizon prevents stable receipt state from growing into + /// permanent application history. + static let privateMediaReceivedLedgerTTLSeconds: TimeInterval = + 7 * 24 * 60 * 60 static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays // Fragment relay TTL in sparse graphs; matches messageTTLDefault so media diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift index 1e3e4b3f..c3ec5ce9 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 @@ -272,8 +279,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, @@ -287,7 +302,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 70e990b1..4fcc86b3 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -252,9 +252,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) @@ -419,9 +427,17 @@ final class ChatMediaTransferCoordinator { try? FileManager.default.removeItem(at: prepared.outputURL) 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) @@ -459,12 +475,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 8f2714e6..20837005 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -495,6 +495,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 0e9d5fe2..824bae69 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -8,7 +8,7 @@ // `ChatPrivateConversationCoordinatorContextTests` exemplars. // // Real file/codec work remains covered by `ChatMediaPreparationTests`. These -// tests inject a paused voice-note preparer to exercise cancellation ownership +// tests inject paused media preparers to exercise cancellation ownership // across the detached-preparation/MainActor boundary deterministically. // @@ -89,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] = [] @@ -154,7 +158,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { transferId: String, allowLegacyFallback: Bool ) { - privateFileSends.append((peerID, transferId)) + privateFileSends.append((packet, peerID, transferId)) privateFileLegacyAllowances.append(allowLegacyFallback) } @@ -172,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() @@ -194,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 { @@ -452,6 +451,109 @@ 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 panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "99aabbccddeeff00") + context.selectedPrivateChatPeer = peerID + let sourceURL = try makeCoordinatorTestImageURL() + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-stale-image-\(UUID().uuidString).jpg" + ) + let preparer = PausedImagePreparer(outputURL: outputURL) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareImagePacket: { url in try preparer.prepare(url) } + ) + defer { + preparer.release() + try? FileManager.default.removeItem(at: sourceURL) + try? FileManager.default.removeItem(at: outputURL) + } + + coordinator.sendImage(from: sourceURL) + #expect(await TestHelpers.waitUntil( + { preparer.hasStarted }, + timeout: TestConstants.longTimeout + )) + + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + .milliseconds(100) + ) { + preparer.release() + } + coordinator.resetForPanic() + + #expect(await TestHelpers.waitUntil( + { preparer.hasFinished }, + timeout: TestConstants.longTimeout + )) + #expect(await TestHelpers.waitUntil( + { !FileManager.default.fileExists(atPath: outputURL.path) }, + timeout: TestConstants.longTimeout + )) + #expect(context.privateChats[peerID]?.isEmpty != false) + #expect(context.appendedPublicMessages.isEmpty) + #expect(context.privateFileSends.isEmpty) + #expect(context.broadcastFileSends.isEmpty) + #expect(context.systemMessages.isEmpty) + #expect(context.deliveryStatusUpdates.isEmpty) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(coordinator.messageIDToTransferId.isEmpty) + } + @Test @MainActor func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws { let context = MockChatMediaTransferContext() diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 46bf79d7..31bea94a 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -220,26 +220,61 @@ 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. Stable-media ACK authorization + // belongs to BLEFileTransferHandler after its durable commit and this + // synchronous acceptance result, not to the generic UI coordinator. + 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.isEmpty) } @Test @MainActor diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index 9b163223..02a7b2ea 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -682,13 +682,18 @@ struct PrivateMediaEndToEndTests { #expect(!identity.hasObservedPrivateMediaCapability( fingerprint: impostorKey.sha256Fingerprint() )) + #expect(identity.hasObservedPrivateMediaCapability( + fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint() + )) alice._test_seedConnectedPeer( bob.myPeerID, nickname: "Bob", capabilities: [], noisePublicKey: impostorKey ) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) + // The exact live Noise identity remains authoritative over a later + // impostor registry rewrite. + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) } @Test @@ -876,7 +881,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, @@ -1147,6 +1152,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) @@ -1289,6 +1305,8 @@ struct PrivateMediaEndToEndTests { return enumerator.compactMap { item in guard let url = item as? URL, + !url.pathComponents.contains(".private-media-receipts"), + url.lastPathComponent != ".private-media-receipts.json", (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { return nil } 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 6371387c..41f242e8 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -13,11 +13,27 @@ 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 receiptStates: [String: BLEPrivateMediaReceiptState] = [:] + var receiptCommits: [(messageID: String, storedURL: URL)] = [] + var receiptCommitSucceeds = true + var removedIncomingFiles: [URL] = [] var lastSeenUpdates: [PeerID] = [] + var deliveryAcks: [(messageID: String, peerID: PeerID)] = [] var deliveredMessages: [BitchatMessage] = [] + var saveOverride: (( + _ data: Data, + _ preferredName: String?, + _ subdirectory: String, + _ fallbackExtension: String?, + _ defaultPrefix: String + ) -> URL?)? + var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)? + var receiptCommitOverride: ((String, URL) -> Bool)? + var removeIncomingFileOverride: ((URL) -> Void)? } private let localPeerID = PeerID(str: "0102030405060708") @@ -46,13 +62,44 @@ 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 }, + privateMediaReceiptState: { messageID in + if let receiptStateOverride = recorder.receiptStateOverride { + return receiptStateOverride(messageID) + } + return recorder.receiptStates[messageID] ?? .absent + }, + commitPrivateMediaFile: { messageID, storedURL in + recorder.receiptCommits.append((messageID, storedURL)) + if let receiptCommitOverride = recorder.receiptCommitOverride { + return receiptCommitOverride(messageID, storedURL) + } + guard recorder.receiptCommitSucceeds else { return false } + recorder.receiptStates[messageID] = .accepted(storedURL) + return true + }, + removeIncomingFile: { storedURL in + recorder.removedIncomingFiles.append(storedURL) + recorder.removeIncomingFileOverride?(storedURL) + }, + isPrivateMediaSenderBlocked: { peerID in + recorder.blockedPeers.contains(peerID) + }, updatePeerLastSeen: { peerID in recorder.lastSeenUpdates.append(peerID) }, - deliverMessage: { message in + acknowledgePrivateMedia: { messageID, peerID in + recorder.deliveryAcks.append((messageID, peerID)) + }, + deliverMessage: { message, shouldDeliver, completion in + guard shouldDeliver() else { return } recorder.deliveredMessages.append(message) + guard shouldDeliver() else { return } + completion() } ) return BLEFileTransferHandler(environment: environment) @@ -284,6 +331,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). @@ -291,13 +339,14 @@ struct BLEFileTransferHandlerTests { } @Test - func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws { + func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() 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: "secret.jpg", + fileName: fileName, fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content @@ -316,6 +365,357 @@ 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 + )) + #expect(recorder.receiptCommits.count == 1) + #expect(recorder.deliveryAcks.count == 1) + #expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id) + } + + @Test + func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true, + signingPublicKey: sampleSigningKey + )] + recorder.signatureVerifies = true + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "image/jpeg", + content: content, + recipientID: Data(hexString: localPeerID.id), + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + ) + + #expect(handler.handle(packet, from: remotePeerID)) + #expect(recorder.receiptCommits.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false) + } + + @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 lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() 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 + )) + + // First encrypted arrival may precede the sender's authenticated bit-9 + // proof. It still uses the bit-8 stable ID/ACK contract. + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + // A later automatic retry after proof must resolve the same durable ID + // rather than create a legacy random-ID bubble. + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + + #expect(recorder.quotaReservations == [content.count]) + #expect(recorder.saveCalls.count == 1) + // The handler re-offers a durable duplicate so a relaunched UI can + // restore its bubble; the synchronous conversation sink deduplicates. + #expect(recorder.deliveredMessages.count == 2) + #expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID]) + #expect(recorder.deliveryAcks.count == 2) + #expect(recorder.deliveryAcks.allSatisfy { + $0.messageID == expectedID && $0.peerID == remotePeerID + }) + } + + @Test + func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "private-media-handler-relaunch-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + 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()) + + func configure(_ recorder: Recorder) { + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + recorder.saveOverride = { + data, + preferredName, + subdirectory, + fallbackExtension, + defaultPrefix in + store.save( + data: data, + preferredName: preferredName, + subdirectory: subdirectory, + fallbackExtension: fallbackExtension, + defaultPrefix: defaultPrefix + ) + } + recorder.receiptStateOverride = { + store.privateMediaReceiptState(messageID: $0) + } + recorder.receiptCommitOverride = { + store.commitPrivateMediaFile(messageID: $0, storedURL: $1) + } + recorder.removeIncomingFileOverride = { + store.removeIncomingFile(at: $0) + } + } + + let first = Recorder() + configure(first) + #expect(makeHandler(recorder: first).handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + let originalMessage = try #require(first.deliveredMessages.first) + #expect(first.deliveryAcks.count == 1) + + // A fresh handler models process relaunch: its in-memory reservation + // cache is empty, so only the durable receipt can suppress disk work. + let relaunched = Recorder() + configure(relaunched) + #expect(makeHandler(recorder: relaunched).handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + + #expect(relaunched.quotaReservations.isEmpty) + #expect(relaunched.saveCalls.isEmpty) + #expect(relaunched.receiptCommits.isEmpty) + #expect(relaunched.deliveredMessages.count == 1) + #expect(relaunched.deliveredMessages.first?.id == originalMessage.id) + #expect(relaunched.deliveredMessages.first?.content == originalMessage.content) + #expect(relaunched.deliveryAcks.count == 1) + #expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id) + } + + @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.deliveryAcks.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.deliveryAcks.count == 1) + #expect(recorder.deliveredMessages.count == 1) + } + + @Test + func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + let fileName = + "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + let messageID = try #require(PrivateMediaMessageIdentity.stableID( + senderPeerID: remotePeerID, + recipientPeerID: localPeerID, + fileName: fileName + )) + recorder.receiptStates[messageID] = .unavailable + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let payload = try #require(BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ).encode()) + + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.receiptCommits.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + } + + @Test + func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + recorder.receiptCommitSucceeds = false + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let payload = try #require(BitchatFilePacket( + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ).encode()) + + #expect(!handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.receiptCommits.count == 1) + #expect(recorder.removedIncomingFiles.count == 1) + #expect(recorder.removedIncomingFiles.first == recorder.saveResult) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + } + + @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.deliveryAcks.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) + #expect(recorder.deliveryAcks.count == 1) } @Test @@ -337,6 +737,7 @@ struct BLEFileTransferHandlerTests { #expect(recorder.quotaReservations.isEmpty) #expect(recorder.saveCalls.isEmpty) #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) #expect(recorder.deliveredMessages.isEmpty) } @@ -468,6 +869,57 @@ struct BLEFileTransferHandlerTests { } } + @Test + func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-receipt-cache-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let messageID = "media-00112233445566778899aabbccddeeff" + + let seed = BLEPrivateMediaReceiptStore(baseDirectory: base) + #expect(seed.recordDeleted(messageID: messageID)) + + // Production wiring: receipt lookups run against the service's OWN + // incoming-file store while the panic wipe runs on the separate store + // `PanicRecoveryOperations.live()` constructs. The test must reset + // the instance BLEService uses, not a same-instance shortcut. + let keychain = MockKeychain() + let identityManager = MockIdentityManager(keychain) + let service = BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: identityManager, + initializeBluetoothManagers: false, + incomingFileStore: BLEIncomingFileStore(baseDirectory: base) + ) + #expect( + service._test_privateMediaReceiptState(messageID: messageID) + == .tombstoned + ) + + service.suspendForPanicReset() + // A receive callback drained during suspension can still consult the + // ledger and re-cache the pre-wipe decision before media deletion. + #expect( + service._test_privateMediaReceiptState(messageID: messageID) + == .tombstoned + ) + + // The wipe itself runs on the recovery operations' distinct store, + // exactly like ChatViewModel's panic transaction. + let recoveryStore = BLEIncomingFileStore(baseDirectory: base) + try recoveryStore.panicWipe() + service.completePanicReset(restartServices: false) + + #expect( + service._test_privateMediaReceiptState(messageID: messageID) + == .absent + ) + } + @Test func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws { enum MarkerFailure: Error { case unavailable } diff --git a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift index fd334d64..8e8f025b 100644 --- a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift +++ b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift @@ -111,5 +111,8 @@ struct BLENoiseReconnectPolicyTests { ) ) #expect(PeerCapabilities.localSupported.contains(.privateMedia)) + #expect( + PeerCapabilities.localSupported.contains(.privateMediaReceipts) + ) } } diff --git a/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift b/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift new file mode 100644 index 00000000..1fd564c5 --- /dev/null +++ b/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift @@ -0,0 +1,291 @@ +import Foundation +import Testing +@testable import bitchat + +struct BLEPrivateMediaReceiptStoreTests { + private struct TestError: Error {} + + private let messageID = "media-00112233445566778899aabbccddeeff" + + @Test + func acceptedReceiptPersistsAcrossStoreInstances() throws { + let root = makeRoot("persist") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + + let first = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(first.commitAccepted(messageID: messageID, storedURL: payload)) + #expect(first.state(for: messageID) == .accepted(payload)) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .accepted(payload)) + } + + @Test + func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws { + let root = makeRoot("list-failure") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let record = receiptRecord(in: root) + #expect(FileManager.default.fileExists(atPath: record.path)) + + var shouldFail = true + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + directoryReader: { directory in + if shouldFail { + shouldFail = false + throw TestError() + } + return try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + } + ) + + #expect(store.state(for: messageID) == .unavailable) + #expect(FileManager.default.fileExists(atPath: record.path)) + #expect(store.state(for: messageID) == .accepted(payload)) + } + + @Test + func recordReadFailureQuarantinesOnlyThatRecord() throws { + let root = makeRoot("read-failure") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let record = receiptRecord(in: root) + let durableBytes = try Data(contentsOf: record) + + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + dataReader: { url in + if url.lastPathComponent.hasPrefix(self.messageID) { + throw TestError() + } + return try Data(contentsOf: url) + } + ) + + #expect(store.state(for: messageID) == .unavailable) + // The record was moved aside, bytes intact, not deleted. + #expect(!FileManager.default.fileExists(atPath: record.path)) + let quarantined = quarantinedRecord(in: root) + #expect(FileManager.default.fileExists(atPath: quarantined.path)) + #expect(try Data(contentsOf: quarantined) == durableBytes) + // The quarantine is sticky for this ID; no absent/accepted flapping. + #expect(store.state(for: messageID) == .unavailable) + } + + @Test + func decodeFailureQuarantinesRecordWithoutRetryOrDeletion() throws { + let root = makeRoot("decode-failure") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let record = receiptRecord(in: root) + let corruptBytes = Data("{not-json".utf8) + try corruptBytes.write(to: record, options: .atomic) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + // Only this ID fails closed; it cannot be re-recorded past the + // quarantine either. + #expect(store.state(for: messageID) == .unavailable) + #expect(!store.commitAccepted(messageID: messageID, storedURL: payload)) + #expect(!store.recordDeleted(messageID: messageID)) + #expect(store.state(for: messageID) == .unavailable) + + // The unreadable bytes were preserved at the quarantine name. + let quarantined = quarantinedRecord(in: root) + #expect(!FileManager.default.fileExists(atPath: record.path)) + #expect(FileManager.default.fileExists(atPath: quarantined.path)) + #expect(try Data(contentsOf: quarantined) == corruptBytes) + + // A relaunch stays fail-closed for this ID without ever re-reading + // the quarantined file. + let readURLs = ReadTracker() + let relaunched = BLEPrivateMediaReceiptStore( + baseDirectory: root, + dataReader: { url in + readURLs.append(url) + return try Data(contentsOf: url) + } + ) + #expect(relaunched.state(for: messageID) == .unavailable) + #expect(!readURLs.urls.contains { + $0.lastPathComponent == quarantined.lastPathComponent + }) + } + + @Test + func corruptRecordDoesNotBlockAnotherSendersMedia() throws { + let root = makeRoot("quarantine-isolation") + defer { try? FileManager.default.removeItem(at: root) } + let otherMessageID = "media-ffeeddccbbaa99887766554433221100" + let corruptPayload = try makePayload(in: root, name: "corrupt.jpg") + let healthyPayload = try makePayload(in: root, name: "healthy.jpg") + + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: corruptPayload + )) + #expect(seed.commitAccepted( + messageID: otherMessageID, + storedURL: healthyPayload + )) + try Data("{not-json".utf8).write( + to: receiptRecord(in: root), + options: .atomic + ) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + // Only the damaged record fails closed; the other sender's ledger + // entry keeps serving and new decisions still commit durably. + #expect(store.state(for: messageID) == .unavailable) + #expect(store.state(for: otherMessageID) == .accepted(healthyPayload)) + + let freshMessageID = "media-0102030405060708090a0b0c0d0e0f10" + let freshPayload = try makePayload(in: root, name: "fresh.jpg") + #expect(store.commitAccepted( + messageID: freshMessageID, + storedURL: freshPayload + )) + #expect(store.state(for: freshMessageID) == .accepted(freshPayload)) + } + + @Test + func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws { + let root = makeRoot("tombstone-decode") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted(messageID: messageID, storedURL: payload)) + #expect(seed.recordDeleted(messageID: messageID)) + #expect(!FileManager.default.fileExists(atPath: payload.path)) + + let record = receiptRecord(in: root) + let corruptBytes = Data([0xFF, 0x00, 0x7B]) + try corruptBytes.write(to: record, options: .atomic) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .unavailable) + // The quarantined tombstone can never flip to absent — a sender retry + // must not resurrect explicitly deleted media even when the payload + // bytes arrive again. + try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) + #expect(!relaunched.commitAccepted( + messageID: messageID, + storedURL: payload + )) + let quarantined = quarantinedRecord(in: root) + #expect(FileManager.default.fileExists(atPath: quarantined.path)) + #expect(try Data(contentsOf: quarantined) == corruptBytes) + #expect( + BLEPrivateMediaReceiptStore(baseDirectory: root) + .state(for: messageID) == .unavailable + ) + } + + @Test + func failedTombstonePersistenceDoesNotPoisonVolatileState() throws { + let root = makeRoot("failed-tombstone-write") + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.state(for: messageID) == .absent) + + // Force the atomic record write itself to fail after the store has + // successfully loaded its empty index. + let record = receiptRecord(in: root) + try FileManager.default.createDirectory( + at: record, + withIntermediateDirectories: true + ) + #expect(!store.recordDeleted(messageID: messageID)) + try FileManager.default.removeItem(at: record) + + // The UI must be able to report the deletion failure without a + // process-lifetime tombstone silently hiding a later retry. + #expect(store.state(for: messageID) == .absent) + } + + @Test + func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws { + let root = makeRoot("no-legacy-migration") + defer { try? FileManager.default.removeItem(at: root) } + let files = root.appendingPathComponent("files", isDirectory: true) + try FileManager.default.createDirectory( + at: files, + withIntermediateDirectories: true + ) + let legacy = files.appendingPathComponent( + ".private-media-receipts.json", + isDirectory: false + ) + let bytes = Data( + #"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"# + .utf8 + ) + try bytes.write(to: legacy, options: .atomic) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.state(for: messageID) == .absent) + #expect(try Data(contentsOf: legacy) == bytes) + } + + private func makeRoot(_ label: String) -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "private-media-receipt-\(label)-\(UUID().uuidString)", + isDirectory: true + ) + } + + private func makePayload( + in root: URL, + name: String = "image.jpg" + ) throws -> URL { + let directory = root.appendingPathComponent( + "files/images/incoming", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let payload = directory.appendingPathComponent(name) + try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) + return payload + } + + private func receiptRecord(in root: URL) -> URL { + root + .appendingPathComponent( + "files/.private-media-receipts", + isDirectory: true + ) + .appendingPathComponent(messageID) + .appendingPathExtension("json") + } + + private func quarantinedRecord(in root: URL) -> URL { + receiptRecord(in: root).appendingPathExtension("corrupt") + } +} + +/// Collects the URLs a store's data reader touched. A reference type so the +/// `@Sendable`-shaped reader closure can record without mutating captures. +private final class ReadTracker: @unchecked Sendable { + private(set) var urls: [URL] = [] + func append(_ url: URL) { urls.append(url) } +} diff --git a/docs/PRIVATE-MEDIA-MIGRATION.md b/docs/PRIVATE-MEDIA-MIGRATION.md index 1f430797..dd90e162 100644 --- a/docs/PRIVATE-MEDIA-MIGRATION.md +++ b/docs/PRIVATE-MEDIA-MIGRATION.md @@ -28,6 +28,13 @@ peer's Noise session before BLE fragmentation. `0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a no-proof timeout reaches the explicit legacy-consent path for an unpinned peer. No timeout automatically sends raw bytes. +- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session + authenticated proof enables bounded sender-side automatic retry; a public + announce never does. It does not replace bit 8: encrypted `0x20` media from + bit-8-only prior iOS clients keeps the same deterministic stable ID and + delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so + a lost proof followed by a later bit-9 retry cannot create a second, + random-ID bubble. - An unpinned peer with a stable Noise key but without that capability is eligible for one signed, directed `fileTransfer`, matching the pre-migration wire form used by older iOS and diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index b0a168da..b1308c9b 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -28,6 +28,13 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { /// before outer BLE fragmentation. Peers that omit this bit require the /// signed directed raw-file migration fallback. public static let privateMedia = PeerCapabilities(rawValue: 1 << 8) + /// Stable private-media IDs are durably deduplicated by the receiver and + /// correlated delivery/read receipts permit bounded automatic resend. + /// + /// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only + /// enables sender-side automatic retry after exact-session proof. + public static let privateMediaReceipts = + PeerCapabilities(rawValue: 1 << 9) /// Reserved for test builds that briefly advertised non-destructive Noise /// replacement. Current clients intentionally do not advertise or act on /// this bit; keep it decodable so the wire assignment is never reused. diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift index 9222e2e6..d66f0cdf 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -18,14 +18,30 @@ struct PeerCapabilitiesTests { #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) #expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01])) - let high = PeerCapabilities(rawValue: 1 << 9) - #expect(high.encoded() == Data([0x00, 0x02])) + #expect( + PeerCapabilities.privateMediaReceipts.encoded() + == Data([0x00, 0x02]) + ) #expect( PeerCapabilities.nonDestructiveNoiseReplacement.encoded() == Data([0x00, 0x04]) ) - let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia] + let high = PeerCapabilities(rawValue: 1 << 11) + #expect(high.encoded() == Data([0x00, 0x08])) + + let all: PeerCapabilities = [ + .prekeys, + .wifiBulk, + .gateway, + .groups, + .board, + .vouch, + .meshDiagnostics, + .privateMedia, + .privateMediaReceipts, + .nonDestructiveNoiseReplacement + ] #expect(PeerCapabilities(encoded: all.encoded()) == all) #expect(PeerCapabilities(encoded: high.encoded()) == high) #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])