Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.floor

/**
* Gossip-based synchronization manager using on-demand GCS filters.
Expand All @@ -17,7 +19,8 @@ import java.util.concurrent.ConcurrentHashMap
class GossipSyncManager(
private val myPeerID: String,
private val scope: CoroutineScope,
private val configProvider: ConfigProvider
private val configProvider: ConfigProvider,
private val nowMs: () -> Long = { System.currentTimeMillis() }
) {
interface Delegate {
fun sendPacket(packet: BitchatPacket)
Expand Down Expand Up @@ -47,6 +50,25 @@ class GossipSyncManager(
// - announcements: only keep latest per sender peerID
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()

/**
* Per-requester token buckets. `requestTokens` bounds how often we are willing to decode a
* peer's filter and hash our stored packets against it; `responseTokens` bounds how many
* packets that peer can pull out of us. Both refill with elapsed time.
*/
private class RequesterBudget(
var requestTokens: Double,
var responseTokens: Double,
var lastRefillMs: Long
)

// Access-ordered so the eldest entry is the least recently seen requester.
private val requesterBudgets =
object : LinkedHashMap<String, RequesterBudget>(16, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, RequesterBudget>
): Boolean = size > AppConstants.Sync.MAX_TRACKED_REQUESTERS
}

private var periodicJob: Job? = null
private var cleanupJob: Job? = null
fun start() {
Expand Down Expand Up @@ -85,6 +107,9 @@ class GossipSyncManager(
messages.clear()
}
latestAnnouncementByPeer.clear()
synchronized(requesterBudgets) {
requesterBudgets.clear()
}
Log.d(TAG, "Cleared all gossip sync messages and announcements")
}

Expand Down Expand Up @@ -174,7 +199,68 @@ class GossipSyncManager(
delegate?.sendPacketToPeer(peerID, signed)
}

/**
* Charges [fromPeerID] for one request and returns how many packets it may be sent in
* response, or 0 when the request must be dropped. Called before any decoding or hashing so
* a flood costs us the admission check and nothing else.
*/
private fun admitRequest(fromPeerID: String): Int {
val capacity = try {
configProvider.seenCapacity()
} catch (_: Exception) {
0
}.coerceAtLeast(1).toDouble()
val now = nowMs()

synchronized(requesterBudgets) {
val budget = requesterBudgets.getOrPut(fromPeerID) {
RequesterBudget(
requestTokens = AppConstants.Sync.REQUEST_BURST.toDouble(),
responseTokens = capacity,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use non-spoofable keys for REQUEST_SYNC budgets

Because REQUEST_SYNC is intentionally outside the signed public-packet set, fromPeerID here is just the sender ID parsed from the packet, so any neighbor can mint fresh buckets by rotating spoofed IDs or spend another peer's request tokens by using that peer's ID. With the 256-entry LRU, a 257-ID cycle keeps each request admitted before decode/hashing, so the flood path this change is meant to close remains available; key the budget to a non-spoofable ingress/link identity or authenticate the claimed peer before granting a new bucket.

AGENTS.md reference: AGENTS.md:L90-L91

Useful? React with 👍 / 👎.

lastRefillMs = now
)
}

// A clock that jumped backwards must not hand out free tokens.
val elapsedMs = (now - budget.lastRefillMs).coerceAtLeast(0L).toDouble()
budget.lastRefillMs = now
budget.requestTokens = (
budget.requestTokens + elapsedMs / AppConstants.Sync.REQUEST_INTERVAL_MS
).coerceAtMost(AppConstants.Sync.REQUEST_BURST.toDouble())
budget.responseTokens = (
budget.responseTokens +
capacity * elapsedMs / AppConstants.Sync.RESPONSE_REFILL_WINDOW_MS
).coerceAtMost(capacity)

if (budget.requestTokens < 1.0) return 0
val allowance = floor(budget.responseTokens).toInt()
if (allowance <= 0) return 0

budget.requestTokens -= 1.0
return allowance
}
}

internal fun trackedRequesterCount(): Int = synchronized(requesterBudgets) {
requesterBudgets.size
}

private fun chargeResponses(fromPeerID: String, sent: Int) {
if (sent <= 0) return
synchronized(requesterBudgets) {
val budget = requesterBudgets[fromPeerID] ?: return
budget.responseTokens = (budget.responseTokens - sent).coerceAtLeast(0.0)
}
}

fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
var remaining = admitRequest(fromPeerID)
if (remaining <= 0) {
Log.d(TAG, "Dropping REQUEST_SYNC from $fromPeerID: over budget")
return
}
val allowance = remaining

// Decode GCS into sorted set for membership checks
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
fun mightContain(id: ByteArray): Boolean {
Expand All @@ -185,26 +271,32 @@ class GossipSyncManager(

// 1) Announcements: send latest per peerID if remote doesn't have them
for ((_, pair) in latestAnnouncementByPeer.entries) {
if (remaining <= 0) break
val (id, pkt) = pair
val idBytes = hexToBytes(id)
if (!mightContain(idBytes)) {
// Send original packet unchanged to requester only (keep local TTL)
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
delegate?.sendPacketToPeer(fromPeerID, toSend)
remaining--
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
}
}

// 2) Broadcast messages: send all they lack
val toSendMsgs = synchronized(messages) { messages.values.toList() }
for (pkt in toSendMsgs) {
if (remaining <= 0) break
val idBytes = PacketIdUtil.computeIdBytes(pkt)
if (!mightContain(idBytes)) {
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
delegate?.sendPacketToPeer(fromPeerID, toSend)
remaining--
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
}
}

chargeResponses(fromPeerID, allowance - remaining)
}

private fun hexStringToByteArray(hexString: String): ByteArray {
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/com/bitchat/android/util/AppConstants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ object AppConstants {

object Sync {
const val CLEANUP_INTERVAL_MS: Long = 60_000L

// REQUEST_SYNC is a public, unauthenticated packet, so a neighbor decides how often we
// decode a filter, hash every stored packet, and transmit whatever it claims to lack.
// The budgets below bound that work without touching what an honest peer needs.

// Shortest spacing between two serviced requests from the same peer. Honest peers ask on
// a 30s timer plus one scheduled first request, so only floods are shed.
const val REQUEST_INTERVAL_MS: Long = 5_000L

// Lets the scheduled first sync and a periodic one land back to back.
const val REQUEST_BURST: Int = 2

// Window over which a peer's response allowance refills to seenCapacity() packets — the
// most a peer can legitimately be missing in one round.
const val RESPONSE_REFILL_WINDOW_MS: Long = 30_000L

// Requester IDs come off the wire, so the bookkeeping itself has to be bounded or the
// mitigation becomes its own memory exhaustion vector.
const val MAX_TRACKED_REQUESTERS: Int = 256
}

object Fragmentation {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package com.bitchat.android.sync

import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test

/**
* REQUEST_SYNC is a public packet handled straight off the wire, so an unauthenticated neighbor
* decides how often we decode a filter, hash every stored packet, and transmit what it claims to
* lack. These cover the budget that bounds that without changing what an honest peer receives.
*/
class GossipSyncRequestBudgetTest {

private companion object {
const val CAPACITY = 5
const val MY_PEER = "aabbccddeeff0011"
const val REQUESTER = "1122334455667788"
}

/** A filter that claims to hold nothing, so every stored packet counts as missing. */
private val emptyFilter = RequestSyncPacket(p = 1, m = 1L, data = ByteArray(0))

private var clock = 1_000_000L
private val sentTo = mutableListOf<Pair<String, BitchatPacket>>()

private lateinit var manager: GossipSyncManager

private val delegate = object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) = Unit
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
sentTo.add(peerID to packet)
}

override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet
}

private val config = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity(): Int = CAPACITY
override fun gcsMaxBytes(): Int = 400
override fun gcsTargetFpr(): Double = 0.01
}

@Before
fun setUp() {
sentTo.clear()
clock = 1_000_000L
manager = GossipSyncManager(
myPeerID = MY_PEER,
scope = CoroutineScope(Dispatchers.Unconfined),
configProvider = config,
nowMs = { clock }
)
manager.delegate = delegate
}

private fun broadcastMessage(body: String): BitchatPacket = BitchatPacket(
type = MessageType.MESSAGE.value,
senderID = ByteArray(8) { 0x11 },
recipientID = SpecialRecipients.BROADCAST,
timestamp = clock.toULong(),
payload = body.toByteArray(),
ttl = 3u
)

private fun storeMessages(count: Int) {
repeat(count) { manager.onPublicPacketSeen(broadcastMessage("m$it")) }
}

@Test
fun `an honest peer still receives every packet it is missing`() {
storeMessages(CAPACITY)

manager.handleRequestSync(REQUESTER, emptyFilter)

assertEquals(CAPACITY, sentTo.size)
assertTrue(sentTo.all { it.first == REQUESTER })
}

@Test
fun `back to back requests stop being serviced after the burst allowance`() {
storeMessages(CAPACITY)

// No time passes between these, so nothing refills.
repeat(100) { manager.handleRequestSync(REQUESTER, emptyFilter) }

// Before the fix every one of the 100 requests replayed all CAPACITY packets.
assertEquals(CAPACITY, sentTo.size)
}

@Test
fun `a flood cannot pull more packets than the refill window allows`() {
storeMessages(CAPACITY)

// Hammer for a simulated minute, far faster than any honest peer syncs.
repeat(600) {
clock += 100L
manager.handleRequestSync(REQUESTER, emptyFilter)
}

// 60s of hammering earns two windows' worth of packets, not 600 replays.
val windows = 60_000L / AppConstants.Sync.RESPONSE_REFILL_WINDOW_MS
assertTrue(
"sent ${sentTo.size} packets, expected at most ${CAPACITY * (windows + 1)}",
sentTo.size <= CAPACITY * (windows + 1)
)
assertTrue("honest sync must still make progress", sentTo.isNotEmpty())
}

@Test
fun `the budget refills so a peer can keep syncing`() {
storeMessages(CAPACITY)

manager.handleRequestSync(REQUESTER, emptyFilter)
assertEquals(CAPACITY, sentTo.size)

sentTo.clear()
clock += AppConstants.Sync.RESPONSE_REFILL_WINDOW_MS
manager.handleRequestSync(REQUESTER, emptyFilter)

assertEquals(CAPACITY, sentTo.size)
}

@Test
fun `one flooding peer does not consume another peer's budget`() {
storeMessages(CAPACITY)
repeat(50) { manager.handleRequestSync(REQUESTER, emptyFilter) }
sentTo.clear()

manager.handleRequestSync("99aabbccddeeff00", emptyFilter)

assertEquals(CAPACITY, sentTo.size)
}

@Test
fun `requester bookkeeping stays bounded when peer ids are spoofed`() {
storeMessages(1)

repeat(AppConstants.Sync.MAX_TRACKED_REQUESTERS * 4) { i ->
manager.handleRequestSync("%016x".format(i), emptyFilter)
}

assertEquals(
AppConstants.Sync.MAX_TRACKED_REQUESTERS,
manager.trackedRequesterCount()
)
}

@Test
fun `a backwards clock jump does not hand out free budget`() {
storeMessages(CAPACITY)

repeat(100) {
clock -= 5_000L
manager.handleRequestSync(REQUESTER, emptyFilter)
}

assertEquals(CAPACITY, sentTo.size)
}

@Test
fun `clearing sync state also clears the budgets`() {
storeMessages(CAPACITY)
manager.handleRequestSync(REQUESTER, emptyFilter)

manager.clear()

assertEquals(0, manager.trackedRequesterCount())
}
}