diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 1fb33389..5f29ade3 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3199,6 +3199,14 @@ extension BLEService { onEngine { linkBindings.peer(forCentralUUID: centralUUID) } } + func _test_linkBinding(_ link: BLEIngressLinkID) -> PeerID? { + onEngine { linkBindings.boundPeer(for: link) } + } + + func _test_knownPeerIDs() -> [PeerID] { + peerRegistry.peerIDs + } + func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) { onEngine { guard linkBindings.peer(forCentralUUID: centralUUID) == peerID else { return } @@ -6241,12 +6249,55 @@ extension BLEService { // them now instead of leaving ghost links that spray duplicate // traffic until the inactivity timeout. 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 linkBindings.links(to: previousPeerID).isEmpty else { return } + // Links we cannot cancel (the remote owns its central connections) + // must still stop claiming the dead identity, or it lingers as a + // ghost peer that the NEW identity's own traffic keeps refreshing + // (issue #1538). + releaseLinksBoundToRotatedPeer(previousPeerID) retireRotatedPeer(previousPeerID) } + /// Unbinds every link still bound to an identity a verified direct + /// announce just rotated away from, and retires those links' Noise + /// proofs. + /// + /// Release, deliberately not rebind: a rotation announce proves only + /// that *its own* link's device now presents as the new ID, so binding + /// a different link to that ID on this evidence is exactly what the + /// #1401 containment rule ("never steal an identity another live link + /// already owns") forbids — and that rule stays intact. Unbinding is + /// strictly less trusting than any binding, and it is correct under + /// both readings of a second link bound to the retired ID: either it is + /// the same physical device (dual links to one phone, the field case), + /// or one of the two links is a spoofer holding a forged binding — + /// since a peer ID is derived from a Noise key fingerprint, two devices + /// cannot both legitimately own it. Dropping the binding is right in + /// the first case and a win in the second. + /// + /// Released links then converge through the ordinary unbound-link path: + /// the next raw direct announce on the link binds it to whoever it + /// actually carries. Until then the link's frames attribute to their + /// claimed sender rather than to a dead ID. + /// + /// Residual (unchanged in kind from what the containment already + /// accepts): an attacker who has bound their own link to X — possible + /// by replaying X's raw announce onto an unbound link — can drive a + /// rebind on it and so evict X's registry entry. X's next announce + /// re-binds its real links and restores presence, and the per-link + /// rebind cooldown bounds the repetition rate. + private func releaseLinksBoundToRotatedPeer(_ peerID: PeerID) { + for link in linkBindings.links(to: peerID) { + linkAuth.retireLink(link) + switch link { + case .peripheral(let peripheralUUID): + // No survivor: every link this peer holds is being released. + _ = linkBindings.peripheralRemoved(peripheralUUID) { _ in nil } + case .central(let centralUUID): + _ = linkBindings.centralRemoved(centralUUID) + } + } + } + /// After a restore relaunch the same phone can reappear under a fresh /// peripheral UUID while its restored connection lives on, leaving /// several live central-role connections to one peer that each carry diff --git a/bitchatTests/Simulation/SimulatedMesh.swift b/bitchatTests/Simulation/SimulatedMesh.swift index d9f418fb..1d5c5094 100644 --- a/bitchatTests/Simulation/SimulatedMesh.swift +++ b/bitchatTests/Simulation/SimulatedMesh.swift @@ -32,6 +32,16 @@ final class SimulatedMesh { private(set) var nodes: [Node] = [] private var neighbors: [Set] = [] + private var duplicateLinkEdges: Set = [] + private var emitted: [[BitchatPacket]] = [] + + /// Every packet a node has put on the wire — the attacker's capture + /// buffer for replay tests. + func emittedPackets(from index: Int) -> [BitchatPacket] { + lock.lock() + defer { lock.unlock() } + return emitted[index] + } @discardableResult func addNode(nickname: String) -> Node { @@ -50,6 +60,7 @@ final class SimulatedMesh { let node = Node(service: service, scheduler: scheduler) nodes.append(node) neighbors.append([]) + emitted.append([]) service.setNickname(nickname) service._test_onOutboundPacket = { [weak self] packet in // Runs on the sender's engine; only buffer here — delivering @@ -57,6 +68,7 @@ final class SimulatedMesh { guard let self else { return } self.lock.lock() self.pendingDeliveries.append((from: index, packet: packet)) + self.emitted[index].append(packet) self.lock.unlock() } return node @@ -67,12 +79,56 @@ final class SimulatedMesh { neighbors[b].insert(a) } - /// The synthetic link a frame from `sender` arrives on at `receiver`. - /// Stable per directed edge, like a CoreBluetooth central UUID. + /// Radio silence: stops delivering between two nodes without reporting + /// any link event, so existing bindings persist exactly as they do when + /// a peer walks out of range before its link times out. Lets a test + /// capture a packet the far side never received. + func silence(_ a: Int, _ b: Int) { + neighbors[a].remove(b) + neighbors[b].remove(a) + } + + /// Models two live links to the same phone (issue #1538): every frame + /// from the neighbour arrives twice, on two link IDs that both bind to + /// the sender. + /// + /// Both are central links — the remote's connections to our peripheral + /// role. That is deliberate and faithful to the defect: central links + /// are the ones we cannot cancel (they belong to the remote), so they + /// are exactly the links the peripheral-cancel path cannot reach after + /// a rotation. Peripheral-role bindings additionally require physical + /// link state keyed by a real CBPeripheral, which no CB-free harness + /// can fabricate. + func connectDuplicateLinks(_ a: Int, _ b: Int) { + connect(a, b) + duplicateLinkEdges.insert(Self.edgeKey(a, b)) + } + + /// The synthetic central link a frame from `sender` arrives on at + /// `receiver`. Stable per directed edge, like a CoreBluetooth central + /// UUID. func linkUUID(from sender: Int, at receiver: Int) -> String { "SIM-\(sender)-TO-\(receiver)" } + /// Order-independent edge key. + private static func edgeKey(_ a: Int, _ b: Int) -> String { + "\(min(a, b))-\(max(a, b))" + } + + /// The second link of a duplicate-link edge. + func duplicateLinkUUID(from sender: Int, at receiver: Int) -> String { + "SIM-DUP-\(sender)-TO-\(receiver)" + } + + private func links(from sender: Int, at receiver: Int) -> [BLEIngressLinkID] { + var links: [BLEIngressLinkID] = [.central(linkUUID(from: sender, at: receiver))] + if duplicateLinkEdges.contains(Self.edgeKey(sender, receiver)) { + links.append(.central(duplicateLinkUUID(from: sender, at: receiver))) + } + return links + } + func forceAnnounce(from index: Int) { nodes[index].service._test_forceAnnounce() pump() @@ -100,11 +156,10 @@ final class SimulatedMesh { for (from, packet) in batch { for receiver in neighbors[from] { - deliveredFrameCount += 1 - nodes[receiver].service._test_ingestFrame( - packet, - link: .central(linkUUID(from: from, at: receiver)) - ) + for link in links(from: from, at: receiver) { + deliveredFrameCount += 1 + nodes[receiver].service._test_ingestFrame(packet, link: link) + } } } nodes.forEach { $0.service._test_fenceEngine() } diff --git a/bitchatTests/Simulation/SimulatedMeshTests.swift b/bitchatTests/Simulation/SimulatedMeshTests.swift index 58ac11fe..c03ed21f 100644 --- a/bitchatTests/Simulation/SimulatedMeshTests.swift +++ b/bitchatTests/Simulation/SimulatedMeshTests.swift @@ -140,6 +140,124 @@ struct SimulatedMeshTests { #expect(a.service.getConnectedPeers().contains(b.service.myPeerID)) } + /// Issue #1538: with two live links to the same phone, a panic + /// rotation used to heal only the link the verified announce arrived + /// on. The second link kept its binding to + /// the retired identity, which therefore stayed in the peer list as a + /// ghost — and, worse, kept being refreshed by the *new* identity's + /// traffic (a bound link attributes non-announce frames to its bound + /// peer, so the dead ID looked alive for as long as the link lived). + @Test + func duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks() { + let mesh = SimulatedMesh() + let a = mesh.addNode(nickname: "alice") + let b = mesh.addNode(nickname: "bob") + mesh.connectDuplicateLinks(0, 1) + mesh.announceAll() + + let centralLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0)) + let duplicateLink = BLEIngressLinkID.central(mesh.duplicateLinkUUID(from: 1, at: 0)) + let oldBobID = b.service.myPeerID + // Both links bind to bob: raw direct announces bind unbound links, + // and that happens before duplicate suppression. + #expect(a.service._test_linkBinding(centralLink) == oldBobID) + #expect(a.service._test_linkBinding(duplicateLink) == oldBobID) + + b.service.suspendForPanicReset() + b.service.resetIdentityForPanic(currentNickname: "anon", restartServices: false) + b.service.completePanicReset(restartServices: false) + mesh.pump() + let newBobID = b.service.myPeerID + #expect(newBobID != oldBobID) + + // One verified direct announce must retire the old identity + // outright — no ghost survives on the link it did not arrive on. + mesh.forceAnnounce(from: 1) + mesh.settleUntil { !a.service._test_knownPeerIDs().contains(oldBobID) } + #expect(!a.service._test_knownPeerIDs().contains(oldBobID)) + #expect(a.service._test_linkBinding(centralLink) != oldBobID) + #expect(a.service._test_linkBinding(duplicateLink) != oldBobID) + + // Both links converge onto the new identity as its announces land + // (the released link binds through the ordinary unbound-link path, + // so no containment rule has to be relaxed). + for _ in 0..<4 { + b.service._test_resetAnnounceThrottle() + mesh.forceAnnounce(from: 1) + mesh.advanceTime(by: 1) + } + #expect(a.service._test_linkBinding(centralLink) == newBobID) + #expect(a.service._test_linkBinding(duplicateLink) == newBobID) + #expect(a.service.getConnectedPeers() == [newBobID]) + } + + /// The #1401 containment rule, pinned against the attack the #1538 fix + /// had to avoid re-opening: a captured verified direct announce replayed + /// onto a link the attacker controls must NOT bind that link to the + /// victim while the victim holds a live link of its own — and must not + /// evict the victim either (the rotation release only runs after a + /// rebind the containment actually permitted). + @Test + func replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim() { + let mesh = SimulatedMesh() + let alice = mesh.addNode(nickname: "alice") + let bob = mesh.addNode(nickname: "bob") + let mallory = mesh.addNode(nickname: "mallory") + mesh.connect(0, 1) + mesh.connect(0, 2) + mesh.announceAll() + + let bobLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0)) + let malloryLink = BLEIngressLinkID.central(mesh.linkUUID(from: 2, at: 0)) + #expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID) + #expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID) + + // Mallory captures a signed direct announce alice has NOT seen, so + // duplicate suppression cannot mask the containment check: bob + // announces while out of alice's range, and mallory replays it on + // her own link. Directness is forgeable; the signature is real. + mesh.silence(0, 1) + bob.service._test_resetAnnounceThrottle() + mesh.forceAnnounce(from: 1) + let replay = mesh.emittedPackets(from: 1).last { + $0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault + } + guard let replay else { + Issue.record("bob emitted no direct announce to capture") + return + } + alice.service._test_ingestFrame(replay, link: malloryLink) + mesh.pump() + mesh.advanceTime(by: 1) + + // The link is not stolen, and bob keeps both his binding and his + // place in the peer list. + #expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID) + #expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID) + #expect(alice.service._test_knownPeerIDs().contains(bob.service.myPeerID)) + #expect(alice.service.getConnectedPeers().contains(bob.service.myPeerID)) + + // Positive control — proves the refusal above was the containment + // rule and not duplicate suppression: once bob holds no live link, + // the very same replayed announce on the very same link does take + // effect. (Long-standing accepted residual: a stolen link carries + // only Noise ciphertext, and the rebind retires the link's proof.) + alice.service.emitLinkEvent(.centralLinkEnded(centralUUID: mesh.linkUUID(from: 1, at: 0))) + alice.service._test_fenceEngine() + bob.service._test_resetAnnounceThrottle() + mesh.forceAnnounce(from: 1) + let secondReplay = mesh.emittedPackets(from: 1).last { + $0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault + } + #expect(secondReplay?.timestamp != replay.timestamp) + if let secondReplay { + alice.service._test_ingestFrame(secondReplay, link: malloryLink) + mesh.pump() + mesh.advanceTime(by: 1) + } + #expect(alice.service._test_linkBinding(malloryLink) == bob.service.myPeerID) + } + @Test func panicRotationRebindsSurvivorExactlyOnceAndStays() { let mesh = SimulatedMesh()