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:
jack
2026-07-08 10:08:23 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 78a291ab77
commit 3e9230229d
7 changed files with 358 additions and 25 deletions
+128 -8
View File
@@ -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/centralpeerID
/// 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 {