mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 11: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>
127 lines
4.4 KiB
Swift
127 lines
4.4 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
|
|
enum BLEIngressLinkID: Hashable, Equatable {
|
|
case peripheral(String)
|
|
case central(String)
|
|
}
|
|
|
|
struct BLEIngressPacketContext: Equatable {
|
|
let receivedFromPeerID: PeerID
|
|
let validationPeerID: PeerID
|
|
}
|
|
|
|
struct BLEIngressLinkRecord: Equatable {
|
|
let link: BLEIngressLinkID
|
|
let peerID: PeerID
|
|
let timestamp: Date
|
|
}
|
|
|
|
enum BLEIngressRejection: Error, Equatable {
|
|
case selfLoopback(packetType: UInt8)
|
|
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
|
}
|
|
|
|
struct BLEIngressLinkRegistry {
|
|
private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:]
|
|
|
|
var isEmpty: Bool {
|
|
ingressByMessageID.isEmpty
|
|
}
|
|
|
|
mutating func removeAll() {
|
|
ingressByMessageID.removeAll()
|
|
}
|
|
|
|
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
|
|
ingressByMessageID[Self.messageID(for: packet)]
|
|
}
|
|
|
|
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
|
|
record(for: packet)?.link
|
|
}
|
|
|
|
func peerID(for packet: BitchatPacket) -> PeerID? {
|
|
record(for: packet)?.peerID
|
|
}
|
|
|
|
mutating func recordIfNew(
|
|
_ packet: BitchatPacket,
|
|
link: BLEIngressLinkID,
|
|
peerID: PeerID,
|
|
now: Date = Date(),
|
|
lifetime: TimeInterval
|
|
) -> Bool {
|
|
let messageID = Self.messageID(for: packet)
|
|
if let existing = ingressByMessageID[messageID],
|
|
now.timeIntervalSince(existing.timestamp) <= lifetime {
|
|
return false
|
|
}
|
|
|
|
ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now)
|
|
return true
|
|
}
|
|
|
|
mutating func prune(before cutoff: Date) {
|
|
ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
|
}
|
|
|
|
static func packetContext(
|
|
for packet: BitchatPacket,
|
|
claimedSenderID: PeerID,
|
|
boundPeerID: PeerID?,
|
|
localPeerID: PeerID,
|
|
directAnnounceTTL: UInt8
|
|
) -> Result<BLEIngressPacketContext, BLEIngressRejection> {
|
|
if claimedSenderID == localPeerID,
|
|
!isSelfAuthoredSyncResponse(packet) {
|
|
return .failure(.selfLoopback(packetType: packet.type))
|
|
}
|
|
|
|
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
|
|
return .success(BLEIngressPacketContext(
|
|
receivedFromPeerID: receivedFromPeerID,
|
|
validationPeerID: validationPeerID
|
|
))
|
|
}
|
|
|
|
static func messageID(for packet: BitchatPacket) -> String {
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
|
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
|
}
|
|
|
|
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.
|
|
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 {
|
|
packet.isRSR && packet.ttl == 0
|
|
}
|
|
}
|