mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* 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>
251 lines
9.0 KiB
Swift
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
|
|
}
|