mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Harden REQUEST_SYNC and stop gossip-sync re-send loops (#1371)
* Harden REQUEST_SYNC and stop gossip-sync re-send loops Two fixes from an end-to-end review of the sync path: Efficiency: the GCS filter (400B, p=7) covers ~355 packet IDs, but stores hold up to 1000 messages + 600 fragments + 200 files. Once a mesh accumulates more than the filter can cover, responders re-sent the entire older tail to every requester every round — ~120KB per pair per 30s during file transfers, dropped by dedup after the airtime was already burned. Requesters now stamp the dormant sinceTimestamp TLV with the oldest timestamp their filter covers, and responders skip older packets (announces exempt: they carry the signing keys needed to verify everything else). Periodic sync also sends one request per type schedule instead of a union filter, so fragment floods can't crowd messages out of the filter budget. Security: a ~40-byte unsigned REQUEST_SYNC with an empty filter could elicit a full store replay (~900KB) — an unauthenticated >10,000x amplification vector, repeatable in a tight loop and relayable with crafted TTL to fan the drain out of every reachable node. Requests now require ttl == 0, a valid signature from the claimed sender's announced signing key, and a matching link binding; REQUEST_SYNC is never relayed regardless of TTL; and responses are rate-limited per peer (8 per 30s sliding window, ~3x the legitimate cadence). Cross-platform: verified against bitchat-android — it signs REQUEST_SYNC and sends SYNC_TTL_HOPS = 0, so both gates hold; it neither sends nor honors sinceTimestamp yet, so mixed pairs keep today's behavior with no regression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address Codex review: enforce no-relay on route path, exact since-cursor Two P2 findings from Codex on the REQUEST_SYNC hardening: - Route-forwarding bypass: handleRequestSync's early return for a rejected (nonzero-TTL / unsigned) request still fell through to forwardAlongRouteIfNeeded, which relays any routed packet with ttl > 1 regardless of type. The no-relay invariant was only enforced on the flood path. BLERouteForwardingPolicy now suppresses REQUEST_SYNC outright, so a crafted request with a route and TTL headroom can't be forwarded to the next hop either. - Inexact since-cursor: GCSFilter.buildFilter trimmed by hash order when the encoding overflowed the byte budget, so the cursor (computed from the untrimmed prefix) could claim coverage of timestamps whose packets were dropped from the filter — re-sending exactly those every round. buildFilter now trims from the input tail (oldest, since candidates are newest-first) and reports includedCount; the cursor is derived from that, so the covered set is always a contiguous newest-prefix and the cursor is exact. Adds GCSFilter includedCount coverage (full vs trimmed), a route-forwarding test for REQUEST_SYNC, and makes the truncated-cursor test robust to trim variance. Full suite: 1029 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Opus 4.8
parent
3ea4188699
commit
295f855b6f
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<<P)-1).
|
||||
// - Bitstream is MSB-first within each byte.
|
||||
enum GCSFilter {
|
||||
struct Params { let p: Int; let m: UInt32; let data: Data }
|
||||
// `includedCount` is how many of the input `ids` (in input order) the
|
||||
// returned filter actually encodes. It can be below `ids.count` when the
|
||||
// Golomb-Rice encoding overflows the byte budget and the tail is trimmed.
|
||||
// Callers that derive a since-cursor need this: trimming drops from the
|
||||
// input tail, so the first `includedCount` inputs are exactly what the
|
||||
// filter covers.
|
||||
struct Params { let p: Int; let m: UInt32; let data: Data; let includedCount: Int }
|
||||
|
||||
// Highest Golomb-Rice parameter we accept from the wire. P maps to an FPR
|
||||
// of ~1/2^P; beyond 32 the remainder width exceeds any practical filter
|
||||
@@ -35,39 +41,40 @@ enum GCSFilter {
|
||||
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> 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] {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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..<totalMessages {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: baseTimestamp + UInt64(i),
|
||||
payload: Data([UInt8(truncatingIfNeeded: i)]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
manager.onPublicPacketSeen(packet)
|
||||
}
|
||||
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
let packet = try #require(delegate.packets.first)
|
||||
let request = try #require(RequestSyncPacket.decode(from: packet.payload))
|
||||
// The store (40) exceeds what the tiny filter can cover, so a cursor
|
||||
// must be present. It points at the oldest timestamp the filter
|
||||
// actually encodes: the filter covers the newest ~28, and byte-budget
|
||||
// trimming can only shrink that further, so the cursor sits at or
|
||||
// above baseTimestamp + 12 (= 40 - 28) and below the newest message.
|
||||
let since = try #require(request.sinceTimestamp)
|
||||
#expect(since >= 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user