From d21ddb79db2b24886b086afee51c07ba09d91889 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 20:49:19 +0200 Subject: [PATCH] Scope authenticated delivery cleanup --- bitchat/App/ConversationStore.swift | 34 ++++ .../Gateway/BridgeCourierService.swift | 116 ++++++++---- .../Gateway/BridgeDropDedupStore.swift | 8 +- bitchat/Services/MessageRouter.swift | 74 +++++--- bitchat/Services/NoiseEncryptionService.swift | 8 +- .../ViewModels/ChatDeliveryCoordinator.swift | 33 +++- .../ChatViewModelDeliveryStatusTests.swift | 173 ++++++++++++++++++ bitchatTests/ConversationStoreTests.swift | 65 +++++++ bitchatTests/Mocks/MockTransport.swift | 7 + .../PerformanceBaselineTests.swift | 13 ++ .../Services/BridgeCourierServiceTests.swift | 132 ++++++++++++- .../Services/MessageRouterTests.swift | 103 ++++++++++- 12 files changed, 693 insertions(+), 73 deletions(-) diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 8128cd38..79e16248 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -426,6 +426,40 @@ final class ConversationStore: ObservableObject { @discardableResult func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { guard let ids = conversationIDsByMessageID[messageID] else { return false } + return applyDeliveryStatus(status, forMessageID: messageID, among: ids) + } + + /// Applies an authenticated delivery/read receipt only to the supplied + /// direct-conversation aliases. A colliding message ID in another peer's + /// conversation (or a public timeline) must not inherit the receipt. + /// + /// Stable and ephemeral aliases can temporarily hold the same message + /// instance during handoff. The shared helper republishes every targeted + /// alias even when the first mutation already changed that instance. + @discardableResult + func setDeliveryStatus( + _ status: DeliveryStatus, + forMessageID messageID: String, + inDirectPeerAliases peerIDs: Set + ) -> Bool { + guard !peerIDs.isEmpty, + let indexedIDs = conversationIDsByMessageID[messageID] else { + return false + } + let allowedIDs = Set(peerIDs.map { ConversationID.directPeer($0) }) + return applyDeliveryStatus( + status, + forMessageID: messageID, + among: indexedIDs.intersection(allowedIDs) + ) + } + + private func applyDeliveryStatus( + _ status: DeliveryStatus, + forMessageID messageID: String, + among ids: Set + ) -> Bool { + guard !ids.isEmpty else { return false } var applied = false var skipped: [ConversationID] = [] for id in ids { diff --git a/bitchat/Services/Gateway/BridgeCourierService.swift b/bitchat/Services/Gateway/BridgeCourierService.swift index 552255a8..db33985b 100644 --- a/bitchat/Services/Gateway/BridgeCourierService.swift +++ b/bitchat/Services/Gateway/BridgeCourierService.swift @@ -106,13 +106,14 @@ final class BridgeCourierService: ObservableObject { dedupKey: String?, operationID: UUID? )] = [] - /// Message IDs already published as drops (sender-side dedup) and drop - /// event IDs already handled (multi-relay dedup). Both persist across - /// relaunches: relays hold drops for the full 24h NIP-40 window and the - /// persisted outbox keeps re-depositing, so in-memory-only dedup meant - /// every relaunch republished the same message as a fresh drop and every - /// gateway relaunch re-delivered the whole backlog (field-verified - /// amplification storm). Entries age out with the 24h drop window. + /// Opaque recipient/message keys already published as drops (sender-side + /// dedup) and drop event IDs already handled (multi-relay dedup). Both + /// persist across relaunches: relays hold drops for the full 24h NIP-40 + /// window and the persisted outbox keeps re-depositing, so in-memory-only + /// dedup meant every relaunch republished the same message as a fresh drop + /// and every gateway relaunch re-delivered the whole backlog + /// (field-verified amplification storm). Entries age out with the 24h drop + /// window. private var publishedDropKeys: ExpiringIDSet private var seenDropEventIDs: ExpiringIDSet private var subscriptionOpen = false @@ -126,7 +127,7 @@ final class BridgeCourierService: ObservableObject { } /// Sender operations queued locally or awaiting relay confirmation. /// The per-attempt ID prevents a stale pre-wipe callback from completing - /// a newer attempt for the same message. + /// a newer attempt for the same recipient/message pair. private var activeDropOperations: [String: ActiveDropOperation] = [:] /// Held-envelope publishes have no sender message ID, but still need an /// in-flight identity: repeated refreshes inside the relay-OK wait window @@ -214,11 +215,46 @@ final class BridgeCourierService: ObservableObject { // MARK: - Sender role + /// Stable, opaque sender-side dedup key. Recipient scope prevents one + /// conversation's colliding message ID from suppressing another's drop, + /// while hashing keeps recipient keys out of the persisted snapshot. + private static func senderDropKey( + messageID: String, + recipientNoiseKey: Data + ) -> String { + var material = Data("bitchat-bridge-drop-dedup-v2".utf8) + appendLengthPrefixed(Data(messageID.utf8), to: &material) + appendLengthPrefixed(recipientNoiseKey, to: &material) + return "v2:\(material.sha256Hex())" + } + + private static func appendLengthPrefixed(_ value: Data, to output: inout Data) { + let length = UInt32(value.count) + output.append(UInt8((length >> 24) & 0xFF)) + output.append(UInt8((length >> 16) & 0xFF)) + output.append(UInt8((length >> 8) & 0xFF)) + output.append(UInt8(length & 0xFF)) + output.append(value) + } + + /// Previous releases persisted raw message IDs without recipient scope. + /// They remain conservative wildcards for their original 24-hour + /// lifetime: assigning one to a recipient would be guesswork and could + /// republish the original drop. New acceptances persist only v2 keys. + private func wasPublished( + legacyMessageID: String, + dedupKey: String, + now date: Date + ) -> Bool { + publishedDropKeys.contains(dedupKey, now: date) + || publishedDropKeys.contains(legacyMessageID, now: date) + } + /// Parallel-deposit a sealed copy of an outbound private message as a /// relay drop. Called by the message router alongside physical courier - /// deposits; idempotent per message ID. Completion becomes true only - /// after a real relay acceptance arrives, which is when the router may - /// show "carried". + /// deposits; idempotent per recipient/message pair. Completion becomes + /// true only after a real relay acceptance arrives, which is when the + /// router may show "carried". func depositDrop( content: String, messageID: String, @@ -229,9 +265,18 @@ final class BridgeCourierService: ObservableObject { completion(false) return } - guard !publishedDropKeys.contains(messageID, now: now()), - activeDropOperations[messageID] == nil, - !rejectedDropKeys.contains(messageID, now: now()) else { + let date = now() + let dedupKey = Self.senderDropKey( + messageID: messageID, + recipientNoiseKey: recipientNoiseKey + ) + guard !wasPublished( + legacyMessageID: messageID, + dedupKey: dedupKey, + now: date + ), + activeDropOperations[dedupKey] == nil, + !rejectedDropKeys.contains(dedupKey, now: date) else { completion(false) return } @@ -244,13 +289,13 @@ final class BridgeCourierService: ObservableObject { // of the sealing); suppress it in-memory so the retry sweep does not // churn, but never persist it as a published drop. guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else { - rejectedDropKeys.insert(messageID, now: now()) + rejectedDropKeys.insert(dedupKey, now: date) completion(false) return } let operationID = UUID() - activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion) - publishDrop(envelope, messageID: messageID, operationID: operationID) + activeDropOperations[dedupKey] = ActiveDropOperation(id: operationID, completion: completion) + publishDrop(envelope, dedupKey: dedupKey, operationID: operationID) } /// Publishes held envelopes (mail we carry for others) as drops, @@ -272,13 +317,14 @@ final class BridgeCourierService: ObservableObject { } } - /// Publishes a drop, or queues it when relays are down. `messageID` is the - /// sender-side dedup key (nil for held/relayed envelopes we don't track); - /// it rides the pending queue so an evicted or failed drop can release its - /// in-flight slot. Completion reports actual NIP-01 relay acceptance. + /// Publishes a drop, or queues it when relays are down. `dedupKey` is the + /// opaque sender-side recipient/message key (nil for held/relayed + /// envelopes we don't track); it rides the pending queue so an evicted or + /// failed drop can release its in-flight slot. Completion reports actual + /// NIP-01 relay acceptance. private func publishDrop( _ envelope: CourierEnvelope, - messageID: String? = nil, + dedupKey: String? = nil, operationID: UUID? = nil, untrackedCompletion: (@MainActor (Bool) -> Void)? = nil ) { @@ -286,7 +332,7 @@ final class BridgeCourierService: ObservableObject { encoded.count <= Limits.maxDropEnvelopeBytes, !envelope.isExpired else { finishPublish( - messageID: messageID, + dedupKey: dedupKey, operationID: operationID, succeeded: false, untrackedCompletion: untrackedCompletion @@ -297,15 +343,15 @@ final class BridgeCourierService: ObservableObject { // Held mail remains in CourierStore and has no sender operation to // recover after an in-memory queue loss. Leave its cooldown unset // and let the next connected refresh offer it again. - guard messageID != nil else { + guard dedupKey != nil else { untrackedCompletion?(false) return } - pendingDrops.append((envelope, messageID, operationID)) + pendingDrops.append((envelope, dedupKey, operationID)) while pendingDrops.count > Limits.maxPendingDrops { let evicted = pendingDrops.removeFirst() finishPublish( - messageID: evicted.dedupKey, + dedupKey: evicted.dedupKey, operationID: evicted.operationID, succeeded: false ) @@ -321,7 +367,7 @@ final class BridgeCourierService: ObservableObject { ) else { SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption) finishPublish( - messageID: messageID, + dedupKey: dedupKey, operationID: operationID, succeeded: false, untrackedCompletion: untrackedCompletion @@ -331,7 +377,7 @@ final class BridgeCourierService: ObservableObject { guard let publishEvent else { SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session) finishPublish( - messageID: messageID, + dedupKey: dedupKey, operationID: operationID, succeeded: false, untrackedCompletion: untrackedCompletion @@ -341,7 +387,7 @@ final class BridgeCourierService: ObservableObject { publishEvent(event) { [weak self] succeeded in guard let self else { return } guard self.finishPublish( - messageID: messageID, + dedupKey: dedupKey, operationID: operationID, succeeded: succeeded, untrackedCompletion: untrackedCompletion @@ -356,23 +402,23 @@ final class BridgeCourierService: ObservableObject { @discardableResult private func finishPublish( - messageID: String?, + dedupKey: String?, operationID: UUID?, succeeded: Bool, untrackedCompletion: (@MainActor (Bool) -> Void)? = nil ) -> Bool { - guard let messageID else { + guard let dedupKey else { untrackedCompletion?(succeeded) return true } // Missing/mismatched means this callback was duplicated, invalidated // by panic wipe, or belongs to an older attempt for the same key. guard let operationID, - let operation = activeDropOperations[messageID], + let operation = activeDropOperations[dedupKey], operation.id == operationID else { return false } - activeDropOperations.removeValue(forKey: messageID) + activeDropOperations.removeValue(forKey: dedupKey) if succeeded { - publishedDropKeys.insert(messageID, now: now()) + publishedDropKeys.insert(dedupKey, now: now()) persistDedup() } operation.completion(succeeded) @@ -387,7 +433,7 @@ final class BridgeCourierService: ObservableObject { for item in queued { publishDrop( item.envelope, - messageID: item.dedupKey, + dedupKey: item.dedupKey, operationID: item.operationID ) } diff --git a/bitchat/Services/Gateway/BridgeDropDedupStore.swift b/bitchat/Services/Gateway/BridgeDropDedupStore.swift index 76a0fe28..0e34a3bd 100644 --- a/bitchat/Services/Gateway/BridgeDropDedupStore.swift +++ b/bitchat/Services/Gateway/BridgeDropDedupStore.swift @@ -64,11 +64,11 @@ struct ExpiringIDSet { /// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every /// gateway relaunch re-delivered the whole backlog. Field-verified: ~20 /// copies of one DM delivered in 40ms fed the storm behind a permanent -/// device freeze. Persisting both sides caps this at one drop per message -/// ID per 24h regardless of relaunch count. +/// device freeze. Persisting both sides caps this at one drop per +/// recipient/message pair per 24h regardless of relaunch count. /// -/// Contents are opaque IDs (message UUIDs, relay event IDs) — no plaintext, -/// no peer identities — so until-first-unlock protection matches +/// Contents are opaque hashes and relay event IDs — no plaintext or peer +/// identities — so until-first-unlock protection matches /// `NostrProcessedEventStore`, and the file must load during a /// locked-background restoration relaunch. Wiped on panic with the rest of /// the courier state. diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 6c624c33..8d4f65fb 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -34,6 +34,20 @@ struct CourierDirectory { final class MessageRouter { typealias QueuedMessage = MessageOutboxStore.QueuedMessage + private struct PeerMessageKey: Hashable { + let peerID: PeerID + let messageID: String + + static func == (lhs: PeerMessageKey, rhs: PeerMessageKey) -> Bool { + lhs.peerID == rhs.peerID && lhs.messageID == rhs.messageID + } + + func hash(into hasher: inout Hasher) { + hasher.combine(peerID) + hasher.combine(messageID) + } + } + private let transports: [Transport] private let now: () -> Date private let courierDirectory: CourierDirectory @@ -104,15 +118,17 @@ final class MessageRouter { } private var bridgeSweepTask: Task? - private var bridgeDepositsInFlight = Set() + private var bridgeDepositsInFlight = Set() private var outbox: [PeerID: [QueuedMessage]] = [:] - /// IDs whose latest router-owned transmission used an already-established - /// secure session and still awaits an ack. This deliberately excludes - /// messages handed to BLE while a handshake is pending: BLE owns those - /// sends and drains its queue after authentication, so retrying them here - /// would duplicate every normal first-handshake DM. - private var securelyTransmittedMessageIDs = Set() + /// Peer/message pairs whose latest router-owned transmission used an + /// already-established secure session and still await an ack. Peer scope + /// is required because message IDs are not globally unique across direct + /// conversations. This deliberately excludes messages handed to BLE while + /// a handshake is pending: BLE owns those sends and drains its queue after + /// authentication, so retrying them here would duplicate every normal + /// first-handshake DM. + private var secureTransmissions = Set() // Outbox limits to prevent unbounded memory growth private static let maxMessagesPerPeer = 100 @@ -205,7 +221,7 @@ final class MessageRouter { // replacement handshake will retry this same message ID, which // receivers deduplicate. enqueue(message, for: peerID) - securelyTransmittedMessageIDs.insert(messageID) + secureTransmissions.insert(PeerMessageKey(peerID: peerID, messageID: messageID)) SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) return @@ -229,7 +245,7 @@ final class MessageRouter { // away. SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) enqueue(message, for: peerID) - securelyTransmittedMessageIDs.remove(messageID) + secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) attemptCourierDeposit(messageID: messageID, for: peerID) return @@ -365,11 +381,12 @@ final class MessageRouter { for peerID: PeerID, recipientKey: Data ) { + let inFlightKey = PeerMessageKey(peerID: peerID, messageID: message.messageID) guard let bridgeCourierDeposit, - bridgeDepositsInFlight.insert(message.messageID).inserted else { return } + bridgeDepositsInFlight.insert(inFlightKey).inserted else { return } bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in guard let self else { return } - self.bridgeDepositsInFlight.remove(message.messageID) + self.bridgeDepositsInFlight.remove(inFlightKey) // A direct delivery may have cleared the outbox while the relay // relay confirmation was in flight; do not regress its UI state. guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return } @@ -401,7 +418,7 @@ final class MessageRouter { /// retry state. func markDelivered(_ messageID: String, from peerIDs: Set) { guard !peerIDs.isEmpty else { return } - clearRetainedMessage(messageID, allowedPeerIDs: peerIDs) + _ = markDelivered(messageID, for: Array(peerIDs)) } private func clearRetainedMessage( @@ -418,11 +435,10 @@ final class MessageRouter { outbox[peerID] = filtered.isEmpty ? nil : filtered cleared = true } - if !outbox.values.contains(where: { queue in - queue.contains { $0.messageID == messageID } - }) { - securelyTransmittedMessageIDs.remove(messageID) + let matchingSecureTransmissions = secureTransmissions.filter { + $0.messageID == messageID } + secureTransmissions.subtract(matchingSecureTransmissions) // The durable snapshot may still be hidden by protected data. Record // the ack even when this cold-load view cannot find the message, then // persist the current view so the store retains a removal tombstone. @@ -449,10 +465,8 @@ final class MessageRouter { outbox[peerID] = filtered.isEmpty ? nil : filtered cleared = true } - if !outbox.values.contains(where: { queue in - queue.contains { $0.messageID == messageID } - }) { - securelyTransmittedMessageIDs.remove(messageID) + for peerID in peerIDs { + secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) } // Preserve the scoped ack even when protected data hides the durable // queue during a cold launch. @@ -514,7 +528,7 @@ final class MessageRouter { } private func dropMessage(_ messageID: String, for peerID: PeerID) { - securelyTransmittedMessageIDs.remove(messageID) + secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) metrics?.record(.outboxDropped) onMessageDropped?(messageID, peerID) } @@ -558,7 +572,7 @@ final class MessageRouter { /// Panic wipe: forget queued mail on disk and in memory. func wipeOutbox() { outbox.removeAll() - securelyTransmittedMessageIDs.removeAll() + secureTransmissions.removeAll() outboxStore?.wipe() } @@ -592,7 +606,7 @@ final class MessageRouter { /// A peer restart can leave that local session looking usable until the /// replacement handshake arrives; the first ciphertext is then /// undecryptable remotely. Normal pre-handshake sends are intentionally - /// absent from `securelyTransmittedMessageIDs` because BLE already queues + /// absent from `secureTransmissions` because BLE already queues /// and drains them when authentication completes. func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) { typealias Candidate = ( @@ -617,7 +631,8 @@ final class MessageRouter { } for (queueOrder, message) in queued.enumerated() { - guard securelyTransmittedMessageIDs.contains(message.messageID) else { continue } + let key = PeerMessageKey(peerID: peerID, messageID: message.messageID) + guard secureTransmissions.contains(key) else { continue } candidates.append(( peerID: peerID, message: message, @@ -647,8 +662,9 @@ final class MessageRouter { for candidate in candidates { let peerID = candidate.peerID let message = candidate.message + let key = PeerMessageKey(peerID: peerID, messageID: message.messageID) guard retriedMessageIDs.insert(message.messageID).inserted, - securelyTransmittedMessageIDs.contains(message.messageID), + secureTransmissions.contains(key), queuedMessage(message.messageID, for: peerID) != nil, let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) else { @@ -732,7 +748,9 @@ final class MessageRouter { continue } SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) - securelyTransmittedMessageIDs.insert(message.messageID) + secureTransmissions.insert( + PeerMessageKey(peerID: peerID, messageID: message.messageID) + ) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) metrics?.record(.outboxResent) outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged @@ -748,7 +766,9 @@ final class MessageRouter { // preserve. Retention stays bounded by the 24h outbox TTL // and the per-peer FIFO cap. SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) - securelyTransmittedMessageIDs.remove(message.messageID) + secureTransmissions.remove( + PeerMessageKey(peerID: peerID, messageID: message.messageID) + ) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) metrics?.record(.outboxResent) } else if let transport = reachableTransport(for: peerID) { diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 8d72ad0e..80ae0b7f 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -97,7 +97,7 @@ enum EncryptionStatus: Equatable { case noiseHandshaking // Currently establishing case noiseSecured // Established but not verified case noiseVerified // Established and verified - + var icon: String? { // Made optional to hide icon when no handshake switch self { case .none: @@ -660,7 +660,7 @@ final class NoiseEncryptionService { guard let packetData = packet.toBinaryDataForSigning() else { return nil } - + // Sign with the noise private key (converted to Ed25519 for signing) guard let signature = signData(packetData) else { return nil @@ -919,7 +919,7 @@ final class NoiseEncryptionService { } return try sessionManager.encrypt(data, for: peerID) } - + /// Decrypt data from a specific peer func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { try decryptWithSessionGeneration(data, from: peerID).plaintext @@ -936,7 +936,7 @@ final class NoiseEncryptionService { let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data) let isAdmittedCiphertext = isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) - + // A quarantined transport is deliberately unavailable for outbound // state, but remains receive-only until the responder proves identity // or the bounded rollback restores it. diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift index c071cef1..7f85e3ac 100644 --- a/bitchat/ViewModels/ChatDeliveryCoordinator.swift +++ b/bitchat/ViewModels/ChatDeliveryCoordinator.swift @@ -21,6 +21,14 @@ protocol ChatDeliveryContext: AnyObject { /// message is unknown or no copy changed. @discardableResult func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool + /// Applies an authenticated receipt only to the direct conversations + /// represented by the supplied peer aliases. + @discardableResult + func setDeliveryStatus( + _ status: DeliveryStatus, + forMessageID messageID: String, + inDirectPeerAliases peerIDs: Set + ) -> Bool /// Current delivery status of the message in whichever conversation holds it. func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? /// Message IDs across all direct conversations (read-receipt pruning). @@ -47,6 +55,19 @@ extension ChatViewModel: ChatDeliveryContext { conversations.setDeliveryStatus(status, forMessageID: messageID) } + @discardableResult + func setDeliveryStatus( + _ status: DeliveryStatus, + forMessageID messageID: String, + inDirectPeerAliases peerIDs: Set + ) -> Bool { + conversations.setDeliveryStatus( + status, + forMessageID: messageID, + inDirectPeerAliases: peerIDs + ) + } + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { conversations.deliveryStatus(forMessageID: messageID) } @@ -68,6 +89,12 @@ extension ChatViewModel: ChatDeliveryContext { func markMessageDelivered(_ messageID: String, from peerIDs: Set) { messageRouter.markDelivered(messageID, from: peerIDs) + // The caller has already bound this receipt to one of our outgoing + // conversations. Release the matching media retry only after that + // peer-scoped validation and accepted delivery-status transition. + mediaTransferCoordinator.confirmPrivateMediaDelivery( + messageID: messageID + ) } func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool { @@ -157,7 +184,11 @@ final class ChatDeliveryCoordinator { } guard !peerIDAliases.isEmpty, context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases), - context.setDeliveryStatus(status, forMessageID: messageID) else { + context.setDeliveryStatus( + status, + forMessageID: messageID, + inDirectPeerAliases: peerIDAliases + ) else { return false } context.markMessageDelivered(messageID, from: peerIDAliases) diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 1c898d13..70c92e81 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -357,6 +357,78 @@ struct ChatViewModelDeliveryStatusTests { #expect(transport.sentPrivateMessages.count == 2) } + @Test @MainActor + func authenticatedNoiseAckClearsOnlyIntendedPeersPrivateMediaRetry() async throws { + let (viewModel, transport) = makeTestableViewModel() + let intendedPeer = PeerID(str: "0102030405060708") + let otherPeer = PeerID(str: "1112131415161718") + let fileName = "voice_0011223344556677.m4a" + let content = Data("voice".utf8) + + transport.privateMediaPolicies[intendedPeer] = .encrypted + transport.privateMediaReceiptSessionGenerations[intendedPeer] = UUID() + viewModel.selectedPrivateChatPeer = intendedPeer + let coordinator = ChatMediaTransferCoordinator( + context: viewModel, + prepareVoiceNotePacket: { _ in + BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "audio/mp4", + content: content + ) + }, + transferIDFactory: { "\($0)-receipt-ack" } + ) + viewModel.mediaTransferCoordinator = coordinator + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "scoped-media-ack-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let sourceURL = directory.appendingPathComponent(fileName) + try content.write(to: sourceURL) + defer { try? FileManager.default.removeItem(at: directory) } + + coordinator.sendVoiceNote(at: sourceURL) + #expect(await TestHelpers.waitUntil( + { + transport.sentPrivateFiles.count == 1 + && coordinator.retainedReconnectRetryCount == 1 + }, + timeout: TestConstants.longTimeout + )) + let messageID = try #require( + viewModel.privateChats[intendedPeer]?.first?.id + ) + let transferID = try #require( + transport.sentPrivateFiles.first?.transferID + ) + + #expect(!viewModel.deliveryCoordinator + .updateAcknowledgedMessageDeliveryStatus( + messageID, + status: .delivered(to: "Other", at: Date()), + from: [otherPeer] + )) + #expect(coordinator.retainedReconnectRetryCount == 1) + #expect(transport.cancelledTransfers.isEmpty) + + #expect(viewModel.deliveryCoordinator + .updateAcknowledgedMessageDeliveryStatus( + messageID, + status: .delivered(to: "Intended", at: Date()), + from: [intendedPeer] + )) + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(transport.cancelledTransfers == [transferID]) + } + @Test @MainActor func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async { let (viewModel, transport) = makeTestableViewModel() @@ -643,6 +715,19 @@ private final class MockChatDeliveryContext: ChatDeliveryContext { store.setDeliveryStatus(status, forMessageID: messageID) } + @discardableResult + func setDeliveryStatus( + _ status: DeliveryStatus, + forMessageID messageID: String, + inDirectPeerAliases peerIDs: Set + ) -> Bool { + store.setDeliveryStatus( + status, + forMessageID: messageID, + inDirectPeerAliases: peerIDs + ) + } + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { store.deliveryStatus(forMessageID: messageID) } @@ -769,6 +854,94 @@ struct ChatDeliveryCoordinatorContextTests { #expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID]) } + @Test @MainActor + func authenticatedReceiptWithCollidingIDUpdatesOnlyAuthenticatedAliases() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + let ephemeralPeerID = PeerID(str: "0102030405060708") + let stablePeerID = PeerID(hexData: Data(repeating: 0x08, count: 32)) + let otherPeerID = PeerID(str: "1112131415161718") + let messageID = "authenticated-receipt-collision" + let mirroredMessage = makePrivateMessage(id: messageID, status: .sent) + + context.store.append(mirroredMessage, to: .directPeer(ephemeralPeerID)) + context.store.append(mirroredMessage, to: .directPeer(stablePeerID)) + context.store.append( + makePrivateMessage(id: messageID, status: .sent), + to: .directPeer(otherPeerID) + ) + context.store.append(makePublicMessage(id: messageID, status: .sent), to: .mesh) + + let aliases: Set = [ephemeralPeerID, stablePeerID] + let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus( + messageID, + status: .delivered(to: "Peer", at: Date()), + from: aliases + ) + + #expect(didUpdate) + #expect(isDelivered( + context.store.conversation(for: .directPeer(ephemeralPeerID)) + .message(withID: messageID)?.deliveryStatus + )) + #expect(isDelivered( + context.store.conversation(for: .directPeer(stablePeerID)) + .message(withID: messageID)?.deliveryStatus + )) + #expect(isSent( + context.store.conversation(for: .directPeer(otherPeerID)) + .message(withID: messageID)?.deliveryStatus + )) + #expect(isSent( + context.store.conversation(for: .mesh) + .message(withID: messageID)?.deliveryStatus + )) + #expect(context.peerBoundDeliveredMessages.count == 1) + #expect(context.peerBoundDeliveredMessages[0].messageID == messageID) + #expect(context.peerBoundDeliveredMessages[0].peerIDs == aliases) + #expect(context.notifyUIChangedCount == 1) + } + + @Test @MainActor + func authenticatedReceiptRemainsScopedAfterPeerAliasMigration() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + let ephemeralPeerID = PeerID(str: "2122232425262728") + let stablePeerID = PeerID(hexData: Data(repeating: 0x28, count: 32)) + let otherPeerID = PeerID(str: "3132333435363738") + let messageID = "authenticated-receipt-after-migration" + + context.store.append( + makePrivateMessage(id: messageID, status: .sent), + to: .directPeer(ephemeralPeerID) + ) + context.store.append( + makePrivateMessage(id: messageID, status: .sent), + to: .directPeer(otherPeerID) + ) + context.store.migrateConversation( + from: .directPeer(ephemeralPeerID), + to: .directPeer(stablePeerID) + ) + + let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus( + messageID, + status: .read(by: "Peer", at: Date()), + from: [ephemeralPeerID, stablePeerID] + ) + + #expect(didUpdate) + #expect(context.store.conversationsByID[.directPeer(ephemeralPeerID)] == nil) + #expect(isRead( + context.store.conversation(for: .directPeer(stablePeerID)) + .message(withID: messageID)?.deliveryStatus + )) + #expect(isSent( + context.store.conversation(for: .directPeer(otherPeerID)) + .message(withID: messageID)?.deliveryStatus + )) + } + @Test @MainActor func receiptFromWrongPeerDoesNotUpdateOrTerminalizeOutgoingMessage() async { let context = MockChatDeliveryContext() diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift index 3c9ef6fd..d1b6d65f 100644 --- a/bitchatTests/ConversationStoreTests.swift +++ b/bitchatTests/ConversationStoreTests.swift @@ -755,6 +755,71 @@ struct ConversationStoreTests { #expect(statusChangedIDs.isEmpty) } + @Test("peer-scoped receipt updates only authenticated direct aliases") + @MainActor + func peerScopedReceiptUpdatesOnlyAuthenticatedDirectAliases() { + let store = ConversationStore() + let ephemeralPeer = PeerID(str: "0102030405060708") + let stablePeer = PeerID(hexData: Data(repeating: 0x08, count: 32)) + let otherPeer = PeerID(str: "1112131415161718") + let ephemeral = ConversationID.directPeer(ephemeralPeer) + let stable = ConversationID.directPeer(stablePeer) + let other = ConversationID.directPeer(otherPeer) + let messageID = "scoped-receipt" + let mirrored = makeMessage( + id: messageID, + timestamp: 1, + isPrivate: true, + deliveryStatus: .sent + ) + store.upsertByID(mirrored, in: ephemeral) + store.upsertByID(mirrored, in: stable) + store.upsertByID( + makeMessage( + id: messageID, + timestamp: 1, + isPrivate: true, + deliveryStatus: .sent + ), + in: other + ) + store.upsertByID( + makeMessage(id: messageID, timestamp: 1, deliveryStatus: .sent), + in: .mesh + ) + + var cancellables = Set() + var publishedIDs: [ConversationID] = [] + for id in [ephemeral, stable, other, .mesh] { + store.conversation(for: id).objectWillChange + .sink { publishedIDs.append(id) } + .store(in: &cancellables) + } + var statusChangedIDs: [ConversationID] = [] + store.changes + .sink { change in + if case .statusChanged(let id, messageID, _) = change, + messageID == "scoped-receipt" { + statusChangedIDs.append(id) + } + } + .store(in: &cancellables) + + let delivered = DeliveryStatus.delivered(to: "bob", at: Date()) + #expect(store.setDeliveryStatus( + delivered, + forMessageID: messageID, + inDirectPeerAliases: [ephemeralPeer, stablePeer] + )) + + #expect(Set(publishedIDs) == Set([ephemeral, stable])) + #expect(Set(statusChangedIDs) == Set([ephemeral, stable])) + #expect(store.conversation(for: ephemeral).message(withID: messageID)?.deliveryStatus == delivered) + #expect(store.conversation(for: stable).message(withID: messageID)?.deliveryStatus == delivered) + #expect(store.conversation(for: other).message(withID: messageID)?.deliveryStatus == .sent) + #expect(store.conversation(for: .mesh).message(withID: messageID)?.deliveryStatus == .sent) + } + // MARK: - Invariant audit (field observability) /// A store exercised through every intent family: public + geohash + diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index dcf8deee..2adad6e8 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -65,6 +65,7 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { var peerFingerprints: [PeerID: String] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:] + var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:] var persistDeletedPrivateMediaResult = true var deferDeletedPrivateMediaPersistence = false private var pendingDeletedPrivateMediaCompletions: [ @@ -223,6 +224,12 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { privateMediaPolicies[peerID] ?? .encrypted } + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + privateMediaReceiptSessionGenerations[peerID] + } + func resolvePrivateMediaSendPolicy( to peerID: PeerID, completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index b102a69b..6a87cef4 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -700,6 +700,19 @@ private final class PerfDeliveryContext: ChatDeliveryContext { store.setDeliveryStatus(status, forMessageID: messageID) } + @discardableResult + func setDeliveryStatus( + _ status: DeliveryStatus, + forMessageID messageID: String, + inDirectPeerAliases peerIDs: Set + ) -> Bool { + store.setDeliveryStatus( + status, + forMessageID: messageID, + inDirectPeerAliases: peerIDs + ) + } + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { store.deliveryStatus(forMessageID: messageID) } diff --git a/bitchatTests/Services/BridgeCourierServiceTests.swift b/bitchatTests/Services/BridgeCourierServiceTests.swift index 6464c12b..edc7ab72 100644 --- a/bitchatTests/Services/BridgeCourierServiceTests.swift +++ b/bitchatTests/Services/BridgeCourierServiceTests.swift @@ -203,6 +203,134 @@ struct BridgeCourierServiceTests { #expect(confirmed.sealRequests.isEmpty) } + @Test func sameMessageIDIsScopedByRecipientAcrossRejectedActiveAndPersistedState() { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + let rejectedKey = Fixture.randomKey() + let firstKey = Fixture.randomKey() + let secondKey = Fixture.randomKey() + let thirdKey = Fixture.randomKey() + let messageID = "recipient-scoped-collision" + + let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL)) + fixture.sealResult = makeEnvelope( + recipientKey: rejectedKey, + ciphertext: Data( + repeating: 7, + count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1 + ) + ) + var rejectedResults: [Bool] = [] + fixture.service.depositDrop( + content: "rejected", + messageID: messageID, + recipientNoiseKey: rejectedKey + ) { rejectedResults.append($0) } + #expect(rejectedResults == [false]) + + fixture.sealResult = makeEnvelope(recipientKey: firstKey) + fixture.automaticPublishResult = nil + var firstResults: [Bool] = [] + var secondResults: [Bool] = [] + var duplicateFirstResults: [Bool] = [] + fixture.service.depositDrop( + content: "first", + messageID: messageID, + recipientNoiseKey: firstKey + ) { firstResults.append($0) } + fixture.service.depositDrop( + content: "second", + messageID: messageID, + recipientNoiseKey: secondKey + ) { secondResults.append($0) } + fixture.service.depositDrop( + content: "first duplicate", + messageID: messageID, + recipientNoiseKey: firstKey + ) { duplicateFirstResults.append($0) } + + #expect(fixture.publishedEvents.count == 2) + #expect(fixture.pendingPublishCompletions.count == 2) + #expect(duplicateFirstResults == [false]) + #expect(firstResults.isEmpty) + #expect(secondResults.isEmpty) + + fixture.resolveNextPublish(true) + fixture.resolveNextPublish(true) + #expect(firstResults == [true]) + #expect(secondResults == [true]) + fixture.service.flushDedupSnapshot() + + let relaunched = Fixture( + dedupStore: BridgeDropDedupStore(fileURL: fileURL) + ) + relaunched.sealResult = makeEnvelope(recipientKey: thirdKey) + var relaunchResults: [Bool] = [] + relaunched.service.depositDrop( + content: "first", + messageID: messageID, + recipientNoiseKey: firstKey + ) { relaunchResults.append($0) } + relaunched.service.depositDrop( + content: "second", + messageID: messageID, + recipientNoiseKey: secondKey + ) { relaunchResults.append($0) } + relaunched.service.depositDrop( + content: "third", + messageID: messageID, + recipientNoiseKey: thirdKey + ) { relaunchResults.append($0) } + + #expect(relaunchResults == [false, false, true]) + #expect(relaunched.sealRequests.count == 1) + #expect(relaunched.sealRequests.first?.key == thirdKey) + #expect(relaunched.publishedEvents.count == 1) + } + + @Test func legacyPublishedMessageIDIsWildcardUntilItsOriginalExpiry() { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + var date = Date() + let messageID = "legacy-wildcard" + let recipientKey = Fixture.randomKey() + let store = BridgeDropDedupStore(fileURL: fileURL) + store.save(BridgeDropDedupStore.Snapshot( + publishedDropKeys: [messageID: date], + seenDropEventIDs: [:] + )) + + let fixture = Fixture( + now: { date }, + dedupStore: BridgeDropDedupStore(fileURL: fileURL) + ) + fixture.sealResult = makeEnvelope(recipientKey: recipientKey) + var results: [Bool] = [] + fixture.service.depositDrop( + content: "legacy", + messageID: messageID, + recipientNoiseKey: recipientKey + ) { results.append($0) } + #expect(results == [false]) + #expect(fixture.sealRequests.isEmpty) + + date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1) + fixture.service.depositDrop( + content: "after expiry", + messageID: messageID, + recipientNoiseKey: recipientKey + ) { results.append($0) } + #expect(results == [false, true]) + #expect(fixture.publishedEvents.count == 1) + fixture.service.flushDedupSnapshot() + + let snapshot = BridgeDropDedupStore(fileURL: fileURL).load() + #expect(snapshot.publishedDropKeys[messageID] == nil) + #expect(snapshot.publishedDropKeys.count == 1) + } + @Test func panicWipeInvalidatesInFlightPublishCompletion() throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") @@ -297,8 +425,10 @@ struct BridgeCourierServiceTests { #expect(firstResults == [false]) // The evicted first drop is deposit-able again (slot released). + let sealCountBeforeRetry = fixture.sealRequests.count fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key) - #expect(fixture.service.pendingDrops.last?.dedupKey == firstID) + #expect(fixture.sealRequests.count == sealCountBeforeRetry + 1) + #expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops) } @Test func oversizeDropConsumesSlotInsteadOfChurning() { diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index ccda1a92..a34acd7e 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -188,6 +188,58 @@ struct MessageRouterTests { router.markDelivered("normal-handshake") } + @Test @MainActor + func authenticationRetry_scopesCollidingMessageIDsByPeer() async { + let securePeer = PeerID(str: "0000000000000025") + let pendingPeer = PeerID(str: "0000000000000026") + let transport = MockTransport() + transport.connectedPeers = [securePeer, pendingPeer] + transport.securePeers = [securePeer] + + let router = MessageRouter(transports: [transport]) + let promotedID = "collision-promoted" + let clearedID = "collision-cleared" + + // Pending B then secure A: an ID-global marker falsely promotes B. + router.sendPrivate( + "pending promoted", + to: pendingPeer, + recipientNickname: "Pending", + messageID: promotedID + ) + router.sendPrivate( + "secure promoted", + to: securePeer, + recipientNickname: "Secure", + messageID: promotedID + ) + + // Secure A then pending B: an ID-global removal falsely clears A. + router.sendPrivate( + "secure cleared", + to: securePeer, + recipientNickname: "Secure", + messageID: clearedID + ) + router.sendPrivate( + "pending cleared", + to: pendingPeer, + recipientNickname: "Pending", + messageID: clearedID + ) + + transport.resetRecordings() + transport.securePeers = [securePeer, pendingPeer] + + router.retrySecurePrivateMessagesAfterAuthentication(for: [pendingPeer]) + #expect(transport.sentPrivateMessages.isEmpty) + + router.retrySecurePrivateMessagesAfterAuthentication(for: [securePeer]) + #expect(transport.sentPrivateMessages.count == 2) + #expect(Set(transport.sentPrivateMessages.map(\.messageID)) == [promotedID, clearedID]) + #expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == securePeer }) + } + @Test @MainActor func authenticationRetry_doesNotDuplicateMessageRequeuedByBLEForHandshake() async { let peerID = PeerID(str: "0000000000000021") @@ -713,6 +765,52 @@ struct MessageRouterTests { #expect(carried == ["bridge-ack"]) } + @Test @MainActor + func bridgeDepositsScopeCollidingMessageIDsByRecipient() async { + let firstRecipient = PeerID(str: "00000000000000b1") + let secondRecipient = PeerID(str: "00000000000000b2") + let firstKey = Data(repeating: 0xB1, count: 32) + let secondKey = Data(repeating: 0xB2, count: 32) + let recipientKeys = [ + firstRecipient: firstKey, + secondRecipient: secondKey + ] + let router = MessageRouter( + transports: [MockTransport()], + courierDirectory: CourierDirectory( + noiseKey: { recipientKeys[$0] }, + isTrustedCourier: { _ in false } + ) + ) + var requestedKeys: [Data] = [] + var completions: [@MainActor (Bool) -> Void] = [] + router.bridgeCourierDeposit = { _, _, recipientKey, completion in + requestedKeys.append(recipientKey) + completions.append(completion) + } + var carriedPeers: [PeerID] = [] + router.onMessageCarried = { _, peerID in carriedPeers.append(peerID) } + + router.sendPrivate( + "First", + to: firstRecipient, + recipientNickname: "First", + messageID: "bridge-collision" + ) + router.sendPrivate( + "Second", + to: secondRecipient, + recipientNickname: "Second", + messageID: "bridge-collision" + ) + + #expect(completions.count == 2) + #expect(Set(requestedKeys) == [firstKey, secondKey]) + + completions.forEach { $0(true) } + #expect(Set(carriedPeers) == [firstRecipient, secondRecipient]) + } + // MARK: - Outbox persistence @Test @MainActor @@ -829,7 +927,10 @@ struct MessageRouterTests { transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) let router = MessageRouter(transports: [transport], outboxStore: restoredStore) - #expect(!router.markDelivered("shared-locked-id", for: [acknowledgedPeer])) + router.markDelivered( + "shared-locked-id", + from: [acknowledgedPeer] + ) protectedDataUnavailable = false restoredStore.retryDeferredLoad() await Task.yield()