diff --git a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift index ba41648c..442ba0d2 100644 --- a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift +++ b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift @@ -99,7 +99,11 @@ struct BLEIngressLinkRegistry { } private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool { - packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL + // 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. + if packet.type == MessageType.requestSync.rawValue { return true } + return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL } private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool { diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 05bf81f4..b6deaf1a 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -53,6 +53,7 @@ struct BLEReceivePipeline { isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isAnnounce: packet.type == MessageType.announce.rawValue, + isRequestSync: packet.type == MessageType.requestSync.rawValue, degree: degree, highDegreeThreshold: highDegreeThreshold ) diff --git a/bitchat/Services/BLE/BLERouteForwardingPolicy.swift b/bitchat/Services/BLE/BLERouteForwardingPolicy.swift index 8c43e0c3..aa12f7e0 100644 --- a/bitchat/Services/BLE/BLERouteForwardingPolicy.swift +++ b/bitchat/Services/BLE/BLERouteForwardingPolicy.swift @@ -35,6 +35,14 @@ struct BLERouteForwardingPolicy { routingPeer: (Data) -> PeerID?, isPeerConnected: (PeerID) -> Bool ) -> BLERouteForwardingPlan { + // REQUEST_SYNC is link-local: never forward it, on the flood path or + // the source-routed path. A crafted request with a route and TTL + // headroom must not be able to fan a full-store replay out to the next + // hop. Suppressing here also short-circuits the flood relay. + if packet.type == MessageType.requestSync.rawValue { + return .suppressFloodRelay + } + if PeerID(hexData: packet.recipientID) == localPeerID { return .suppressFloodRelay } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 531fe9f8..e82d7ff9 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -282,7 +282,9 @@ final class BLEService: NSObject { fileTransferCapacity: TransportConfig.syncFileTransferCapacity, fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds, fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds, - messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds + messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds, + responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses, + responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds ) let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) @@ -3287,6 +3289,26 @@ extension BLEService { // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) { + // REQUEST_SYNC is link-local by design (always sent with ttl 0): a + // nonzero TTL means a crafted or relayed request, and answering one + // would let a single small packet fan a full store replay out of + // every node it reaches. + guard packet.ttl == 0 else { + if logRateLimiter.shouldLog(key: "sync-ttl:\(peerID.id)") { + SecureLogger.warning("🚫 Dropping REQUEST_SYNC with nonzero TTL from \(peerID.id.prefix(8))…", category: .security) + } + return + } + // A response can replay the entire gossip store, so require proof the + // requester owns the claimed sender ID: the request must verify + // against the signing key from that peer's announce. + let signingKey = collectionsQueue.sync { peerRegistry.info(for: peerID)?.signingPublicKey } + guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") { + SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security) + } + return + } guard let req = RequestSyncPacket.decode(from: packet.payload) else { SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session) return diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index 37fa8584..fae850ea 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -18,10 +18,17 @@ struct RelayController { isDirectedFragment: Bool, isHandshake: Bool, isAnnounce: Bool, + isRequestSync: Bool = false, degree: Int, highDegreeThreshold: Int) -> RelayDecision { let ttlCap = min(ttl, TransportConfig.messageTTLDefault) + // REQUEST_SYNC is link-local: never relay it, even when a peer crafts + // one with TTL headroom to turn every reachable node into a responder. + if isRequestSync { + return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) + } + // Suppress obvious non-relays if ttlCap <= 1 || senderIsSelf || recipientIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 4aae7294..b5cb00e0 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -271,4 +271,6 @@ enum TransportConfig { static let syncFragmentIntervalSeconds: TimeInterval = 30.0 static let syncFileTransferIntervalSeconds: TimeInterval = 60.0 static let syncMessageIntervalSeconds: TimeInterval = 15.0 + static let syncResponseRateLimitMaxResponses: Int = 8 + static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0 } diff --git a/bitchat/Sync/GCSFilter.swift b/bitchat/Sync/GCSFilter.swift index 7d76c23c..8b36b376 100644 --- a/bitchat/Sync/GCSFilter.swift +++ b/bitchat/Sync/GCSFilter.swift @@ -10,7 +10,13 @@ import CryptoKit // - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1< Params { let p = deriveP(targetFpr: targetFpr) guard !ids.isEmpty else { - return Params(p: p, m: 1, data: Data()) + return Params(p: p, m: 1, data: Data(), includedCount: 0) } let cap = estimateMaxElements(sizeBytes: maxBytes, p: p) - let selected = Array(ids.prefix(cap)) - let range = max(1, hashRange(count: selected.count, p: p)) + // Modulus is fixed to the initial candidate count so `m` stays stable + // as the tail is trimmed to fit the byte budget below. + let range = max(1, hashRange(count: min(ids.count, cap), p: p)) let modulo = UInt64(range) - var mapped = selected - .map { h64($0) } - .map { mapHash($0, modulo: modulo) } - .sorted() - mapped = normalizeMappedValues(mapped, modulo: modulo) - - if mapped.isEmpty { - return Params(p: p, m: range, data: Data()) + // Encode the first `count` inputs (input order). The caller passes IDs + // newest-first, so trimming from the tail drops the oldest — which is + // what lets a since-cursor stay exact: the surviving set is always a + // contiguous newest-prefix, never a hash-order-arbitrary subset. + func encodeFirst(_ count: Int) -> Data { + var mapped = ids.prefix(count) + .map { h64($0) } + .map { mapHash($0, modulo: modulo) } + .sorted() + mapped = normalizeMappedValues(mapped, modulo: modulo) + return mapped.isEmpty ? Data() : encode(sorted: mapped, p: p) } - var encoded = encode(sorted: mapped, p: p) - var trimmedCount = mapped.count - - while encoded.count > maxBytes && trimmedCount > 0 { - if trimmedCount == 1 { - mapped.removeAll() - encoded = Data() - break - } - trimmedCount = max(1, (trimmedCount * 9) / 10) - mapped = Array(mapped.prefix(trimmedCount)) - encoded = encode(sorted: mapped, p: p) + var count = min(ids.count, cap) + var encoded = encodeFirst(count) + while encoded.count > maxBytes && count > 1 { + count = max(1, (count * 9) / 10) + encoded = encodeFirst(count) + } + // A single element that still overflows can't be represented. + if encoded.count > maxBytes { + return Params(p: p, m: range, data: Data(), includedCount: 0) } - return Params(p: p, m: range, data: encoded) + return Params(p: p, m: range, data: encoded, includedCount: encoded.isEmpty ? 0 : count) } static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] { diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index 3c8472d2..ee337baa 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -73,6 +73,8 @@ final class GossipSyncManager { var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var messageSyncIntervalSeconds: TimeInterval = 15.0 + var responseRateLimitMaxResponses: Int = 8 + var responseRateLimitWindowSeconds: TimeInterval = 30.0 } private let myPeerID: PeerID @@ -91,11 +93,16 @@ final class GossipSyncManager { private let queue = DispatchQueue(label: "mesh.sync", qos: .utility) private var lastStalePeerCleanup: Date = .distantPast private var syncSchedules: [SyncSchedule] = [] + private var responseRateLimiter: SyncResponseRateLimiter init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) { self.myPeerID = myPeerID self.config = config self.requestSyncManager = requestSyncManager + self.responseRateLimiter = SyncResponseRateLimiter( + maxResponses: config.responseRateLimitMaxResponses, + window: config.responseRateLimitWindowSeconds + ) var schedules: [SyncSchedule] = [] if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) @@ -265,7 +272,17 @@ final class GossipSyncManager { } private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) { + // A response can replay the whole store, so bound how often one peer + // can trigger a diff pass regardless of how fast it asks. + guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else { + SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync) + return + } let requestedTypes = (request.types ?? .publicMessages) + // The requester's filter only covers packets at or after this cursor; + // older packets are outside the filter but not missing, and without + // the cursor they would be re-sent every round. + let since = request.sinceTimestamp // Decode GCS into sorted set and prepare membership checker let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) func mightContain(_ id: Data) -> Bool { @@ -273,6 +290,9 @@ final class GossipSyncManager { return GCSFilter.contains(sortedValues: sorted, candidate: bucket) } + // Announces are exempt from the since-cursor: they carry the signing + // keys needed to verify everything else, and there is at most one per + // peer, so the resend cost is negligible. if requestedTypes.contains(.announce) { for (_, pair) in latestAnnouncementByPeer { let (idHex, pkt) = pair @@ -290,6 +310,7 @@ final class GossipSyncManager { if requestedTypes.contains(.message) { let toSendMsgs = messages.allPackets(isFresh: isPacketFresh) for pkt in toSendMsgs { + if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -303,6 +324,7 @@ final class GossipSyncManager { if requestedTypes.contains(.fragment) { let frags = fragments.allPackets(isFresh: isPacketFresh) for pkt in frags { + if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -316,6 +338,7 @@ final class GossipSyncManager { if requestedTypes.contains(.fileTransfer) { let files = fileTransfers.allPackets(isFresh: isPacketFresh) for pkt in files { + if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -368,9 +391,22 @@ final class GossipSyncManager { let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) return req.encode() } - let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) } + let included = Array(candidates.prefix(takeN)) + let ids: [Data] = included.map { PacketIdUtil.computeId($0) } let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr) - let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types) + // When the filter can't cover every candidate — either the store + // exceeds `takeN` or the encoder trimmed the tail to fit the byte + // budget — tell the responder how far back the filter actually + // reaches. `includedCount` counts inputs in newest-first order, so the + // covered set is a contiguous newest-prefix and the oldest included + // timestamp is an exact cursor. Packets older than it are outside the + // filter but not missing; without the cursor the responder would + // re-send that entire tail every round. + let covered = params.includedCount + let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0) + ? included[covered - 1].timestamp + : nil + let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp) return req.encode() } @@ -390,19 +426,18 @@ final class GossipSyncManager { cleanupExpiredMessages() cleanupStaleAnnouncementsIfNeeded(now: now) requestSyncManager.cleanup() // Cleanup expired sync requests + responseRateLimiter.prune(now: now) - var dueTypes: SyncTypeFlags = [] + // One request per due schedule rather than a union filter: each type + // group gets the full GCS capacity and its own since-cursor, so heavy + // fragment traffic can't crowd messages out of the filter. for index in syncSchedules.indices { guard syncSchedules[index].interval > 0 else { continue } if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { syncSchedules[index].lastSent = now - dueTypes.formUnion(syncSchedules[index].types) + sendPeriodicSync(for: syncSchedules[index].types) } } - - if !dueTypes.isEmpty { - sendPeriodicSync(for: dueTypes) - } } private func cleanupStaleAnnouncementsIfNeeded(now: Date) { diff --git a/bitchat/Sync/SyncResponseRateLimiter.swift b/bitchat/Sync/SyncResponseRateLimiter.swift new file mode 100644 index 00000000..a6ffc6a1 --- /dev/null +++ b/bitchat/Sync/SyncResponseRateLimiter.swift @@ -0,0 +1,42 @@ +import BitFoundation +import Foundation + +/// Sliding-window limiter for REQUEST_SYNC responses. +/// +/// A single sync response can replay the entire gossip store, so a peer that +/// requests in a tight loop must not be able to drain the airtime and battery +/// of everyone in radio range. Legitimate peers send at most a few requests +/// per maintenance tick (one per type schedule, plus the initial sync). +struct SyncResponseRateLimiter { + private let maxResponses: Int + private let window: TimeInterval + private var history: [PeerID: [Date]] = [:] + + init(maxResponses: Int, window: TimeInterval) { + self.maxResponses = max(1, maxResponses) + self.window = max(0, window) + } + + /// Returns true (and records the response) if the peer is under its + /// response budget for the current window. + mutating func shouldRespond(to peerID: PeerID, now: Date) -> Bool { + let cutoff = now.addingTimeInterval(-window) + var recent = (history[peerID] ?? []).filter { $0 >= cutoff } + guard recent.count < maxResponses else { + history[peerID] = recent + return false + } + recent.append(now) + history[peerID] = recent + return true + } + + /// Drops history outside the window so departed peers don't accumulate. + mutating func prune(now: Date) { + let cutoff = now.addingTimeInterval(-window) + history = history.compactMapValues { dates in + let recent = dates.filter { $0 >= cutoff } + return recent.isEmpty ? nil : recent + } + } +} diff --git a/bitchatTests/GCSFilterTests.swift b/bitchatTests/GCSFilterTests.swift index 464f368e..b544f843 100644 --- a/bitchatTests/GCSFilterTests.swift +++ b/bitchatTests/GCSFilterTests.swift @@ -41,6 +41,24 @@ struct GCSFilterTests { #expect(truncated.allSatisfy { full.contains($0) }) } + @Test func buildFilterReportsFullCoverageWhenBudgetFits() { + let ids = (0..<8).map { i in Data(repeating: UInt8(i), count: 16) } + let params = GCSFilter.buildFilter(ids: ids, maxBytes: 1024, targetFpr: 0.01) + #expect(params.includedCount == ids.count) + } + + @Test func buildFilterTrimsTailWhenBudgetExceeded() { + // A tight byte budget can't hold every ID, so the encoder trims from + // the input tail and reports how many it actually covered. + let ids = (0..<200).map { i in + Data((0..<16).map { UInt8((i &* 31 &+ $0) & 0xFF) }) + } + let params = GCSFilter.buildFilter(ids: ids, maxBytes: 32, targetFpr: 0.01) + #expect(params.includedCount > 0) + #expect(params.includedCount < ids.count) + #expect(params.data.count <= 32) + } + @Test func requestSyncPacketDecodeRejectsOversizedP() { let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02])) #expect(RequestSyncPacket.decode(from: valid.encode()) != nil) diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 54e65afc..b717e2f0 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -194,15 +194,204 @@ struct GossipSyncManagerTests { manager._performMaintenanceSynchronously(now: Date()) + // One request per due schedule so each type group gets the full + // filter capacity: publicMessages, fragment, and fileTransfer. let sentPackets = delegate.packets - #expect(sentPackets.count == 1) + #expect(sentPackets.count == 3) let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) } - #expect(decoded.count == 1) - let types = try #require(decoded.first?.types) - #expect(types.contains(.announce)) - #expect(types.contains(.message)) - #expect(types.contains(.fragment)) - #expect(types.contains(.fileTransfer)) + #expect(decoded.count == 3) + let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) } + #expect(allTypes.contains(.announce)) + #expect(allTypes.contains(.message)) + #expect(allTypes.contains(.fragment)) + #expect(allTypes.contains(.fileTransfer)) + #expect(decoded.contains { $0.types == .publicMessages }) + #expect(decoded.contains { $0.types == .fragment }) + #expect(decoded.contains { $0.types == .fileTransfer }) + } + + @Test func truncatedFilterCarriesSinceCursor() throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 100 + config.gcsMaxBytes = 32 // caps the filter at 28 IDs (256 bits / 9 bits per element) + config.messageSyncIntervalSeconds = 1 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.maintenanceIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "1122334455667788")) + let baseTimestamp = UInt64(Date().timeIntervalSince1970 * 1000) + let totalMessages = 40 + for i in 0..= baseTimestamp + 12) + #expect(since < baseTimestamp + UInt64(totalMessages)) + } + + @Test func fullCoverageFilterOmitsSinceCursor() throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 100 + config.messageSyncIntervalSeconds = 1 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.maintenanceIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "1122334455667788")) + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01]), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(packet) + + manager._performMaintenanceSynchronously(now: Date()) + + let sent = try #require(delegate.packets.first) + let request = try #require(RequestSyncPacket.decode(from: sent.payload)) + #expect(request.sinceTimestamp == nil) + } + + @Test func handleRequestSyncHonorsSinceCursorButAlwaysSendsAnnounces() async throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 5 + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + // Announce older than the cursor: must still be sent (identity is + // needed to verify everything else). + let announcePacket = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs - 50_000, + payload: Data(), + signature: nil, + ttl: 1 + ) + let oldMessage = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs - 60_000, + payload: Data([0x01]), + signature: nil, + ttl: 1 + ) + let newMessage = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs, + payload: Data([0x02]), + signature: nil, + ttl: 1 + ) + + manager.onPublicPacketSeen(announcePacket) + manager.onPublicPacketSeen(oldMessage) + manager.onPublicPacketSeen(newMessage) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + let request = RequestSyncPacket( + p: 7, + m: 1, + data: Data(), + types: .publicMessages, + sinceTimestamp: nowMs - 30_000 + ) + manager.handleRequestSync(from: peer, request: request) + + try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout) + // Barrier: flush the sync queue so a late third packet would be visible. + manager._performMaintenanceSynchronously(now: Date()) + let sentPackets = delegate.packets + #expect(sentPackets.count == 2) + #expect(sentPackets.contains { $0.type == MessageType.announce.rawValue }) + let sentMessages = sentPackets.filter { $0.type == MessageType.message.rawValue } + #expect(sentMessages.count == 1) + #expect(sentMessages.first?.payload == Data([0x02])) + #expect(sentPackets.allSatisfy { $0.isRSR }) + } + + @Test func handleRequestSyncIsRateLimitedPerPeer() async throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 5 + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.responseRateLimitMaxResponses = 1 + config.responseRateLimitWindowSeconds = 60 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let messagePacket = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x10]), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(messagePacket) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .message) + manager.handleRequestSync(from: peer, request: request) + manager.handleRequestSync(from: peer, request: request) + + try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout) + // Barrier: both requests have been processed once this returns. + manager._performMaintenanceSynchronously(now: Date()) + #expect(delegate.packets.count == 1) } @Test func initialSyncCoalescesEnabledTypes() async throws { diff --git a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift index af8c2777..75550bc5 100644 --- a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift +++ b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift @@ -105,6 +105,41 @@ struct BLEIngressLinkRegistryTests { #expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: 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") @@ -158,6 +193,18 @@ private func makePacket(sender: PeerID, timestamp: UInt64) -> BitchatPacket { ) } +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, diff --git a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift index e6bfce05..7e580098 100644 --- a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift +++ b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift @@ -112,6 +112,36 @@ struct BLERouteForwardingPolicyTests { #expect(plan.nextHop == nil) } + @Test("REQUEST_SYNC is never route-forwarded even with a route and TTL headroom") + func requestSyncNeverRouteForwarded() { + let previous = peer("1111111111111111") + let local = peer("2222222222222222") + let nextHop = peer("3333333333333333") + let destination = peer("4444444444444444") + var packet = makePacket( + sender: previous, + recipient: destination, + ttl: 7, + route: [routeData(local), routeData(nextHop)] + ) + packet = BitchatPacket( + type: MessageType.requestSync.rawValue, + senderID: packet.senderID, + recipientID: packet.recipientID, + timestamp: packet.timestamp, + payload: packet.payload, + signature: nil, + ttl: packet.ttl, + route: packet.route + ) + + let plan = forwardingPlan(packet, local: local, connected: [nextHop]) + + #expect(plan.shouldSuppressFloodRelay) + #expect(plan.forwardPacket == nil) + #expect(plan.nextHop == nil) + } + private func forwardingPlan( _ packet: BitchatPacket, local: PeerID, diff --git a/bitchatTests/Services/RelayControllerTests.swift b/bitchatTests/Services/RelayControllerTests.swift index c0c473cf..7c04fab6 100644 --- a/bitchatTests/Services/RelayControllerTests.swift +++ b/bitchatTests/Services/RelayControllerTests.swift @@ -134,6 +134,25 @@ struct RelayControllerTests { #expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1) } + @Test + func requestSync_neverRelaysEvenWithTTLHeadroom() async { + let decision = RelayController.decide( + ttl: 7, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: false, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + isRequestSync: true, + degree: 3, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(!decision.shouldRelay) + } + @Test func denseGraph_capsTTL() async { let decision = RelayController.decide( diff --git a/bitchatTests/Sync/SyncResponseRateLimiterTests.swift b/bitchatTests/Sync/SyncResponseRateLimiterTests.swift new file mode 100644 index 00000000..63f5329a --- /dev/null +++ b/bitchatTests/Sync/SyncResponseRateLimiterTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncResponseRateLimiterTests { + + private let peer = PeerID(str: "1122334455667788") + private let otherPeer = PeerID(str: "8899aabbccddeeff") + + @Test func allowsResponsesUpToBudgetThenBlocks() { + var limiter = SyncResponseRateLimiter(maxResponses: 2, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + let second = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(1)) + let third = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(2)) + + #expect(first) + #expect(second) + #expect(!third) + } + + @Test func budgetIsPerPeer() { + var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + let repeated = limiter.shouldRespond(to: peer, now: now) + let other = limiter.shouldRespond(to: otherPeer, now: now) + + #expect(first) + #expect(!repeated) + #expect(other) + } + + @Test func allowsAgainAfterWindowSlides() { + var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + let insideWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(29)) + let afterWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(31)) + + #expect(first) + #expect(!insideWindow) + #expect(afterWindow) + } + + @Test func pruneDropsExpiredHistory() { + var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + limiter.prune(now: now.addingTimeInterval(31)) + let afterPrune = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(32)) + + #expect(first) + #expect(afterPrune) + } +}