From a0b7985cbee6a8d0797f3a018a280dd14839cf95 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:19:13 +0100 Subject: [PATCH] Link layer slice 2: cohere link-auth state and split bindings from the physical store (#1540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cohere per-link Noise auth and rebind containment into BLELinkAuthState The authenticated-link owners, the reconnect revalidation policy, and the two rebind-containment cooldowns were four loose bleQueue-owned maps whose invariants lived in call-site discipline: every teardown path had to remember to retire the proof AND close the revalidation epoch (the pair appeared seven times), and both cooldowns hand-rolled the same prune-check-record dance. BLELinkAuthState owns them as whole transitions — retireLink, retireLinks(ownedBy:), permitRebind, permitRedundantRetirement — with the ownership question (bleQueue today, engine after the option-B flip) answered in one place. No behavior change; the one call-site reordering (redundant retirement computes the survivor before the cooldown check instead of after) is outcome-equivalent since the cooldown only ever recorded when a survivor existed. Co-Authored-By: Claude Fable 5 * Split identity-link bindings out of the physical link store BLELinkStateStore owned two different kinds of truth: what physical links exist (CB handles, connect lifecycles, characteristics, stream assemblers) and who each link belongs to (peer bindings in both roles plus the preferred-peripheral reverse map for directed sends and fanout collapse). The bindings now live on BLELinkBindings — same bleQueue ownership, whole-transition methods, direct tests for the rotation reverse-map cleanup and the preferred-link survivor repair that were previously only exercised end to end. Composed operations that need both truths (remove-with-repair, direct link state, the subscribed- central snapshot, bind-only-live-links) live on the transport as explicitly bleQueue-confined helpers. This is the structural half of the option-B boundary flip (docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move to the engine without touching what-links-exist. An audit of every physical clear/remove found three sites (emergency clear, both unauthorized branches) that needed explicit binding-clear pairing under the split — each now clears both. Co-Authored-By: Claude Fable 5 * Fix iOS-gated constructors and preserve containment cooldowns on reset CI caught what the macOS SwiftPM build cannot see: two #if os(iOS) sites still passed the peerID field that slice B1 removed from BLEPeripheralLinkState (willRestoreState in BLEService and armPendingBackgroundConnects in BLERadioController). Both fixed and verified with a local iOS simulator xcodebuild. Codex also caught a real regression: BLELinkAuthState.removeAll() cleared the rebind/retirement cooldown maps, which the original panic and emergency reset paths deliberately left alive. A stable CoreBluetooth UUID must not earn a fresh rebind allowance just because the session state around it was wiped. removeAll() now clears only the proofs and revalidation epochs, and BLELinkAuthStateTests pins the survival invariant along with the other auth-state transitions. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Services/BLE/BLELinkAuthState.swift | 115 ++++++++ bitchat/Services/BLE/BLELinkBindings.swift | 149 ++++++++++ bitchat/Services/BLE/BLELinkStateStore.swift | 135 ++------- bitchat/Services/BLE/BLERadioController.swift | 1 - bitchat/Services/BLE/BLEService.swift | 265 ++++++++++-------- .../Services/BLELinkAuthStateTests.swift | 75 +++++ .../Services/BLELinkBindingsTests.swift | 111 ++++++++ .../Services/BLELinkStateStoreTests.swift | 46 --- 8 files changed, 607 insertions(+), 290 deletions(-) create mode 100644 bitchat/Services/BLE/BLELinkAuthState.swift create mode 100644 bitchat/Services/BLE/BLELinkBindings.swift create mode 100644 bitchatTests/Services/BLELinkAuthStateTests.swift create mode 100644 bitchatTests/Services/BLELinkBindingsTests.swift delete mode 100644 bitchatTests/Services/BLELinkStateStoreTests.swift diff --git a/bitchat/Services/BLE/BLELinkAuthState.swift b/bitchat/Services/BLE/BLELinkAuthState.swift new file mode 100644 index 00000000..ce31e350 --- /dev/null +++ b/bitchat/Services/BLE/BLELinkAuthState.swift @@ -0,0 +1,115 @@ +import BitFoundation +import Foundation + +/// Per-link Noise authentication and rebind-containment state. +/// +/// A peer ID can retain an established Noise session after its physical +/// link disappears, and link bindings heal on announces whose directness +/// is forgeable (TTL is unsigned). This state pins the stronger facts the +/// containment rules need: which exact ingress link a Noise handshake +/// completed on, each link's revalidation epoch, and the cooldowns that +/// stop a replayed announce from flip-flopping bindings or survivor +/// selection. +/// +/// bleQueue-confined today, alongside the link bindings it qualifies; +/// both move to the engine together in the option-B boundary flip +/// (docs/BLE-ARCHITECTURE-V3.md). +struct BLELinkAuthState { + private var authenticatedOwners: [BLEIngressLinkID: PeerID] = [:] + private var reconnectPolicy = BLENoiseReconnectPolicy() + // Entries older than the cooldown are pruned on each check. + private var lastRebindAt: [String: Date] = [:] + private var lastRedundantRetirementAt: [PeerID: Date] = [:] + + // MARK: - Authentication ownership + + /// Whether `peerID`'s Noise session was established on this exact link. + func isAuthenticated(_ link: BLEIngressLinkID, for peerID: PeerID) -> Bool { + authenticatedOwners[link] == peerID + } + + func links(ownedBy peerID: PeerID) -> [BLEIngressLinkID] { + authenticatedOwners.compactMap { link, owner in + owner == peerID ? link : nil + } + } + + mutating func markAuthenticated(_ link: BLEIngressLinkID, owner peerID: PeerID) { + authenticatedOwners[link] = peerID + } + + /// Retires a link's proof and closes its revalidation epoch — the pair + /// every teardown path (disconnect, unsubscribe, timeout, rebind, + /// redundant retirement) must apply together. + mutating func retireLink(_ link: BLEIngressLinkID) { + authenticatedOwners.removeValue(forKey: link) + reconnectPolicy.endLinkEpoch(link) + } + + /// Retires every link the departing peer's proofs still own; returns + /// the retired links. + mutating func retireLinks(ownedBy peerID: PeerID) -> [BLEIngressLinkID] { + let departed = links(ownedBy: peerID) + for link in departed { + retireLink(link) + } + return departed + } + + /// Drops every link proof and revalidation epoch. The containment + /// cooldowns deliberately SURVIVE this: panic and emergency resets can + /// restart services well inside `bleLinkRebindCooldownSeconds`, and a + /// stable CoreBluetooth UUID must not get a fresh rebind/retirement + /// allowance just because the session state around it was wiped. The + /// maps stay time-pruned on each permit check. + mutating func removeAll() { + authenticatedOwners.removeAll() + reconnectPolicy.removeAll() + } + + // MARK: - Session revalidation + + /// Whether a fresh direct link warrants revalidating a cached + /// peer-level session with a new XX exchange. + mutating func shouldRevalidate( + on link: BLEIngressLinkID, + for peerID: PeerID, + hasEstablishedSession: Bool, + hasAuthenticatedPeerLink: Bool, + now: Date + ) -> Bool { + reconnectPolicy.shouldRevalidate( + on: link, + hasEstablishedSession: hasEstablishedSession, + isNoiseAuthenticatedLink: isAuthenticated(link, for: peerID), + hasAuthenticatedPeerLink: hasAuthenticatedPeerLink, + now: now + ) + } + + // MARK: - Rebind containment cooldowns + + /// At most one rotation rebind per link per cooldown window, so two + /// identities can't fight over a link in a replay flip-flop. Prunes, + /// checks, and records in one transition; true = permitted (recorded). + mutating func permitRebind(linkUUID: String, now: Date, cooldown: TimeInterval) -> Bool { + lastRebindAt = lastRebindAt.filter { + now.timeIntervalSince($0.value) < cooldown + } + guard lastRebindAt[linkUUID] == nil else { return false } + lastRebindAt[linkUUID] = now + return true + } + + /// At most one redundant-link retirement per peer per cooldown window, + /// bounding how often a replayed announce could flip which duplicate + /// link survives. True = permitted (recorded). + mutating func permitRedundantRetirement(peerID: PeerID, now: Date, cooldown: TimeInterval) -> Bool { + lastRedundantRetirementAt = lastRedundantRetirementAt.filter { + now.timeIntervalSince($0.value) < cooldown + } + guard lastRedundantRetirementAt[peerID] == nil else { return false } + lastRedundantRetirementAt[peerID] = now + return true + } +} diff --git a/bitchat/Services/BLE/BLELinkBindings.swift b/bitchat/Services/BLE/BLELinkBindings.swift new file mode 100644 index 00000000..d4c5342c --- /dev/null +++ b/bitchat/Services/BLE/BLELinkBindings.swift @@ -0,0 +1,149 @@ +import BitFoundation +import Foundation + +/// Identity↔link bindings: which peer each physical link currently +/// belongs to, in both roles, plus each peer's preferred peripheral link +/// for directed sends and fanout collapse. +/// +/// Split from the physical link-state store so the option-B boundary flip +/// (docs/BLE-ARCHITECTURE-V3.md) can move ownership of *who owns a link* +/// to the engine without touching *what links exist*. bleQueue-confined +/// today, alongside the physical store and `BLELinkAuthState`. +/// +/// Lifecycle contract: bindings are only created for live physical links +/// (callers guard existence) and are retired through +/// `peripheralRemoved`/`centralRemoved`/`clear*` when the physical link +/// goes, so binding queries never see departed links. +struct BLELinkBindings { + private var peripheralPeers: [String: PeerID] = [:] + private var centralPeers: [String: PeerID] = [:] + /// The peer's most recently bound peripheral link, kept so duplicate- + /// link fanout collapse stays deterministic (see BLEFanoutSelector). + private var preferredPeripheral: [PeerID: String] = [:] + + // MARK: - Queries + + func peer(forPeripheralID peripheralID: String) -> PeerID? { + peripheralPeers[peripheralID] + } + + func peer(forCentralUUID centralUUID: String) -> PeerID? { + centralPeers[centralUUID] + } + + func boundPeer(for link: BLEIngressLinkID) -> PeerID? { + switch link { + case .peripheral(let peripheralUUID): + return peripheralPeers[peripheralUUID] + case .central(let centralUUID): + return centralPeers[centralUUID] + } + } + + /// Every link bound to the peer, both roles. After a state restoration + /// the same device can hold several live peripheral links bound to one + /// peer (it reappears under a fresh UUID while the restored connection + /// lives on), so this scans all bindings rather than the 1:1 preferred + /// map. + func links(to peerID: PeerID?) -> Set { + guard let peerID else { return [] } + var links: Set = [] + for (peripheralUUID, boundPeer) in peripheralPeers where boundPeer == peerID { + links.insert(.peripheral(peripheralUUID)) + } + for (centralUUID, boundPeer) in centralPeers where boundPeer == peerID { + links.insert(.central(centralUUID)) + } + return links + } + + func hasCentral(boundTo peerID: PeerID) -> Bool { + centralPeers.values.contains(peerID) + } + + func preferredPeripheralUUID(for peerID: PeerID) -> String? { + preferredPeripheral[peerID] + } + + /// The full preferred-peripheral map, for fanout collapse. + var preferredPeripheralBindings: [PeerID: String] { + preferredPeripheral + } + + /// The full central binding map, for the subscribed-central snapshot. + var centralPeersByUUID: [String: PeerID] { + centralPeers + } + + // MARK: - Binding transitions + + mutating func bindCentral(_ centralUUID: String, to peerID: PeerID) { + centralPeers[centralUUID] = peerID + } + + mutating func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) { + let previousPeerID = peripheralPeers[peripheralUUID] + peripheralPeers[peripheralUUID] = peerID + // Rebinding (peer-ID rotation): drop the retired ID's reverse + // mapping so the old peer no longer claims this link. + if let previousPeerID, previousPeerID != peerID, + preferredPeripheral[previousPeerID] == peripheralUUID { + preferredPeripheral.removeValue(forKey: previousPeerID) + } + preferredPeripheral[peerID] = peripheralUUID + } + + /// Retires a peripheral link's binding. When the removed link was the + /// peer's preferred one, the reverse map is repaired onto a surviving + /// duplicate chosen by the caller from the peer's remaining bound links + /// (the caller knows physical liveness; prefer a writable survivor — + /// repairing onto a link mid-service-rediscovery would strand directed + /// sends until its characteristic comes back). + mutating func peripheralRemoved( + _ peripheralUUID: String, + chooseSurvivor: (_ remainingBoundUUIDs: [String]) -> String? + ) -> PeerID? { + guard let peerID = peripheralPeers.removeValue(forKey: peripheralUUID) else { + return nil + } + // Only clear (or repair) the reverse map when it points at the + // removed link: with duplicate links to one peer, removing a stale + // duplicate must not strand the peer's surviving bound link. + if preferredPeripheral[peerID] == peripheralUUID { + let remaining = peripheralPeers.compactMap { uuid, boundPeer in + boundPeer == peerID ? uuid : nil + } + if let survivorUUID = chooseSurvivor(remaining) { + preferredPeripheral[peerID] = survivorUUID + } else { + preferredPeripheral.removeValue(forKey: peerID) + } + } + return peerID + } + + mutating func centralRemoved(_ centralUUID: String) -> PeerID? { + centralPeers.removeValue(forKey: centralUUID) + } + + /// Drops every peripheral binding; returns the peers that held one. + mutating func clearPeripherals() -> [PeerID] { + let peerIDs = Array(peripheralPeers.values) + peripheralPeers.removeAll() + preferredPeripheral.removeAll() + return peerIDs + } + + /// Drops every central binding; returns the peers that held one. + mutating func clearCentrals() -> [PeerID] { + let peerIDs = Array(centralPeers.values) + centralPeers.removeAll() + return peerIDs + } + + mutating func removeAll() { + peripheralPeers.removeAll() + centralPeers.removeAll() + preferredPeripheral.removeAll() + } +} diff --git a/bitchat/Services/BLE/BLELinkStateStore.swift b/bitchat/Services/BLE/BLELinkStateStore.swift index 31914092..ebf39114 100644 --- a/bitchat/Services/BLE/BLELinkStateStore.swift +++ b/bitchat/Services/BLE/BLELinkStateStore.swift @@ -5,7 +5,6 @@ import Foundation struct BLEPeripheralLinkState { let peripheral: CBPeripheral var characteristic: CBCharacteristic? - var peerID: PeerID? var isConnecting: Bool var isConnected: Bool var lastConnectionAttempt: Date? @@ -26,17 +25,20 @@ struct BLESubscribedCentralSnapshot { } } -/// Owns all BLE link state (peripheral connections we hold as central, and -/// central subscriptions we serve as peripheral). The store has no internal -/// locking: every access must happen on the single owning queue (the BLE -/// queue). Other queues must go through BLEService's `readLinkState`, which -/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap -/// any access from the wrong queue. +// BLEDirectLinkState and the identity↔link binding queries live on +// BLELinkBindings; this store owns only physical link state. + +/// Owns the PHYSICAL BLE link state (peripheral connections we hold as +/// central, and central subscriptions we serve as peripheral) — CB object +/// handles, connect lifecycles, characteristics, and stream assemblers. +/// Identity↔link bindings live on `BLELinkBindings`. The store has no +/// internal locking: every access must happen on the single owning queue +/// (the BLE queue). Other queues must go through BLEService's +/// `readLinkState`, which hops to that queue. Call `assumeOwnership(of:)` +/// to have debug builds trap any access from the wrong queue. final class BLELinkStateStore { private(set) var peripherals: [String: BLEPeripheralLinkState] = [:] - private(set) var peerToPeripheralUUID: [PeerID: String] = [:] private(set) var subscribedCentrals: [CBCentral] = [] - private(set) var centralToPeerID: [String: PeerID] = [:] #if DEBUG private var ownerQueue: DispatchQueue? @@ -64,14 +66,6 @@ final class BLELinkStateStore { return Array(peripherals.values) } - var subscribedCentralSnapshot: BLESubscribedCentralSnapshot { - assertOwned() - return BLESubscribedCentralSnapshot( - centrals: subscribedCentrals, - peerIDsByCentralUUID: centralToPeerID - ) - } - var subscribedCentralCount: Int { assertOwned() return subscribedCentrals.count @@ -109,7 +103,6 @@ final class BLELinkStateStore { BLEPeripheralLinkState( peripheral: peripheral, characteristic: nil, - peerID: nil, isConnecting: true, isConnected: false, lastConnectionAttempt: date, @@ -129,7 +122,6 @@ final class BLELinkStateStore { BLEPeripheralLinkState( peripheral: peripheral, characteristic: nil, - peerID: nil, isConnecting: false, isConnected: true, lastConnectionAttempt: nil, @@ -146,130 +138,35 @@ final class BLELinkStateStore { } } - func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { - assertOwned() - return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] } - } - - func directLinkState(for peerID: PeerID) -> BLEDirectLinkState { - assertOwned() - let peripheralUUID = peerToPeripheralUUID[peerID] - let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false - let hasCentral = centralToPeerID.values.contains(peerID) - return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral) - } - - func links(to peerID: PeerID?) -> Set { - assertOwned() - guard let peerID else { return [] } - - var links: Set = [] - // Scan all states rather than the 1:1 reverse map: after a state - // restoration the same device can hold several live peripheral links - // bound to one peer (it reappears under a fresh UUID while the - // restored connection lives on). - for (peripheralUUID, state) in peripherals where state.peerID == peerID { - links.insert(.peripheral(peripheralUUID)) - } - for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID { - links.insert(.central(centralUUID)) - } - return links - } - - /// The peer's most recently bound peripheral link, per peer. Used to keep - /// duplicate-link fanout collapse deterministic (see BLEFanoutSelector). - var preferredPeripheralBindings: [PeerID: String] { - assertOwned() - return peerToPeripheralUUID - } - - func peerID(forPeripheralID peripheralID: String) -> PeerID? { - assertOwned() - return peripherals[peripheralID]?.peerID - } - - func peerID(forCentralUUID centralUUID: String) -> PeerID? { - assertOwned() - return centralToPeerID[centralUUID] - } - func addSubscribedCentral(_ central: CBCentral) { assertOwned() guard !subscribedCentrals.contains(central) else { return } subscribedCentrals.append(central) } - func removeSubscribedCentral(_ central: CBCentral) -> PeerID? { + func removeSubscribedCentral(_ central: CBCentral) { assertOwned() - let centralUUID = central.identifier.uuidString subscribedCentrals.removeAll { $0.identifier == central.identifier } - return centralToPeerID.removeValue(forKey: centralUUID) } - func bindCentral(_ centralUUID: String, to peerID: PeerID) { + func removePeripheral(_ peripheralID: String) { assertOwned() - centralToPeerID[centralUUID] = peerID + peripherals.removeValue(forKey: peripheralID) } - func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) { + func clearPeripherals() { assertOwned() - var previousPeerID: PeerID? - let updated = updatePeripheral(peripheralUUID) { - previousPeerID = $0.peerID - $0.peerID = peerID - } - guard updated != nil else { return } - // Rebinding (peer-ID rotation): drop the retired ID's reverse mapping - // so the old peer no longer claims this link. - if let previousPeerID, previousPeerID != peerID, - peerToPeripheralUUID[previousPeerID] == peripheralUUID { - peerToPeripheralUUID.removeValue(forKey: previousPeerID) - } - peerToPeripheralUUID[peerID] = peripheralUUID - } - - func removePeripheral(_ peripheralID: String) -> PeerID? { - assertOwned() - let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID - // Only clear (or repair) the reverse map when it points at the removed - // link: with duplicate links to one peer, removing a stale duplicate - // must not strand the peer's surviving bound link. - if let peerID, peerToPeripheralUUID[peerID] == peripheralID { - // Prefer a writable survivor: repairing onto a link that is - // mid-service-rediscovery would strand directed sends until the - // characteristic comes back. - let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected } - if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key { - peerToPeripheralUUID[peerID] = survivorUUID - } else { - peerToPeripheralUUID.removeValue(forKey: peerID) - } - } - return peerID - } - - func clearPeripherals() -> [PeerID] { - assertOwned() - let peerIDs = peripherals.compactMap { $0.value.peerID } peripherals.removeAll() - peerToPeripheralUUID.removeAll() - return peerIDs } - func clearCentrals() -> [PeerID] { + func clearCentrals() { assertOwned() - let peerIDs = Array(centralToPeerID.values) subscribedCentrals.removeAll() - centralToPeerID.removeAll() - return peerIDs } func clearAll() { assertOwned() peripherals.removeAll() - peerToPeripheralUUID.removeAll() subscribedCentrals.removeAll() - centralToPeerID.removeAll() } } diff --git a/bitchat/Services/BLE/BLERadioController.swift b/bitchat/Services/BLE/BLERadioController.swift index 8c7813e1..cf9d6c2d 100644 --- a/bitchat/Services/BLE/BLERadioController.swift +++ b/bitchat/Services/BLE/BLERadioController.swift @@ -337,7 +337,6 @@ final class BLERadioController { BLEPeripheralLinkState( peripheral: target.peripheral, characteristic: nil, - peerID: nil, isConnecting: true, isConnected: false, lastConnectionAttempt: nil, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 6b0dc932..5ad02cf9 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -206,20 +206,14 @@ final class BLEService: NSObject { // 1. Consolidated BLE link tracking for both central and peripheral roles. private var linkStateStore = BLELinkStateStore() - // A peer ID can retain an established Noise session after its physical - // link disappears. Courier handover therefore needs the stronger fact - // that the session was established *on this current ingress link*, not - // merely that some session exists for the claimed ID. bleQueue-owned. - private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:] - private var noiseReconnectPolicy = BLENoiseReconnectPolicy() - - // Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link - // store): entries older than the cooldown are pruned on insert. - private var lastLinkRebindAt: [String: Date] = [:] - - // Redundant-link retirement cooldown per peer (bleQueue-owned): bounds - // how often a replayed announce could flip which duplicate link survives. - private var lastRedundantLinkRetirementAt: [PeerID: Date] = [:] + // Per-link Noise authentication and rebind containment (bleQueue-owned, + // like the link store — courier handover needs the stronger fact that a + // session was established *on this current ingress link*, not merely + // that some session exists for the claimed ID). + private var linkAuth = BLELinkAuthState() + // Identity↔link bindings, split from the physical link store so the + // option-B flip can move ownership to the engine (bleQueue-owned). + private var linkBindings = BLELinkBindings() // BCH-01-004: Rate-limiting for subscription-triggered announces. private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() @@ -781,8 +775,7 @@ final class BLEService: NSObject { pendingPeripheralWrites.removeAll() pendingNotifications.removeAll() pendingWriteBuffers.removeAll() - noiseAuthenticatedLinkOwners.removeAll() - noiseReconnectPolicy.removeAll() + linkAuth.removeAll() radio.reset() } disconnectNotifyDebouncer.removeAll() @@ -1073,8 +1066,8 @@ final class BLEService: NSObject { // Clear peripheral references (synchronized access to avoid races with BLE callbacks) bleQueue.sync { linkStateStore.clearAll() - noiseAuthenticatedLinkOwners.removeAll() - noiseReconnectPolicy.removeAll() + linkBindings.removeAll() + linkAuth.removeAll() radio.reset() subscriptionAnnounceLimiter.removeAll() } @@ -2207,8 +2200,8 @@ final class BLEService: NSObject { if let peerID = requiredAuthenticatedPeer { eligible = centrals.filter { central in let link = BLEIngressLinkID.central(central.identifier.uuidString) - return noiseAuthenticatedLinkOwners[link] == peerID - && linkStateStore.peerID(forCentralUUID: central.identifier.uuidString) == peerID + return linkAuth.isAuthenticated(link, for: peerID) + && linkBindings.peer(forCentralUUID: central.identifier.uuidString) == peerID } } else { eligible = centrals @@ -2265,8 +2258,9 @@ final class BLEService: NSObject { let subscribedCentrals = characteristic == nil ? [] : centralSnapshot.centrals let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString } let centralIDs = subscribedCentrals.map { $0.identifier.uuidString } - let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state in - state.peerID.map { (state.peripheral.identifier.uuidString, $0) } + let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state -> (String, PeerID)? in + let uuid = state.peripheral.identifier.uuidString + return readLinkState { _ in linkBindings.peer(forPeripheralID: uuid) }.map { (uuid, $0) } }) let plan = BLEOutboundLinkPlanner.plan( packet: packet, @@ -2282,7 +2276,7 @@ final class BLEService: NSObject { // Perf note: this is a third bleQueue hop per send; if send-path // profiling ever flags it, fold it into snapshotPeripheralStates // as a combined snapshot. - preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings }, + preferredPeripheralPerPeer: readLinkState { _ in linkBindings.preferredPeripheralBindings }, directAnnounceTTL: messageTTL, directedOnlyPeer: directedOnlyPeer, requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink @@ -2703,13 +2697,7 @@ final class BLEService: NSObject { // canDeliverSecurely could remain true for a peer we just removed. clearNoiseSession(for: peerID) readLinkState { _ in - let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in - owner == peerID ? link : nil - } - for link in departedLinks { - noiseAuthenticatedLinkOwners.removeValue(forKey: link) - noiseReconnectPolicy.endLinkEpoch(link) - } + _ = linkAuth.retireLinks(ownedBy: peerID) } // Remove the peer when they leave peerRegistry.mutate { _ = $0.remove(peerID) } @@ -2962,14 +2950,12 @@ extension BLEService: CBCentralManagerDelegate { let existing = linkStateStore.state(forPeripheralID: identifier) let assembler = existing?.assembler ?? NotificationStreamAssembler() let characteristic = existing?.characteristic - let peerID = existing?.peerID let wasConnecting = existing?.isConnecting ?? false let wasConnected = existing?.isConnected ?? false let restoredState = BLEPeripheralLinkState( peripheral: peripheral, characteristic: characteristic, - peerID: peerID, isConnecting: wasConnecting || peripheral.state == .connecting, isConnected: wasConnected || peripheral.state == .connected, lastConnectionAttempt: existing?.lastConnectionAttempt, @@ -3026,16 +3012,13 @@ extension BLEService: CBCentralManagerDelegate { // misuse. Retire our link state locally instead. SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session) let peripheralStates = linkStateStore.peripheralStates - let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID) for state in peripheralStates { let peripheralID = state.peripheral.identifier.uuidString pendingPeripheralWrites.discardAll(for: peripheralID) - noiseAuthenticatedLinkOwners.removeValue( - forKey: .peripheral(peripheralID) - ) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) + linkAuth.retireLink(.peripheral(peripheralID)) } - _ = linkStateStore.clearPeripherals() + linkStateStore.clearPeripherals() + let peerIDs = linkBindings.clearPeripherals() // Notify UI of disconnections for peerID in peerIDs { notifyUI { [weak self] in @@ -3046,7 +3029,8 @@ extension BLEService: CBCentralManagerDelegate { case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) - _ = linkStateStore.clearPeripherals() + linkStateStore.clearPeripherals() + _ = linkBindings.clearPeripherals() case .unsupported: // Device doesn't support BLE @@ -3101,7 +3085,7 @@ extension BLEService: CBCentralManagerDelegate { let peripheralID = peripheral.identifier.uuidString // Find the peer ID if we have it - let peerID = linkStateStore.peerID(forPeripheralID: peripheralID) + let peerID = linkBindings.peer(forPeripheralID: peripheralID) SecureLogger.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session) @@ -3139,7 +3123,7 @@ extension BLEService: CBCentralManagerDelegate { // here. The scan restart and connect-slot refill below stay // unguarded — they respond to the physical drop regardless of // remaining logical links. - let remainingLinks = peerID.map { linkStateStore.directLinkState(for: $0) } + let remainingLinks = peerID.map { directLinkState(for: $0) } let peerStillLinked = (remainingLinks?.hasPeripheral ?? false) || (remainingLinks?.hasCentral ?? false) if let peerID, !peerStillLinked { // Do not remove peer; mark as not connected but retain for reachability @@ -3211,13 +3195,62 @@ extension BLEService: BLERadioControllerDelegate { /// Retires one peripheral link's transport bookkeeping: its write /// backpressure, its Noise link proof and reconnect epoch, and the - /// link-state entry (which repairs the peer's reverse mapping onto a - /// surviving duplicate link). bleQueue-confined. + /// link-state entry plus binding (which repairs the peer's reverse + /// mapping onto a surviving duplicate link). bleQueue-confined. func tearDownPeripheralLink(_ peripheralID: String) { pendingPeripheralWrites.discardAll(for: peripheralID) - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = linkStateStore.removePeripheral(peripheralID) + linkAuth.retireLink(.peripheral(peripheralID)) + removePeripheralLink(peripheralID) + } + + /// Physical removal plus binding retirement, one unit: the preferred + /// link repairs onto a connected survivor, preferring a writable one + /// (a link mid-service-rediscovery would strand directed sends until + /// its characteristic comes back). bleQueue-confined. + @discardableResult + func removePeripheralLink(_ peripheralID: String) -> PeerID? { + linkStateStore.removePeripheral(peripheralID) + return linkBindings.peripheralRemoved(peripheralID) { remaining in + let alive = remaining.compactMap { uuid -> (uuid: String, writable: Bool)? in + guard let state = linkStateStore.state(forPeripheralID: uuid), + state.isConnected else { return nil } + return (uuid, state.characteristic != nil) + } + return (alive.first(where: \.writable) ?? alive.first)?.uuid + } + } + + /// Binds only live physical links, preserving the store-era guard that + /// a binding can never outlive (or precede) its link. bleQueue-confined. + func bindPeripheralLink(_ peripheralUUID: String, to peerID: PeerID) { + guard linkStateStore.state(forPeripheralID: peripheralUUID) != nil else { return } + linkBindings.bindPeripheral(peripheralUUID, to: peerID) + } + + /// Whether the peer holds a live direct link in either role. + /// bleQueue-confined (physical liveness + bindings in one view). + func directLinkState(for peerID: PeerID) -> BLEDirectLinkState { + let hasPeripheral = linkBindings.preferredPeripheralUUID(for: peerID) + .flatMap { linkStateStore.state(forPeripheralID: $0)?.isConnected } ?? false + return BLEDirectLinkState( + hasPeripheral: hasPeripheral, + hasCentral: linkBindings.hasCentral(boundTo: peerID) + ) + } + + /// The peer's preferred peripheral link state, when physically present. + /// bleQueue-confined. + func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { + linkBindings.preferredPeripheralUUID(for: peerID) + .flatMap { linkStateStore.state(forPeripheralID: $0) } + } + + /// Subscribed centrals with their bindings, one view. bleQueue-confined. + func subscribedCentralSnapshot() -> BLESubscribedCentralSnapshot { + BLESubscribedCentralSnapshot( + centrals: linkStateStore.subscribedCentrals, + peerIDsByCentralUUID: linkBindings.centralPeersByUUID + ) } } @@ -3343,23 +3376,23 @@ extension BLEService { } func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) { - bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) } + bleQueue.sync { linkBindings.bindCentral(centralUUID, to: peerID) } } func _test_centralBinding(_ centralUUID: String) -> PeerID? { - bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) } + bleQueue.sync { linkBindings.peer(forCentralUUID: centralUUID) } } func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) { bleQueue.sync { - guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return } - noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID + guard linkBindings.peer(forCentralUUID: centralUUID) == peerID else { return } + linkAuth.markAuthenticated(.central(centralUUID), owner: peerID) } } func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool { bleQueue.sync { - noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID + linkAuth.isAuthenticated(.central(centralUUID), for: peerID) } } @@ -3650,7 +3683,6 @@ extension BLEService: CBPeripheralDelegate { var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState( peripheral: peripheral, characteristic: nil, - peerID: nil, isConnecting: false, isConnected: peripheral.state == .connected, lastConnectionAttempt: nil, @@ -3675,7 +3707,7 @@ extension BLEService: CBPeripheralDelegate { // NOTE: `processNotificationPacket` may bind the stored peer ID when an announce // is processed, but `state` above is a snapshot. Track a local binding that we update as soon as // we see a binding-eligible announce so subsequent frames can't spoof a different sender. - var boundPeerID: PeerID? = state.peerID + var boundPeerID: PeerID? = linkBindings.peer(forPeripheralID: peripheralUUID) for frame in result.frames { guard let packet = BinaryProtocol.decode(frame) else { @@ -3699,8 +3731,7 @@ extension BLEService: CBPeripheralDelegate { packet.type == MessageType.announce.rawValue, packet.ttl == messageTTL { boundPeerID = claimedSenderID - state.peerID = claimedSenderID - linkStateStore.bindPeripheral(peripheralUUID, to: claimedSenderID) + bindPeripheralLink(peripheralUUID, to: claimedSenderID) } if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID), peerID: context.receivedFromPeerID) { @@ -3728,9 +3759,9 @@ extension BLEService: CBPeripheralDelegate { // verification, so a bound link must not be re-bound by a raw // announce (spoofable). Rotation rebinds happen after the announce // verifies (rebindLinkAfterVerifiedDirectAnnounce). - let boundPeerID = linkStateStore.peerID(forPeripheralID: peripheralUUID) + let boundPeerID = linkBindings.peer(forPeripheralID: peripheralUUID) if boundPeerID == nil || boundPeerID == senderID { - linkStateStore.bindPeripheral(peripheralUUID, to: senderID) + bindPeripheralLink(peripheralUUID, to: senderID) refreshLocalTopology() } } @@ -3831,17 +3862,15 @@ extension BLEService: CBPeripheralManagerDelegate { // Bluetooth was turned off - clean up peripheral state SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session) // Clear subscribed centrals (they are now invalid) - let centralSnapshot = linkStateStore.subscribedCentralSnapshot + let centralSnapshot = subscribedCentralSnapshot() for central in centralSnapshot.centrals { let centralID = central.identifier.uuidString - noiseAuthenticatedLinkOwners.removeValue( - forKey: .central(centralID) - ) - noiseReconnectPolicy.endLinkEpoch(.central(centralID)) + linkAuth.retireLink(.central(centralID)) } pendingNotifications.removeAll() pendingWriteBuffers.removeAll() - let centralPeerIDs = linkStateStore.clearCentrals() + linkStateStore.clearCentrals() + let centralPeerIDs = linkBindings.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil // Notify UI of disconnections @@ -3854,7 +3883,8 @@ extension BLEService: CBPeripheralManagerDelegate { case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) - _ = linkStateStore.clearCentrals() + linkStateStore.clearCentrals() + _ = linkBindings.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil @@ -3963,9 +3993,9 @@ extension BLEService: CBPeripheralManagerDelegate { let centralID = central.identifier.uuidString SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session) pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } - noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID)) - noiseReconnectPolicy.endLinkEpoch(.central(centralID)) - let removedPeerID = linkStateStore.removeSubscribedCentral(central) + linkAuth.retireLink(.central(centralID)) + linkStateStore.removeSubscribedCentral(central) + let removedPeerID = linkBindings.centralRemoved(centralID) // Ensure we're still advertising for other devices to find us if !isPanicSuspended, peripheral.isAdvertising == false { @@ -3981,7 +4011,7 @@ extension BLEService: CBPeripheralManagerDelegate { // counts. If every link truly dropped, the surviving-link // callbacks (didDisconnectPeripheral, or this one again) run // the bookkeeping. - guard linkStateStore.links(to: peerID).isEmpty else { return } + guard linkBindings.links(to: peerID).isEmpty else { return } // Mark peer as not connected; retain for reachability peerRegistry.mutate { $0.markDisconnected(peerID) } @@ -4124,7 +4154,7 @@ extension BLEService: CBPeripheralManagerDelegate { let context = acceptedIngressContext( for: packet, claimedSenderID: claimedSenderID, - boundPeerID: linkStateStore.peerID(forCentralUUID: centralUUID), + boundPeerID: linkBindings.peer(forCentralUUID: centralUUID), linkDescription: "Central \(centralUUID.prefix(8))…" ) guard let context else { return } @@ -4139,9 +4169,9 @@ extension BLEService: CBPeripheralManagerDelegate { packet.ttl == messageTTL { // Same rule as the peripheral path: raw announces only bind // unbound links; rotation rebinds require a verified announce. - let boundPeerID = linkStateStore.peerID(forCentralUUID: centralUUID) + let boundPeerID = linkBindings.peer(forCentralUUID: centralUUID) if boundPeerID == nil || boundPeerID == claimedSenderID { - linkStateStore.bindCentral(centralUUID, to: claimedSenderID) + linkBindings.bindCentral(centralUUID, to: claimedSenderID) refreshLocalTopology() } } @@ -4683,22 +4713,15 @@ extension BLEService { /// Safely fetch the current direct-link state for a peer using the BLE queue. private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) { - let state = readLinkState { $0.directLinkState(for: peerID) } + let state = readLinkState { _ in directLinkState(for: peerID) } return (state.hasPeripheral, state.hasCentral) } private func links(to peerID: PeerID?) -> Set { - readLinkState { $0.links(to: peerID) } + readLinkState { _ in linkBindings.links(to: peerID) } } - private func boundPeerID(for link: BLEIngressLinkID, in store: BLELinkStateStore) -> PeerID? { - switch link { - case .peripheral(let peripheralUUID): - store.peerID(forPeripheralID: peripheralUUID) - case .central(let centralUUID): - store.peerID(forCentralUUID: centralUUID) - } - } + /// Marks the exact physical ingress link that completed a fresh Noise /// handshake. An old session keyed only by peer ID is insufficient: a @@ -4706,15 +4729,15 @@ extension BLEService { private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) { guard let link = ingressLinks.link(for: packet) else { return } readLinkState { store in - guard boundPeerID(for: link, in: store) == peerID else { return } - noiseAuthenticatedLinkOwners[link] = peerID + guard linkBindings.boundPeer(for: link) == peerID else { return } + linkAuth.markAuthenticated(link, owner: peerID) } } private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool { guard let link = ingressLinks.link(for: packet) else { return false } return readLinkState { store in - noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID + linkAuth.isAuthenticated(link, for: peerID) && linkBindings.boundPeer(for: link) == peerID } } @@ -4724,8 +4747,8 @@ extension BLEService { private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set { readLinkState { store in - Set(noiseAuthenticatedLinkOwners.compactMap { link, owner in - owner == peerID && boundPeerID(for: link, in: store) == peerID ? link : nil + Set(linkAuth.links(ownedBy: peerID).filter { link in + linkBindings.boundPeer(for: link) == peerID }) } } @@ -4744,13 +4767,13 @@ extension BLEService { let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID) let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID) let shouldRevalidate = readLinkState { store in - guard boundPeerID(for: link, in: store) == peerID else { + guard linkBindings.boundPeer(for: link) == peerID else { return false } - return noiseReconnectPolicy.shouldRevalidate( + return linkAuth.shouldRevalidate( on: link, + for: peerID, hasEstablishedSession: hasEstablishedSession, - isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID, hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty, now: Date() ) @@ -5801,7 +5824,7 @@ extension BLEService { } private func snapshotDirectPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { - readLinkState { $0.directPeripheralState(for: peerID) } + readLinkState { _ in directPeripheralState(for: peerID) } } private func snapshotPeripheralStates() -> [BLEPeripheralLinkState] { @@ -5809,7 +5832,7 @@ extension BLEService { } private func snapshotSubscribedCentrals() -> BLESubscribedCentralSnapshot { - readLinkState(\.subscribedCentralSnapshot) + readLinkState { _ in subscribedCentralSnapshot() } } // MARK: Helpers: IDs, selection, and write backpressure @@ -5861,8 +5884,8 @@ extension BLEService { } if let peerID = requiredAuthenticatedPeer { let link = BLEIngressLinkID.peripheral(uuid) - guard state.peerID == peerID, - noiseAuthenticatedLinkOwners[link] == peerID else { + guard linkBindings.peer(forPeripheralID: uuid) == peerID, + linkAuth.isAuthenticated(link, for: peerID) else { return false } } @@ -6747,10 +6770,10 @@ extension BLEService { switch link { case .peripheral(let peripheralUUID): linkUUID = peripheralUUID - previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID) + previousPeerID = self.linkBindings.peer(forPeripheralID: peripheralUUID) case .central(let centralUUID): linkUUID = centralUUID - previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID) + previousPeerID = self.linkBindings.peer(forCentralUUID: centralUUID) } guard let previousPeerID else { return } guard previousPeerID != peerID else { @@ -6768,30 +6791,29 @@ extension BLEService { // never steal an identity another live link already owns, and // allow at most one rebind per link per cooldown window so two // identities can't fight over a link in a replay flip-flop. - guard self.linkStateStore.links(to: peerID).isEmpty else { + guard self.linkBindings.links(to: peerID).isEmpty else { SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security) return } let now = Date() - self.lastLinkRebindAt = self.lastLinkRebindAt.filter { - now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds - } - guard self.lastLinkRebindAt[linkUUID] == nil else { + guard self.linkAuth.permitRebind( + linkUUID: linkUUID, + now: now, + cooldown: TransportConfig.bleLinkRebindCooldownSeconds + ) else { SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security) return } - self.lastLinkRebindAt[linkUUID] = now // A Noise proof belongs to the old physical binding. Never carry // it across an announce-driven rebind, whose direct TTL is // replayable; the new owner must complete a fresh handshake. - self.noiseAuthenticatedLinkOwners.removeValue(forKey: link) - self.noiseReconnectPolicy.endLinkEpoch(link) + self.linkAuth.retireLink(link) switch link { case .peripheral(let peripheralUUID): - self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID) + self.bindPeripheralLink(peripheralUUID, to: peerID) case .central(let centralUUID): - self.linkStateStore.bindCentral(centralUUID, to: peerID) + self.linkBindings.bindCentral(centralUUID, to: peerID) } // Keep the rebind and reconnect decision in one bleQueue critical // section. No observer may see the new binding while a cached @@ -6820,7 +6842,7 @@ extension BLEService { self.cancelBoundPeripheralLinks(to: previousPeerID, keeping: linkUUID) // Retire the rotated-away ID only once its last link is gone; a // remaining stale link heals the same way or ages out. - guard self.linkStateStore.links(to: previousPeerID).isEmpty else { return } + guard self.linkBindings.links(to: previousPeerID).isEmpty else { return } self.messageQueue.async { [weak self] in self?.retireRotatedPeer(previousPeerID) } @@ -6849,26 +6871,25 @@ extension BLEService { bleQueue.async { [weak self] in guard let self else { return } let now = Date() - self.lastRedundantLinkRetirementAt = self.lastRedundantLinkRetirementAt.filter { - now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds - } - guard self.lastRedundantLinkRetirementAt[peerID] == nil else { return } - var ingressPeripheralUUID: String? if case .peripheral(let uuid) = ingressLink { ingressPeripheralUUID = uuid } guard let keptUUID = BLERedundantLinkPolicy.keptPeripheralUUID( ingressPeripheralUUID: ingressPeripheralUUID, - mostRecentlyBoundUUID: self.linkStateStore.preferredPeripheralBindings[peerID], + mostRecentlyBoundUUID: self.linkBindings.preferredPeripheralUUID(for: peerID), links: self.peripheralLinkPolicySnapshot(), peerID: peerID ) else { return } - self.lastRedundantLinkRetirementAt[peerID] = now + guard self.linkAuth.permitRedundantRetirement( + peerID: peerID, + now: now, + cooldown: TransportConfig.bleLinkRebindCooldownSeconds + ) else { return } // The survivor becomes the peer's reverse-mapped link so directed // sends follow the consolidation. - self.linkStateStore.bindPeripheral(keptUUID, to: peerID) + self.bindPeripheralLink(keptUUID, to: peerID) self.cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID) self.refreshLocalTopology() } @@ -6899,9 +6920,10 @@ extension BLEService { /// bleQueue only (reads the link store). private func peripheralLinkPolicySnapshot() -> [BLERedundantLinkPolicy.PeripheralLink] { linkStateStore.peripheralStates.map { - BLERedundantLinkPolicy.PeripheralLink( - uuid: $0.peripheral.identifier.uuidString, - peerID: $0.peerID, + let uuid = $0.peripheral.identifier.uuidString + return BLERedundantLinkPolicy.PeripheralLink( + uuid: uuid, + peerID: linkBindings.peer(forPeripheralID: uuid), isConnected: $0.isConnected, hasCharacteristic: $0.characteristic != nil ) @@ -6986,13 +7008,8 @@ extension BLEService { // residual forged-presence window this leaves is accepted. guard let self else { return false } guard let link = self.ingressLinks.link(for: packet) else { return false } - let boundPeerID: PeerID? = self.readLinkState { store in - switch link { - case .peripheral(let peripheralUUID): - return store.peerID(forPeripheralID: peripheralUUID) - case .central(let centralUUID): - return store.peerID(forCentralUUID: centralUUID) - } + let boundPeerID: PeerID? = self.readLinkState { _ in + self.linkBindings.boundPeer(for: link) } guard let boundPeerID else { return false } return boundPeerID != peerID diff --git a/bitchatTests/Services/BLELinkAuthStateTests.swift b/bitchatTests/Services/BLELinkAuthStateTests.swift new file mode 100644 index 00000000..c16d41a0 --- /dev/null +++ b/bitchatTests/Services/BLELinkAuthStateTests.swift @@ -0,0 +1,75 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLELinkAuthStateTests { + private let peerID = PeerID(str: "1122334455667788") + private let link = BLEIngressLinkID.peripheral("periph-a") + + @Test + func authenticationBindsToTheExactLinkAndOwner() { + var auth = BLELinkAuthState() + auth.markAuthenticated(link, owner: peerID) + + #expect(auth.isAuthenticated(link, for: peerID)) + #expect(!auth.isAuthenticated(link, for: PeerID(str: "8899aabbccddeeff"))) + #expect(!auth.isAuthenticated(.peripheral("periph-b"), for: peerID)) + + auth.retireLink(link) + #expect(!auth.isAuthenticated(link, for: peerID)) + } + + @Test + func retireLinksOwnedByPeerReturnsAndRetiresThemAll() { + var auth = BLELinkAuthState() + auth.markAuthenticated(.peripheral("periph-a"), owner: peerID) + auth.markAuthenticated(.central("central-a"), owner: peerID) + auth.markAuthenticated(.central("central-b"), owner: PeerID(str: "8899aabbccddeeff")) + + let departed = Set(auth.retireLinks(ownedBy: peerID)) + + #expect(departed == [.peripheral("periph-a"), .central("central-a")]) + #expect(auth.links(ownedBy: peerID).isEmpty) + #expect(auth.isAuthenticated(.central("central-b"), for: PeerID(str: "8899aabbccddeeff"))) + } + + @Test + func rebindCooldownPermitsOncePerWindowAndAgesOut() { + var auth = BLELinkAuthState() + let start = Date(timeIntervalSince1970: 1_000) + + let first = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30) + #expect(first) + let withinWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(10), cooldown: 30) + #expect(!withinWindow) + // A different link has its own allowance. + let otherLink = auth.permitRebind(linkUUID: "periph-b", now: start.addingTimeInterval(10), cooldown: 30) + #expect(otherLink) + // The window ages out. + let afterWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(31), cooldown: 30) + #expect(afterWindow) + } + + @Test + func containmentCooldownsSurviveASessionReset() { + var auth = BLELinkAuthState() + let start = Date(timeIntervalSince1970: 2_000) + auth.markAuthenticated(link, owner: peerID) + let rebindBefore = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30) + let retirementBefore = auth.permitRedundantRetirement(peerID: peerID, now: start, cooldown: 30) + #expect(rebindBefore) + #expect(retirementBefore) + + // Panic/emergency resets wipe proofs and epochs — but a stable + // CoreBluetooth UUID must not earn a fresh rebind or retirement + // allowance just because the session state around it was wiped. + auth.removeAll() + + #expect(!auth.isAuthenticated(link, for: peerID)) + let rebindAfterReset = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(5), cooldown: 30) + let retirementAfterReset = auth.permitRedundantRetirement(peerID: peerID, now: start.addingTimeInterval(5), cooldown: 30) + #expect(!rebindAfterReset) + #expect(!retirementAfterReset) + } +} diff --git a/bitchatTests/Services/BLELinkBindingsTests.swift b/bitchatTests/Services/BLELinkBindingsTests.swift new file mode 100644 index 00000000..3730cb2a --- /dev/null +++ b/bitchatTests/Services/BLELinkBindingsTests.swift @@ -0,0 +1,111 @@ +import BitFoundation +import Testing +@testable import bitchat + +struct BLELinkBindingsTests { + private let peerID = PeerID(str: "1122334455667788") + private let otherPeerID = PeerID(str: "8899aabbccddeeff") + + @Test + func centralBindingExposesBoundPeerAndLinks() { + var bindings = BLELinkBindings() + + bindings.bindCentral("central-a", to: peerID) + + #expect(bindings.peer(forCentralUUID: "central-a") == peerID) + #expect(bindings.hasCentral(boundTo: peerID)) + #expect(bindings.boundPeer(for: .central("central-a")) == peerID) + #expect(bindings.links(to: peerID) == [.central("central-a")]) + } + + @Test + func linksReturnsAllBindingsForPeerAcrossRoles() { + var bindings = BLELinkBindings() + + bindings.bindCentral("central-a", to: peerID) + bindings.bindCentral("central-b", to: peerID) + bindings.bindCentral("central-c", to: otherPeerID) + bindings.bindPeripheral("periph-a", to: peerID) + + #expect(bindings.links(to: peerID) == [.central("central-a"), .central("central-b"), .peripheral("periph-a")]) + } + + @Test + func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() { + var bindings = BLELinkBindings() + + bindings.bindCentral("central-a", to: peerID) + bindings.bindCentral("central-b", to: otherPeerID) + + let removedPeerIDs = Set(bindings.clearCentrals()) + + #expect(removedPeerIDs == Set([peerID, otherPeerID])) + #expect(bindings.peer(forCentralUUID: "central-a") == nil) + #expect(bindings.links(to: peerID).isEmpty) + } + + @Test + func rotationRebindDropsTheRetiredIdentitysReverseMapping() { + var bindings = BLELinkBindings() + bindings.bindPeripheral("periph-a", to: peerID) + #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a") + + // The link's owner rotates: the old identity must no longer claim + // this link as its preferred peripheral. + bindings.bindPeripheral("periph-a", to: otherPeerID) + + #expect(bindings.preferredPeripheralUUID(for: peerID) == nil) + #expect(bindings.preferredPeripheralUUID(for: otherPeerID) == "periph-a") + #expect(bindings.peer(forPeripheralID: "periph-a") == otherPeerID) + } + + @Test + func removingThePreferredLinkRepairsOntoTheChosenSurvivor() { + var bindings = BLELinkBindings() + bindings.bindPeripheral("periph-a", to: peerID) + bindings.bindPeripheral("periph-b", to: peerID) + // periph-b bound last: it is the preferred link. + #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b") + + let removed = bindings.peripheralRemoved("periph-b") { remaining in + #expect(remaining == ["periph-a"]) + return remaining.first + } + + #expect(removed == peerID) + #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a") + #expect(bindings.links(to: peerID) == [.peripheral("periph-a")]) + } + + @Test + func removingADuplicateLinkDoesNotStrandThePreferredOne() { + var bindings = BLELinkBindings() + bindings.bindPeripheral("periph-a", to: peerID) + bindings.bindPeripheral("periph-b", to: peerID) + + // Removing the non-preferred duplicate must leave the reverse map + // untouched (no repair callback consulted for a non-preferred link). + let removed = bindings.peripheralRemoved("periph-a") { _ in + Issue.record("survivor choice must not run for a non-preferred link") + return nil + } + + #expect(removed == peerID) + #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b") + } + + @Test + func removingTheLastLinkClearsThePreferredMapping() { + var bindings = BLELinkBindings() + bindings.bindPeripheral("periph-a", to: peerID) + + let removed = bindings.peripheralRemoved("periph-a") { remaining in + #expect(remaining.isEmpty) + return nil + } + + #expect(removed == peerID) + #expect(bindings.preferredPeripheralUUID(for: peerID) == nil) + #expect(bindings.links(to: peerID).isEmpty) + } +} diff --git a/bitchatTests/Services/BLELinkStateStoreTests.swift b/bitchatTests/Services/BLELinkStateStoreTests.swift deleted file mode 100644 index 9a5179df..00000000 --- a/bitchatTests/Services/BLELinkStateStoreTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import BitFoundation -import Testing -@testable import bitchat - -struct BLELinkStateStoreTests { - @Test - func centralBindingExposesDirectLinkStateAndLinks() { - let store = BLELinkStateStore() - let peerID = PeerID(str: "1122334455667788") - - store.bindCentral("central-a", to: peerID) - - #expect(store.peerID(forCentralUUID: "central-a") == peerID) - #expect(store.directLinkState(for: peerID) == BLEDirectLinkState(hasPeripheral: false, hasCentral: true)) - #expect(store.links(to: peerID) == [.central("central-a")]) - } - - @Test - func linksReturnsAllCentralBindingsForPeer() { - let store = BLELinkStateStore() - let peerID = PeerID(str: "1122334455667788") - let otherPeerID = PeerID(str: "8899aabbccddeeff") - - store.bindCentral("central-a", to: peerID) - store.bindCentral("central-b", to: peerID) - store.bindCentral("central-c", to: otherPeerID) - - #expect(store.links(to: peerID) == [.central("central-a"), .central("central-b")]) - } - - @Test - func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() { - let store = BLELinkStateStore() - let firstPeerID = PeerID(str: "1122334455667788") - let secondPeerID = PeerID(str: "8899aabbccddeeff") - - store.bindCentral("central-a", to: firstPeerID) - store.bindCentral("central-b", to: secondPeerID) - - let removedPeerIDs = Set(store.clearCentrals()) - - #expect(removedPeerIDs == Set([firstPeerID, secondPeerID])) - #expect(store.peerID(forCentralUUID: "central-a") == nil) - #expect(store.links(to: firstPeerID).isEmpty) - } -}