From 7149182c56dcada230ff86b722edeaac99b1f2d1 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Oct 2025 00:56:25 +0200 Subject: [PATCH] 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 --- bitchat/Services/BLEService.swift | 35 ++++++++++++++++- bitchat/Sync/GossipSyncManager.swift | 58 +++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index bd612398..29082e0c 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -2415,7 +2415,20 @@ extension BLEService { if peerID == myPeerID { 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 // Precompute signature verification outside barrier to reduce contention @@ -2591,6 +2604,26 @@ extension BLEService { // the sync path without re-processing regular relayed copies. 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 senderNickname: String = "" diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index 5093d60d..85ded306 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -12,6 +12,7 @@ final class GossipSyncManager { var seenCapacity: Int = 1000 // max packets per sync (cap across types) var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsTargetFpr: Double = 0.01 // 1% + var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages } private let myPeerID: PeerID @@ -36,7 +37,10 @@ final class GossipSyncManager { stop() let timer = DispatchSource.makeTimerSource(queue: queue) 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() 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) { let mt = MessageType(rawValue: packet.type) let isBroadcastRecipient: Bool = { @@ -67,6 +83,9 @@ final class GossipSyncManager { let isAnnounce = (mt == .announce) 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() if isBroadcastMessage { @@ -137,9 +156,10 @@ final class GossipSyncManager { 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 { let (idHex, pkt) = pair + guard isPacketFresh(pkt) else { continue } let idBytes = Data(hexString: idHex) ?? Data() if !mightContain(idBytes) { 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] } for pkt in toSendMsgs { + guard isPacketFresh(pkt) else { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -162,11 +183,19 @@ final class GossipSyncManager { // Build REQUEST_SYNC payload using current candidates and GCS params 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] = [] candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count) - for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) } - for id in messageOrder { if let p = messages[id] { candidates.append(p) } } + for (_, pair) in latestAnnouncementByPeer { + 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 candidates.sort { $0.timestamp > $1.timestamp } @@ -184,6 +213,23 @@ final class GossipSyncManager { 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 func removeAnnouncementForPeer(_ peerID: PeerID) { queue.async { [weak self] in