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
@@ -78,11 +78,22 @@ struct BLEIngressLinkRegistry {
return .failure(.selfLoopback(packetType: packet.type))
}
if let boundPeerID,
boundPeerID != claimedSenderID,
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
if let boundPeerID, boundPeerID != claimedSenderID {
if requiresDirectSenderBinding(packet) {
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
// A direct announce claiming a new sender on a bound link is either
// a spoof or a legitimate peer-ID rotation on a connection that
// outlived the old ID. Attribute it to the claimed sender and let
// it through: announces are self-authenticating, and only a
// signature-verified announce may rebind the link (BLEService).
if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) {
return .success(BLEIngressPacketContext(
receivedFromPeerID: claimedSenderID,
validationPeerID: claimedSenderID
))
}
}
let receivedFromPeerID = boundPeerID ?? claimedSenderID
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
@@ -98,12 +109,15 @@ struct BLEIngressLinkRegistry {
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
}
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
// must be the link peer it elicits a full store replay, and the
// response is addressed to whoever the sender claims to be.
if packet.type == MessageType.requestSync.rawValue { return true }
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
packet.type == MessageType.requestSync.rawValue
}
static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
}
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
+12 -2
View File
@@ -203,9 +203,19 @@ final class BLELinkStateStore {
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
assertOwned()
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
peerToPeripheralUUID[peerID] = peripheralUUID
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? {
+125 -5
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)
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
@@ -2523,9 +2553,14 @@ extension BLEService: CBPeripheralManagerDelegate {
if packet.type == MessageType.announce.rawValue,
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)
if boundPeerID == nil || boundPeerID == claimedSenderID {
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
refreshLocalTopology()
}
}
guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else {
return
@@ -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 {
+4
View File
@@ -137,6 +137,10 @@ enum TransportConfig {
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 45.0 // unknown/unverified
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
// At most one rotation rebind per link per window: TTL is not signed, so
// a replayed announce can forge "direct", and without a cooldown two
// identities could fight over a link in a rebind flip-flop.
static let bleLinkRebindCooldownSeconds: TimeInterval = 60.0
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
static let bleRecentPacketWindowMaxCount: Int = 100
+123 -1
View File
@@ -114,7 +114,9 @@ struct BLEServiceCoreTests {
}
@Test
func ingressRejectsDirectAnnounceThatConflictsWithBoundLink() async throws {
func ingressAllowsDirectAnnounceThatConflictsWithBoundLink() async throws {
// Peer-ID rotation heal: the announce must reach signature
// verification, which decides whether the link rebinds.
let ble = makeService()
let boundPeer = PeerID(str: "1122334455667788")
let claimedPeer = PeerID(str: "8899aabbccddeeff")
@@ -128,9 +130,129 @@ struct BLEServiceCoreTests {
ttl: 7
)
#expect(ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer))
}
@Test
func ingressRejectsRequestSyncThatConflictsWithBoundLink() async throws {
let ble = makeService()
let boundPeer = PeerID(str: "1122334455667788")
let claimedPeer = PeerID(str: "8899aabbccddeeff")
let packet = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: claimedPeer.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(),
signature: nil,
ttl: 0
)
#expect(!ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer))
}
@Test
func verifiedDirectAnnounceRebindsRotatedLinkAndRetiresOldPeer() async throws {
let ble = makeService()
let oldPeerID = PeerID(str: "1122334455667788")
let centralUUID = "central-rotation"
// A connected peer whose link binding predates its relaunch.
ble._test_seedConnectedPeer(oldPeerID, nickname: "alice")
ble._test_bindCentral(centralUUID, to: oldPeerID)
// The relaunched device re-announces its rotated identity over the
// still-open link.
let signer = NoiseEncryptionService(keychain: MockKeychain())
let announcement = AnnouncementPacket(
nickname: "alice",
noisePublicKey: signer.getStaticPublicKeyData(),
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode(), "Failed to encode announcement")
let newPeerID = PeerID(publicKey: announcement.noisePublicKey)
let unsigned = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: newPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet")
#expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID))
ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false)
let rebound = await TestHelpers.waitUntil(
{ ble._test_centralBinding(centralUUID) == newPeerID },
timeout: TestConstants.defaultTimeout
)
#expect(rebound)
let retired = await TestHelpers.waitUntil(
{
let peerIDs = ble.currentPeerSnapshots().map(\.peerID)
return peerIDs.contains(newPeerID) && !peerIDs.contains(oldPeerID)
},
timeout: TestConstants.defaultTimeout
)
#expect(retired)
}
@Test
func replayedDirectAnnounceCannotStealBoundIdentity() async throws {
let ble = makeService()
let attackerPeerID = PeerID(str: "1122334455667788")
let victimLink = "central-victim"
let attackerLink = "central-attacker"
// The victim's identity, genuinely bound on its own link.
let victimSigner = NoiseEncryptionService(keychain: MockKeychain())
let announcement = AnnouncementPacket(
nickname: "victim",
noisePublicKey: victimSigner.getStaticPublicKeyData(),
signingPublicKey: victimSigner.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode(), "Failed to encode announcement")
let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)
ble._test_seedConnectedPeer(victimPeerID, nickname: "victim")
ble._test_bindCentral(victimLink, to: victimPeerID)
ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
ble._test_bindCentral(attackerLink, to: attackerPeerID)
// The victim's fresh signed announce replayed on the attacker's bound
// link with its direct TTL restored (TTL is excluded from signing, so
// the signature still verifies).
let unsigned = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: victimPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet")
#expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink))
ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false)
// The rebind must be refused: the identity already owns a live link.
let stolen = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID },
timeout: 0.3
)
#expect(!stolen)
#expect(ble._test_centralBinding(attackerLink) == attackerPeerID)
#expect(ble._test_centralBinding(victimLink) == victimPeerID)
// And the replay must not retire the link's real bound peer.
#expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID))
}
@Test
func ingressRejectsSelfLoopbackBeforeSpoofChecks() async throws {
let ble = makeService()
@@ -88,21 +88,46 @@ struct BLEIngressLinkRegistryTests {
}
@Test
func packetContextRejectsDirectAnnounceMismatchOnBoundLink() {
func packetContextAttributesDirectAnnounceMismatchToClaimedSender() throws {
// A rotated peer re-announces its new ID on a link still bound to the
// old one. The announce must flow through (attributed to the claimed
// sender) so signature verification can decide whether to rebind.
let localPeer = PeerID(str: "0011223344556677")
let boundPeer = PeerID(str: "1122334455667788")
let claimedPeer = PeerID(str: "8899aabbccddeeff")
let packet = makeAnnouncePacket(sender: claimedPeer, ttl: 7)
let result = BLEIngressLinkRegistry.packetContext(
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: claimedPeer,
boundPeerID: boundPeer,
localPeerID: localPeer,
directAnnounceTTL: 7
)
)))
#expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer)))
#expect(context.receivedFromPeerID == claimedPeer)
#expect(context.validationPeerID == claimedPeer)
}
@Test
func packetContextAttributesRelayedAnnounceMismatchToBoundPeer() throws {
// Relayed announces (ttl below direct) keep relayed attribution: the
// link peer forwarded someone else's announce.
let localPeer = PeerID(str: "0011223344556677")
let boundPeer = PeerID(str: "1122334455667788")
let claimedPeer = PeerID(str: "8899aabbccddeeff")
let packet = makeAnnouncePacket(sender: claimedPeer, ttl: 6)
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: claimedPeer,
boundPeerID: boundPeer,
localPeerID: localPeer,
directAnnounceTTL: 7
)))
#expect(context.receivedFromPeerID == boundPeer)
#expect(context.validationPeerID == claimedPeer)
}
@Test
@@ -26,13 +26,13 @@ struct BLEIngressPacketGuardTests {
#expect(context.validationPeerID == sender)
}
@Test("self loopback and direct announce spoofing are rejected before timestamp checks")
@Test("self loopback and request-sync spoofing are rejected before timestamp checks")
func linkBindingRejectionsWinBeforeTimestampChecks() {
let local = PeerID(str: "0011223344556677")
let bound = PeerID(str: "1122334455667788")
let claimed = PeerID(str: "8899aabbccddeeff")
let selfPacket = makePacket(sender: local, timestamp: 0)
let spoofedAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 0, ttl: 7)
let spoofedRequestSync = makePacket(type: .requestSync, sender: claimed, timestamp: 0, ttl: 0)
let selfResult = BLEIngressPacketGuard.evaluate(
packet: selfPacket,
@@ -44,7 +44,7 @@ struct BLEIngressPacketGuardTests {
isValidSyncResponse: { _ in false }
)
let spoofResult = BLEIngressPacketGuard.evaluate(
packet: spoofedAnnounce,
packet: spoofedRequestSync,
claimedSenderID: claimed,
boundPeerID: bound,
localPeerID: local,
@@ -57,6 +57,44 @@ struct BLEIngressPacketGuardTests {
#expect(spoofResult == .failure(.directSenderMismatch(boundPeerID: bound, claimedSenderID: claimed)))
}
@Test("direct announce with a mismatched binding flows through to normal validation")
func directAnnounceMismatchStillValidatesTimestamp() throws {
// Rotation heal path: the announce passes the binding check attributed
// to the claimed sender, but stays subject to timestamp validation.
let local = PeerID(str: "0011223344556677")
let bound = PeerID(str: "1122334455667788")
let claimed = PeerID(str: "8899aabbccddeeff")
let freshAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 1_000_000, ttl: 7)
let staleAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 0, ttl: 7)
let freshContext = try #require(success(BLEIngressPacketGuard.evaluate(
packet: freshAnnounce,
claimedSenderID: claimed,
boundPeerID: bound,
localPeerID: local,
directAnnounceTTL: 7,
nowMs: 1_000_000,
isValidSyncResponse: { _ in false }
)))
let staleResult = BLEIngressPacketGuard.evaluate(
packet: staleAnnounce,
claimedSenderID: claimed,
boundPeerID: bound,
localPeerID: local,
directAnnounceTTL: 7,
nowMs: 1_000_000,
isValidSyncResponse: { _ in false }
)
#expect(freshContext.receivedFromPeerID == claimed)
#expect(freshContext.validationPeerID == claimed)
#expect(staleResult == .failure(.timestampSkew(
peerID: claimed,
skewMs: 1_000_000,
maxSkewMs: 120_000
)))
}
@Test("timestamp skew outside the window is rejected")
func timestampSkewIsRejected() {
let local = PeerID(str: "0011223344556677")