mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:05:19 +00:00
Fix ghost peers and stale messages from gossip sync (#766)
Problem: - Ghost peers from yesterday appeared on app restart - Old messages resurfaced after restarting - Peers running 24+ hours synced stale data to restarting peers Root causes: 1. GossipSyncManager stored packets indefinitely (no time limit) 2. handleAnnounce/handleMessage had no timestamp validation 3. Relayed announces from other peers could be arbitrarily old Solution (defense in depth): - Added 15-minute age window to GossipSyncManager config - Gossip storage rejects expired packets at ingestion - Gossip sync responses filter out expired packets - GCS payload building excludes expired packets - Periodic cleanup removes expired announcements/messages - handleAnnounce rejects stale announces before processing - handleMessage rejects stale broadcast messages before processing This prevents ghost peers from appearing on restart and ensures only recent mesh state is synchronized between peers. Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -2416,6 +2416,19 @@ extension BLEService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject stale announces to prevent ghost peers from appearing
|
||||||
|
// Use same 15-minute window as gossip sync (900 seconds)
|
||||||
|
let maxAnnounceAgeSeconds: TimeInterval = 900
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let ageThresholdMs = UInt64(maxAnnounceAgeSeconds * 1000)
|
||||||
|
if nowMs >= ageThresholdMs {
|
||||||
|
let cutoffMs = nowMs - ageThresholdMs
|
||||||
|
if packet.timestamp < cutoffMs {
|
||||||
|
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.prefix(8))… (age: \(Double(nowMs - packet.timestamp) / 1000.0)s)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
|
|
||||||
// Precompute signature verification outside barrier to reduce contention
|
// Precompute signature verification outside barrier to reduce contention
|
||||||
@@ -2591,6 +2604,26 @@ extension BLEService {
|
|||||||
// the sync path without re-processing regular relayed copies.
|
// the sync path without re-processing regular relayed copies.
|
||||||
if peerID == myPeerID && packet.ttl != 0 { return }
|
if peerID == myPeerID && packet.ttl != 0 { return }
|
||||||
|
|
||||||
|
// Reject stale broadcast messages to prevent old messages from appearing
|
||||||
|
// Use same 15-minute window as gossip sync (900 seconds)
|
||||||
|
// Check if this is a broadcast message (recipient is all 0xFF or nil)
|
||||||
|
let isBroadcast: Bool = {
|
||||||
|
guard let r = packet.recipientID else { return true }
|
||||||
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
|
}()
|
||||||
|
if isBroadcast {
|
||||||
|
let maxMessageAgeSeconds: TimeInterval = 900
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let ageThresholdMs = UInt64(maxMessageAgeSeconds * 1000)
|
||||||
|
if nowMs >= ageThresholdMs {
|
||||||
|
let cutoffMs = nowMs - ageThresholdMs
|
||||||
|
if packet.timestamp < cutoffMs {
|
||||||
|
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.prefix(8))… (age: \(Double(nowMs - packet.timestamp) / 1000.0)s)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var accepted = false
|
var accepted = false
|
||||||
var senderNickname: String = ""
|
var senderNickname: String = ""
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class GossipSyncManager {
|
|||||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
var gcsTargetFpr: Double = 0.01 // 1%
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
|
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: PeerID
|
private let myPeerID: PeerID
|
||||||
@@ -36,7 +37,10 @@ final class GossipSyncManager {
|
|||||||
stop()
|
stop()
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
||||||
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
|
timer.setEventHandler { [weak self] in
|
||||||
|
self?.cleanupExpiredMessages()
|
||||||
|
self?.sendRequestSync()
|
||||||
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
periodicTimer = timer
|
periodicTimer = timer
|
||||||
}
|
}
|
||||||
@@ -57,6 +61,18 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to check if a packet is within the age threshold
|
||||||
|
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||||
|
|
||||||
|
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||||
|
guard nowMs >= ageThresholdMs else { return true }
|
||||||
|
|
||||||
|
let cutoffMs = nowMs - ageThresholdMs
|
||||||
|
return packet.timestamp >= cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
@@ -67,6 +83,9 @@ final class GossipSyncManager {
|
|||||||
let isAnnounce = (mt == .announce)
|
let isAnnounce = (mt == .announce)
|
||||||
guard isBroadcastMessage || isAnnounce else { return }
|
guard isBroadcastMessage || isAnnounce else { return }
|
||||||
|
|
||||||
|
// Reject expired packets to prevent ghost peers and old messages
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
if isBroadcastMessage {
|
if isBroadcastMessage {
|
||||||
@@ -137,9 +156,10 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Announcements: send latest per peer if requester lacks them
|
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = Data(hexString: idHex) ?? Data()
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
@@ -148,9 +168,10 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Broadcast messages: send all missing
|
// 2) Broadcast messages: send all missing (and not expired)
|
||||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
@@ -162,11 +183,19 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
private func buildGcsPayload() -> Data {
|
private func buildGcsPayload() -> Data {
|
||||||
// Collect candidates: latest announce per peer + broadcast messages
|
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||||
var candidates: [BitchatPacket] = []
|
var candidates: [BitchatPacket] = []
|
||||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||||
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
|
if isPacketFresh(pair.packet) {
|
||||||
|
candidates.append(pair.packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in messageOrder {
|
||||||
|
if let p = messages[id], isPacketFresh(p) {
|
||||||
|
candidates.append(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Sort by timestamp desc
|
// Sort by timestamp desc
|
||||||
candidates.sort { $0.timestamp > $1.timestamp }
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
@@ -184,6 +213,23 @@ final class GossipSyncManager {
|
|||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Periodic cleanup of expired messages and announcements
|
||||||
|
private func cleanupExpiredMessages() {
|
||||||
|
// Remove expired announcements
|
||||||
|
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||||
|
isPacketFresh(pair.packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired messages
|
||||||
|
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||||
|
isPacketFresh(pkt) ? nil : id
|
||||||
|
}
|
||||||
|
for id in expiredMessageIds {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
|
|||||||
Reference in New Issue
Block a user