diff --git a/.periphery.baseline.json b/.periphery.baseline.json index 9400fdf1..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: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: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/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 fa0853d6..4d7467e2 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -23,7 +23,7 @@ enum BLEOutboundPacketPolicy { // width); the residual variation is the capability width and whether a // bridge geohash is present. Making those fixed-width would be cheaper // than padding. See docs/PEER-ID-ROTATION.md. - case .none, .announce, .announceV2, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame: + case .none, .announce, .announceV2, .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 0c4ecfb1..a8ef8a8d 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -313,6 +313,12 @@ 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 (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() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() @@ -4594,9 +4600,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), @@ -4726,17 +4732,49 @@ 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 + ) + // 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 + // (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) } } } @@ -4789,34 +4827,167 @@ 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 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 + // 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 = 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 } + guard packet.payload.count == CourierEnvelope.tagLength else { return } + let ciphertextHash = packet.payload + guard courierStore.confirmSpray(courierNoiseKey: takerKey, ciphertextHash: ciphertextHash) else { return } + sprayTimeouts.cancel(BLESprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey)) + } + + /// 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 } + guard packet.payload.count == CourierEnvelope.tagLength else { return } + let ciphertextHash = packet.payload + guard courierStore.cancelSpray(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) else { return } + sprayTimeouts.cancel(BLESprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey)) + } + + /// 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 = BLESprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: courierNoiseKey) + let store = courierStore + let timeout = DispatchWorkItem { [weak self] in + guard let self, self.sprayTimeouts.claim(key) else { return } + store.confirmSpray(courierNoiseKey: courierNoiseKey, ciphertextHash: ciphertextHash) + } + sprayTimeouts.arm(key, timeout: timeout) + engineScheduler.schedule(after: TransportConfig.courierSprayAckTimeoutSeconds, execute: timeout) + } + // MARK: One-Time Prekey Bundles /// Broadcasts our signed prekey bundle and tracks it for gossip sync. @@ -6017,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/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 } + } +} diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 565eef17..7f1dbd9a 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -120,7 +120,88 @@ 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") + + #if DEBUG + /// 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 + /// 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. + /// + /// 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. + /// + /// 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 + /// overlap with one already in flight. No-op in release builds. + private func beginSprayOfferSpan(_ function: StaticString = #function) { + #if DEBUG + queue.sync { + defer { sprayOffersInFlight += 1 } + guard sprayOffersInFlight > 0 else { return } + if let hook = _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 { sprayOffersInFlight = max(0, sprayOffersInFlight - 1) } + #endif + } private let fileURL: URL? private let now: () -> Date private let readData: (URL) throws -> Data @@ -388,6 +469,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 @@ -413,12 +499,199 @@ 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 + // 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. + // + // 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 + // 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. + /// + /// **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 + /// 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 4dbbb53a..9130f755 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 // Rotating-ID presence is valid only inside its epoch, and gossiping it // would defeat the point: a synced announce would let a device that was // never in radio range collect tag blocks, turning a local presence diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift index ea90b6a7..0c8064d4 100644 --- a/bitchatTests/CourierStoreTests.swift +++ b/bitchatTests/CourierStoreTests.swift @@ -479,4 +479,689 @@ 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) + } + + /// 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. + /// + /// 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() + 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. + _ = 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() + store._test_onSprayOfferOverlap = { overlaps.count += 1 } + + _ = store.offerSprayCopies(to: courierA) { _ in + _ = store.takeSprayCopies(for: courierB) + return true + } + + #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() + store._test_onSprayOfferOverlap = { overlaps.count += 1 } + + _ = 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 { + var count = 0 + } + #endif + + @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 372720d6..0ef0aea8 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 + /// Identity-free presence for rotating peer IDs. Carries an epoch, a fixed /// block of pairwise recognition tags, and capabilities — no nickname, no /// public keys, no neighbour list. A separate type rather than a version of @@ -76,6 +82,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..97b84904 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -40,6 +40,15 @@ 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-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 /// empty set is distinguishable from an absent TLV.