Extract mesh-ping diagnostics state into a pure engine-confined tracker

First slice of the feature-module direction: BLEMeshPingTracker owns the
outstanding-probe map and the per-link inbound response budget as pure
state (register/resolve/expire/reset), so the security invariants — a
pong only resolves against the probed peer, the budget keys on the
ingress link because claimed senders are forgeable, panic reset drops
probes and budget together — are now unit-tested without queues or
radios. The transport keeps only packet I/O, timers, and main-actor
delivery around it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-27 00:57:26 +01:00
co-authored by Claude Fable 5
parent cbfdb9696e
commit 5c2903ec7c
3 changed files with 162 additions and 35 deletions
@@ -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
}
}
+16 -35
View File
@@ -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()
@@ -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..<TransportConfig.meshPingInboundMaxPerLink {
_ = tracker.shouldRespond(toLink: link, now: now)
}
let saturated = tracker.shouldRespond(toLink: link, now: now)
#expect(!saturated)
let timeouts = tracker.reset()
#expect(timeouts.count == 1)
let resolveAfterReset = tracker.resolve(nonce: nonce, from: link)
#expect(resolveAfterReset == nil)
let allowedAfterReset = tracker.shouldRespond(toLink: link, now: now)
#expect(allowedAfterReset)
}
}