mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-08 06:56:10 +00:00
Merge 0d2660e2b13eff193335e7d3fce459e0ec5d3260 into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
962e30ddfd
@ -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"]}}
|
||||
|
||||
@ -8,6 +8,7 @@ extension PeerCapabilities {
|
||||
.prekeys,
|
||||
.groups,
|
||||
.privateMedia,
|
||||
.privateMediaReceipts
|
||||
.privateMediaReceipts,
|
||||
.courierAck
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
53
bitchat/Services/BLE/BLESprayTimeoutStore.swift
Normal file
53
bitchat/Services/BLE/BLESprayTimeoutStore.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user