From 3e9230229dcf1f90975772426a9d1c20d6d6ba96 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:08:23 +0200 Subject: [PATCH] Heal peer-ID rotation: rebind link on verified announce, retire ghost peer (#1401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .../Services/BLE/BLEIngressLinkRegistry.swift | 28 +++- bitchat/Services/BLE/BLELinkStateStore.swift | 14 +- bitchat/Services/BLE/BLEService.swift | 136 ++++++++++++++++-- bitchat/Services/TransportConfig.swift | 4 + bitchatTests/BLEServiceCoreTests.swift | 124 +++++++++++++++- .../BLEIngressLinkRegistryTests.swift | 33 ++++- .../Services/BLEIngressPacketGuardTests.swift | 44 +++++- 7 files changed, 358 insertions(+), 25 deletions(-) diff --git a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift index 442ba0d2..970921b1 100644 --- a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift +++ b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift @@ -78,10 +78,21 @@ struct BLEIngressLinkRegistry { return .failure(.selfLoopback(packetType: packet.type)) } - if let boundPeerID, - boundPeerID != claimedSenderID, - requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) { - return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID)) + 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 @@ -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 { diff --git a/bitchat/Services/BLE/BLELinkStateStore.swift b/bitchat/Services/BLE/BLELinkStateStore.swift index 54fad9b9..cbb75fa2 100644 --- a/bitchat/Services/BLE/BLELinkStateStore.swift +++ b/bitchat/Services/BLE/BLELinkStateStore.swift @@ -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? { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 412e4e32..b4b5aaea 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -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 { diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index bf8c0270..b9603564 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -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 diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 6a9cdfc1..3da4a700 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -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() diff --git a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift index 75550bc5..40532d19 100644 --- a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift +++ b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift @@ -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 diff --git a/bitchatTests/Services/BLEIngressPacketGuardTests.swift b/bitchatTests/Services/BLEIngressPacketGuardTests.swift index 754bc585..0d07ec1b 100644 --- a/bitchatTests/Services/BLEIngressPacketGuardTests.swift +++ b/bitchatTests/Services/BLEIngressPacketGuardTests.swift @@ -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")