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..