From 874924caf8956adc55dcecf7cb8f0622d7483a83 Mon Sep 17 00:00:00 2001 From: ecgang Date: Mon, 13 Jul 2026 11:56:08 -0700 Subject: [PATCH 1/8] Courier spray: restore the budget when a taker deterministically declines Upstream's send-gated `transferSprayCopies` already closes the link-drop-before-send loss case: it only decrements copies, inserts the courier into `sprayedTo`, and persists after the local BLE send is accepted. But courier writes are `.withoutResponse`, so send-accepted is not taker-received: the spend still stands when the taker deterministically refuses the deposit (bad/missing signature, unknown peer, policy nil, quota full, invalid), and those copies leave the network unretryable. This adds the orthogonal second half on top of the send-gated commit: a capability-gated, signed receipt that gives the budget back only when the taker deterministically declines. - Wire (additive, ignored by old peers): courierSprayAck 0x2A ("stored it", retire the offer early) and courierSprayDecline 0x2B ("deterministically refused" -> restore). New capability bit PeerCapabilities.courierAck (1 << 8), advertised in localSupported; givers branch on the taker's advertised capability, so old takers keep the unchanged path. - Receipt payload is the envelope's ciphertext hash (existing dedup key), so no CourierEnvelope wire change. - Giver: offerSprayCopies is transferSprayCopies verbatim plus one in-memory pendingSprayOffers restore record in the commit block. Signed decline -> cancelSpray (restore, keep courier in sprayedTo); signed ack or timeout -> confirmSpray (spend stands). Restore map is memory-only and cleared by wipe(), so a restart can never inflate copies. - Receipts are recipient-bound and authenticated exactly like a deposit: packet.recipientID must equal our peer id, the claimed sender must be the direct ingress peer, and the signature must verify against that peer's registry-bound signing key. This blocks a valid signed receipt addressed to a different giver from mutating our pending offer. Tests: CourierStoreTests (offer/confirm/cancel conservation, ack-loss timeout and restart cannot re-inflate, replayed decline cannot inflate) and CourierEndToEndTests recipient-binding regressions (mis-addressed decline does not restore; correctly-addressed decline does; stray ack does not clear the restore window). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Protocols/PeerCapabilities+Local.swift | 3 +- .../BLE/BLEOutboundPacketPolicy.swift | 2 +- bitchat/Services/BLE/BLEService.swift | 232 ++++++- bitchat/Services/Courier/CourierStore.swift | 183 ++++++ bitchat/Services/TransportConfig.swift | 5 + bitchat/Sync/SyncTypeFlags.swift | 3 + bitchatTests/CourierStoreTests.swift | 573 ++++++++++++++++++ .../EndToEnd/CourierEndToEndTests.swift | 116 ++++ .../Sources/BitFoundation/MessageType.swift | 8 + .../BitFoundation/PeerCapabilities.swift | 7 + 10 files changed, 1109 insertions(+), 23 deletions(-) diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index d48891d7..9e5b3001 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -8,6 +8,7 @@ extension PeerCapabilities { .prekeys, .groups, .privateMedia, - .privateMediaReceipts + .privateMediaReceipts, + .courierAck ] } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index ddcc4abc..29a61b47 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -15,7 +15,7 @@ enum BLEOutboundPacketPolicy { // voiceFrame is deliberately unpadded: padding to the 512 block would // push every ~490-byte signed voice packet over the MTU into the // fragment path. - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame, .courierSprayAck, .courierSprayDecline: return false } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 5f29ade3..466b7e3d 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -313,6 +313,20 @@ final class BLEService: NSObject { // Mesh diagnostics (/ping): engine-confined probe and budget state. private var meshPings = BLEMeshPingTracker() + // Per-offer assume-delivered timeouts for spray copies handed to a + // `.courierAck`-capable taker, keyed by `(ciphertextHash, courierNoiseKey)` + // to match CourierStore's own pending-offer map so an ack/decline (or the + // timeout) resolves the right entry. This dictionary only holds the + // `DispatchWorkItem`s for cancellation — the offer/budget state lives in + // `CourierStore` behind its own serial queue; this side just calls + // `offerSprayCopies`/`confirmSpray`/`cancelSpray` (mirrors the ping/pong + // queue split). + private struct PendingSprayTimeoutKey: Hashable { + let ciphertextHash: Data + let courierNoiseKey: Data + } + private var pendingSprayTimeouts: [PendingSprayTimeoutKey: DispatchWorkItem] = [:] + // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() @@ -4590,9 +4604,9 @@ extension BLEService { return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) } - private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket { + private func makeCourierPacket(_ payload: Data, to peerID: PeerID, type: UInt8 = MessageType.courierEnvelope.rawValue) -> BitchatPacket { let packet = BitchatPacket( - type: MessageType.courierEnvelope.rawValue, + type: type, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), @@ -4722,17 +4736,41 @@ extension BLEService { return } let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false + // Point-in-time check: only send receipts to a depositor that can use + // them, so we don't emit spray-ack/decline packets to peers that would + // just ignore them. + let depositorWantsAck = peerCapabilities(peerID).contains(.courierAck) let store = courierStore let policy = courierDepositPolicy let metrics = sfMetrics + let sendReceipt: (Data, UInt8) -> Void = { [weak self] ciphertext, type in + guard let self else { return } + let receiptPacket = self.makeCourierPacket( + CourierStore.ciphertextHash(ciphertext), + to: peerID, + type: type + ) + self.sendPacketDirected(receiptPacket, to: peerID) + } + // Ack when we hold the copy (fresh store *or* idempotent dedup-hit, + // both `deposit == true`); decline only on a deterministic non-store + // (policy reject / quota / invalid), so the giver never restores budget + // for a copy we actually carry — that would inflate copies. + let sendAck: (Data) -> Void = { sendReceipt($0, MessageType.courierSprayAck.rawValue) } + let sendDecline: (Data) -> Void = { sendReceipt($0, MessageType.courierSprayDecline.rawValue) } notifyUI { guard let tier = policy(depositorKey, isVerifiedPeer) else { SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session) + if depositorWantsAck { sendDecline(envelope.ciphertext) } return } if store.deposit(envelope, from: depositorKey, tier: tier) { SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session) metrics?.record(.courierAccepted) + if depositorWantsAck { sendAck(envelope.ciphertext) } + } else if depositorWantsAck { + SecureLogger.debug("📦 Declining courier envelope from \(peerID.id.prefix(8))… (quota/validity)", category: .session) + sendDecline(envelope.ciphertext) } } } @@ -4785,34 +4823,180 @@ extension BLEService { private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) { let store = courierStore let metrics = sfMetrics - let sendSpray: () -> Void = { [weak self] in - guard let self else { return } - let accepted = store.transferSprayCopies(to: noiseKey) { envelope in - guard let payload = envelope.encode(), - self.sendPacketDirected( - self.makeCourierPacket(payload, to: peerID), - to: peerID, - requireDirectPeerLink: true, - requireNoiseAuthenticatedPeerLink: true - ) else { - return false - } - metrics?.record(.courierSprayed) - return true - } - if accepted > 0 { - SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session) + let ackCapable = peerCapabilities(peerID).contains(.courierAck) + // Send one spray copy over the authenticated direct link. The Bool is + // whether the BLE stack accepted it onto the peer's physical link, so + // both spray paths commit budget send-gated (a refused send is never + // charged). + let sendCopy: (CourierEnvelope) -> Bool = { [weak self] envelope in + guard let self, + let payload = envelope.encode(), + self.sendPacketDirected( + self.makeCourierPacket(payload, to: peerID), + to: peerID, + requireDirectPeerLink: true, + requireNoiseAuthenticatedPeerLink: true + ) else { + return false } + metrics?.record(.courierSprayed) + return true } let policy = courierDepositPolicy - notifyUI { + notifyUI { [weak self] in + guard let self else { return } // Same trust gate as deposits: don't hand mail to a peer who // would reject it from us. guard policy(noiseKey, isVerifiedPeer) != nil else { return } - sendSpray() + if ackCapable { + // Deferred-spend path: `offerSprayCopies` commits the budget + // send-gated like `transferSprayCopies`, and additionally + // records a restore window so a later signed decline can undo + // the spend. Start the assume-delivered timeout for each copy + // whose send is accepted, so a lost ack/decline still resolves + // the pending offer. + let accepted = store.offerSprayCopies(to: noiseKey) { envelope in + guard sendCopy(envelope) else { return false } + self.scheduleSprayOfferTimeout( + ciphertextHash: CourierStore.ciphertextHash(envelope.ciphertext), + courierNoiseKey: noiseKey + ) + return true + } + if accepted > 0 { + SecureLogger.debug("📦 Offered \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))… (awaiting spray-ack)", category: .session) + } + } else { + // Old taker (no `.courierAck`): unchanged optimistic path. + let accepted = store.transferSprayCopies(to: noiseKey, accepting: sendCopy) + if accepted > 0 { + SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session) + } + } } } + /// Authenticates a taker's spray receipt (ack *or* decline) and returns the + /// taker's noise key — the key for the pending-offer tuple. Both receipt + /// kinds are authenticated exactly like a deposit: the claimed sender must + /// be the direct ingress peer, and the packet signature must verify against + /// that peer's registry-bound signing key — link-binding alone is not + /// enough, since `senderID` is otherwise attacker-controlled. A forgeable + /// ack could trigger a premature spend and a forgeable decline could + /// restore budget already spent, so both go through the same signature + /// gate. `.courierAck` is not re-checked here; the pending-offer tuple + /// match (inside `confirmSpray`/`cancelSpray`) is the real gate. `kind` + /// only labels the security log line. + private func verifiedSprayReceiptTakerKey(_ packet: BitchatPacket, from peerID: PeerID, kind: String) -> Data? { + // Bind the receipt to *our* offer, exactly like `handleMeshPing`/ + // `handleMeshPong` gate on `recipientID`. Receipts are directed packets + // relayed across the mesh; a valid signature only proves the courier + // signed *a* receipt, not that it was addressed to this giver. Without + // this a signed ack/decline meant for a different depositor of the same + // envelope could resolve our pending offer for the same (hash, courier) + // — a cross-recipient decline would restore budget we already spent + // (inflation), a cross-recipient ack would clear a restore window we + // still need (loss). + guard packet.recipientID == myPeerIDData else { + SecureLogger.debug("📦 Spray-\(kind) rejected: addressed to another peer, not us", category: .security) + return nil + } + guard PeerID(hexData: packet.senderID) == peerID else { + SecureLogger.debug("📦 Spray-\(kind) rejected: relayed \(kind) claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security) + return nil + } + let takerInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) } + guard let takerKey = takerInfo?.noisePublicKey else { + SecureLogger.debug("📦 Spray-\(kind) from unknown peer \(peerID.id.prefix(8))… rejected", category: .session) + return nil + } + guard let signingKey = takerInfo?.signingPublicKey, + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.debug("📦 Spray-\(kind) from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security) + return nil + } + return takerKey + } + + /// Applies a taker's confirmation that it stored a sprayed copy, letting the + /// giver clear the deferred restore window. Idempotent: a duplicate or + /// post-timeout ack finds no pending offer and is a harmless no-op. + /// + /// Resolves the store entry *before* touching the timeout: if the ack raced + /// ahead of the send-gated commit (no pending offer yet, `confirmSpray` + /// returns false), we leave the armed timeout in place so it still clears + /// the entry the commit is about to insert — never orphaning a pending + /// offer. + private func handleCourierSprayAck(_ packet: BitchatPacket, from peerID: PeerID) { + guard let takerKey = verifiedSprayReceiptTakerKey(packet, from: peerID, kind: "ack") else { return } + let ciphertextHash = packet.payload + guard courierStore.confirmSpray(courierNoiseKey: takerKey, ciphertextHash: ciphertextHash) else { return } + let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) + collectionsQueue.sync(flags: .barrier) { + pendingSprayTimeouts.removeValue(forKey: key)?.cancel() + } + } + + /// Applies a taker's signed refusal of an offered copy, restoring the + /// giver's deferred budget instead of leaving it spent on the timeout. + /// + /// Restores *before* touching the timeout, and only cancels the timeout when + /// a pending offer was actually consumed. This keeps every ordering safe: + /// - decline vs assume-delivered timeout: if the timeout already cleared + /// the offer, `cancelSpray` returns false and we keep the spend (no worse + /// than the send-gated baseline); if the decline wins, it restores and + /// cancels the now-unneeded timeout. + /// - decline vs the send-gated commit: over `.withoutResponse` the copy is + /// put on the wire *before* `offerSprayCopies` commits the pending entry, + /// so a fast decline can be handled first. `cancelSpray` then returns + /// false; because we do NOT cancel the timeout, the armed timeout still + /// reaps the entry the commit inserts — the spend stands (baseline) + /// rather than leaving an orphaned pending offer. Restoring in that window + /// is impossible without committing before the send, which would trade + /// this benign degrade-to-baseline for a crash-before-send loss *below* + /// the baseline — the strictly worse failure, so we accept the floor. + private func handleCourierSprayDecline(_ packet: BitchatPacket, from peerID: PeerID) { + guard let takerKey = verifiedSprayReceiptTakerKey(packet, from: peerID, kind: "decline") else { return } + let ciphertextHash = packet.payload + guard courierStore.cancelSpray(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) else { return } + let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) + collectionsQueue.sync(flags: .barrier) { + pendingSprayTimeouts.removeValue(forKey: key)?.cancel() + } + } + + /// Starts (or replaces) the timeout for one outstanding spray offer. When + /// neither a signed `courierSprayAck` nor a signed `courierSprayDecline` + /// arrives in time, the delivery state is unknown, so the timeout *commits* + /// the spend (`confirmSpray` clears the restore window) rather than + /// restoring the budget: over a one-way lossy link a lost ack after a + /// successful deposit must not let the giver re-spray a copy the taker + /// already carries (copy inflation). Assume-delivered is at worst as bad as + /// today's optimistic decrement and never worse; a taker that genuinely + /// refused sends an explicit decline, which restores the budget before this + /// fires. + private func scheduleSprayOfferTimeout(ciphertextHash: Data, courierNoiseKey: Data) { + let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: courierNoiseKey) + let store = courierStore + let timeout = DispatchWorkItem { [weak self] in + guard let self else { return } + let stillPending = self.collectionsQueue.sync(flags: .barrier) { + self.pendingSprayTimeouts.removeValue(forKey: key) != nil + } + guard stillPending else { return } + store.confirmSpray(courierNoiseKey: courierNoiseKey, ciphertextHash: ciphertextHash) + } + collectionsQueue.sync(flags: .barrier) { + // Commit-time revalidation in `offerSprayCopies` means at most one + // copy per courier is committed for this envelope, but a re-announce + // can schedule a timeout before the losing commit no-ops — cancel + // any prior timeout for this key and keep the latest. + pendingSprayTimeouts.removeValue(forKey: key)?.cancel() + pendingSprayTimeouts[key] = timeout + } + messageQueue.asyncAfter(deadline: .now() + TransportConfig.courierSprayAckTimeoutSeconds, execute: timeout) + } + // MARK: One-Time Prekey Bundles /// Broadcasts our signed prekey bundle and tracks it for gossip sync. @@ -6004,6 +6188,12 @@ extension BLEService { case .courierEnvelope: handleCourierEnvelope(packet, from: peerID) + case .courierSprayAck: + handleCourierSprayAck(packet, from: peerID) + + case .courierSprayDecline: + handleCourierSprayDecline(packet, from: peerID) + case .groupMessage: handleGroupMessage(packet, from: senderID) diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 565eef17..d781a87b 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -120,6 +120,35 @@ final class CourierStore { } private var envelopes: [StoredEnvelope] = [] + + /// Records how many copies were handed to a `.courierAck`-capable taker in + /// an outstanding offer, so a signed decline can restore *exactly* that + /// many. The spend is committed send-gated in `offerSprayCopies` (budget + /// decremented and `sprayedTo` inserted only after the directed send is + /// accepted, identical to `transferSprayCopies`), so a copy whose send is + /// refused is never charged and there is never a committed window where the + /// taker holds the copy while the giver's budget is undiminished. In-memory + /// only — never persisted — so a restart before the offer resolves simply + /// forgets how to restore it and keeps the (durable) spend, degrading to the + /// optimistic path rather than inflating copies. Cleared by `wipe()`. + private struct PendingSprayOffer { + let given: UInt8 + } + + private struct PendingSprayOfferKey: Hashable { + let ciphertextHash: Data + let courierNoiseKey: Data + } + + /// Outstanding offers keyed by `(ciphertextHash, courierNoiseKey)`. Because + /// `cancelSpray` leaves the courier in `sprayedTo`, a single carried record is + /// offered to a given courier at most once in its lifetime, so any receipt — + /// real, late, duplicate, or replayed — resolves that entry idempotently. + /// Across a remove+redeposit of the same ciphertext the hash is reused by a + /// fresh record, so `cancelSpray` additionally requires the courier to still + /// be in the matched record's `sprayedTo` before restoring — a stale decline + /// from a deleted generation can never inflate a new deposit's budget. + private var pendingSprayOffers: [PendingSprayOfferKey: PendingSprayOffer] = [:] private let queue = DispatchQueue(label: "chat.bitchat.courier.store") private let fileURL: URL? private let now: () -> Date @@ -413,12 +442,166 @@ final class CourierStore { return acceptedCount } + /// Offers binary-spray copies to a `.courierAck`-capable courier, committing + /// the reduced budget and `sprayedTo` marker only after the directed + /// transport accepts each copy — identical send-gating to + /// `transferSprayCopies`, so an ack-capable taker gets the same link-drop + /// protection (a copy whose send is refused is never charged). The one + /// addition is that each committed offer records the `given` amount in + /// `pendingSprayOffers`, so a later *signed decline* (`cancelSpray`) can + /// restore exactly those copies when the taker deterministically refused the + /// deposit (policy reject / quota full). An ack — or the assume-delivered + /// timeout — instead clears the pending entry (`confirmSpray`); the spend is + /// already durable, so a lost ack degrades to the optimistic path, never + /// inflates. + /// + /// Inserting `courierNoiseKey` into `sprayedTo` in the commit block (append- + /// only, exactly like `transferSprayCopies`) both closes the announce-repeat + /// race — a re-announce before the commit revalidates and no-ops — and, + /// because `cancelSpray` never removes it, bounds each (envelope, courier) + /// pair to at most one lifetime pending offer, so a stale/replayed receipt + /// can never cross-attribute to a different offer. + @discardableResult + func offerSprayCopies( + to courierNoiseKey: Data, + accepting: (CourierEnvelope) -> Bool + ) -> Int { + let date = now() + let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date) + let offered = queue.sync { + pruneExpiredLocked(at: date) + return envelopes.compactMap { stored -> CourierEnvelope? in + guard stored.copies > 1, + stored.depositorNoiseKey != courierNoiseKey, + !stored.sprayedTo.contains(courierNoiseKey), + // Do not re-offer a courier while a pending offer for this + // ciphertext+courier is still live. A pending entry outlives + // its record (it is memory-only, cleared only by + // ack/decline/timeout), so a delivered-then-redeposited + // ciphertext cannot re-offer the same courier from the fresh + // generation — which is what let a stale decline from the + // deleted generation cross-attribute onto the fresh record's + // sprayed copy the courier already holds. + pendingSprayOffers[PendingSprayOfferKey( + ciphertextHash: Self.ciphertextHash(stored.ciphertext), + courierNoiseKey: courierNoiseKey)] == nil, + !courierTags.contains(stored.recipientTag) else { return nil } + return stored.envelope.withCopies(stored.copies / 2) + } + } + + var acceptedCount = 0 + for copy in offered where accepting(copy) { + // As with `transferSprayCopies`, BLE acceptance runs outside the + // store queue. Revalidate and commit the exact budget that left this + // device, and record the restore amount in the same critical + // section; a competing successful transfer makes this a no-op. + let committed = queue.sync { + guard let index = envelopes.firstIndex(where: { $0.ciphertext == copy.ciphertext }) else { + return false + } + let stored = envelopes[index] + guard stored.copies > copy.copies, + stored.depositorNoiseKey != courierNoiseKey, + !stored.sprayedTo.contains(courierNoiseKey), + !courierTags.contains(stored.recipientTag) else { + return false + } + envelopes[index].copies = stored.copies - copy.copies + envelopes[index].sprayedTo.insert(courierNoiseKey) + let key = PendingSprayOfferKey(ciphertextHash: Self.ciphertextHash(copy.ciphertext), + courierNoiseKey: courierNoiseKey) + pendingSprayOffers[key] = PendingSprayOffer(given: copy.copies) + persistLocked() + return true + } + if committed { acceptedCount += 1 } + } + return acceptedCount + } + + /// Clears an outstanding offer once the taker confirms receipt (signed ack) + /// or the assume-delivered timeout fires. No budget change: the spend was + /// already committed by `offerSprayCopies`, so this just drops the ability + /// to restore it. Returns whether a pending offer was actually cleared, so + /// the caller can tell a real resolution from a no-op: a duplicate/late ack, + /// or an ack that races *ahead* of the send-gated commit, finds no pending + /// offer and returns `false` — the caller then leaves the armed timeout in + /// place to reap the entry once the commit inserts it (no orphaned offer). + @discardableResult + func confirmSpray(courierNoiseKey: Data, ciphertextHash: Data) -> Bool { + queue.sync { + pendingSprayOffers.removeValue( + forKey: PendingSprayOfferKey(ciphertextHash: ciphertextHash, courierNoiseKey: courierNoiseKey) + ) != nil + } + } + + /// Restores an offer's budget on a signed decline: re-adds the `given` + /// copies (clamped to `maxCopies`) and persists, so a deterministically + /// refused spray costs the giver nothing. Returns whether a pending offer + /// was actually consumed. `false` means the offer isn't outstanding — a + /// decline that races a timeout/ack, arrives after a restart cleared the + /// pending map, or (over `.withoutResponse`) is processed *before* the + /// send-gated commit inserts the entry. In every `false` case the durable + /// spend is simply left in place (degrading to the send-gated baseline, + /// never inflating); the caller keeps the armed timeout so a decline that + /// merely lost the race to the commit is still reaped by `confirmSpray` + /// rather than orphaning the pending offer. + /// + /// The courier is deliberately *left* in `sprayedTo` (append-only, exactly + /// like `transferSprayCopies`): the budget is restored for *other* couriers, + /// but this envelope is never re-offered to the courier that just declined. + /// That closes a receipt-replay hole — a stale decline from an earlier offer + /// could otherwise resolve a later offer's pending entry and restore copies + /// the courier now actually holds. At most one lifetime pending offer per + /// (envelope, courier) means every receipt resolves that one entry + /// idempotently. + /// + /// The restore is gated on the matched record *still listing this courier in + /// `sprayedTo`*, not on the ciphertext hash alone. A hash match is not proof + /// of identity across a remove+redeposit: handover/eviction/prune can drop + /// the sprayed record while its pending offer is still outstanding, and a + /// re-deposit of the same ciphertext (`deposit` dedups only against carried + /// envelopes) then appends a *fresh* record with an empty `sprayedTo`. + /// Restoring onto that new record by hash would let a stale decline from the + /// deleted generation inflate a brand-new deposit's budget. Requiring the + /// courier to be in the matched record's `sprayedTo` means a restore can only + /// land on the exact generation this courier was sprayed from; a stale + /// decline onto any other generation drops its (consumed) pending entry + /// without touching copies. + @discardableResult + func cancelSpray(ciphertextHash: Data, courierNoiseKey: Data) -> Bool { + queue.sync { + let key = PendingSprayOfferKey(ciphertextHash: ciphertextHash, courierNoiseKey: courierNoiseKey) + guard let offer = pendingSprayOffers.removeValue(forKey: key) else { return false } + guard let index = envelopes.firstIndex(where: { + Self.ciphertextHash($0.ciphertext) == ciphertextHash && $0.sprayedTo.contains(courierNoiseKey) + }) else { return true } + let restored = Int(envelopes[index].copies) + Int(offer.given) + envelopes[index].copies = UInt8(min(restored, Int(CourierEnvelope.maxCopies))) + persistLocked() + return true + } + } + + /// Compact identifier for an envelope's ciphertext, carried in spray-ack and + /// spray-decline packets so the receipt doesn't have to echo the ciphertext + /// itself (up to 16 KB). Truncated SHA-256 (same primitive as + /// `Data.sha256Hash()`, truncated to `CourierEnvelope.tagLength` like the + /// recipient tag); a collision here could only misdirect a confirm/cancel to + /// the wrong pending offer, never affect decryption. + static func ciphertextHash(_ ciphertext: Data) -> Data { + Data(ciphertext.sha256Hash().prefix(CourierEnvelope.tagLength)) + } + // MARK: - Maintenance /// Panic wipe: drop all carried mail from memory and disk. func wipe() { queue.sync { envelopes.removeAll() + pendingSprayOffers.removeAll() diskLoadDeferred = false if let fileURL { try? FileManager.default.removeItem(at: fileURL) diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 98029094..d47f84c3 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -394,6 +394,11 @@ enum TransportConfig { // Recently opened courier inner message IDs kept for receiver-side dedup // (redundant copies ride distinct seals, so only the inner ID matches). static let courierOpenedMessageIDCap: Int = 512 + // How long a giver waits for a signed spray receipt (ack/decline) from a + // `.courierAck`-capable taker before assuming the copy was delivered and + // committing the spend. Bounds the offer's restore window; a late receipt + // after this is a harmless no-op. + static let courierSprayAckTimeoutSeconds: TimeInterval = 10 // One-time prekey bundles (forward-secret courier sealing) // Own gossip-sync round for bundles: modest cadence, bounded peer count, diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index c2c96a1c..2333c8bb 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -57,6 +57,9 @@ struct SyncTypeFlags: OptionSet { // Live voice is only useful now; replaying stale audio frames via // sync would waste airtime (receivers drop them as stale anyway). case .voiceFrame: return nil + // Courier spray receipts are ephemeral directed acks/declines between + // trusted peers; replaying them via gossip sync would be meaningless. + case .courierSprayAck, .courierSprayDecline: return nil // Prekey bundles gossip like board posts. The bitfield is a // wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits // ignored by `type(forBit:)`), so bits 8+ need no format change: old diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift index ea90b6a7..e822f3e0 100644 --- a/bitchatTests/CourierStoreTests.swift +++ b/bitchatTests/CourierStoreTests.swift @@ -479,4 +479,577 @@ struct CourierStoreTests { // ...but still delivered on encounter. #expect(store.takeEnvelopes(for: recipientKey).count == 1) } + + // MARK: - Deferred spray offers (courier-ack path) + + /// Commits the offered copy (the directed transport always accepts) and + /// returns the number of copies handed to `courier` for the single sprayable + /// envelope these tests deposit — i.e. the value the old + /// `offerSprayCopies(for:).first?.copies` exposed, or 0 if nothing was + /// offered. Note `offerSprayCopies` itself returns the *count of envelopes* + /// committed (like `transferSprayCopies`); the per-envelope copy split lives + /// in the `CourierEnvelope` passed to the accept closure, which is what we + /// capture here. This drives the send gate with a send that never drops, so + /// the committed budget matches the optimistic split — these cases exercise + /// the pending-offer bookkeeping (confirm/cancel), while the send gate itself + /// is exercised by `offerWithRefusedSendCommitsNothing`. + private func offerAll(_ store: CourierStore, to courier: Data) -> Int { + var given = 0 + let committedEnvelopes = store.offerSprayCopies(to: courier) { copy in + given = Int(copy.copies) + return true + } + // These tests deposit exactly one sprayable envelope, so a commit means + // `given` holds its split; a no-op (nothing eligible, or commit-time + // revalidation refused) means zero copies actually left the giver. + return committedEnvelopes == 0 ? 0 : given + } + + /// The ack-capable path is send-gated exactly like `transferSprayCopies`: a + /// copy whose directed send the transport refuses (e.g. the link dropped + /// before the write landed) is never charged — no budget spent, no + /// `sprayedTo` marker, no pending restore entry. This is the link-drop + /// protection an ack-capable taker inherits from the send gate, on top of the + /// decline recovery below. + @Test func offerWithRefusedSendCommitsNothing() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + // Transport refuses the send: nothing commits. + #expect(store.offerSprayCopies(to: courierA) { _ in false } == 0) + + // A receipt referencing the never-committed offer is a no-op — there is + // no pending entry to restore or clear, so the budget cannot inflate. + store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + + // Budget untouched: A was never marked sprayed, so a subsequent accepted + // offer still gets the full half-split (2) of the intact 4. + #expect(offerAll(store, to: courierA) == 2) + } + + @Test func committedOfferSpendsOnceAndConfirmIsANoOp() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + // A committed offer spends half the budget (same split as the optimistic + // path) and records a pending restore entry. + #expect(offerAll(store, to: courierA) == 2) // half of 4 + + // The spend is durable at commit time: `confirmSpray` (ack or the + // assume-delivered timeout) only clears the pending restore entry — it + // must not decrement again. + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + + // A is recorded as sprayed, so a repeat offer to A is refused. + #expect(offerAll(store, to: courierA) == 0) + + // A fresh courier gets half of the post-offer remainder (2 → 1), not + // half of the original 4 — the decrement landed exactly once, and the + // confirm added nothing. + let courierC = Data(repeating: 0xC3, count: 32) + let sprayedToC = store.takeSprayCopies(for: courierC) + #expect(sprayedToC.count == 1) + #expect(sprayedToC.first?.copies == 1) + } + + @Test func offerThenDeclineRestoresBudgetForOtherCouriers() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + #expect(offerAll(store, to: courierA) == 2) // committed: budget is now 2 + + // A signed decline (the giver's `cancelSpray`) re-adds the offered + // copies — this is the whole fix: a courier that deterministically + // refuses the copy no longer costs the giver its budget. + store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + + // A is deliberately kept in `sprayedTo`, so the giver will NOT re-offer + // the same envelope to the courier that just declined it (this is what + // closes the receipt-replay hole — see replayedDeclineAfterReoffer...). + #expect(offerAll(store, to: courierA) == 0) + + // But the budget really was restored: a *different* courier now gets + // half of the full 4 (2), proving the declined copies went back into + // the pool rather than being destroyed. + let courierB = Data(repeating: 0xC2, count: 32) + #expect(offerAll(store, to: courierB) == 2) + } + + /// Regression for Codex's send/commit-race finding. Over `.withoutResponse` + /// the sprayed copy is put on the wire *inside* the accept closure, before + /// `offerSprayCopies` commits the pending entry. A taker that deterministically + /// refuses can therefore have its signed decline handled *before* the commit. + /// This reproduces that exact interleaving by calling `cancelSpray` from + /// inside the accept closure — a decline that races ahead of the commit — and + /// pins the safe outcome: the racing decline finds no pending entry (returns + /// false, so it can neither falsely restore nor inflate), the spend still + /// commits (degrading to the send-gated baseline, never worse), and the + /// committed entry is NOT orphaned — a later `confirmSpray` (the armed + /// timeout, which BLEService deliberately leaves alive when `cancelSpray` + /// returns false) still reaps it. Restoring in that window is impossible + /// without committing before the send, which would trade this benign + /// degrade-to-baseline for a crash-before-send loss *below* the baseline. + @Test func declineRacingAheadOfCommitDegradesToBaselineWithoutOrphaning() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + var racedRestore: Bool? + var given = 0 + let committed = store.offerSprayCopies(to: courierA) { copy in + given = Int(copy.copies) + // The copy is now "on the wire"; a fast decline is handled before the + // commit block inserts the pending entry. + racedRestore = store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + return true + } + + // The racing decline saw no pending entry: it must not restore (which + // would inflate once the commit then spends). + #expect(racedRestore == false) + // The offer still committed the send-gated spend of half the budget. + #expect(committed == 1) + #expect(given == 2) + + // The committed entry was NOT orphaned by the racing decline: the armed + // timeout's `confirmSpray` still finds and clears it (returns true) — + // exactly what BLEService relies on when it leaves the timeout alive on + // the decline's false return. + #expect(store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) == true) + + // Spend stands (baseline): courierA is in `sprayedTo` and the budget + // dropped to 2, so a fresh courier gets half of 2, not half of 4. + #expect(offerAll(store, to: courierA) == 0) + let courierC = Data(repeating: 0xC3, count: 32) + #expect(store.takeSprayCopies(for: courierC).first?.copies == 1) + } + + /// The load-bearing regression for Codex's copy-inflation finding: when a + /// taker stores the copy but its ack is lost, the spend was already + /// committed durably at send-accept time, and the taker is already recorded + /// in `sprayedTo`. The assume-delivered timeout (`confirmSpray`) merely + /// clears the pending restore entry — it does not restore. The giver can + /// therefore never re-spray the copy the taker already carries, so total + /// copies stay bounded by the original budget — no inflation over a lossy + /// one-way link. + @Test func ackLossTimeoutCommitsAndCannotReinflate() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + #expect(offerAll(store, to: courierA) == 2) // taker physically stores these 2 + + // Ack is lost; the timeout fires. In BLEService the timeout invokes + // exactly this call — it drops the ability to restore, leaving the + // durable send-accept-time spend in place. + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + + // Cannot re-offer to the taker that already holds the copy. + #expect(offerAll(store, to: courierA) == 0) + + // Total handed out is conserved: taker holds 2, remaining budget is + // 4 - 2 = 2, so a fresh courier gets exactly 1 (half of 2), never a + // second half of the undiminished 4. + let courierC = Data(repeating: 0xC3, count: 32) + let sprayedToC = store.takeSprayCopies(for: courierC) + #expect(sprayedToC.first?.copies == 1) + // Drain to prove no underflow / no phantom copies: 4 - 2 - 1 = 1 left + // (carry-only), so nothing more can be sprayed. + let courierD = Data(repeating: 0xC4, count: 32) + #expect(store.takeSprayCopies(for: courierD).isEmpty) + } + + @Test func repeatOfferBeforeAckIsBlockedBySprayedTo() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + #expect(offerAll(store, to: courierA) == 2) + + // The first offer committed A into `sprayedTo`, so a second announce + // from the same courier before its ack lands wins no second offer for + // this envelope (the announce-repeat race, closed by commit-time + // revalidation). + #expect(offerAll(store, to: courierA) == 0) + + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + + let courierC = Data(repeating: 0xC3, count: 32) + let before = store.takeSprayCopies(for: courierC) + #expect(before.count == 1) + #expect(before.first?.copies == 1) // half of the post-offer remainder (2) + + // A duplicate/late ack (second confirm) is a harmless no-op. + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + + // If the duplicate confirm had decremented again, budget would be + // exhausted and a fresh courier would see nothing left to spray. + let courierD = Data(repeating: 0xC4, count: 32) + #expect(store.takeSprayCopies(for: courierD).isEmpty) + } + + @Test func lateAckAfterDeclineIsANoOp() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + #expect(offerAll(store, to: courierA) == 2) + // The taker declined; the giver restored the budget. + store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + + // A stray/forged ack arrives after the decline already consumed the + // offer; it must not resurrect and spend it (a forged ack cannot + // override a decline). The pending entry is gone, so it's a no-op. + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + + // Budget is fully intact: a different courier still sees the original + // split (A itself stays in `sprayedTo`, so it isn't re-offered). + let courierB = Data(repeating: 0xC2, count: 32) + #expect(offerAll(store, to: courierB) == 2) + } + + @Test func concurrentOffersToDifferentCouriersConserveCopies() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(8) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + let courierB = Data(repeating: 0xC2, count: 32) + + #expect(offerAll(store, to: courierA) == 4) // half of 8, budget now 4 + + // B halves the *live* budget, which A's offer already decremented to 4, + // so B gets 2 — offers can never jointly overcommit because each spends + // synchronously in turn (no reservation arithmetic needed). + #expect(offerAll(store, to: courierB) == 2) + + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + store.confirmSpray(courierNoiseKey: courierB, ciphertextHash: hash) + + // Both are now recorded as sprayed. + #expect(offerAll(store, to: courierA) == 0) + #expect(offerAll(store, to: courierB) == 0) + + // Drain the remainder to prove copies never underflowed: 8 - 4 - 2 = + // 2 remaining, half of that is 1. + let courierD = Data(repeating: 0xC4, count: 32) + let drained = store.takeSprayCopies(for: courierD) + #expect(drained.count == 1) + #expect(drained.first?.copies == 1) + } + + @Test func oldAndNewPathsShareOneLiveBudgetAndConserveCopies() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(8) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + let courierB = Data(repeating: 0xC2, count: 32) + let courierC = Data(repeating: 0xC3, count: 32) + + #expect(offerAll(store, to: courierA) == 4) // given 4, budget now 4 + #expect(offerAll(store, to: courierB) == 2) // half of live 4, budget now 2 + + // The old (non-ack) path halves the SAME live budget the offers already + // decremented — copies is 2, so C gets 1, not half of the raw 8. Both + // paths spend one shared, synchronously-decremented budget. + let takenByC = store.takeSprayCopies(for: courierC) + #expect(takenByC.count == 1) + let givenC = takenByC.first!.copies + #expect(givenC == 1) + + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + store.confirmSpray(courierNoiseKey: courierB, ciphertextHash: hash) + + // Conservation: A(4) + B(2) + C(1) all spent, leaving 1 carry-only copy; + // nothing more can be sprayed -- no inflation, no underflow. + let courierD = Data(repeating: 0xC4, count: 32) + let remainder = store.takeSprayCopies(for: courierD) + #expect(remainder.isEmpty) // nothing left: 8 - 4 - 2 - 1 = 1 (carry-only) + #expect(givenC + 4 + 2 + 1 == 8) + } + + @Test func wipeClearsPendingSoStaleReceiptsCannotInflate() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let ciphertext = Data(repeating: 0x77, count: 96) + let envelope = makeEnvelope(recipientKey: recipientKey, ciphertext: ciphertext).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + #expect(offerAll(store, to: courierA) == 2) // committed; pending{A:2} + + store.wipe() + + // Re-deposit an identical-copies envelope (same ciphertext/budget) so + // the stale receipts below have a matching envelope to (not) act on. + #expect(store.deposit(envelope, from: depositorA)) + + // The dangerous stale receipt is a DECLINE: if `cancelSpray` didn't guard + // on the (wiped) pending entry it would re-add 2 copies to the freshly + // re-deposited envelope → 4 + 2 = 6 = inflation, and clear a `sprayedTo` + // it never set. It must be a no-op. + store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + // A stale confirm is likewise a no-op (no pending to clear). + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + + // Budget is exactly the re-deposited 4: A gets the same fresh half-split + // (2) a brand-new envelope would — not half of an inflated 6 (3). + #expect(offerAll(store, to: courierA) == 2) + } + + @Test func copyConservationHoldsAcrossOffersConfirmsAndCancels() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let originalCopies: UInt8 = 8 + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(originalCopies) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + + var confirmedTotal: UInt8 = 0 + var courierByte: UInt8 = 0xC1 + func nextCourier() -> Data { + let key = Data(repeating: courierByte, count: 32) + courierByte += 1 + return key + } + + // Offer + confirm. + let courierA = nextCourier() + let offeredA = offerAll(store, to: courierA) + #expect(offeredA > 0) + confirmedTotal += UInt8(offeredA) + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + + // Offer + cancel: contributes nothing to the confirmed total. + let courierB = nextCourier() + #expect(offerAll(store, to: courierB) > 0) + store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierB) + + // Offer + confirm again. + let courierC = nextCourier() + let offeredC = offerAll(store, to: courierC) + #expect(offeredC > 0) + confirmedTotal += UInt8(offeredC) + store.confirmSpray(courierNoiseKey: courierC, ciphertextHash: hash) + + // Drain whatever spray budget remains via the old optimistic path + // so the final carried copy count is exposed as exactly 1. + while true { + let courier = nextCourier() + let taken = store.takeSprayCopies(for: courier) + guard let copy = taken.first else { break } + confirmedTotal += copy.copies + } + + // Conservation: everything actually confirmed/taken, plus the + // single carry-only copy left behind, equals the original budget. + #expect(confirmedTotal + 1 == originalCopies) + } + + /// The load-bearing regression for Codex's *second* finding (restart + /// inflation): the spend is durable at send-accept time, so a process + /// restart between offer-delivery and ack/timeout can NOT resurrect the + /// budget the taker already carries. On reload the giver sees the decremented + /// `copies` and the persisted `sprayedTo`, so it can neither re-offer the + /// taker nor hand a fresh courier half of the pre-offer budget. A decline + /// that arrives after the restart (pending map is memory-only, so it's empty) + /// simply fails to restore — degrading to the optimistic path, never + /// inflating. + @Test func restartBetweenOfferAndAckCannotReinflate() { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("courier-spray-restart-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("store.json") + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + let giver = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) + #expect(giver.deposit(envelope, from: depositorA)) + #expect(offerAll(giver, to: courierA) == 2) // durably spent + persisted; taker holds these 2 + + // Process dies before the ack or the 10s timeout — reload from disk. + let afterRestart = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) + + // The taker A is still in the persisted `sprayedTo`, so the giver cannot + // re-offer A the copy it already carries. + #expect(offerAll(afterRestart, to: courierA) == 0) + + // A fresh courier gets half of the persisted remainder (2 → 1), never a + // second half of the pre-offer 4 — the restart did not inflate copies. + let courierC = Data(repeating: 0xC3, count: 32) + let sprayedToC = afterRestart.takeSprayCopies(for: courierC) + #expect(sprayedToC.first?.copies == 1) + + // A decline that arrives after the restart finds no pending entry (the + // map didn't survive), so it cannot restore — the durable spend stands. + afterRestart.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + let courierD = Data(repeating: 0xC4, count: 32) + #expect(afterRestart.takeSprayCopies(for: courierD).isEmpty) // 4-2-1 = 1, carry-only + } + + /// The load-bearing regression for Codex's *third* finding (receipt replay + /// across re-offer): a stale/replayed signed decline from an earlier offer + /// must not restore budget against a later state. Because a declined courier + /// is kept in `sprayedTo`, the giver never re-offers it the same envelope, so + /// there is never a second pending offer for the same (envelope, courier) + /// pair for a stale receipt to cross-attribute to — the replay window the + /// finding needs is never reopened. + @Test func replayedDeclineAfterReofferCannotInflate() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + // Offer #1 to A, then A deterministically declines: budget restored to 4. + #expect(offerAll(store, to: courierA) == 2) + store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + + // The giver will NOT re-offer A the same envelope — the window a stale + // decline needs (a fresh pending entry for the same pair) never opens. + #expect(offerAll(store, to: courierA) == 0) + + // A replayed/delayed decline from offer #1 (past packet-dedup expiry or + // after a restart) now finds no matching pending entry and cannot + // inflate: budget is still exactly 4, so a different courier gets half + // of 4 (2), never half of an inflated 6 (3). + store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + let courierB = Data(repeating: 0xC2, count: 32) + #expect(offerAll(store, to: courierB) == 2) + + // Drive the mirror too: a replayed/late ack after the decline is also a + // no-op — it can neither spend nor destroy the restored budget. + store.confirmSpray(courierNoiseKey: courierA, ciphertextHash: hash) + let courierC = Data(repeating: 0xC3, count: 32) + // Budget after B's offer is 4 - 2 = 2, so C gets 1; if the stale ack had + // corrupted state, this split would differ. + #expect(store.takeSprayCopies(for: courierC).first?.copies == 1) + } + + /// Regression for Codex's redeposit cross-attribution finding. `cancelSpray` + /// locates the record to restore by the receipt's ciphertext hash, but a hash + /// is not identity across a remove+redeposit. Delivering the sprayed record + /// (handover removes it — likewise prune/eviction) drops it while its pending + /// offer is still outstanding; re-depositing the same ciphertext then appends + /// a *fresh* record with an empty `sprayedTo` (deposit dedups only against + /// carried envelopes). A stale decline from the deleted generation must not + /// restore its copies onto that brand-new deposit. The `sprayedTo.contains` + /// gate in `cancelSpray` pins the restore to the exact generation the courier + /// was sprayed from, so this cross-generation replay is a consumed no-op. + @Test func staleDeclineAfterRedepositCannotInflateFreshRecord() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + // Offer to A: budget 4 → 2, A recorded in `sprayedTo`, pending(A, hash) + // still outstanding. + #expect(offerAll(store, to: courierA) == 2) + + // The remaining copies are handed to the actual recipient, which REMOVES + // the carried record — but nothing clears A's outstanding pending offer. + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + #expect(store.isEmpty) + + // The depositor re-sends the same envelope. Dedup matches only carried + // envelopes, so this appends a brand-new record: full budget 4, empty + // `sprayedTo` (A was never sprayed from *this* generation). + #expect(store.deposit(envelope, from: depositorA)) + + // A's late/replayed decline from the deleted generation arrives. It + // consumes the stale pending entry but must NOT restore onto the fresh + // record — A isn't in its `sprayedTo`, so the hash match is rejected. + store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + + // No inflation: the fresh record still holds exactly its deposited 4, so a + // courier gets half of 4 (2). Pre-fix the stale decline restored 4 + 2 = 6 + // and this split would be 3. + let courierB = Data(repeating: 0xC2, count: 32) + #expect(offerAll(store, to: courierB) == 2) + } + + /// Regression for Codex's round-3 finding: the `sprayedTo` gate alone does not + /// close the redeposit replay if the SAME courier is re-offered from the fresh + /// generation. After handover removes the record, a same-ciphertext redeposit + /// appends a fresh record with empty `sprayedTo`; re-offering A would insert it + /// into the fresh `sprayedTo`, and then a stale decline from the deleted + /// generation passes `sprayedTo.contains(A)` and inflates a copy A already + /// holds. The offer-time pending-existence guard closes this: A's pending offer + /// outlives its removed record (pending is memory-only, cleared only by + /// ack/decline/timeout), so A is INELIGIBLE for a fresh offer while it is live — + /// A is never sprayed from the new generation, and the stale decline consumes + /// its pending entry as a no-op. + @Test func staleDeclineAfterRedepositAndSameCourierReofferCannotInflate() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + let hash = CourierStore.ciphertextHash(envelope.ciphertext) + let courierA = Data(repeating: 0xC1, count: 32) + + // Offer to A: budget 4 → 2, A in `sprayedTo`, pending(A, hash) outstanding. + #expect(offerAll(store, to: courierA) == 2) + + // Deliver the remainder to the recipient — REMOVES the carried record, + // leaving A's pending offer outstanding (nothing clears it). + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + #expect(store.isEmpty) + + // Same ciphertext redeposited: a brand-new record, full budget 4, empty + // `sprayedTo`. + #expect(store.deposit(envelope, from: depositorA)) + + // Re-offering A from the fresh generation is REFUSED by the pending guard + // (A's offer from the deleted generation is still outstanding), so A is + // never inserted into the fresh record's `sprayedTo`. This is the step that + // the round-2 `sprayedTo` gate alone left open. + #expect(offerAll(store, to: courierA) == 0) + + // A's late/replayed decline from the deleted generation now finds the fresh + // record without A in `sprayedTo`: it consumes the stale pending entry but + // restores nothing. + store.cancelSpray(ciphertextHash: hash, courierNoiseKey: courierA) + + // No inflation: the fresh record still holds exactly 4, so B gets half (2). + // Pre-fix, A's re-offer would have taken 2 and the stale decline restored to + // 4 while A held the fresh 2 (total 6), making this split 2 off an inflated + // budget. + let courierB = Data(repeating: 0xC2, count: 32) + #expect(offerAll(store, to: courierB) == 2) + } } diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index 6ce23355..72243349 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -663,6 +663,122 @@ struct CourierEndToEndTests { ttl: TransportConfig.messageTTLDefault ) } + + // MARK: - Spray-receipt recipient binding + + /// Builds a signed spray receipt (`courierSprayAck`/`courierSprayDecline`) + /// as it appears on the wire from `taker`, addressed to `recipientID` and + /// carrying `ciphertextHash` as its payload. The signature is genuine; only + /// the recipient field varies, isolating the recipient-binding guard. + private func signedSprayReceipt( + _ type: MessageType, + from taker: NoiseEncryptionService, + to recipientID: Data, + ciphertextHash: Data + ) throws -> BitchatPacket { + let takerPeerID = PeerID(publicKey: taker.getStaticPublicKeyData()) + let unsigned = BitchatPacket( + type: type.rawValue, + senderID: Data(hexString: takerPeerID.id) ?? Data(), + recipientID: recipientID, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: ciphertextHash, + signature: nil, + ttl: 1 + ) + return try #require(taker.signPacket(unsigned)) + } + + /// Seeds `giver` with a 4-copy envelope and commits one spray offer to + /// `courierNoiseKey`, leaving budget 2 with a single outstanding pending + /// offer. Returns the envelope's ciphertext hash (the receipt payload). + private func seedSprayOffer(in giver: BLEService, to courierNoiseKey: Data) -> Data { + let recipientKey = Data(repeating: 0xB0, count: 32) + let now = Date() + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: Data((0..<96).map { _ in UInt8.random(in: 0...255) }) + ).withCopies(4) + let depositor = Data(repeating: 0xA1, count: 32) + _ = giver.courierStore.deposit(envelope, from: depositor) + let committed = giver.courierStore.offerSprayCopies(to: courierNoiseKey) { _ in true } + #expect(committed == 1) + return CourierStore.ciphertextHash(envelope.ciphertext) + } + + /// A validly signed decline addressed to a *different* giver, merely relayed + /// onto our link, must not restore budget we already spent. The signature is + /// genuine, so only the recipient-binding guard prevents cross-recipient + /// copy inflation. + @Test func sprayDeclineAddressedToAnotherGiverDoesNotRestoreBudget() async throws { + let alice = makeService() + let carol = NoiseEncryptionService(keychain: MockKeychain()) + let carolPeerID = PeerID(publicKey: carol.getStaticPublicKeyData()) + let carolSenderData = Data(hexString: carolPeerID.id) ?? Data() + + let ciphertextHash = seedSprayOffer(in: alice, to: carolSenderData) + + let strangerID = Data(hexString: PeerID(publicKey: Data(repeating: 0xE0, count: 32)).id) ?? Data() + let decline = try signedSprayReceipt(.courierSprayDecline, from: carol, to: strangerID, ciphertextHash: ciphertextHash) + alice._test_handlePacket(decline, fromPeerID: carolPeerID, signingPublicKey: carol.getSigningPublicKeyData()) + await alice._test_drainFragmentPipeline() + + // Budget still 2 (not restored to 4): a fresh courier gets 2/2 = 1. + let probe = Data(repeating: 0xD1, count: 32) + #expect(alice.courierStore.takeSprayCopies(for: probe).map(\.copies) == [1]) + } + + /// The same signed decline addressed to us *does* restore the deferred + /// budget — proving the recipient check is the only thing gating the + /// mis-addressed case (the negative test above is not passing vacuously). + @Test func sprayDeclineAddressedToUsRestoresBudget() async throws { + let alice = makeService() + let carol = NoiseEncryptionService(keychain: MockKeychain()) + let carolPeerID = PeerID(publicKey: carol.getStaticPublicKeyData()) + let carolSenderData = Data(hexString: carolPeerID.id) ?? Data() + + let ciphertextHash = seedSprayOffer(in: alice, to: carolSenderData) + + let aliceID = Data(hexString: alice.myPeerID.id) ?? Data() + let decline = try signedSprayReceipt(.courierSprayDecline, from: carol, to: aliceID, ciphertextHash: ciphertextHash) + alice._test_handlePacket(decline, fromPeerID: carolPeerID, signingPublicKey: carol.getSigningPublicKeyData()) + await alice._test_drainFragmentPipeline() + + // Budget restored to 4: a fresh courier gets 4/2 = 2. + let probe = Data(repeating: 0xD1, count: 32) + #expect(alice.courierStore.takeSprayCopies(for: probe).map(\.copies) == [2]) + } + + /// A validly signed ack addressed to a *different* giver must not consume our + /// pending offer. If it did, a later legitimate decline could no longer + /// restore the budget — cross-recipient copy loss. Here the stray ack is + /// ignored, so Carol's own signed decline still restores. + @Test func sprayAckAddressedToAnotherGiverDoesNotClearRestoreWindow() async throws { + let alice = makeService() + let carol = NoiseEncryptionService(keychain: MockKeychain()) + let carolPeerID = PeerID(publicKey: carol.getStaticPublicKeyData()) + let carolSenderData = Data(hexString: carolPeerID.id) ?? Data() + + let ciphertextHash = seedSprayOffer(in: alice, to: carolSenderData) + + let strangerID = Data(hexString: PeerID(publicKey: Data(repeating: 0xE0, count: 32)).id) ?? Data() + let strayAck = try signedSprayReceipt(.courierSprayAck, from: carol, to: strangerID, ciphertextHash: ciphertextHash) + alice._test_handlePacket(strayAck, fromPeerID: carolPeerID, signingPublicKey: carol.getSigningPublicKeyData()) + await alice._test_drainFragmentPipeline() + + let aliceID = Data(hexString: alice.myPeerID.id) ?? Data() + let decline = try signedSprayReceipt(.courierSprayDecline, from: carol, to: aliceID, ciphertextHash: ciphertextHash) + alice._test_handlePacket(decline, fromPeerID: carolPeerID, signingPublicKey: carol.getSigningPublicKeyData()) + await alice._test_drainFragmentPipeline() + + // Pending offer survived the stray ack, so the decline restores to 4. + let probe = Data(repeating: 0xD1, count: 32) + #expect(alice.courierStore.takeSprayCopies(for: probe).map(\.copies) == [2]) + } } // MARK: - Router courier selection diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 0ba2730f..8d672e63 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -40,6 +40,12 @@ public enum MessageType: UInt8 { // never gossip-synced). Private bursts ride noiseEncrypted instead. case voiceFrame = 0x29 + // Courier spray receipts (signed, capability-gated by `.courierAck`). A + // courier acknowledges a stored spray copy (ack) or reports a deterministic + // refusal (decline) so the giver can restore that copy's budget. + case courierSprayAck = 0x2A + case courierSprayDecline = 0x2B + public var description: String { switch self { case .announce: return "announce" @@ -58,6 +64,8 @@ public enum MessageType: UInt8 { case .pong: return "pong" case .nostrCarrier: return "nostrCarrier" case .voiceFrame: return "voiceFrame" + case .courierSprayAck: return "courierSprayAck" + case .courierSprayDecline: return "courierSprayDecline" } } } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index b1308c9b..ee41e3a4 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -40,6 +40,13 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { /// this bit; keep it decodable so the wire assignment is never reused. public static let nonDestructiveNoiseReplacement = PeerCapabilities(rawValue: 1 << 10) + /// Sends and understands signed courier spray receipts (ack / decline), so + /// a giver can defer spending a spray copy's budget until the taker either + /// confirms it stored the copy or reports a deterministic refusal. + /// + /// Bits 8-11 are left to the private-media stack (#1434, #1463, #1466); + /// 8-15 all encode to the same two wire bytes, so the gap costs nothing. + public static let courierAck = PeerCapabilities(rawValue: 1 << 12) /// Minimal little-endian byte encoding; always at least one byte so an /// empty set is distinguishable from an absent TLV. From 02e156e7a903941b2b277d7a5a478ac6404f05d6 Mon Sep 17 00:00:00 2001 From: ecgang Date: Mon, 13 Jul 2026 16:45:19 -0700 Subject: [PATCH 2/8] Courier spray: send receipts direct-only to match the verify guard The receipt verify path (verifiedSprayReceiptTakerKey) authenticates a spray ack/decline exactly like a deposit: the claimed sender must be the direct BLE ingress peer. But the send path used sendPacketDirected with the default requireDirectPeerLink: false, so a receipt could relay multi-hop when the direct link dropped. A relayed receipt arrives with ingress != signer and is therefore always rejected by the guard -- so the relay-capable send only ever produced a mesh flood the giver was guaranteed to discard, and the doc comment claiming receipts are "relayed across the mesh" was true of the send but false of the verify. Make receipts direct-only on send (requireDirectPeerLink: true) so send, verify, and the comment agree. Behavior is unchanged: with a direct link the receipt is sent and verified as before; with none it is dropped and the giver's offer degrades to timeout-commit (the spend stands) rather than restoring -- never worse than today's optimistic decrement. This also removes an unreachable-by-construction relay path. Restoring the budget on a genuinely relayed decline needs a per-offer nonce echoed giver->taker->giver (a CourierEnvelope wire change), left as follow-up work along with re-keying the receipt lookup off the signed sender. Co-Authored-By: Claude Opus 4.8 (1M context) --- bitchat/Services/BLE/BLEService.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 466b7e3d..4cd649a8 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -4750,7 +4750,15 @@ extension BLEService { to: peerID, type: type ) - self.sendPacketDirected(receiptPacket, to: peerID) + // Direct-only: the giver verifies receipts as direct (ingress peer + // == signer), so relaying one would only produce a mesh flood the + // giver is guaranteed to reject. If the taker has no direct link + // back, the decline is dropped and the giver's offer degrades to + // timeout-commit (the spend stands) rather than restoring — never + // worse than today's optimistic decrement. Restoring on a genuinely + // relayed decline needs a per-offer nonce echoed giver->taker->giver + // (a CourierEnvelope wire change), left as follow-up work. + self.sendPacketDirected(receiptPacket, to: peerID, requireDirectPeerLink: true) } // Ack when we hold the copy (fresh store *or* idempotent dedup-hit, // both `deposit == true`); decline only on a deterministic non-store @@ -4889,10 +4897,12 @@ extension BLEService { /// only labels the security log line. private func verifiedSprayReceiptTakerKey(_ packet: BitchatPacket, from peerID: PeerID, kind: String) -> Data? { // Bind the receipt to *our* offer, exactly like `handleMeshPing`/ - // `handleMeshPong` gate on `recipientID`. Receipts are directed packets - // relayed across the mesh; a valid signature only proves the courier - // signed *a* receipt, not that it was addressed to this giver. Without - // this a signed ack/decline meant for a different depositor of the same + // `handleMeshPong` gate on `recipientID`. Receipts are sent and + // verified direct-only, but a taker directly linked to two givers can + // still sign a receipt for the *other* giver's deposit of the same + // envelope; a valid signature only proves the courier signed *a* + // receipt, not that it was addressed to this giver. Without this bind a + // signed ack/decline meant for a different depositor of the same // envelope could resolve our pending offer for the same (hash, courier) // — a cross-recipient decline would restore budget we already spent // (inflation), a cross-recipient ack would clear a restore window we From 444da6b6846a4d1b4a7698ff504af8b64468209a Mon Sep 17 00:00:00 2001 From: ecgang Date: Tue, 14 Jul 2026 23:10:35 -0700 Subject: [PATCH 3/8] Baseline the spray-offer key structs' synthesized-Hashable Periphery hits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PendingSprayTimeoutKey (BLEService) and PendingSprayOfferKey (CourierStore) store ciphertextHash/courierNoiseKey that are only read through synthesized ==/hash(into:) as dictionary keys — reads Periphery's indexer doesn't attribute, the same false-positive class retain_codable_properties covers for Codable. No retain_hashable_properties toggle exists, so these four go in the baseline per the config's documented convention. Co-Authored-By: Claude Fable 5 --- .periphery.baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.periphery.baseline.json b/.periphery.baseline.json index 9400fdf1..eed93745 100644 --- a/.periphery.baseline.json +++ b/.periphery.baseline.json @@ -1 +1 @@ -{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file +{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22PendingSprayTimeoutKey33_69191C53E68500C17D98DBCF2BDA7100LLV012courierNoiseF010Foundation4DataVvp","s:7bitchat10BLEServiceC22PendingSprayTimeoutKey33_69191C53E68500C17D98DBCF2BDA7100LLV14ciphertextHash10Foundation4DataVvp","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat12CourierStoreC20PendingSprayOfferKey33_2ACC0C4A3AC49437986BFAB5D4E72E70LLV012courierNoiseG010Foundation4DataVvp","s:7bitchat12CourierStoreC20PendingSprayOfferKey33_2ACC0C4A3AC49437986BFAB5D4E72E70LLV14ciphertextHash10Foundation4DataVvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} From bd0d1a31882a762cb6914f7b4391131e4e3a5c5c Mon Sep 17 00:00:00 2001 From: ecgang Date: Sat, 25 Jul 2026 13:01:47 -0700 Subject: [PATCH 4/8] Courier spray: move courierAck to capability bit 12 Bit 8 is claimed by privateMedia in #1434, which #1463 and #1466 build on; 9 and 10 are claimed by that same stack. Skip to 12. The gap is free on the wire: encoded() drops only trailing zero bytes, so every bit in 8...15 encodes to the same two bytes. Also in this commit: - Restore .periphery.baseline.json to the repo's minified formatting. It had been regenerated with a JSON pretty-printer, so 537 bytes of whitespace churn sat on top of the four real entries. - Reject spray receipts whose payload is not a 16-byte ciphertext hash before using it as a map key. The signature check already gates the handler, so this is consistency with the deposit path rather than a fix. - Record the trust assumption behind cancelSpray: a taker can sign a decline and keep the copy, bounded at 2x per envelope by maxCopies. Spray only reaches favorites and verified peers, who can already drop carried mail outright, so the restore is not defended against. - Record the isolation invariant offerSprayCopies depends on: no suspension point between the scan and the commit, since acceptance puts copies on the wire irreversibly. The sole caller satisfies it with an await-free @MainActor task; adding an await there would reopen the window. Co-Authored-By: Claude Opus 5 (1M context) --- bitchat/Services/BLE/BLEService.swift | 2 ++ bitchat/Services/Courier/CourierStore.swift | 17 +++++++++++++++++ .../BitFoundation/PeerCapabilities.swift | 6 ++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 4cd649a8..fc232103 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -4939,6 +4939,7 @@ extension BLEService { /// offer. private func handleCourierSprayAck(_ packet: BitchatPacket, from peerID: PeerID) { guard let takerKey = verifiedSprayReceiptTakerKey(packet, from: peerID, kind: "ack") else { return } + guard packet.payload.count == CourierEnvelope.tagLength else { return } let ciphertextHash = packet.payload guard courierStore.confirmSpray(courierNoiseKey: takerKey, ciphertextHash: ciphertextHash) else { return } let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) @@ -4967,6 +4968,7 @@ extension BLEService { /// the baseline — the strictly worse failure, so we accept the floor. private func handleCourierSprayDecline(_ packet: BitchatPacket, from peerID: PeerID) { guard let takerKey = verifiedSprayReceiptTakerKey(packet, from: peerID, kind: "decline") else { return } + guard packet.payload.count == CourierEnvelope.tagLength else { return } let ciphertextHash = packet.payload guard courierStore.cancelSpray(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) else { return } let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index d781a87b..c2ee1e9a 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -491,6 +491,15 @@ final class CourierStore { } var acceptedCount = 0 + // CALLER INVARIANT: no suspension point between the scan above and the + // commit below. `accepting` puts copies on the wire irreversibly, so a + // commit that then loses its revalidation leaves those copies uncharged + // — two couriers each offered `copies / 2` before either commits would + // put 6 copies out from a budget of 4. What prevents it is that the sole + // caller (`BLEService.sprayCourierMail`) runs scan/send/commit inside a + // single `Task { @MainActor }` containing no `await`, so the actor's + // executor runs it to completion. Adding an `await` anywhere in that + // block reopens this. See `concurrentOffersToDifferentCouriersConserveCopies`. for copy in offered where accepting(copy) { // As with `transferSprayCopies`, BLE acceptance runs outside the // store queue. Revalidate and commit the exact budget that left this @@ -558,6 +567,14 @@ final class CourierStore { /// (envelope, courier) means every receipt resolves that one entry /// idempotently. /// + /// **Trust assumption.** A taker can sign a decline and keep the copy, so the + /// giver restores a budget the copy still occupies: at most 2x on that + /// envelope, bounded by `maxCopies`. This is not defended against, because + /// spray only ever reaches favorites and verified peers (`courierDepositPolicy`), + /// and a peer inside that boundary can already drop carried mail outright — + /// total loss, no protocol needed. Closing the smaller hole would cost a + /// three-round offer/accept/deliver handshake on contacts that last seconds. + /// /// The restore is gated on the matched record *still listing this courier in /// `sprayedTo`*, not on the ciphertext hash alone. A hash match is not proof /// of identity across a remove+redeposit: handover/eviction/prune can drop diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index ee41e3a4..97b84904 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -44,8 +44,10 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { /// a giver can defer spending a spray copy's budget until the taker either /// confirms it stored the copy or reports a deterministic refusal. /// - /// Bits 8-11 are left to the private-media stack (#1434, #1463, #1466); - /// 8-15 all encode to the same two wire bytes, so the gap costs nothing. + /// Bits 8-10 are deliberately skipped: they are claimed by in-flight work + /// (`privateMedia` 8, `privateMediaReceipts` 9, `nonDestructiveNoiseReplacement` + /// 10). The gap costs nothing on the wire — `encoded()` drops only trailing + /// zero bytes, so every bit in 8...15 encodes to the same two bytes. public static let courierAck = PeerCapabilities(rawValue: 1 << 12) /// Minimal little-endian byte encoding; always at least one byte so an From 77b31f218885964e5328cdf32917c9d5f6d5afe3 Mon Sep 17 00:00:00 2001 From: ecgang Date: Sun, 26 Jul 2026 11:32:28 -0700 Subject: [PATCH 5/8] Courier spray: enforce the offer span instead of describing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan-to-commit span in offerSprayCopies was held by a comment asking callers not to suspend inside it. Addresses the #1438 review ask. Two things now hold it. The rebase onto main supplied the first for free: notifyUI takes a non-async @MainActor closure, so `await` inside the sole caller's block is a compile error rather than a review catch. That is a property of that call site, not of the method, so beginSprayOfferSpan adds a debug-build detector for a future caller reached from some other context. An isolation assertion would have been the wrong check. MainActor.assertIsolated needs iOS 17 / macOS 14 and both packages pin 16 / 13; it would fail every existing test, since CourierStoreTests is not MainActor-isolated; and the violation that would actually ship is a suspension inside an already-MainActor block, which it passes. Overlap is the real predicate. The flag covers transferSprayCopies too — the two paths spend from one budget, so a transfer overlapping an offer overcommits exactly as two offers would. Restructuring the API was considered and rejected: no moment exists where the caller holds every courier (announces arrive one peer at a time), and the store still loops `accepting` per copy, so the invariant would only move down a level. Moving the commit inside the store queue closes the race but inverts a rule this file states twice, and would hold the store queue across radio I/O. Neither is worth it for a gap the type system now blocks at the only call site. Both guards are proven by mutation: dropping either span call fails its test, and the first attempt at this proof removed the wrong one and stayed green, which is how the transfer-path case got written. Co-Authored-By: Claude Opus 5 (1M context) --- bitchat/Services/Courier/CourierStore.swift | 77 +++++++++++++++++++-- bitchatTests/CourierStoreTests.swift | 66 ++++++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index c2ee1e9a..9a1bb94a 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -150,6 +150,52 @@ final class CourierStore { /// from a deleted generation can never inflate a new deposit's budget. private var pendingSprayOffers: [PendingSprayOfferKey: PendingSprayOffer] = [:] private let queue = DispatchQueue(label: "chat.bitchat.courier.store") + + #if DEBUG + /// True while a spray offer is between its scan and its last commit. + /// + /// Both spray paths spend from one budget and both release the store queue + /// across `accepting` (they must: it enters BLE/collections queues). The + /// scan is therefore only sound if offers do not overlap — see the caller + /// invariant on `offerSprayCopies`. Guarded rather than asserted on actor + /// isolation, because the violation that would actually ship is a + /// suspension *inside* an already-MainActor block, which every isolation + /// check passes. + private var sprayOfferInFlight = false + /// Test seam for the overlap detector, mirroring `_test_onOutboundPacket` + /// in `BLEService`. Unset in normal debug runs, where an overlap trips + /// `assertionFailure` instead. + static var _test_onSprayOfferOverlap: (() -> Void)? + #endif + + /// Marks the start of a spray offer's scan-to-commit span, reporting an + /// overlap with one already in flight. No-op in release builds. + private func beginSprayOfferSpan(_ function: StaticString = #function) { + #if DEBUG + queue.sync { + guard sprayOfferInFlight else { + sprayOfferInFlight = true + return + } + if let hook = Self._test_onSprayOfferOverlap { + hook() + } else { + assertionFailure(""" + \(function) overlapped another spray offer. Copies are on the \ + wire before either commit runs, so the second scan reads a \ + budget the first has already spent and the excess ships \ + uncharged. Offers must run to completion one at a time. + """) + } + } + #endif + } + + private func endSprayOfferSpan() { + #if DEBUG + queue.sync { sprayOfferInFlight = false } + #endif + } private let fileURL: URL? private let now: () -> Date private let readData: (URL) throws -> Data @@ -417,6 +463,11 @@ final class CourierStore { } var acceptedCount = 0 + // Same span rule as `offerSprayCopies` — both spend from one budget and + // both release the queue across `accepting`, so an overlap between them + // inflates copies exactly as an overlap within either one would. + beginSprayOfferSpan() + defer { endSprayOfferSpan() } for copy in offered where accepting(copy) { // As with direct handover, BLE acceptance runs outside the store // queue. Revalidate and commit the exact budget that left this @@ -495,11 +546,27 @@ final class CourierStore { // commit below. `accepting` puts copies on the wire irreversibly, so a // commit that then loses its revalidation leaves those copies uncharged // — two couriers each offered `copies / 2` before either commits would - // put 6 copies out from a budget of 4. What prevents it is that the sole - // caller (`BLEService.sprayCourierMail`) runs scan/send/commit inside a - // single `Task { @MainActor }` containing no `await`, so the actor's - // executor runs it to completion. Adding an `await` anywhere in that - // block reopens this. See `concurrentOffersToDifferentCouriersConserveCopies`. + // put 6 copies out from a budget of 4. + // + // Two things hold it, in order of strength: + // + // 1. The sole caller (`BLEService.sprayCourierMail`) runs scan/send/ + // commit inside `notifyUI`, whose closure is a NON-ASYNC + // `@MainActor () -> Void`. `await` inside it is a compile error, so + // at that call site the invariant is enforced by the type system, + // not by this comment. + // 2. `beginSprayOfferSpan` traps an overlap in debug builds, which + // covers a future caller reached from some other context — the type + // guarantee above is a property of that one call site, not of this + // method. + // + // No lock here can substitute: the copies are already on the wire + // before either commit runs, so the revalidation below protects the + // ledger and nothing else. + // See `concurrentOffersToDifferentCouriersConserveCopies` and + // `overlappingSprayOffersAreDetected`. + beginSprayOfferSpan() + defer { endSprayOfferSpan() } for copy in offered where accepting(copy) { // As with `transferSprayCopies`, BLE acceptance runs outside the // store queue. Revalidate and commit the exact budget that left this diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift index e822f3e0..c2f9dc84 100644 --- a/bitchatTests/CourierStoreTests.swift +++ b/bitchatTests/CourierStoreTests.swift @@ -733,6 +733,72 @@ struct CourierStoreTests { #expect(offerAll(store, to: courierB) == 2) } + #if DEBUG + /// The scan-to-commit span guard. + /// + /// Note this test's sibling below runs its two offers *in turn*, which is + /// safe and is what production does. This one nests the second offer inside + /// the first's accept closure — the one interleaving that breaks the + /// accounting, because `accepting` runs outside the store queue, so B's scan + /// reads a budget A has not yet spent while A's copies are already on the + /// wire. + /// + /// Production cannot reach this today: the sole caller runs inside + /// `notifyUI`, whose closure is a non-async `@MainActor () -> Void`, so + /// `await` there is a compile error. That is precisely why the guard needs a + /// test — nothing else would notice a future caller reintroducing the gap. + @Test func overlappingSprayOffersAreDetected() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(8) + #expect(store.deposit(envelope, from: depositorA)) + let courierA = Data(repeating: 0xC1, count: 32) + let courierB = Data(repeating: 0xC2, count: 32) + + let overlaps = OverlapCounter() + CourierStore._test_onSprayOfferOverlap = { overlaps.count += 1 } + defer { CourierStore._test_onSprayOfferOverlap = nil } + + _ = store.offerSprayCopies(to: courierA) { _ in + // Copies for A are on the wire; B now scans before A has committed. + _ = store.offerSprayCopies(to: courierB) { _ in true } + return true + } + + #expect(overlaps.count == 1) + } + + /// The same guard across the two spray paths, which is the reason one flag + /// covers both: `transferSprayCopies` and `offerSprayCopies` spend from a + /// single budget, so a transfer started inside an offer's accept closure + /// overcommits exactly as two offers would. + @Test func sprayTransferOverlappingAnOfferIsDetected() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(8) + #expect(store.deposit(envelope, from: depositorA)) + let courierA = Data(repeating: 0xC1, count: 32) + let courierB = Data(repeating: 0xC2, count: 32) + + let overlaps = OverlapCounter() + CourierStore._test_onSprayOfferOverlap = { overlaps.count += 1 } + defer { CourierStore._test_onSprayOfferOverlap = nil } + + _ = store.offerSprayCopies(to: courierA) { _ in + _ = store.takeSprayCopies(for: courierB) + return true + } + + #expect(overlaps.count == 1) + } + + /// Box so the detector's escaping closure can tally without capturing a + /// local `var`. + private final class OverlapCounter: @unchecked Sendable { + var count = 0 + } + #endif + @Test func concurrentOffersToDifferentCouriersConserveCopies() { let store = makeStore() let recipientKey = Data(repeating: 0xB0, count: 32) From 85ef044af6ed45bc714224452f98548935ca35fc Mon Sep 17 00:00:00 2001 From: ecgang Date: Sun, 26 Jul 2026 11:45:09 -0700 Subject: [PATCH 6/8] Courier spray: count offer spans instead of flagging one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model review (agy) found a false negative in the overlap detector added in the previous commit: with a boolean flag, a nested offer's `defer` clears it while the OUTER offer is still between its scan and its commit, so every later overlap with that outer offer goes unreported. A depth count fixes it — report when the count is already above zero, then increment; decrement on the way out. Pinned by a test that provokes two overlaps against one outer offer. Reverting the counter to flag semantics reports one of them, which is the mutation that proves the test guards the behaviour rather than the implementation. The same review confirmed no deadlock (the store queue is never held across beginSprayOfferSpan, endSprayOfferSpan or the accepting callback), that bit 12 is wire-compatible with main's bits 8-10, and that notifyUI's non-async closure does make `await` a compile error at the call site. Co-Authored-By: Claude Opus 5 (1M context) --- bitchat/Services/Courier/CourierStore.swift | 16 ++++++----- bitchatTests/CourierStoreTests.swift | 30 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 9a1bb94a..91029e3b 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -152,7 +152,7 @@ final class CourierStore { private let queue = DispatchQueue(label: "chat.bitchat.courier.store") #if DEBUG - /// True while a spray offer is between its scan and its last commit. + /// How many spray offers are between their scan and their last commit. /// /// Both spray paths spend from one budget and both release the store queue /// across `accepting` (they must: it enters BLE/collections queues). The @@ -161,7 +161,11 @@ final class CourierStore { /// isolation, because the violation that would actually ship is a /// suspension *inside* an already-MainActor block, which every isolation /// check passes. - private var sprayOfferInFlight = false + /// + /// A count rather than a flag: with a flag, a nested offer's `defer` clears + /// it while the outer offer is still in flight, so any later overlap with + /// that outer offer goes unreported. + private var sprayOffersInFlight = 0 /// Test seam for the overlap detector, mirroring `_test_onOutboundPacket` /// in `BLEService`. Unset in normal debug runs, where an overlap trips /// `assertionFailure` instead. @@ -173,10 +177,8 @@ final class CourierStore { private func beginSprayOfferSpan(_ function: StaticString = #function) { #if DEBUG queue.sync { - guard sprayOfferInFlight else { - sprayOfferInFlight = true - return - } + defer { sprayOffersInFlight += 1 } + guard sprayOffersInFlight > 0 else { return } if let hook = Self._test_onSprayOfferOverlap { hook() } else { @@ -193,7 +195,7 @@ final class CourierStore { private func endSprayOfferSpan() { #if DEBUG - queue.sync { sprayOfferInFlight = false } + queue.sync { sprayOffersInFlight = max(0, sprayOffersInFlight - 1) } #endif } private let fileURL: URL? diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift index c2f9dc84..d36de0e7 100644 --- a/bitchatTests/CourierStoreTests.swift +++ b/bitchatTests/CourierStoreTests.swift @@ -792,6 +792,36 @@ struct CourierStoreTests { #expect(overlaps.count == 1) } + /// A nested offer must not end the OUTER offer's span when it returns. + /// + /// This is why the detector counts rather than sets a flag: with a flag, + /// the inner offer's `defer` clears it while the outer one is still between + /// its scan and its commit, so every later overlap with that outer offer + /// goes unreported. Two overlaps are provoked here; a flag reports one. + @Test func nestedOfferDoesNotEndTheOuterSpan() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(8) + #expect(store.deposit(envelope, from: depositorA)) + let courierA = Data(repeating: 0xC1, count: 32) + let courierB = Data(repeating: 0xC2, count: 32) + let courierC = Data(repeating: 0xC3, count: 32) + + let overlaps = OverlapCounter() + CourierStore._test_onSprayOfferOverlap = { overlaps.count += 1 } + defer { CourierStore._test_onSprayOfferOverlap = nil } + + _ = store.offerSprayCopies(to: courierA) { _ in + // B overlaps A and finishes; A is still in flight afterwards. + _ = store.offerSprayCopies(to: courierB) { _ in true } + // C therefore also overlaps A, and must be reported too. + _ = store.offerSprayCopies(to: courierC) { _ in true } + return true + } + + #expect(overlaps.count == 2) + } + /// Box so the detector's escaping closure can tally without capturing a /// local `var`. private final class OverlapCounter: @unchecked Sendable { From c1100273c35dd26711d20395992c91b6c3a547f7 Mon Sep 17 00:00:00 2001 From: ecgang Date: Sun, 26 Jul 2026 11:53:55 -0700 Subject: [PATCH 7/8] Courier spray: scope the overlap seam per store, and pin the receipt identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more from cross-model review (codex). The overlap seam was a mutable static. Swift Testing runs cases in parallel, so the three tests that install it could overwrite each other's hook and tally an overlap onto the wrong test — a flake that would have shown up as an unreproducible count long after this landed. It is per-instance now; each test already owns its store, so each owns its seam, and no serialization is needed. Separately, ciphertextHash had no pinned value anywhere. Every use in the test file derived it exactly as production does — CourierStore.ciphertextHash on both sides of the assertion — so a change to the derivation moved both sides and every test stayed green, while every already-shipped peer's receipts stopped matching. The bytes are written down once now. That pin uses the published courier vector's input and expected value, so it moves into the vectors file unchanged once that lands. It is here rather than there because this is where the function it checks lives. Mutation-verified: changing the production truncation from 16 bytes to 12 fails that one test and only that one. Four consecutive runs of the suite pass with the per-instance seam. Co-Authored-By: Claude Opus 5 (1M context) --- bitchat/Services/Courier/CourierStore.swift | 8 ++++-- bitchatTests/CourierStoreTests.swift | 28 ++++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 91029e3b..7f1dbd9a 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -169,7 +169,11 @@ final class CourierStore { /// Test seam for the overlap detector, mirroring `_test_onOutboundPacket` /// in `BLEService`. Unset in normal debug runs, where an overlap trips /// `assertionFailure` instead. - static var _test_onSprayOfferOverlap: (() -> Void)? + /// + /// Per instance, not static: Swift Testing runs cases in parallel, so a + /// shared hook would let one case overwrite another's and tally overlaps + /// onto the wrong test. Each test owns its store, so each owns its seam. + var _test_onSprayOfferOverlap: (() -> Void)? #endif /// Marks the start of a spray offer's scan-to-commit span, reporting an @@ -179,7 +183,7 @@ final class CourierStore { queue.sync { defer { sprayOffersInFlight += 1 } guard sprayOffersInFlight > 0 else { return } - if let hook = Self._test_onSprayOfferOverlap { + if let hook = _test_onSprayOfferOverlap { hook() } else { assertionFailure(""" diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift index d36de0e7..0c8064d4 100644 --- a/bitchatTests/CourierStoreTests.swift +++ b/bitchatTests/CourierStoreTests.swift @@ -733,6 +733,25 @@ struct CourierStoreTests { #expect(offerAll(store, to: courierB) == 2) } + /// Pins the envelope identity a spray receipt carries. + /// + /// Every other use in this file derives the hash the same way production + /// does — `CourierStore.ciphertextHash(envelope.ciphertext)` on both sides — + /// so a change to the derivation moves both and every one of them stays + /// green. This is the one place the bytes are written down, which is the + /// only way a receipt-identity change can fail a test rather than silently + /// re-key every pending offer and break interop with an already-shipped + /// peer. + /// + /// Input and expected value are the published courier vector's, so when + /// the vectors file lands this assertion moves into it unchanged. + @Test func ciphertextHashIsPinnedSHA256TruncatedTo16() { + let ciphertext = Data("courier-vector-ciphertext-0001".utf8) + #expect(CourierStore.ciphertextHash(ciphertext).hexEncodedString() + == "bb85dcc4d8b17377c61817992df95826") + #expect(CourierStore.ciphertextHash(ciphertext).count == CourierEnvelope.tagLength) + } + #if DEBUG /// The scan-to-commit span guard. /// @@ -756,8 +775,7 @@ struct CourierStoreTests { let courierB = Data(repeating: 0xC2, count: 32) let overlaps = OverlapCounter() - CourierStore._test_onSprayOfferOverlap = { overlaps.count += 1 } - defer { CourierStore._test_onSprayOfferOverlap = nil } + store._test_onSprayOfferOverlap = { overlaps.count += 1 } _ = store.offerSprayCopies(to: courierA) { _ in // Copies for A are on the wire; B now scans before A has committed. @@ -781,8 +799,7 @@ struct CourierStoreTests { let courierB = Data(repeating: 0xC2, count: 32) let overlaps = OverlapCounter() - CourierStore._test_onSprayOfferOverlap = { overlaps.count += 1 } - defer { CourierStore._test_onSprayOfferOverlap = nil } + store._test_onSprayOfferOverlap = { overlaps.count += 1 } _ = store.offerSprayCopies(to: courierA) { _ in _ = store.takeSprayCopies(for: courierB) @@ -808,8 +825,7 @@ struct CourierStoreTests { let courierC = Data(repeating: 0xC3, count: 32) let overlaps = OverlapCounter() - CourierStore._test_onSprayOfferOverlap = { overlaps.count += 1 } - defer { CourierStore._test_onSprayOfferOverlap = nil } + store._test_onSprayOfferOverlap = { overlaps.count += 1 } _ = store.offerSprayCopies(to: courierA) { _ in // B overlaps A and finishes; A is still in flight afterwards. From d42d5b29feaa51bb2b9937dc2b1866265ae6b0b7 Mon Sep 17 00:00:00 2001 From: ecgang Date: Fri, 31 Jul 2026 00:41:44 -0700 Subject: [PATCH 8/8] Courier spray: adopt the link-layer slices' engine contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V3 slices removed `collectionsQueue`, so the spray timeouts and the taker lookup no longer had the barrier they were written against. `peerRegistry.info(for:)` is read directly now, matching every other call site on main. The timeouts move into `BLESprayTimeoutStore`, lock-backed rather than engine-confined for the same reason `BLEPrivateMediaSessionStore` is: the two ends genuinely run in different contexts. Offers are armed on the main actor inside the `notifyUI` hop that decides whether to hand mail to a peer; acks, declines, and the timeout itself run on the engine. A leaf lock is safe from both, where re-entering the engine from the main actor is not. Each method is one whole transition under the lock, so an ack and a firing timeout cannot both claim the same offer. The delay goes through `engineScheduler` instead of `messageQueue.asyncAfter` — BLEQueueContractTests caught that one, and the manual clock now drives the assume-delivered deadline instead of the wall clock. --- .periphery.baseline.json | 2 +- bitchat/Services/BLE/BLEService.swift | 47 ++++------------ .../Services/BLE/BLESprayTimeoutStore.swift | 53 +++++++++++++++++++ 3 files changed, 65 insertions(+), 37 deletions(-) create mode 100644 bitchat/Services/BLE/BLESprayTimeoutStore.swift diff --git a/.periphery.baseline.json b/.periphery.baseline.json index eed93745..a52745a1 100644 --- a/.periphery.baseline.json +++ b/.periphery.baseline.json @@ -1 +1 @@ -{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22PendingSprayTimeoutKey33_69191C53E68500C17D98DBCF2BDA7100LLV012courierNoiseF010Foundation4DataVvp","s:7bitchat10BLEServiceC22PendingSprayTimeoutKey33_69191C53E68500C17D98DBCF2BDA7100LLV14ciphertextHash10Foundation4DataVvp","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat12CourierStoreC20PendingSprayOfferKey33_2ACC0C4A3AC49437986BFAB5D4E72E70LLV012courierNoiseG010Foundation4DataVvp","s:7bitchat12CourierStoreC20PendingSprayOfferKey33_2ACC0C4A3AC49437986BFAB5D4E72E70LLV14ciphertextHash10Foundation4DataVvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} +{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat12CourierStoreC20PendingSprayOfferKey33_2ACC0C4A3AC49437986BFAB5D4E72E70LLV012courierNoiseG010Foundation4DataVvp","s:7bitchat12CourierStoreC20PendingSprayOfferKey33_2ACC0C4A3AC49437986BFAB5D4E72E70LLV14ciphertextHash10Foundation4DataVvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat18BLESprayTimeoutKeyV012courierNoiseD010Foundation4DataVvp","s:7bitchat18BLESprayTimeoutKeyV14ciphertextHash10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index fc232103..49b9470b 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -314,18 +314,10 @@ final class BLEService: NSObject { private var meshPings = BLEMeshPingTracker() // Per-offer assume-delivered timeouts for spray copies handed to a - // `.courierAck`-capable taker, keyed by `(ciphertextHash, courierNoiseKey)` - // to match CourierStore's own pending-offer map so an ack/decline (or the - // timeout) resolves the right entry. This dictionary only holds the - // `DispatchWorkItem`s for cancellation — the offer/budget state lives in - // `CourierStore` behind its own serial queue; this side just calls - // `offerSprayCopies`/`confirmSpray`/`cancelSpray` (mirrors the ping/pong - // queue split). - private struct PendingSprayTimeoutKey: Hashable { - let ciphertextHash: Data - let courierNoiseKey: Data - } - private var pendingSprayTimeouts: [PendingSprayTimeoutKey: DispatchWorkItem] = [:] + // `.courierAck`-capable taker (lock-backed store: armed on the main actor + // inside the notifyUI hop, resolved on the engine by an ack, a decline, or + // the timeout itself). + private let sprayTimeouts = BLESprayTimeoutStore() // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() @@ -4915,7 +4907,7 @@ extension BLEService { SecureLogger.debug("📦 Spray-\(kind) rejected: relayed \(kind) claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security) return nil } - let takerInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) } + let takerInfo = peerRegistry.info(for: peerID) guard let takerKey = takerInfo?.noisePublicKey else { SecureLogger.debug("📦 Spray-\(kind) from unknown peer \(peerID.id.prefix(8))… rejected", category: .session) return nil @@ -4942,10 +4934,7 @@ extension BLEService { guard packet.payload.count == CourierEnvelope.tagLength else { return } let ciphertextHash = packet.payload guard courierStore.confirmSpray(courierNoiseKey: takerKey, ciphertextHash: ciphertextHash) else { return } - let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) - collectionsQueue.sync(flags: .barrier) { - pendingSprayTimeouts.removeValue(forKey: key)?.cancel() - } + sprayTimeouts.cancel(BLESprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey)) } /// Applies a taker's signed refusal of an offered copy, restoring the @@ -4971,10 +4960,7 @@ extension BLEService { guard packet.payload.count == CourierEnvelope.tagLength else { return } let ciphertextHash = packet.payload guard courierStore.cancelSpray(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) else { return } - let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) - collectionsQueue.sync(flags: .barrier) { - pendingSprayTimeouts.removeValue(forKey: key)?.cancel() - } + sprayTimeouts.cancel(BLESprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey)) } /// Starts (or replaces) the timeout for one outstanding spray offer. When @@ -4988,25 +4974,14 @@ extension BLEService { /// refused sends an explicit decline, which restores the budget before this /// fires. private func scheduleSprayOfferTimeout(ciphertextHash: Data, courierNoiseKey: Data) { - let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: courierNoiseKey) + let key = BLESprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: courierNoiseKey) let store = courierStore let timeout = DispatchWorkItem { [weak self] in - guard let self else { return } - let stillPending = self.collectionsQueue.sync(flags: .barrier) { - self.pendingSprayTimeouts.removeValue(forKey: key) != nil - } - guard stillPending else { return } + guard let self, self.sprayTimeouts.claim(key) else { return } store.confirmSpray(courierNoiseKey: courierNoiseKey, ciphertextHash: ciphertextHash) } - collectionsQueue.sync(flags: .barrier) { - // Commit-time revalidation in `offerSprayCopies` means at most one - // copy per courier is committed for this envelope, but a re-announce - // can schedule a timeout before the losing commit no-ops — cancel - // any prior timeout for this key and keep the latest. - pendingSprayTimeouts.removeValue(forKey: key)?.cancel() - pendingSprayTimeouts[key] = timeout - } - messageQueue.asyncAfter(deadline: .now() + TransportConfig.courierSprayAckTimeoutSeconds, execute: timeout) + sprayTimeouts.arm(key, timeout: timeout) + engineScheduler.schedule(after: TransportConfig.courierSprayAckTimeoutSeconds, execute: timeout) } // MARK: One-Time Prekey Bundles diff --git a/bitchat/Services/BLE/BLESprayTimeoutStore.swift b/bitchat/Services/BLE/BLESprayTimeoutStore.swift new file mode 100644 index 00000000..b0d400eb --- /dev/null +++ b/bitchat/Services/BLE/BLESprayTimeoutStore.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Identifies one outstanding spray offer. Mirrors `CourierStore`'s own +/// pending-offer key so an ack, a decline, or the assume-delivered timeout +/// all resolve the same entry. +struct BLESprayTimeoutKey: Hashable { + let ciphertextHash: Data + let courierNoiseKey: Data +} + +/// Lock-backed assume-delivered timeouts for spray copies handed to a +/// `.courierAck`-capable taker. +/// +/// This holds only the `DispatchWorkItem`s, for cancellation. The offer and +/// budget state lives in `CourierStore` behind its own serial queue; the +/// transport just calls `offerSprayCopies`/`confirmSpray`/`cancelSpray`. +/// +/// Lock-backed rather than engine-confined because the two ends run in +/// different contexts: offers are armed on the main actor (inside the +/// `notifyUI` hop that decides whether to hand mail to a peer), while acks, +/// declines, and the timeout itself run on the engine queue. A leaf lock is +/// safe from both; re-entering the engine from the main actor is not. +/// +/// Every method is one whole transition under the lock, so an ack and the +/// timeout racing for the same offer cannot both claim it. +final class BLESprayTimeoutStore: @unchecked Sendable { + private let lock = NSLock() + private var timeouts: [BLESprayTimeoutKey: DispatchWorkItem] = [:] + + /// Arms the timeout for one offer, cancelling any prior timeout for the + /// same key. Commit-time revalidation in `offerSprayCopies` means at most + /// one copy per courier is committed for an envelope, but a re-announce + /// can arm a timeout before the losing commit no-ops — so the latest wins. + func arm(_ key: BLESprayTimeoutKey, timeout: DispatchWorkItem) { + lock.withLock { + timeouts.removeValue(forKey: key)?.cancel() + timeouts[key] = timeout + } + } + + /// Cancels the timeout for a resolved offer. A no-op when the timeout + /// already fired or the offer was never armed. + func cancel(_ key: BLESprayTimeoutKey) { + lock.withLock { timeouts.removeValue(forKey: key)?.cancel() } + } + + /// Claims the offer on behalf of a firing timeout. Returns false when an + /// ack or decline already resolved it, so the timeout must not commit the + /// spend a second time. + func claim(_ key: BLESprayTimeoutKey) -> Bool { + lock.withLock { timeouts.removeValue(forKey: key) != nil } + } +}