mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* 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>
185 lines
6.2 KiB
Swift
185 lines
6.2 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
@Suite("BLE route forwarding policy tests")
|
|
struct BLERouteForwardingPolicyTests {
|
|
@Test("local recipient suppresses flood relay")
|
|
func localRecipientSuppressesFloodRelay() {
|
|
let local = peer("1111111111111111")
|
|
let remote = peer("2222222222222222")
|
|
let packet = makePacket(sender: remote, recipient: local)
|
|
|
|
let plan = forwardingPlan(packet, local: local)
|
|
|
|
#expect(plan.shouldSuppressFloodRelay)
|
|
#expect(plan.forwardPacket == nil)
|
|
#expect(plan.nextHop == nil)
|
|
}
|
|
|
|
@Test("unrouted packets are left for flood relay")
|
|
func unroutedPacketAllowsFloodRelay() {
|
|
let local = peer("1111111111111111")
|
|
let remote = peer("2222222222222222")
|
|
let packet = makePacket(sender: remote, recipient: peer("3333333333333333"), route: nil)
|
|
|
|
let plan = forwardingPlan(packet, local: local)
|
|
|
|
#expect(!plan.shouldSuppressFloodRelay)
|
|
#expect(plan.forwardPacket == nil)
|
|
#expect(plan.nextHop == nil)
|
|
}
|
|
|
|
@Test("origin forwards routed packet to first hop")
|
|
func originForwardsToFirstHop() {
|
|
let local = peer("1111111111111111")
|
|
let firstHop = peer("2222222222222222")
|
|
let destination = peer("3333333333333333")
|
|
let packet = makePacket(sender: local, recipient: destination, ttl: 6, route: [routeData(firstHop)])
|
|
|
|
let plan = forwardingPlan(packet, local: local, connected: [firstHop])
|
|
|
|
#expect(plan.shouldSuppressFloodRelay)
|
|
#expect(plan.nextHop == firstHop)
|
|
#expect(plan.forwardPacket?.ttl == 5)
|
|
}
|
|
|
|
@Test("origin leaves packet for flood relay when first hop is unavailable")
|
|
func originAllowsFloodWhenFirstHopUnavailable() {
|
|
let local = peer("1111111111111111")
|
|
let firstHop = peer("2222222222222222")
|
|
let destination = peer("3333333333333333")
|
|
let packet = makePacket(sender: local, recipient: destination, route: [routeData(firstHop)])
|
|
|
|
let plan = forwardingPlan(packet, local: local, connected: [])
|
|
|
|
#expect(!plan.shouldSuppressFloodRelay)
|
|
#expect(plan.forwardPacket == nil)
|
|
#expect(plan.nextHop == nil)
|
|
}
|
|
|
|
@Test("intermediate forwards routed packet to next route hop")
|
|
func intermediateForwardsToNextHop() {
|
|
let previous = peer("1111111111111111")
|
|
let local = peer("2222222222222222")
|
|
let nextHop = peer("3333333333333333")
|
|
let destination = peer("4444444444444444")
|
|
let packet = makePacket(
|
|
sender: previous,
|
|
recipient: destination,
|
|
ttl: 4,
|
|
route: [routeData(local), routeData(nextHop)]
|
|
)
|
|
|
|
let plan = forwardingPlan(packet, local: local, connected: [nextHop])
|
|
|
|
#expect(plan.shouldSuppressFloodRelay)
|
|
#expect(plan.nextHop == nextHop)
|
|
#expect(plan.forwardPacket?.ttl == 3)
|
|
}
|
|
|
|
@Test("last intermediate forwards routed packet to destination")
|
|
func lastIntermediateForwardsToDestination() {
|
|
let previous = peer("1111111111111111")
|
|
let local = peer("2222222222222222")
|
|
let destination = peer("3333333333333333")
|
|
let packet = makePacket(
|
|
sender: previous,
|
|
recipient: destination,
|
|
ttl: 4,
|
|
route: [routeData(local)]
|
|
)
|
|
|
|
let plan = forwardingPlan(packet, local: local, connected: [destination])
|
|
|
|
#expect(plan.shouldSuppressFloodRelay)
|
|
#expect(plan.nextHop == destination)
|
|
#expect(plan.forwardPacket?.ttl == 3)
|
|
}
|
|
|
|
@Test("expired routed packets suppress further relay")
|
|
func expiredRoutedPacketSuppressesRelay() {
|
|
let local = peer("1111111111111111")
|
|
let firstHop = peer("2222222222222222")
|
|
let destination = peer("3333333333333333")
|
|
let packet = makePacket(sender: local, recipient: destination, ttl: 1, route: [routeData(firstHop)])
|
|
|
|
let plan = forwardingPlan(packet, local: local, connected: [firstHop])
|
|
|
|
#expect(plan.shouldSuppressFloodRelay)
|
|
#expect(plan.forwardPacket == nil)
|
|
#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,
|
|
connected: Set<PeerID> = []
|
|
) -> BLERouteForwardingPlan {
|
|
BLERouteForwardingPolicy.plan(
|
|
for: packet,
|
|
localPeerID: local,
|
|
localRoutingData: local.routingData,
|
|
routingPeer: PeerID.init(routingData:),
|
|
isPeerConnected: { connected.contains($0) }
|
|
)
|
|
}
|
|
|
|
private func makePacket(
|
|
sender: PeerID,
|
|
recipient: PeerID?,
|
|
ttl: UInt8 = 7,
|
|
route: [Data]? = []
|
|
) -> BitchatPacket {
|
|
BitchatPacket(
|
|
type: MessageType.noiseEncrypted.rawValue,
|
|
senderID: routeData(sender),
|
|
recipientID: recipient.map { routeData($0) },
|
|
timestamp: 1,
|
|
payload: Data([0x01, 0x02]),
|
|
signature: nil,
|
|
ttl: ttl,
|
|
route: route
|
|
)
|
|
}
|
|
|
|
private func peer(_ id: String) -> PeerID {
|
|
PeerID(str: id)
|
|
}
|
|
|
|
private func routeData(_ peerID: PeerID) -> Data {
|
|
peerID.routingData ?? Data()
|
|
}
|
|
}
|