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:
jack
2026-07-06 19:17:26 +02:00
committed by GitHub
co-authored by jack Claude Opus 4.8
parent 3ea4188699
commit 295f855b6f
15 changed files with 534 additions and 42 deletions
+18
View File
@@ -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)
+196 -7
View File
@@ -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)
}
}