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