Files
bitchat/bitchatTests/Services/BLEIngressLinkRegistryTests.swift
T
3e9230229d 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>
2026-07-08 10:08:23 +02:00

251 lines
9.0 KiB
Swift

import Foundation
import Testing
import BitFoundation
@testable import bitchat
struct BLEIngressLinkRegistryTests {
@Test
func recordIfNewSuppressesDuplicateWithinLifetime() {
var registry = BLEIngressLinkRegistry()
let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1)
let peer = PeerID(str: "1122334455667788")
let now = Date()
let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0)
let duplicateRecord = registry.recordIfNew(packet, link: .peripheral("peripheral-b"), peerID: peer, now: now.addingTimeInterval(1.0), lifetime: 3.0)
#expect(firstRecord)
#expect(!duplicateRecord)
#expect(registry.link(for: packet) == .central("central-a"))
#expect(registry.peerID(for: packet) == peer)
}
@Test
func recordIfNewAllowsExpiredDuplicateAndUpdatesLink() {
var registry = BLEIngressLinkRegistry()
let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1)
let peer = PeerID(str: "1122334455667788")
let now = Date()
let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0)
let expiredRecord = registry.recordIfNew(packet, link: .peripheral("peripheral-b"), peerID: peer, now: now.addingTimeInterval(4.0), lifetime: 3.0)
#expect(firstRecord)
#expect(expiredRecord)
#expect(registry.link(for: packet) == .peripheral("peripheral-b"))
#expect(registry.peerID(for: packet) == peer)
}
@Test
func pruneRemovesExpiredIngressLinks() {
var registry = BLEIngressLinkRegistry()
let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1)
let peer = PeerID(str: "1122334455667788")
let now = Date()
let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0)
#expect(firstRecord)
registry.prune(before: now.addingTimeInterval(4.0))
#expect(registry.isEmpty)
#expect(registry.link(for: packet) == nil)
#expect(registry.peerID(for: packet) == nil)
}
@Test
func packetContextRejectsSelfLoopback() {
let localPeer = PeerID(str: "1122334455667788")
let packet = makePacket(sender: localPeer, timestamp: 1)
let result = BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: localPeer,
boundPeerID: nil,
localPeerID: localPeer,
directAnnounceTTL: 7
)
#expect(result == .failure(.selfLoopback(packetType: MessageType.message.rawValue)))
}
@Test
func packetContextAllowsRelayedSenderOnBoundLink() throws {
let localPeer = PeerID(str: "0011223344556677")
let boundPeer = PeerID(str: "1122334455667788")
let relayedSender = PeerID(str: "8899aabbccddeeff")
let packet = makePacket(sender: relayedSender, timestamp: 1)
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: relayedSender,
boundPeerID: boundPeer,
localPeerID: localPeer,
directAnnounceTTL: 7
)))
#expect(context.receivedFromPeerID == boundPeer)
#expect(context.validationPeerID == relayedSender)
}
@Test
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 context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: claimedPeer,
boundPeerID: boundPeer,
localPeerID: localPeer,
directAnnounceTTL: 7
)))
#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
func packetContextRejectsRequestSyncSenderMismatchOnBoundLink() {
let localPeer = PeerID(str: "0011223344556677")
let boundPeer = PeerID(str: "1122334455667788")
let claimedPeer = PeerID(str: "8899aabbccddeeff")
let packet = makeRequestSyncPacket(sender: claimedPeer)
let result = BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: claimedPeer,
boundPeerID: boundPeer,
localPeerID: localPeer,
directAnnounceTTL: 7
)
#expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer)))
}
@Test
func packetContextAllowsRequestSyncFromBoundPeer() throws {
let localPeer = PeerID(str: "0011223344556677")
let boundPeer = PeerID(str: "1122334455667788")
let packet = makeRequestSyncPacket(sender: boundPeer)
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: boundPeer,
boundPeerID: boundPeer,
localPeerID: localPeer,
directAnnounceTTL: 7
)))
#expect(context.receivedFromPeerID == boundPeer)
}
@Test
func packetContextUsesBoundPeerForRSRValidation() throws {
let localPeer = PeerID(str: "0011223344556677")
let boundPeer = PeerID(str: "1122334455667788")
let relayedSender = PeerID(str: "8899aabbccddeeff")
var packet = makePacket(sender: relayedSender, timestamp: 1)
packet.isRSR = true
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: relayedSender,
boundPeerID: boundPeer,
localPeerID: localPeer,
directAnnounceTTL: 7
)))
#expect(context.receivedFromPeerID == boundPeer)
#expect(context.validationPeerID == boundPeer)
}
@Test
func packetContextAllowsSelfAuthoredRSRWithTTLZeroFromBoundPeer() throws {
let localPeer = PeerID(str: "0011223344556677")
let boundPeer = PeerID(str: "1122334455667788")
var packet = makePacket(sender: localPeer, timestamp: 1)
packet.isRSR = true
packet.ttl = 0
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: localPeer,
boundPeerID: boundPeer,
localPeerID: localPeer,
directAnnounceTTL: 7
)))
#expect(context.receivedFromPeerID == boundPeer)
#expect(context.validationPeerID == boundPeer)
}
}
private func makePacket(sender: PeerID, timestamp: UInt64) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: nil,
timestamp: timestamp,
payload: Data("hello".utf8),
signature: nil,
ttl: 3
)
}
private func makeRequestSyncPacket(sender: PeerID) -> BitchatPacket {
BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: nil,
timestamp: 1,
payload: Data(),
signature: nil,
ttl: 0
)
}
private func makeAnnouncePacket(sender: PeerID, ttl: UInt8) -> BitchatPacket {
BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: nil,
timestamp: 1,
payload: Data(),
signature: nil,
ttl: ttl
)
}
private func trySuccess(_ result: Result<BLEIngressPacketContext, BLEIngressRejection>) -> BLEIngressPacketContext? {
guard case .success(let context) = result else {
return nil
}
return context
}