Merge 0d2660e2b13eff193335e7d3fce459e0ec5d3260 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
ecgang 2026-08-02 10:59:56 -07:00 committed by GitHub
commit 962e30ddfd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1354 additions and 24 deletions

View File

@ -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"]}}
{"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"]}}

View File

@ -8,6 +8,7 @@ extension PeerCapabilities {
.prekeys,
.groups,
.privateMedia,
.privateMediaReceipts
.privateMediaReceipts,
.courierAck
]
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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 }
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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)
}
}

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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.