mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 08:45:19 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user