mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Heal peer-ID rotation: rebind link on verified announce, retire ghost peer (#1401)
* Heal peer-ID rotation: rebind link on verified announce, retire ghost When a peer relaunches it rotates its ephemeral peer ID, but a still-open BLE connection kept its stale peripheral/central→peerID binding for the reachability retention window (~45-60s). Until it aged out, the other side showed a duplicate ghost peer and dropped the rotated peer's direct announces as spoofing attempts. Root cause: the ingress guard rejected a direct announce whose claimed sender differed from the link binding before signature verification could ever see it, so the binding could never heal. - Ingress guard: let a mismatched direct announce through, attributed to the claimed sender; REQUEST_SYNC keeps the strict binding check. - Bind sites: raw (pre-verification) announces may only bind unbound links, never rebind bound ones — rebinding now requires the announce handler's signature verification to pass. - On a verified direct announce whose ingress link is bound to a different peer, rebind the link to the announced ID and retire the rotated-away ID immediately (registry + gossip + UI), mirroring handleLeave. - BLELinkStateStore.bindPeripheral: drop the previous peer's reverse mapping on rebind so the retired ID no longer claims the link. Fixes #1387 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Contain forged-directness rebinds: no identity steal, per-link cooldown The announce signature does not authenticate directness (TTL is excluded from signing because relays mutate it), so a bound peer could replay another peer's fresh signed announce with its TTL restored and reach the rotation rebind path. Contain what such a replay can do: - Refuse a rebind when the claimed identity already owns another live link — a replayed announce can no longer steal a connected peer's binding or route its directed traffic to the replaying link. - Allow at most one rebind per link per cooldown window (60s) so two identities cannot fight over a link in a replay flip-flop, with each flip retiring the other peer. A replay against an identity with no live link remains possible, but that capability already exists today on unbound links, where any raw direct announce binds pre-verification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
78a291ab77
commit
3e9230229d
@@ -38,6 +38,10 @@ final class BLEService: NSObject {
|
||||
// 1. Consolidated BLE link tracking for both central and peripheral roles.
|
||||
private var linkStateStore = BLELinkStateStore()
|
||||
|
||||
// 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] = [:]
|
||||
|
||||
// BCH-01-004: Rate-limiting for subscription-triggered announces.
|
||||
private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter()
|
||||
|
||||
@@ -1972,6 +1976,28 @@ extension BLEService {
|
||||
recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID))
|
||||
}
|
||||
|
||||
func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||
bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) }
|
||||
}
|
||||
|
||||
func _test_centralBinding(_ centralUUID: String) -> PeerID? {
|
||||
bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
|
||||
}
|
||||
|
||||
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
peerRegistry.upsert(BLEPeerInfo(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: true,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
static func _test_shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: [CBUUID],
|
||||
cachedServiceUUIDs: [CBUUID]?
|
||||
@@ -2152,16 +2178,20 @@ extension BLEService: CBPeripheralDelegate {
|
||||
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID.id.prefix(8))…", category: .session)
|
||||
}
|
||||
|
||||
if packet.type == MessageType.announce.rawValue {
|
||||
if packet.ttl == messageTTL {
|
||||
if packet.type == MessageType.announce.rawValue,
|
||||
packet.ttl == messageTTL {
|
||||
// Only bind an unbound link here: this runs before signature
|
||||
// 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)
|
||||
if boundPeerID == nil || boundPeerID == senderID {
|
||||
linkStateStore.bindPeripheral(peripheralUUID, to: senderID)
|
||||
refreshLocalTopology()
|
||||
}
|
||||
|
||||
handleReceivedPacket(packet, from: peerID)
|
||||
} else {
|
||||
handleReceivedPacket(packet, from: peerID)
|
||||
}
|
||||
|
||||
handleReceivedPacket(packet, from: peerID)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
@@ -2523,8 +2553,13 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
|
||||
if packet.type == MessageType.announce.rawValue,
|
||||
packet.ttl == messageTTL {
|
||||
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
|
||||
refreshLocalTopology()
|
||||
// Same rule as the peripheral path: raw announces only bind
|
||||
// unbound links; rotation rebinds require a verified announce.
|
||||
let boundPeerID = linkStateStore.peerID(forCentralUUID: centralUUID)
|
||||
if boundPeerID == nil || boundPeerID == claimedSenderID {
|
||||
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
|
||||
refreshLocalTopology()
|
||||
}
|
||||
}
|
||||
|
||||
guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else {
|
||||
@@ -4096,6 +4131,12 @@ extension BLEService {
|
||||
drainPendingPrekeyBundles(for: result.peerID)
|
||||
}
|
||||
|
||||
// A verified direct announce proves the sender owns the link it came
|
||||
// in on: heal any stale binding left by a peer-ID rotation.
|
||||
if let result, result.isVerified, result.isDirectAnnounce {
|
||||
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
|
||||
}
|
||||
|
||||
// Courier work: an announce is the moment we learn a peer's Noise
|
||||
// static key, so check whether we're carrying mail addressed to them
|
||||
// (or spray-able mail they could carry). Verified announces only.
|
||||
@@ -4115,6 +4156,85 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
/// When a peer relaunches it rotates its ephemeral peer ID, but an
|
||||
/// already-open BLE connection keeps its old peripheral/central→peerID
|
||||
/// binding. Until that binding heals, the rotated peer shows up twice in
|
||||
/// the peer list and its directed traffic on this link is dropped as
|
||||
/// spoofed. A signature-verified direct announce proves the claimed
|
||||
/// sender owns the link it arrived on, so rebind the link to the new ID
|
||||
/// and retire the old identity.
|
||||
private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) {
|
||||
guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return }
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let linkUUID: String
|
||||
let previousPeerID: PeerID?
|
||||
switch link {
|
||||
case .peripheral(let peripheralUUID):
|
||||
linkUUID = peripheralUUID
|
||||
previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID)
|
||||
case .central(let centralUUID):
|
||||
linkUUID = centralUUID
|
||||
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
|
||||
}
|
||||
guard let previousPeerID, previousPeerID != peerID else { return }
|
||||
|
||||
// The signature does not authenticate directness (TTL is excluded
|
||||
// from signing because relays mutate it), so a "verified direct"
|
||||
// announce can be a replay of another peer's fresh announce with
|
||||
// its TTL restored. Contain what a forged rebind could do:
|
||||
// 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 {
|
||||
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 {
|
||||
SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security)
|
||||
return
|
||||
}
|
||||
self.lastLinkRebindAt[linkUUID] = now
|
||||
|
||||
switch link {
|
||||
case .peripheral(let peripheralUUID):
|
||||
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
|
||||
case .central(let centralUUID):
|
||||
self.linkStateStore.bindCentral(centralUUID, to: peerID)
|
||||
}
|
||||
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session)
|
||||
self.refreshLocalTopology()
|
||||
// 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 }
|
||||
self.messageQueue.async { [weak self] in
|
||||
self?.retireRotatedPeer(previousPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotation is an implicit leave of the old identity: drop it immediately
|
||||
/// instead of letting a ghost duplicate linger for the reachability
|
||||
/// retention window.
|
||||
private func retireRotatedPeer(_ peerID: PeerID) {
|
||||
let removed = collectionsQueue.sync(flags: .barrier) {
|
||||
peerRegistry.remove(peerID) != nil
|
||||
}
|
||||
guard removed else { return }
|
||||
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||
refreshLocalTopology()
|
||||
notifyUI { [weak self] in
|
||||
guard let self else { return }
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||||
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the announce handler environment. All queue hops stay here so
|
||||
/// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
|
||||
private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
|
||||
|
||||
Reference in New Issue
Block a user