diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 7631c495..eb174120 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -442,6 +442,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..80a20a36 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 0f4b5499..a070c7bf 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -34,6 +34,15 @@ struct CourierDirectory { final class MessageRouter { typealias QueuedMessage = MessageOutboxStore.QueuedMessage + private struct PeerMessageKey: Hashable { + // periphery:ignore - read only via the synthesized Hashable + // conformance (dictionary-key identity), which the indexer + // cannot attribute; see retain_codable_properties in .periphery.yml + // for the same class of false positive. + let peerID: PeerID + let messageID: String + } + private let transports: [Transport] private let now: () -> Date private let courierDirectory: CourierDirectory @@ -104,15 +113,25 @@ final class MessageRouter { } private var bridgeSweepTask: Task? - private var bridgeDepositsInFlight = Set() + private var bridgeDepositsInFlight = Set() private var outbox: [PeerID: [QueuedMessage]] = [:] + /// 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 private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours - // Bound resends of messages sent on a weak reachability signal that never - // get a delivery ack (e.g. peer on an old client that doesn't ack). + // Bound actual sends that never receive an ack, whether they used weak + // reachability or an apparently secure session that keeps being replaced. + // Connected pre-handshake sends are transport-owned and do not burn this + // cap because BLE queues/drains them itself. private static let maxSendAttempts = 8 // Redundant couriers improve delivery odds; receivers dedup by message ID. private static let maxCouriersPerMessage = 3 @@ -171,15 +190,28 @@ final class MessageRouter { // MARK: - Message Sending func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { + let message = QueuedMessage( + content: content, + nickname: recipientNickname, + messageID: messageID, + timestamp: now(), + sendAttempts: 1 + ) + if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { - // A live link that can complete an encrypted delivery is a - // strong delivery signal; trust it outright. + // Even an established Noise session can be stale after the peer + // restarts or replaces its app. Persist before handing the packet + // to the transport so a fast ack cannot race ahead of retention, + // then keep the copy until a delivery/read ack clears it. A + // replacement handshake will retry this same message ID, which + // receivers deduplicate. + enqueue(message, for: peerID) + 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 } - let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1) if let transport = connectedTransport(for: peerID) { // "Connected" without an established secure session is forgeable: // link bindings heal on signature-verified "direct" announces, but @@ -197,8 +229,9 @@ final class MessageRouter { // deposit is cleared on ack. Don't "optimize" the courier call // away. SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) enqueue(message, for: peerID) + secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) + transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) attemptCourierDeposit(messageID: messageID, for: peerID) return } @@ -209,8 +242,8 @@ final class MessageRouter { // Send now, but retain a copy until a delivery/read ack clears it; // receivers dedup resends by message ID. SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) enqueue(message, for: peerID) + transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) // "Reachable" without prompt delivery means the send only joined // a queue (Nostr with relays down): also hand a sealed copy to // any connected couriers rather than waiting for internet that @@ -333,11 +366,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 } @@ -356,8 +390,23 @@ final class MessageRouter { // MARK: - Outbox Management - /// A delivery or read ack confirms receipt; stop retaining the message. + /// A locally trusted delivery transition confirms receipt; stop retaining + /// every copy of the message. Authenticated remote receipts must use the + /// peer-bound overload below instead. func markDelivered(_ messageID: String) { + clearRetainedMessage(messageID) + } + + /// Stops retaining a message only for the authenticated conversation + /// aliases that produced the accepted receipt. A peer that learns another + /// conversation's message ID cannot use it to clear that conversation's + /// retry state. + func markDelivered(_ messageID: String, from peerIDs: Set) { + guard !peerIDs.isEmpty else { return } + _ = markDelivered(messageID, for: Array(peerIDs)) + } + + private func clearRetainedMessage(_ messageID: String) { var cleared = false for (peerID, queue) in outbox { let filtered = queue.filter { $0.messageID != messageID } @@ -365,6 +414,10 @@ final class MessageRouter { outbox[peerID] = filtered.isEmpty ? nil : filtered cleared = true } + 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. @@ -391,6 +444,9 @@ final class MessageRouter { outbox[peerID] = filtered.isEmpty ? nil : filtered cleared = true } + 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. outboxStore?.recordRemoval(messageID: messageID, for: peerIDs) @@ -424,7 +480,34 @@ final class MessageRouter { persistOutbox() } + @discardableResult + private func removeQueuedMessage(_ messageID: String, for peerID: PeerID) -> Bool { + guard let queue = outbox[peerID], + let index = queue.firstIndex(where: { $0.messageID == messageID }) else { + return false + } + var updated = queue + updated.remove(at: index) + outbox[peerID] = updated.isEmpty ? nil : updated + return true + } + + @discardableResult + private func incrementSendAttemptsIfQueued(_ messageID: String, for peerID: PeerID) -> Bool { + guard var queue = outbox[peerID], + let index = queue.firstIndex(where: { $0.messageID == messageID }) else { + // A synchronous delivery/read ack may have cleared the retained + // copy while `sendPrivateMessage` was on the stack. Never + // resurrect it from the flush snapshot. + return false + } + queue[index].sendAttempts += 1 + outbox[peerID] = queue + return true + } + private func dropMessage(_ messageID: String, for peerID: PeerID) { + secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) metrics?.record(.outboxDropped) onMessageDropped?(messageID, peerID) } @@ -468,6 +551,7 @@ final class MessageRouter { /// Panic wipe: forget queued mail on disk and in memory. func wipeOutbox() { outbox.removeAll() + secureTransmissions.removeAll() outboxStore?.wipe() } @@ -495,26 +579,160 @@ final class MessageRouter { } } + /// Retries only messages that the router previously transmitted through + /// an already-established secure session and that still await an ack. + /// + /// 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 `secureTransmissions` because BLE already queues + /// and drains them when authentication completes. + func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) { + typealias Candidate = ( + peerID: PeerID, + message: QueuedMessage, + aliasOrder: Int, + queueOrder: Int + ) + + var visitedPeerIDs = Set() + var retriedMessageIDs = Set() + var outboxChanged = false + let currentDate = now() + var candidates: [Candidate] = [] + + for (aliasOrder, peerID) in peerIDAliases.enumerated() { + guard visitedPeerIDs.insert(peerID).inserted else { continue } + guard let queued = outbox[peerID], !queued.isEmpty, + let transport = connectedTransport(for: peerID), + transport.canDeliverSecurely(to: peerID) else { + continue + } + + for (queueOrder, message) in queued.enumerated() { + let key = PeerMessageKey(peerID: peerID, messageID: message.messageID) + guard secureTransmissions.contains(key) else { continue } + candidates.append(( + peerID: peerID, + message: message, + aliasOrder: aliasOrder, + queueOrder: queueOrder + )) + } + } + + // Conversation migration can leave retained messages split across the + // ephemeral and stable outbox keys. Merge both queues into one + // chronological stream so callback alias order cannot send newer mail + // ahead of older mail. + candidates.sort { lhs, rhs in + if lhs.message.timestamp != rhs.message.timestamp { + return lhs.message.timestamp < rhs.message.timestamp + } + if lhs.aliasOrder != rhs.aliasOrder { + return lhs.aliasOrder < rhs.aliasOrder + } + if lhs.queueOrder != rhs.queueOrder { + return lhs.queueOrder < rhs.queueOrder + } + return lhs.message.messageID < rhs.message.messageID + } + + 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, + secureTransmissions.contains(key), + queuedMessage(message.messageID, for: peerID) != nil, + let transport = connectedTransport(for: peerID), + transport.canDeliverSecurely(to: peerID) else { + continue + } + + if currentDate.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { + if removeQueuedMessage(message.messageID, for: peerID) { + dropMessage(message.messageID, for: peerID) + outboxChanged = true + } + continue + } + + guard message.sendAttempts < Self.maxSendAttempts else { + SecureLogger.warning( + "πŸ“€ Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) secure attempts", + category: .session + ) + if removeQueuedMessage(message.messageID, for: peerID) { + dropMessage(message.messageID, for: peerID) + outboxChanged = true + } + continue + } + + SecureLogger.debug( + "Auth retry -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", + category: .session + ) + transport.sendPrivateMessage( + message.content, + to: peerID, + recipientNickname: message.nickname, + messageID: message.messageID + ) + metrics?.record(.outboxResent) + outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged + } + + if outboxChanged { + persistOutbox() + } + } + func flushOutbox(for peerID: PeerID) { guard let queued = outbox[peerID], !queued.isEmpty else { return } SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) let now = now() - var remaining: [QueuedMessage] = [] + var outboxChanged = false for message in queued { + // A synchronous ack from an earlier send in this flush may have + // removed an entry from the live outbox. The snapshot is only an + // iteration order; never use it to recreate removed messages. + guard queuedMessage(message.messageID, for: peerID) != nil else { continue } + // Skip expired messages (TTL exceeded) if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session) - dropMessage(message.messageID, for: peerID) + if removeQueuedMessage(message.messageID, for: peerID) { + dropMessage(message.messageID, for: peerID) + outboxChanged = true + } continue } if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { - // Live link with a secure session: send and stop retaining. + // A secure session is meaningful enough to retry, but not + // proof that this particular ciphertext reached the peer: the + // remote app may have restarted while our old session still + // looked established. Retain until an ack, while bounding + // actual secure transmissions for peers that never ack. + guard message.sendAttempts < Self.maxSendAttempts else { + SecureLogger.warning("πŸ“€ Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) + if removeQueuedMessage(message.messageID, for: peerID) { + dropMessage(message.messageID, for: peerID) + outboxChanged = true + } + continue + } SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) + 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 } else if let transport = connectedTransport(for: peerID) { // "Connected" without a secure session β€” possibly a stolen // binding from a replayed announce: send (a genuine link @@ -527,9 +745,11 @@ 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) + secureTransmissions.remove( + PeerMessageKey(peerID: peerID, messageID: message.messageID) + ) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) metrics?.record(.outboxResent) - remaining.append(message) } else if let transport = reachableTransport(for: peerID) { // Reachability without a connection is a freshness heuristic, // so the send can silently go nowhere: send but keep retaining @@ -537,26 +757,22 @@ final class MessageRouter { // that never ack. guard message.sendAttempts < Self.maxSendAttempts else { SecureLogger.warning("πŸ“€ Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) - dropMessage(message.messageID, for: peerID) + if removeQueuedMessage(message.messageID, for: peerID) { + dropMessage(message.messageID, for: peerID) + outboxChanged = true + } continue } SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) metrics?.record(.outboxResent) - var retained = message - retained.sendAttempts += 1 - remaining.append(retained) - } else { - remaining.append(message) + outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged } } - if remaining.isEmpty { - outbox.removeValue(forKey: peerID) - } else { - outbox[peerID] = remaining + if outboxChanged { + persistOutbox() } - persistOutbox() } func flushAllOutbox() { diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index de9bb347..1bf2b574 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: @@ -663,7 +663,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 @@ -922,7 +922,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 @@ -939,7 +939,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 20049439..dcaf95e6 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). @@ -33,6 +41,19 @@ protocol ChatDeliveryContext: AnyObject { func notifyUIChanged() /// Confirms receipt so the message router stops retaining the message for resend. func markMessageDelivered(_ messageID: String) + /// Peer-bound form for authenticated remote receipts. Only the supplied + /// conversation aliases may have retained state terminalized. This is the + /// router-side clear only: it is safe without a conversation lookup + /// because the router scopes removal to the acking peer's own queues. + func markMessageDelivered(_ messageID: String, from peerIDs: Set) + /// Releases the media reconnect retry for a private transfer. Unlike the + /// router clear above this is keyed only by the stable media message ID, + /// so callers must first bind the receipt to one of our outgoing + /// conversations for the acking peer. + func confirmPrivateMediaDelivery(_ messageID: String) + /// Returns true only when `messageID` is one of our outgoing messages in + /// at least one of the authenticated peer's direct-conversation aliases. + func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool } extension ChatViewModel: ChatDeliveryContext { @@ -41,6 +62,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) } @@ -59,6 +93,24 @@ extension ChatViewModel: ChatDeliveryContext { messageID: messageID ) } + + func markMessageDelivered(_ messageID: String, from peerIDs: Set) { + messageRouter.markDelivered(messageID, from: peerIDs) + } + + func confirmPrivateMediaDelivery(_ messageID: String) { + mediaTransferCoordinator.confirmPrivateMediaDelivery( + messageID: messageID + ) + } + + func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool { + peerIDs.contains { peerID in + privateMessages(for: peerID).contains { message in + message.id == messageID && message.senderPeerID == myPeerID + } + } + } } /// Thin mapper from delivery events (read receipts, transport delivery @@ -86,9 +138,10 @@ final class ChatDeliveryCoordinator { @MainActor func didReceiveReadReceipt(_ receipt: ReadReceipt) { - updateMessageDeliveryStatus( + updateAcknowledgedMessageDeliveryStatus( receipt.originalMessageID, - status: .read(by: receipt.readerNickname, at: receipt.timestamp) + status: .read(by: receipt.readerNickname, at: receipt.timestamp), + from: [receipt.readerID] ) } @@ -105,17 +158,59 @@ final class ChatDeliveryCoordinator { @MainActor @discardableResult func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { + guard context.setDeliveryStatus(status, forMessageID: messageID) else { + return false + } switch status { case .delivered, .read: - // Confirmed receipt β€” stop retaining the message for resend. + // Terminalize only after the store accepted the transition. context.markMessageDelivered(messageID) default: break } + context.notifyUIChanged() + return true + } - guard context.setDeliveryStatus(status, forMessageID: messageID) else { + /// Applies an authenticated remote delivery/read receipt. The durable + /// retry state is always released for the acking peer's own aliases + /// (`markMessageDelivered(_:from:)` is peer-scoped on the router side, so + /// a receipt can only terminalize messages queued for that peer). Only the + /// UI status transition and the media-retry release are gated on the + /// in-memory conversation holding the message as one of ours: after a + /// force-quit β†’ relaunch the durable outbox is restored + /// while the conversation may not be, and discarding the ack there would + /// re-send an already-delivered message on every flush/auth event until + /// the attempt cap marks it failed. Mirrors the Nostr path + /// (`ChatPrivateConversationCoordinator.handleDelivered`), which clears + /// retained state unconditionally. + @MainActor + @discardableResult + func updateAcknowledgedMessageDeliveryStatus( + _ messageID: String, + status: DeliveryStatus, + from peerIDAliases: Set + ) -> Bool { + switch status { + case .delivered, .read: + break + default: return false } + guard !peerIDAliases.isEmpty else { return false } + context.markMessageDelivered(messageID, from: peerIDAliases) + guard context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases), + context.setDeliveryStatus( + status, + forMessageID: messageID, + inDirectPeerAliases: peerIDAliases + ) else { + return false + } + // The receipt is now bound to one of our outgoing conversations for + // the acking peer; only then release the media reconnect retry, whose + // stable message ID is not peer-scoped. + context.confirmPrivateMediaDelivery(messageID) context.notifyUIChanged() return true } diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 72641812..ee2d8b6c 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -62,10 +62,15 @@ protocol ChatTransportEventContext: AnyObject { func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) // MARK: Delivery status - /// Applies the status to every known location of the message. - /// Returns `false` when no message with that ID was updated. + /// Applies an authenticated receipt to the message only when it belongs + /// to the supplied peer conversation aliases. Returns `false` for an + /// unknown ID, wrong peer, or rejected status transition. @discardableResult - func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool + func applyAcknowledgedMessageDeliveryStatus( + _ messageID: String, + status: DeliveryStatus, + from peerIDAliases: Set + ) -> Bool func deliveryStatus(for messageID: String) -> DeliveryStatus? // MARK: Verification payloads @@ -122,8 +127,16 @@ extension ChatViewModel: ChatTransportEventContext { } @discardableResult - func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { - deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status) + func applyAcknowledgedMessageDeliveryStatus( + _ messageID: String, + status: DeliveryStatus, + from peerIDAliases: Set + ) -> Bool { + deliveryCoordinator.updateAcknowledgedMessageDeliveryStatus( + messageID, + status: status, + from: peerIDAliases + ) } func deliveryStatus(for messageID: String) -> DeliveryStatus? { @@ -446,9 +459,10 @@ private extension ChatTransportEventCoordinator { guard let messageID = String(data: payload, encoding: .utf8) else { return } let name = deliveryStatusName(for: peerID, in: context) - let didUpdate = context.applyMessageDeliveryStatus( + let didUpdate = context.applyAcknowledgedMessageDeliveryStatus( messageID, - status: .delivered(to: name, at: Date()) + status: .delivered(to: name, at: Date()), + from: receiptPeerAliases(for: peerID, in: context) ) if !didUpdate { @@ -463,9 +477,10 @@ private extension ChatTransportEventCoordinator { guard let messageID = String(data: payload, encoding: .utf8) else { return } let name = deliveryStatusName(for: peerID, in: context) - let didUpdate = context.applyMessageDeliveryStatus( + let didUpdate = context.applyAcknowledgedMessageDeliveryStatus( messageID, - status: .read(by: name, at: Date()) + status: .read(by: name, at: Date()), + from: receiptPeerAliases(for: peerID, in: context) ) if !didUpdate { @@ -503,4 +518,21 @@ private extension ChatTransportEventCoordinator { func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String { context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID) } + + @MainActor + func receiptPeerAliases( + for peerID: PeerID, + in context: any ChatTransportEventContext + ) -> Set { + var aliases: Set = [peerID] + // The active authenticated Noise key is authoritative. A cached + // ephemeralβ†’stable mapping can predate an identity replacement, so + // use it only when the live session cannot provide its static key. + if let keyData = context.noiseSessionPublicKeyData(for: peerID) { + aliases.insert(PeerID(hexData: keyData)) + } else if let stablePeerID = context.cachedStablePeerID(for: peerID) { + aliases.insert(stablePeerID) + } + return aliases + } } diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index e767a5e9..2de291ff 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -59,6 +59,10 @@ protocol ChatVerificationContext: AnyObject { func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool func triggerHandshake(with peerID: PeerID) func privateMediaPeerDidAuthenticate(_ peerID: PeerID) + /// Retries only private messages previously transmitted through a secure + /// session and still pending an ack. Both ephemeral and stable aliases + /// are supplied because either can own the outbox entry. + func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -121,6 +125,10 @@ extension ChatViewModel: ChatVerificationContext { mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort()) } + func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) { + messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases) + } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) } @@ -216,16 +224,37 @@ final class ChatVerificationCoordinator { self.context.invalidateEncryptionCache(for: peerID) - if self.context.cachedStablePeerID(for: peerID) == nil, - let keyData = self.context.noiseSessionPublicKeyData(for: peerID) { + var authenticatedStablePeerID: PeerID? + if let keyData = self.context.noiseSessionPublicKeyData(for: peerID) { let stablePeerID = PeerID(hexData: keyData) - self.context.cacheStablePeerID(stablePeerID, for: peerID) + authenticatedStablePeerID = stablePeerID + if self.context.cachedStablePeerID(for: peerID) != stablePeerID { + // The freshly authenticated Noise key outranks a + // stale announce-derived alias. + self.context.cacheStablePeerID(stablePeerID, for: peerID) + } SecureLogger.debug( "πŸ—ΊοΈ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…", category: .session ) } + // A locally established session may have belonged to the + // peer's previous app process. The first ciphertext sent + // into that stale session is retained by MessageRouter; + // retry it now that this newly authenticated/replacement + // session can actually decrypt it. + var peerIDAliases = [peerID] + if let stablePeerID = authenticatedStablePeerID + ?? self.context.cachedStablePeerID(for: peerID), + stablePeerID != peerID { + // Conversations can migrate from the ephemeral BLE ID + // to the authenticated Noise-key ID. Retry both aliases + // because either may own the retained outbox entry. + peerIDAliases.append(stablePeerID) + } + self.context.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases) + if var pending = self.pendingQRVerifications[peerID], pending.sent == false { self.context.sendVerifyChallenge( to: peerID, diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 31bea94a..aa02b255 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -133,11 +133,17 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { // Delivery status var applyMessageDeliveryStatusResult = true var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:] - private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = [] + private(set) var appliedDeliveryStatuses: [ + (messageID: String, status: DeliveryStatus, peerIDAliases: Set) + ] = [] @discardableResult - func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { - appliedDeliveryStatuses.append((messageID, status)) + func applyAcknowledgedMessageDeliveryStatus( + _ messageID: String, + status: DeliveryStatus, + from peerIDAliases: Set + ) -> Bool { + appliedDeliveryStatuses.append((messageID, status, peerIDAliases)) return applyMessageDeliveryStatusResult } @@ -417,6 +423,10 @@ struct ChatTransportEventCoordinatorContextTests { let peerID = PeerID(str: "99aabbccddeeff00") let noiseKey = Data(repeating: 0x44, count: 32) context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") + let stablePeerID = PeerID(hexData: noiseKey) + let staleStablePeerID = PeerID(hexData: Data(repeating: 0x55, count: 32)) + context.cacheStablePeerID(staleStablePeerID, for: peerID) + context.noiseSessionKeysByPeerID[peerID] = noiseKey // Inbound private message: decoded, handled, and delivery-acked. let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there") @@ -438,6 +448,8 @@ struct ChatTransportEventCoordinatorContextTests { await drainMainActorTasks() #expect(context.appliedDeliveryStatuses.count == 2) #expect(context.appliedDeliveryStatuses[0].messageID == "m-1") + #expect(context.appliedDeliveryStatuses[0].peerIDAliases == [peerID, stablePeerID]) + #expect(!context.appliedDeliveryStatuses[0].peerIDAliases.contains(staleStablePeerID)) if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status { #expect(to == "alice") } else { diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift index 59d04b9a..83cc7296 100644 --- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -98,6 +98,7 @@ private final class MockChatVerificationContext: ChatVerificationContext { private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)? private(set) var triggeredHandshakes: [PeerID] = [] private(set) var privateMediaAuthenticatedPeers: [PeerID] = [] + private(set) var securePrivateMessageRetryAliases: [[PeerID]] = [] private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] @@ -118,6 +119,10 @@ private final class MockChatVerificationContext: ChatVerificationContext { privateMediaAuthenticatedPeers.append(peerID) } + func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) { + securePrivateMessageRetryAliases.append(peerIDAliases) + } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { sentChallenges.append((peerID, noiseKeyHex, nonceA)) } @@ -269,6 +274,10 @@ struct ChatVerificationCoordinatorContextTests { let peerID = PeerID(str: "1122334455667788") let noiseKey = Data(repeating: 0x33, count: 32) context.noiseSessionKeysByPeerID[peerID] = noiseKey + context.cacheStablePeerID( + PeerID(hexData: Data(repeating: 0x44, count: 32)), + for: peerID + ) context.verifiedFingerprints = ["fp-verified"] coordinator.setupNoiseCallbacks() @@ -279,9 +288,11 @@ struct ChatVerificationCoordinatorContextTests { callbacks?.onPeerAuthenticated(peerID, "fp-verified") await waitForMainQueue() #expect(context.encryptionStatuses[peerID] == .noiseVerified) - #expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey)) + let stablePeerID = PeerID(hexData: noiseKey) + #expect(context.stablePeerIDCache[peerID] == stablePeerID) #expect(context.invalidatedEncryptionCachePeers.contains(peerID)) #expect(context.privateMediaAuthenticatedPeers == [peerID]) + #expect(context.securePrivateMessageRetryAliases == [[peerID, stablePeerID]]) // Handshake required -> handshaking status. callbacks?.onHandshakeRequired(peerID) diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index fca755f2..15a1edea 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -13,8 +13,11 @@ import BitFoundation // MARK: - Test Helpers @MainActor -private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { - let keychain = MockKeychain() +private func makeTestableViewModel( + keychain injectedKeychain: MockKeychain? = nil, + outboxStore: MessageOutboxStore? = nil +) -> (viewModel: ChatViewModel, transport: MockTransport) { + let keychain = injectedKeychain ?? MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) @@ -24,7 +27,8 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo keychain: keychain, idBridge: idBridge, identityManager: identityManager, - transport: transport + transport: transport, + outboxStore: outboxStore ) return (viewModel, transport) @@ -298,6 +302,137 @@ struct ChatViewModelDeliveryStatusTests { }()) } + @Test @MainActor + func authenticatedNoiseAckCannotClearAnotherPeersRetryState() async { + let (viewModel, transport) = makeTestableViewModel() + let intendedPeer = PeerID(str: "0102030405060708") + let otherPeer = PeerID(str: "1112131415161718") + let messageID = "noise-peer-bound-ack" + + viewModel.seedPrivateChat( + [ + BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Keep retrying", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Intended", + senderPeerID: viewModel.myPeerID, + deliveryStatus: .sent + ) + ], + for: intendedPeer + ) + transport.reachablePeers.insert(intendedPeer) + viewModel.messageRouter.sendPrivate( + "Keep retrying", + to: intendedPeer, + recipientNickname: "Intended", + messageID: messageID + ) + #expect(transport.sentPrivateMessages.count == 1) + + // This models a decrypted Noise receipt: the transport-authenticated + // peer is authoritative, not the attacker-controlled message ID. + viewModel.didReceiveNoisePayload( + from: otherPeer, + type: .delivered, + payload: Data(messageID.utf8), + timestamp: Date() + ) + for _ in 0..<10 { await Task.yield() } + + #expect(isSent(viewModel.conversations.deliveryStatus(forMessageID: messageID))) + viewModel.messageRouter.flushOutbox(for: intendedPeer) + #expect(transport.sentPrivateMessages.count == 2) + + viewModel.didReceiveNoisePayload( + from: intendedPeer, + type: .delivered, + payload: Data(messageID.utf8), + timestamp: Date() + ) + for _ in 0..<10 { await Task.yield() } + + #expect(isDelivered(viewModel.conversations.deliveryStatus(forMessageID: messageID))) + viewModel.messageRouter.flushOutbox(for: intendedPeer) + #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() @@ -323,6 +458,65 @@ struct ChatViewModelDeliveryStatusTests { #expect(viewModel.sentReadReceipts == ["keep-receipt"]) } + // MARK: - Relaunch-Restored Outbox Tests + + @Test @MainActor + func deliveryAckAfterRelaunchClearsRestoredOutboxWithoutConversation() async { + // Force-quit β†’ relaunch: the durable outbox restores the retained DM, + // but the in-memory conversation store starts empty. The delivery ack + // must still clear the router's retained copy β€” gating it on the + // conversation lookup would leave the entry re-sending on every + // flush/auth event until the attempt cap marks it failed despite + // delivery. + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("relaunch-ack-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0102030405060708") + let messageID = "relaunch-retained-dm" + + // Pre-quit state: one retained private message on disk. + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ + peerID: [MessageOutboxStore.QueuedMessage( + content: "Sent before force-quit", + nickname: "Peer", + messageID: messageID, + timestamp: Date() + )] + ]) + + // Relaunch: fresh view model over the same durable store. + let (viewModel, transport) = makeTestableViewModel( + keychain: keychain, + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + #expect(viewModel.privateChats[peerID] == nil) + + // The peer's delivery ack arrives with no conversation in the store. + // No UI transition is possible (and none must crash), but the durable + // retry state has to clear. + let didUpdate = viewModel.deliveryCoordinator + .updateAcknowledgedMessageDeliveryStatus( + messageID, + status: .delivered(to: "Peer", at: Date()), + from: [peerID] + ) + #expect(!didUpdate) + #expect(viewModel.privateChats[peerID] == nil) + + // Neither a flush nor a replacement-handshake auth event may re-send + // the already-delivered message. + transport.connectedPeers.insert(peerID) + transport.securePeers = [peerID] + viewModel.messageRouter.flushOutbox(for: peerID) + viewModel.messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) + #expect(transport.sentPrivateMessages.isEmpty) + + // The clear reached the durable snapshot: the next relaunch restores + // nothing. + #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) + } + // MARK: - Public Timeline Status Tests @Test @MainActor @@ -577,12 +771,27 @@ private final class MockChatDeliveryContext: ChatDeliveryContext { var isStartupPhase = false private(set) var notifyUIChangedCount = 0 private(set) var markedDeliveredMessageIDs: [String] = [] + private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set)] = [] + private(set) var confirmedMediaMessageIDs: [String] = [] @discardableResult func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { 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) } @@ -604,6 +813,28 @@ private final class MockChatDeliveryContext: ChatDeliveryContext { func markMessageDelivered(_ messageID: String) { markedDeliveredMessageIDs.append(messageID) } + + func markMessageDelivered(_ messageID: String, from peerIDs: Set) { + peerBoundDeliveredMessages.append((messageID, peerIDs)) + } + + func confirmPrivateMediaDelivery(_ messageID: String) { + confirmedMediaMessageIDs.append(messageID) + } + + func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool { + peerIDs.contains { peerID in + contextMessages(for: peerID).contains { message in + message.id == messageID && message.senderPeerID == localPeerID + } + } + } + + private let localPeerID = PeerID(str: "aabbccddeeff0011") + + private func contextMessages(for peerID: PeerID) -> [BitchatMessage] { + store.conversationsByID[.directPeer(peerID)]?.messages ?? [] + } } @MainActor @@ -685,7 +916,163 @@ struct ChatDeliveryCoordinatorContextTests { #expect(isRead(coordinator.deliveryStatus(for: messageID))) #expect(context.notifyUIChangedCount == 1) - #expect(context.markedDeliveredMessageIDs == [messageID]) + #expect(context.markedDeliveredMessageIDs.isEmpty) + #expect(context.peerBoundDeliveredMessages.count == 1) + #expect(context.peerBoundDeliveredMessages[0].messageID == messageID) + #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() + let coordinator = ChatDeliveryCoordinator(context: context) + let intendedPeer = PeerID(str: "0102030405060708") + let otherPeer = PeerID(str: "1112131415161718") + let messageID = "wrong-peer-receipt" + context.store.append( + makePrivateMessage(id: messageID, status: .sent), + to: .directPeer(intendedPeer) + ) + + coordinator.didReceiveReadReceipt( + ReadReceipt( + originalMessageID: messageID, + readerID: otherPeer, + readerNickname: "Other" + ) + ) + + #expect(isSent(coordinator.deliveryStatus(for: messageID))) + // The router-side clear runs, but bound only to the wrong peer's own + // aliases β€” a scoped no-op that cannot touch the intended peer's + // retained copy. Status, media retry, and UI stay untouched. + #expect(context.peerBoundDeliveredMessages.count == 1) + #expect(context.peerBoundDeliveredMessages[0].messageID == messageID) + #expect(context.peerBoundDeliveredMessages[0].peerIDs == [otherPeer]) + #expect(context.markedDeliveredMessageIDs.isEmpty) + #expect(context.confirmedMediaMessageIDs.isEmpty) + #expect(context.notifyUIChangedCount == 0) + } + + @Test @MainActor + func rejectedStaleReceiptDoesNotDowngradeStatusOrNotify() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + let peerID = PeerID(str: "0102030405060708") + let messageID = "stale-receipt" + context.store.append( + makePrivateMessage( + id: messageID, + status: .read(by: "Peer", at: Date()) + ), + to: .directPeer(peerID) + ) + + let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus( + messageID, + status: .delivered(to: "Peer", at: Date()), + from: [peerID] + ) + + #expect(!didUpdate) + #expect(isRead(coordinator.deliveryStatus(for: messageID))) + // The peer-scoped router clear re-runs (idempotent: the earlier read + // receipt already emptied this peer's retained copy), but the stale + // delivered ack must not downgrade the read status, release media, or + // notify the UI. + #expect(context.peerBoundDeliveredMessages.count == 1) + #expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID]) + #expect(context.markedDeliveredMessageIDs.isEmpty) + #expect(context.confirmedMediaMessageIDs.isEmpty) + #expect(context.notifyUIChangedCount == 0) } @Test @MainActor diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift index fe200353..cc1e6e09 100644 --- a/bitchatTests/ConversationStoreTests.swift +++ b/bitchatTests/ConversationStoreTests.swift @@ -1179,6 +1179,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 b98bee5f..4620408f 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -65,11 +65,15 @@ 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: [ @MainActor (Bool) -> Void ] = [] + /// Optional synchronous hook for send-ordering tests (for example, an ack + /// arriving before the router's send call returns). + var onSendPrivateMessage: (@MainActor (_ messageID: String) -> Void)? private let mockKeychain = MockKeychain() // MARK: - Transport Protocol Implementation @@ -174,6 +178,11 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { sentPrivateMessages.append((content, peerID, recipientNickname, messageID)) + if let onSendPrivateMessage { + MainActor.assumeIsolated { + onSendPrivateMessage(messageID) + } + } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { @@ -215,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 048e71a6..453a4a9a 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -749,12 +749,30 @@ private final class PerfDeliveryContext: ChatDeliveryContext { func notifyUIChanged() {} func markMessageDelivered(_ messageID: String) {} + func markMessageDelivered(_ messageID: String, from peerIDs: Set) {} + func confirmPrivateMediaDelivery(_ messageID: String) {} + func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool { + true + } @discardableResult func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { 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 a45b0bcd..a34acd7e 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -79,18 +79,231 @@ struct MessageRouterTests { } @Test @MainActor - func sendPrivate_connectedSendIsNotRetained() async { + func peerBoundDeliveryAckCannotClearAnotherPeersRetainedMessage() async { + let intendedPeer = PeerID(str: "0000000000000023") + let otherPeer = PeerID(str: "0000000000000024") + let transport = MockTransport() + transport.reachablePeers = [intendedPeer, otherPeer] + + let router = MessageRouter(transports: [transport]) + router.sendPrivate( + "Secret", + to: intendedPeer, + recipientNickname: "Intended", + messageID: "peer-bound-ack" + ) + #expect(transport.sentPrivateMessages.count == 1) + + // Even a receipt arriving over another authenticated conversation + // must not terminalize the intended peer's retained retry. + router.markDelivered("peer-bound-ack", from: [otherPeer]) + router.flushOutbox(for: intendedPeer) + #expect(transport.sentPrivateMessages.count == 2) + + router.markDelivered("peer-bound-ack", from: [intendedPeer]) + router.flushOutbox(for: intendedPeer) + #expect(transport.sentPrivateMessages.count == 2) + } + + @Test @MainActor + func sendPrivate_connectedSecureSendRetainsUntilDeliveryAck() async { let peerID = PeerID(str: "0000000000000007") let transport = MockTransport() transport.connectedPeers.insert(peerID) transport.reachablePeers.insert(peerID) + transport.securePeers = [peerID] let router = MessageRouter(transports: [transport]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7") #expect(transport.sentPrivateMessages.count == 1) - router.flushOutbox(for: peerID) + // A newly authenticated/replacement session retries the retained + // message instead of losing the first ciphertext to a stale session. + router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) + #expect(transport.sentPrivateMessages.count == 2) + + router.markDelivered("m7") + router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) + #expect(transport.sentPrivateMessages.count == 2) + } + + @Test @MainActor + func authenticationRetry_matchesStableOutboxAliasWithoutDoubleSending() async { + let shortPeerID = PeerID(str: "0000000000000019") + let stablePeerID = PeerID(hexData: Data(repeating: 0x19, count: 32)) + let transport = MockTransport() + transport.connectedPeers.insert(stablePeerID) + transport.securePeers = [stablePeerID] + + let router = MessageRouter(transports: [transport]) + router.sendPrivate("Hello", to: stablePeerID, recipientNickname: "Peer", messageID: "alias-retry") + + router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID, stablePeerID]) + + #expect(transport.sentPrivateMessages.map(\.messageID) == ["alias-retry", "alias-retry"]) + #expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == stablePeerID }) + } + + @Test @MainActor + func authenticationRetry_preservesFIFOAcrossSplitAliases() async { + let shortPeerID = PeerID(str: "0000000000000022") + let stablePeerID = PeerID(hexData: Data(repeating: 0x22, count: 32)) + let transport = MockTransport() + transport.connectedPeers = [shortPeerID, stablePeerID] + transport.securePeers = [shortPeerID, stablePeerID] + let clock = MutableTestClock() + let router = MessageRouter(transports: [transport], now: { clock.now }) + + // The older message lives under the stable key, even though the auth + // callback supplies the ephemeral alias first. + router.sendPrivate("Older", to: stablePeerID, recipientNickname: "Peer", messageID: "fifo-old") + clock.now = clock.now.addingTimeInterval(1) + router.sendPrivate("Newer", to: shortPeerID, recipientNickname: "Peer", messageID: "fifo-new") + transport.resetRecordings() + + router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID]) + + #expect(transport.sentPrivateMessages.map(\.messageID) == ["fifo-old", "fifo-new"]) + #expect(transport.sentPrivateMessages.map(\.peerID) == [stablePeerID, shortPeerID]) + } + + @Test @MainActor + func authenticationRetry_doesNotDuplicateNormalPendingHandshakeSend() async { + let peerID = PeerID(str: "0000000000000020") + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.securePeers = [] + + let router = MessageRouter(transports: [transport]) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "normal-handshake") #expect(transport.sentPrivateMessages.count == 1) + + // BLE owns this pending send and drains it after authentication. Once + // the session becomes secure, the router's targeted auth retry must + // stay silent instead of producing a second copy. + transport.securePeers = [peerID] + router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) + #expect(transport.sentPrivateMessages.count == 1) + + 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") + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.securePeers = [peerID] + + let router = MessageRouter(transports: [transport]) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "session-lost") + #expect(transport.sentPrivateMessages.count == 1) + + // The session disappears before a normal outbox flush. That send is + // now owned by BLE's pending-handshake queue, so it clears the + // router's secure-auth retry marker. + transport.securePeers = [] + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 2) + + transport.securePeers = [peerID] + router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) + #expect(transport.sentPrivateMessages.count == 2) + + router.markDelivered("session-lost") + } + + @Test @MainActor + func sendPrivate_fastDeliveryAckCannotRaceAheadOfRetention() async { + let peerID = PeerID(str: "0000000000000017") + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.securePeers = [peerID] + + let router = MessageRouter(transports: [transport]) + transport.onSendPrivateMessage = { messageID in + router.markDelivered(messageID) + } + + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "fast-ack") + #expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"]) + + transport.onSendPrivateMessage = nil + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"]) + } + + @Test @MainActor + func flushOutbox_synchronousAckDoesNotResurrectSnapshotEntry() async { + let peerID = PeerID(str: "0000000000000018") + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.securePeers = [peerID] + + let router = MessageRouter(transports: [transport]) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "flush-fast-ack") + transport.onSendPrivateMessage = { messageID in + router.markDelivered(messageID) + } + + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 2) + + transport.onSendPrivateMessage = nil + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 2) } @Test @MainActor @@ -392,10 +605,31 @@ struct MessageRouterTests { #expect(transport.sentPrivateMessages.count == 11) } - /// With an established secure session the connected fast-path stays - /// exactly as before: trusted outright, no retained copy, no courier. @Test @MainActor - func sendPrivate_connectedWithSecureSessionIsTrustedOutright() async { + func authenticationRetry_capsActualSecureTransmissions() async { + let peerID = PeerID(str: "00000000000000ad") + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.securePeers = [peerID] + + let router = MessageRouter(transports: [transport]) + var dropped: [String] = [] + router.onMessageDropped = { messageID, _ in dropped.append(messageID) } + + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "secure-retry") + for _ in 0..<10 { + router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) + } + + #expect(dropped == ["secure-retry"]) + #expect(transport.sentPrivateMessages.count == 8) + } + + /// With an established secure session the connected fast-path sends + /// immediately and never leaks to couriers, but retains a local encrypted + /// outbox copy until the peer confirms receipt. + @Test @MainActor + func sendPrivate_connectedWithSecureSessionRetainsLocallyWithoutCourier() async { let peerID = PeerID(str: "00000000000000ab") let peerKey = Data(repeating: 0xAB, count: 32) let courier = PeerID(str: "00000000000000cc") @@ -415,7 +649,11 @@ struct MessageRouterTests { #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"]) #expect(transport.sentCourierMessages.isEmpty) router.flushOutbox(for: peerID) - #expect(transport.sentPrivateMessages.count == 1) + #expect(transport.sentPrivateMessages.count == 2) + #expect(transport.sentCourierMessages.isEmpty) + router.markDelivered("cs2") + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 2) } @Test @MainActor @@ -527,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 @@ -643,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() @@ -853,12 +1140,14 @@ struct MessageRouterTests { protectedDataUnavailable = false restoredStore.retryDeferredLoad() // captures unseen durable + known wake - // Secure direct flush removes the wake message before recovery's - // MainActor merge. It must remain removed, while the unseen durable - // message still arrives through the pending recovery claim. + // A secure direct retry followed by its delivery ack removes the wake + // message before recovery's MainActor merge. It must remain removed, + // while the unseen durable message still arrives through the pending + // recovery claim. transport.connectedPeers.insert(peerID) transport.securePeers = [peerID] router.flushOutbox(for: peerID) + router.markDelivered("recovery-gap-known") await Task.yield() await Task.yield() @@ -929,7 +1218,8 @@ struct MessageRouterTests { restoredStore.retryDeferredLoad() // persists D+W and queues recovery transport.connectedPeers.insert(peerID) transport.securePeers = [peerID] - router.flushOutbox(for: peerID) // removes W before queued callback + router.flushOutbox(for: peerID) + router.markDelivered("recovery-write-failure-known") // removes W before queued callback // The gap save may remove W, but it must leave unseen D durable until // MessageRouter receives the pending recovery callback. diff --git a/bitchatTests/VoiceRecorderTests.swift b/bitchatTests/VoiceRecorderTests.swift index 116e73fe..c9b6839e 100644 --- a/bitchatTests/VoiceRecorderTests.swift +++ b/bitchatTests/VoiceRecorderTests.swift @@ -13,17 +13,16 @@ import Testing private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable { private let lock = NSLock() private let activationGate = DispatchSemaphore(value: 0) + private let activationBeganGate = DispatchSemaphore(value: 0) private let shouldGateFirstActivation: Bool private var gatedFirstActivation = false private var _activationCalls: [Bool] = [] - private var _activationBegan = false init(gateFirstActivation: Bool = false) { self.shouldGateFirstActivation = gateFirstActivation } var activationCalls: [Bool] { lock.withLock { _activationCalls } } - var activationBegan: Bool { lock.withLock { _activationBegan } } func setCategory(_ category: AudioSessionCoordinator.Category) throws {} @@ -32,14 +31,28 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab _activationCalls.append(active) guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false } gatedFirstActivation = true - _activationBegan = true return true } if shouldWait { + activationBeganGate.signal() activationGate.wait() } } + func waitUntilActivationBegan( + timeout: DispatchTimeInterval = .seconds(5) + ) async -> Bool { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume( + returning: self.activationBeganGate.wait( + timeout: DispatchTime.now() + timeout + ) == .success + ) + } + } + } + func resumeActivation() { activationGate.signal() } @@ -142,26 +155,38 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating { /// this remains deterministic when the full test suite saturates the executor. private final class VoiceRecorderPaddingGate: @unchecked Sendable { private let lock = NSLock() - private var _entered = false + private let enteredGate = DispatchSemaphore(value: 0) private var isOpen = false private var openWaiters: [CheckedContinuation] = [] - var entered: Bool { lock.withLock { _entered } } - func wait() async { await withCheckedContinuation { continuation in let resumeImmediately = lock.withLock { () -> Bool in - _entered = true guard !isOpen else { return true } openWaiters.append(continuation) return false } + enteredGate.signal() if resumeImmediately { continuation.resume() } } } + func waitUntilEntered( + timeout: DispatchTimeInterval = .seconds(5) + ) async -> Bool { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume( + returning: self.enteredGate.wait( + timeout: DispatchTime.now() + timeout + ) == .success + ) + } + } + } + func open() { let waiters = lock.withLock { () -> [CheckedContinuation] in isOpen = true @@ -181,18 +206,6 @@ struct VoiceRecorderTests { return url } - private func waitUntil( - _ condition: () -> Bool, - sourceLocation: SourceLocation = #_sourceLocation - ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) - while !condition(), ContinuousClock.now < deadline { - await Task.yield() - try? await Task.sleep(nanoseconds: 1_000_000) - } - #expect(condition(), sourceLocation: sourceLocation) - } - @Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws { let directory = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } @@ -210,7 +223,7 @@ struct VoiceRecorderTests { let owner = VoiceRecorder.RecordingOwner() let startTask = Task { try await voiceRecorder.startRecording(owner: owner) } - await waitUntil { session.activationBegan } + #expect(await session.waitUntilActivationBegan()) await voiceRecorder.cancelRecording(owner: owner) session.resumeActivation() @@ -308,7 +321,7 @@ struct VoiceRecorderTests { try await finishingHold.start() let firstURL = try #require(factory.urls.first) let finishTask = Task { await finishingHold.finish() } - await waitUntil { paddingGate.entered } + #expect(await paddingGate.waitUntilEntered()) await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) { try await rejectedHold.start()