diff --git a/bitchat/Services/BLE/BLEMeshPingTracker.swift b/bitchat/Services/BLE/BLEMeshPingTracker.swift new file mode 100644 index 00000000..3e1e6644 --- /dev/null +++ b/bitchat/Services/BLE/BLEMeshPingTracker.swift @@ -0,0 +1,62 @@ +import BitFoundation +import Foundation + +struct BLEMeshPingProbe { + let peerID: PeerID + let sentAt: Date + let lifecycleGeneration: UInt64 + let completion: @MainActor (MeshPingResult?) -> Void + let timeout: DispatchWorkItem +} + +/// Engine-confined /ping diagnostics state: outstanding probes keyed by +/// their unguessable nonce, plus the inbound response budget. +/// +/// The budget is keyed by the ingress link (the directly connected peer +/// that delivered the packet), never the packet-claimed sender: pings are +/// unsigned, so the claimed sender is attacker-controlled and rotating it +/// would reset the budget, turning a directed unencrypted probe into an +/// amplification primitive. +/// +/// Pure state — the transport owns packet I/O, timers, and main-actor +/// completion delivery around it. +struct BLEMeshPingTracker { + private var pendingProbes: [Data: BLEMeshPingProbe] = [:] + private var responseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) + + mutating func register(_ probe: BLEMeshPingProbe, nonce: Data) { + pendingProbes[nonce] = probe + } + + /// Resolves a pong against its outstanding probe. The echoed nonce plus + /// the sender check bind the reply to the probed peer. + mutating func resolve(nonce: Data, from peerID: PeerID) -> BLEMeshPingProbe? { + guard pendingProbes[nonce]?.peerID == peerID else { return nil } + return pendingProbes.removeValue(forKey: nonce) + } + + /// Removes a timed-out probe so its completion can fire once with nil. + mutating func expire(nonce: Data) -> BLEMeshPingProbe? { + pendingProbes.removeValue(forKey: nonce) + } + + /// Whether an inbound ping delivered by this link is within budget. + mutating func shouldRespond(toLink linkPeerID: PeerID, now: Date) -> Bool { + responseLimiter.shouldRespond(to: linkPeerID, now: now) + } + + /// Drops all probes and restores a fresh response budget (panic wipe). + /// Returns the orphaned timeout work items for the caller to cancel. + mutating func reset() -> [DispatchWorkItem] { + let timeouts = pendingProbes.values.map(\.timeout) + pendingProbes.removeAll() + responseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) + return timeouts + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3cc9f236..afa8dc41 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -325,24 +325,8 @@ final class BLEService: NSObject { // Route health for originated source routes (engine-confined). private var sourceRouteFailures = BLESourceRouteFailureCache() - // Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the - // inbound ping budget — keyed by the ingress link (the directly connected - // peer that delivered the packet), since the unsigned claimed sender is - // spoofable — so a directed unencrypted probe cannot be turned into an - // amplification primitive. Both are engine-confined like the other - // mutable collections. - private struct PendingMeshPing { - let peerID: PeerID - let sentAt: Date - let lifecycleGeneration: UInt64 - let completion: @MainActor (MeshPingResult?) -> Void - let timeout: DispatchWorkItem - } - private var pendingMeshPings: [Data: PendingMeshPing] = [:] - private var meshPingResponseLimiter = SyncResponseRateLimiter( - maxResponses: TransportConfig.meshPingInboundMaxPerLink, - window: TransportConfig.meshPingInboundWindowSeconds - ) + // Mesh diagnostics (/ping): engine-confined probe and budget state. + private var meshPings = BLEMeshPingTracker() // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() @@ -1095,12 +1079,7 @@ final class BLEService: NSObject { let entries = outboundFragmentTransfers.removeAll().map { (id: $0.id, items: $0.workItems) } - let pingTimeouts = pendingMeshPings.values.map(\.timeout) - pendingMeshPings.removeAll() - meshPingResponseLimiter = SyncResponseRateLimiter( - maxResponses: TransportConfig.meshPingInboundMaxPerLink, - window: TransportConfig.meshPingInboundWindowSeconds - ) + let pingTimeouts = meshPings.reset() peerRegistry.mutate { $0.removeAll() } fragmentAssemblyBuffer.removeAll() sourceRouteFailures = BLESourceRouteFailureCache() @@ -4815,7 +4794,7 @@ extension BLEService { let timeout = DispatchWorkItem { [weak self] in guard let self else { return } let expired = onEngine { - self.pendingMeshPings.removeValue(forKey: nonce) + self.meshPings.expire(nonce: nonce) } guard let expired else { return } self.notifyUI { [weak self] in @@ -4829,12 +4808,15 @@ extension BLEService { } } onEngine { - self.pendingMeshPings[nonce] = PendingMeshPing( - peerID: PeerID(hexData: recipientData), - sentAt: Date(), - lifecycleGeneration: generation, - completion: completion, - timeout: timeout + self.meshPings.register( + BLEMeshPingProbe( + peerID: PeerID(hexData: recipientData), + sentAt: Date(), + lifecycleGeneration: generation, + completion: completion, + timeout: timeout + ), + nonce: nonce ) } self.messageQueue.asyncAfter( @@ -4861,7 +4843,7 @@ extension BLEService { return } let allowed = onEngine { - meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date()) + meshPings.shouldRespond(toLink: linkPeerID, now: Date()) } guard allowed else { if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") { @@ -4888,9 +4870,8 @@ extension BLEService { private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) { guard packet.recipientID == myPeerIDData else { return } guard let pong = MeshPingPayload.decode(packet.payload) else { return } - let pending = onEngine { () -> PendingMeshPing? in - guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil } - return pendingMeshPings.removeValue(forKey: pong.nonce) + let pending = onEngine { + meshPings.resolve(nonce: pong.nonce, from: peerID) } guard let pending else { return } pending.timeout.cancel() diff --git a/bitchatTests/Services/BLEMeshPingTrackerTests.swift b/bitchatTests/Services/BLEMeshPingTrackerTests.swift new file mode 100644 index 00000000..a3a81008 --- /dev/null +++ b/bitchatTests/Services/BLEMeshPingTrackerTests.swift @@ -0,0 +1,84 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEMeshPingTrackerTests { + private func makeProbe(peerID: PeerID) -> BLEMeshPingProbe { + BLEMeshPingProbe( + peerID: peerID, + sentAt: Date(timeIntervalSince1970: 1_000), + lifecycleGeneration: 1, + completion: { _ in }, + timeout: DispatchWorkItem {} + ) + } + + @Test func resolveReturnsProbeOnlyForTheProbedPeer() { + var tracker = BLEMeshPingTracker() + let nonce = Data([1, 2, 3, 4, 5, 6, 7, 8]) + let probed = PeerID(str: "aaaaaaaaaaaaaaaa") + tracker.register(makeProbe(peerID: probed), nonce: nonce) + + // A pong claiming the right nonce from the wrong peer must not + // consume the probe. + let wrongPeer = tracker.resolve(nonce: nonce, from: PeerID(str: "bbbbbbbbbbbbbbbb")) + #expect(wrongPeer == nil) + let rightPeer = tracker.resolve(nonce: nonce, from: probed) + #expect(rightPeer != nil) + // Consumed exactly once. + let secondResolve = tracker.resolve(nonce: nonce, from: probed) + #expect(secondResolve == nil) + } + + @Test func expireConsumesTheProbeSoResolveCannotFireTwice() { + var tracker = BLEMeshPingTracker() + let nonce = Data([9, 9, 9, 9, 9, 9, 9, 9]) + let probed = PeerID(str: "aaaaaaaaaaaaaaaa") + tracker.register(makeProbe(peerID: probed), nonce: nonce) + + let firstExpire = tracker.expire(nonce: nonce) + #expect(firstExpire != nil) + let secondExpire = tracker.expire(nonce: nonce) + #expect(secondExpire == nil) + let resolveAfterExpire = tracker.resolve(nonce: nonce, from: probed) + #expect(resolveAfterExpire == nil) + } + + @Test func inboundBudgetIsPerLinkAndBounded() { + var tracker = BLEMeshPingTracker() + let now = Date(timeIntervalSince1970: 2_000) + let linkA = PeerID(str: "aaaaaaaaaaaaaaaa") + let linkB = PeerID(str: "bbbbbbbbbbbbbbbb") + + var allowedOnA = 0 + for _ in 0..<(TransportConfig.meshPingInboundMaxPerLink + 5) { + if tracker.shouldRespond(toLink: linkA, now: now) { allowedOnA += 1 } + } + #expect(allowedOnA == TransportConfig.meshPingInboundMaxPerLink) + // One saturated link must not consume another link's budget. + let allowedOnB = tracker.shouldRespond(toLink: linkB, now: now) + #expect(allowedOnB) + } + + @Test func resetDropsProbesRestoresBudgetAndHandsBackTimeouts() { + var tracker = BLEMeshPingTracker() + let now = Date(timeIntervalSince1970: 3_000) + let link = PeerID(str: "aaaaaaaaaaaaaaaa") + let nonce = Data([4, 4, 4, 4, 4, 4, 4, 4]) + tracker.register(makeProbe(peerID: link), nonce: nonce) + for _ in 0..