mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 03:45:21 +00:00
Gossip routing tmp with connection limit fixed (#569)
* fix: r8 exception for LocationManager (#566) * fragmented packets inherit route * update spec * fix connection limits * fix: deserialization issue with routed packets * log * dynamic fragment size * fragment size * add tests
This commit is contained in:
Vendored
+5
@@ -26,3 +26,8 @@
|
||||
-keepnames class org.torproject.arti.**
|
||||
-dontwarn info.guardianproject.arti.**
|
||||
-dontwarn org.torproject.arti.**
|
||||
|
||||
# Fix for AbstractMethodError on API < 29 where LocationListener methods are abstract
|
||||
-keepclassmembers class * implements android.location.LocationListener {
|
||||
public <methods>;
|
||||
}
|
||||
|
||||
@@ -109,11 +109,19 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
// Connection caps: enforce on change
|
||||
connectionScope.launch {
|
||||
dbg.maxConnectionsOverall.collect {
|
||||
dbg.maxConnectionsOverall.collect { maxOverall ->
|
||||
if (!isActive) return@collect
|
||||
// 1. Enforce client limits (handled by tracker)
|
||||
connectionTracker.enforceConnectionLimits()
|
||||
// Also enforce server side best-effort
|
||||
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
|
||||
|
||||
// 2. Enforce overall limit on server connections if needed
|
||||
// (Tracker knows about all connections but can't disconnect servers directly)
|
||||
val maxServer = dbg.maxServerConnections.value
|
||||
val excessServers = connectionTracker.getExcessServerConnections(maxServer, maxOverall)
|
||||
excessServers.forEach { device ->
|
||||
Log.d(TAG, "Disconnecting server ${device.address} due to overall cap")
|
||||
serverManager.disconnectDevice(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
connectionScope.launch {
|
||||
@@ -123,9 +131,18 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
}
|
||||
connectionScope.launch {
|
||||
dbg.maxServerConnections.collect {
|
||||
dbg.maxServerConnections.collect { maxServer ->
|
||||
if (!isActive) return@collect
|
||||
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
|
||||
// Enforce server specific limit
|
||||
serverManager.enforceServerLimit(maxServer)
|
||||
|
||||
// Also check if this change puts us over the overall limit
|
||||
val maxOverall = dbg.maxConnectionsOverall.value
|
||||
val excessServers = connectionTracker.getExcessServerConnections(maxServer, maxOverall)
|
||||
excessServers.forEach { device ->
|
||||
Log.d(TAG, "Disconnecting server ${device.address} due to overall cap")
|
||||
serverManager.disconnectDevice(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
@@ -229,11 +229,17 @@ class BluetoothConnectionTracker(
|
||||
*/
|
||||
fun getConnectedDeviceCount(): Int = connectedDevices.size
|
||||
|
||||
/**
|
||||
* Check if connection limit is reached
|
||||
*/
|
||||
/**
|
||||
* Check if connection limit is reached
|
||||
*/
|
||||
fun isConnectionLimitReached(): Boolean {
|
||||
return connectedDevices.size >= powerManager.getMaxConnections()
|
||||
// Respect debug override if set
|
||||
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
|
||||
val maxConnections = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
|
||||
return connectedDevices.size >= maxConnections
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,10 +250,9 @@ class BluetoothConnectionTracker(
|
||||
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
|
||||
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
|
||||
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
|
||||
val maxServer = dbg?.maxServerConnections?.value ?: maxOverall
|
||||
// Note: maxServer is handled by GattServerManager, but we need to respect overall limit here too
|
||||
|
||||
val clients = connectedDevices.values.filter { it.isClient }
|
||||
val servers = connectedDevices.values.filter { !it.isClient }
|
||||
|
||||
// Enforce client cap first (we can actively disconnect)
|
||||
if (clients.size > maxClient) {
|
||||
@@ -259,22 +264,56 @@ class BluetoothConnectionTracker(
|
||||
}
|
||||
}
|
||||
|
||||
// Note: server cap enforced in GattServerManager (we don't have server handle here)
|
||||
|
||||
// Enforce overall cap by disconnecting oldest client connections
|
||||
// Re-check overall cap after client cleanup
|
||||
if (connectedDevices.size > maxOverall) {
|
||||
Log.i(TAG, "Enforcing overall cap: ${connectedDevices.size} > $maxOverall")
|
||||
val excess = connectedDevices.size - maxOverall
|
||||
val toDisconnect = connectedDevices.values
|
||||
.filter { it.isClient } // only clients from here
|
||||
|
||||
// Prefer disconnecting clients first to satisfy overall cap
|
||||
val clientsToDisconnect = connectedDevices.values
|
||||
.filter { it.isClient }
|
||||
.sortedBy { it.connectedAt }
|
||||
.take(excess)
|
||||
toDisconnect.forEach { dc ->
|
||||
|
||||
clientsToDisconnect.forEach { dc ->
|
||||
Log.d(TAG, "Disconnecting client ${dc.device.address} due to overall cap")
|
||||
dc.gatt?.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get excess server connections that should be disconnected to satisfy limits.
|
||||
* This allows the Manager to coordinate server disconnects since Tracker doesn't control the server.
|
||||
*/
|
||||
fun getExcessServerConnections(maxServer: Int, maxOverall: Int): List<BluetoothDevice> {
|
||||
val servers = connectedDevices.values.filter { !it.isClient }
|
||||
val excessList = mutableListOf<BluetoothDevice>()
|
||||
|
||||
// 1. Check server specific limit
|
||||
if (servers.size > maxServer) {
|
||||
val excessCount = servers.size - maxServer
|
||||
val toRemove = servers.sortedBy { it.connectedAt }.take(excessCount)
|
||||
excessList.addAll(toRemove.map { it.device })
|
||||
}
|
||||
|
||||
// 2. Check overall limit (considering we might have already removed some above)
|
||||
// We need to count how many connections we will have after the above removals
|
||||
val currentTotal = connectedDevices.size
|
||||
val plannedRemovals = excessList.size
|
||||
val projectedTotal = currentTotal - plannedRemovals
|
||||
|
||||
if (projectedTotal > maxOverall) {
|
||||
val furtherExcess = projectedTotal - maxOverall
|
||||
// We can only remove servers here. Clients are handled in enforceConnectionLimits.
|
||||
// Filter out devices we already planned to remove
|
||||
val remainingServers = servers.filter { s -> excessList.none { it.address == s.device.address } }
|
||||
val toRemove = remainingServers.sortedBy { it.connectedAt }.take(furtherExcess)
|
||||
excessList.addAll(toRemove.map { it.device })
|
||||
}
|
||||
|
||||
return excessList
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a specific device connection
|
||||
|
||||
@@ -49,15 +49,28 @@ class BluetoothGattServerManager(
|
||||
fun enforceServerLimit(maxServer: Int) {
|
||||
if (maxServer <= 0) return
|
||||
try {
|
||||
val subs = connectionTracker.getSubscribedDevices()
|
||||
if (subs.size > maxServer) {
|
||||
val excess = subs.size - maxServer
|
||||
subs.take(excess).forEach { d ->
|
||||
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
|
||||
// Use connection tracker to get actual connected server devices
|
||||
val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient }
|
||||
if (servers.size > maxServer) {
|
||||
val excess = servers.size - maxServer
|
||||
// Disconnect oldest
|
||||
servers.sortedBy { it.connectedAt }.take(excess).forEach { d ->
|
||||
try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a specific device (used by ConnectionManager to enforce overall limits)
|
||||
*/
|
||||
fun disconnectDevice(device: BluetoothDevice) {
|
||||
try {
|
||||
gattServer?.cancelConnection(device)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error disconnecting device ${device.address}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start GATT server
|
||||
@@ -122,9 +135,10 @@ class BluetoothGattServerManager(
|
||||
|
||||
// Try to cancel any active connections explicitly before closing
|
||||
try {
|
||||
val devices = connectionTracker.getSubscribedDevices()
|
||||
devices.forEach { d ->
|
||||
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
|
||||
// Disconnect ALL server connections
|
||||
val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient }
|
||||
servers.forEach { d ->
|
||||
try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
|
||||
@@ -378,7 +378,7 @@ class BluetoothPacketBroadcaster(
|
||||
val subscribedDevices = connectionTracker.getSubscribedDevices()
|
||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||
|
||||
Log.i(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
|
||||
Log.i(TAG, "Broadcasting packet v${packet.version} type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
|
||||
|
||||
val senderID = String(packet.senderID).replace("\u0000", "")
|
||||
|
||||
|
||||
@@ -77,8 +77,31 @@ class FragmentManager {
|
||||
val fragmentID = FragmentPayload.generateFragmentID()
|
||||
|
||||
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
|
||||
val fragmentChunks = stride(0, fullData.size, MAX_FRAGMENT_SIZE) { offset ->
|
||||
val endOffset = minOf(offset + MAX_FRAGMENT_SIZE, fullData.size)
|
||||
// Calculate dynamic fragment size to fit in MTU (512)
|
||||
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
|
||||
val hasRoute = packet.route != null
|
||||
val version = if (hasRoute) 2 else 1
|
||||
val headerSize = if (version == 2) 15 else 13
|
||||
val senderSize = 8
|
||||
val recipientSize = if (packet.recipientID != null) 8 else 0
|
||||
// Route: 1 byte count + 8 bytes per hop
|
||||
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
|
||||
val fragmentHeaderSize = 13 // FragmentPayload header
|
||||
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
|
||||
|
||||
// 512 - Overhead
|
||||
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
|
||||
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
|
||||
|
||||
if (maxDataSize <= 0) {
|
||||
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
|
||||
|
||||
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
|
||||
val endOffset = minOf(offset + maxDataSize, fullData.size)
|
||||
fullData.sliceArray(offset..<endOffset)
|
||||
}
|
||||
|
||||
@@ -98,13 +121,16 @@ class FragmentManager {
|
||||
)
|
||||
|
||||
// iOS: MessageType.fragment.rawValue (single fragment type)
|
||||
// Fix: Fragments must inherit source route and use v2 if routed
|
||||
val fragmentPacket = BitchatPacket(
|
||||
version = if (packet.route != null) 2u else 1u,
|
||||
type = MessageType.FRAGMENT.value,
|
||||
ttl = packet.ttl,
|
||||
senderID = packet.senderID,
|
||||
recipientID = packet.recipientID,
|
||||
timestamp = packet.timestamp,
|
||||
payload = fragmentPayload.encode(),
|
||||
route = packet.route,
|
||||
signature = null // iOS: signature: nil
|
||||
)
|
||||
|
||||
|
||||
@@ -219,7 +219,10 @@ object BinaryProtocol {
|
||||
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
|
||||
val sizeFieldBytes = if (isCompressed) (if (packet.version >= 2u.toUByte()) 4 else 2) else 0
|
||||
val payloadBytes = payload.size + sizeFieldBytes
|
||||
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + 16 // small slack
|
||||
val routeBytes = if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
|
||||
1 + (packet.route!!.size.coerceAtMost(255) * SENDER_ID_SIZE)
|
||||
} else 0
|
||||
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + routeBytes + 16 // small slack
|
||||
val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) }
|
||||
|
||||
// Header
|
||||
@@ -368,9 +371,16 @@ object BinaryProtocol {
|
||||
var routeCount = 0
|
||||
if (hasRoute) {
|
||||
// Peek count (1 byte) without consuming buffer for now
|
||||
val mark = buffer.position()
|
||||
if (raw.size >= mark + 1) {
|
||||
routeCount = raw[mark].toUByte().toInt()
|
||||
// The buffer is currently positioned at the start of SenderID (after fixed header)
|
||||
// We must skip SenderID and RecipientID (if present) to find the route count
|
||||
val currentPos = buffer.position()
|
||||
var routeOffset = currentPos + SENDER_ID_SIZE
|
||||
if (hasRecipient) {
|
||||
routeOffset += RECIPIENT_ID_SIZE
|
||||
}
|
||||
|
||||
if (raw.size >= routeOffset + 1) {
|
||||
routeCount = raw[routeOffset].toUByte().toInt()
|
||||
}
|
||||
expectedSize += 1 + (routeCount * SENDER_ID_SIZE)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.model.FragmentPayload
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.util.Random
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FragmentManagerTest {
|
||||
|
||||
private lateinit var fragmentManager: FragmentManager
|
||||
private val senderID = "1122334455667788"
|
||||
private val recipientID = "8877665544332211"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fragmentManager = FragmentManager()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test fragmentation without route`() {
|
||||
// Create a large payload (e.g., 1000 bytes)
|
||||
val payload = ByteArray(1000)
|
||||
Random().nextBytes(payload)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 7u,
|
||||
route = null
|
||||
)
|
||||
|
||||
val fragments = fragmentManager.createFragments(packet)
|
||||
|
||||
assertTrue("Should create multiple fragments", fragments.size > 1)
|
||||
|
||||
// Verify each fragment fits in MTU (512)
|
||||
for (fragment in fragments) {
|
||||
val encodedSize = fragment.toBinaryData()?.size ?: 0
|
||||
assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512)
|
||||
|
||||
// Inspect the payload data size
|
||||
val fragmentPayload = FragmentPayload.decode(fragment.payload)
|
||||
assertNotNull(fragmentPayload)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test fragmentation with route`() {
|
||||
// Create a large payload
|
||||
val payload = ByteArray(1000)
|
||||
Random().nextBytes(payload)
|
||||
|
||||
// Create a fake route (3 hops)
|
||||
val route = listOf(
|
||||
hexStringToByteArray("AABBCCDDEEFF0011"),
|
||||
hexStringToByteArray("1100FFEEDDCCBBAA"),
|
||||
hexStringToByteArray("1234567890ABCDEF")
|
||||
)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 7u,
|
||||
route = route
|
||||
)
|
||||
|
||||
val fragments = fragmentManager.createFragments(packet)
|
||||
|
||||
assertTrue("Should create multiple fragments", fragments.size > 1)
|
||||
|
||||
// Verify fragments retain the route and version 2
|
||||
for (fragment in fragments) {
|
||||
assertEquals("Fragment version should be 2", 2u.toUByte(), fragment.version)
|
||||
assertEquals("Fragment should have the route", route.size, fragment.route?.size)
|
||||
|
||||
val encodedSize = fragment.toBinaryData()?.size ?: 0
|
||||
assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test fragmentation size difference with and without route`() {
|
||||
// This test specifically checks if the dynamic calculation logic works
|
||||
// by observing that fragments with routes carry less data payload per fragment
|
||||
|
||||
val payload = ByteArray(2000) // Large enough to ensure full fragments
|
||||
Random().nextBytes(payload)
|
||||
|
||||
// 1. Without route
|
||||
val packetNoRoute = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 7u,
|
||||
route = null
|
||||
)
|
||||
val fragmentsNoRoute = fragmentManager.createFragments(packetNoRoute)
|
||||
val firstFragPayloadNoRoute = FragmentPayload.decode(fragmentsNoRoute[0].payload)
|
||||
val dataSizeNoRoute = firstFragPayloadNoRoute?.data?.size ?: 0
|
||||
|
||||
// 2. With large route (e.g., 5 hops)
|
||||
val route = List(5) { hexStringToByteArray("000000000000000$it") }
|
||||
val packetWithRoute = BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 7u,
|
||||
route = route
|
||||
)
|
||||
val fragmentsWithRoute = fragmentManager.createFragments(packetWithRoute)
|
||||
val firstFragPayloadWithRoute = FragmentPayload.decode(fragmentsWithRoute[0].payload)
|
||||
val dataSizeWithRoute = firstFragPayloadWithRoute?.data?.size ?: 0
|
||||
|
||||
println("Data size without route: $dataSizeNoRoute")
|
||||
println("Data size with route: $dataSizeWithRoute")
|
||||
|
||||
assertTrue("Data payload should be smaller with route", dataSizeWithRoute < dataSizeNoRoute)
|
||||
|
||||
// Rough verification of the math:
|
||||
// 5 hops * 8 bytes = 40 bytes extra.
|
||||
// Plus v2 header overhead differences.
|
||||
// The difference should be roughly 40+ bytes.
|
||||
assertTrue("Difference should be significant", (dataSizeNoRoute - dataSizeWithRoute) >= 40)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test reassembly`() {
|
||||
val originalPayload = ByteArray(1500)
|
||||
Random().nextBytes(originalPayload)
|
||||
|
||||
val originalPacket = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = originalPayload,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val fragments = fragmentManager.createFragments(originalPacket)
|
||||
|
||||
var reassembledPacket: BitchatPacket? = null
|
||||
|
||||
// Feed fragments back into FragmentManager
|
||||
// Note: FragmentManager stores state in incomingFragments
|
||||
|
||||
for (fragment in fragments) {
|
||||
val result = fragmentManager.handleFragment(fragment)
|
||||
if (result != null) {
|
||||
reassembledPacket = result
|
||||
}
|
||||
}
|
||||
|
||||
assertNotNull("Should have reassembled packet", reassembledPacket)
|
||||
assertEquals("Type should match", originalPacket.type, reassembledPacket!!.type)
|
||||
assertEquals("Payload size should match", originalPacket.payload.size, reassembledPacket.payload.size)
|
||||
assertTrue("Payload content should match", originalPacket.payload.contentEquals(reassembledPacket.payload))
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
val result = ByteArray(8)
|
||||
for (i in 0 until 8) {
|
||||
val byteStr = hexString.substring(i * 2, i * 2 + 2)
|
||||
result[i] = byteStr.toInt(16).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
+113
-106
@@ -1,128 +1,135 @@
|
||||
# Source-Based Routing for BitChat Packets
|
||||
# Source-Based Routing for BitChat Packets (v2)
|
||||
|
||||
This document specifies an optional source-based routing extension to the BitChat packet format. A sender may attach a hop-by-hop route (list of peer IDs) to instruct relays on the intended path. Relays that support this feature will try to forward to the next hop directly; otherwise, they fall back to regular broadcast relaying.
|
||||
This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays.
|
||||
|
||||
Status: optional and backward-compatible.
|
||||
|
||||
## Layering Overview
|
||||
|
||||
- Outer packet: BitChat binary packet with unchanged fixed header (version/type/ttl/timestamp/flags/payloadLength).
|
||||
- Flags: adds a new bit `HAS_ROUTE (0x08)`. This flag is **only valid for packet version >= 2**.
|
||||
- Variable sections (when present, in order):
|
||||
1) `SenderID` (8 bytes)
|
||||
2) `RecipientID` (8 bytes) if `HAS_RECIPIENT`
|
||||
3) `Route` (if `HAS_ROUTE` AND version >= 2): `count` (1 byte) + `count * 8` bytes hop IDs
|
||||
4) `Payload` (with optional compression preamble)
|
||||
5) `Signature` (64 bytes) if `HAS_SIGNATURE`
|
||||
|
||||
Unknown flags are ignored by older implementations. For v1 packets, the `HAS_ROUTE` flag MUST be ignored even if set, and the route field MUST NOT be present. This ensures strict backward compatibility.
|
||||
|
||||
## Detailed Packet Structure (v1 vs v2)
|
||||
|
||||
The Bitchat packet structure is designed to be compact and efficient for BLE transmission.
|
||||
|
||||
### Fixed Header
|
||||
The fixed header is present in all packets. Its size depends on the version.
|
||||
|
||||
| Field | Size (v1) | Size (v2) | Description |
|
||||
|---|---|---|---|
|
||||
| Version | 1 byte | 1 byte | Protocol version (`0x01` or `0x02`). |
|
||||
| Type | 1 byte | 1 byte | Message Type (e.g., `0x01` Announce, `0x02` Message). |
|
||||
| TTL | 1 byte | 1 byte | Time-To-Live (hop limit). |
|
||||
| Timestamp | 8 bytes | 8 bytes | `UInt64` (big-endian) creation time (ms since epoch). |
|
||||
| Flags | 1 byte | 1 byte | Bitmask: `HAS_RECIPIENT(0x01)`, `HAS_SIGNATURE(0x02)`, `IS_COMPRESSED(0x04)`, `HAS_ROUTE(0x08)`. |
|
||||
| Payload Length | **2 bytes** | **4 bytes** | `UInt16` (v1) or `UInt32` (v2) length of the *Payload* section only. **Does NOT include route or other headers.** |
|
||||
| **Total Header** | **14 bytes** | **16 bytes** | |
|
||||
|
||||
### Variable Sections (In Order)
|
||||
|
||||
These fields follow the fixed header immediately.
|
||||
|
||||
1. **Sender ID** (Fixed 8 bytes)
|
||||
* Present in ALL packets.
|
||||
* Derived from the sender's public key.
|
||||
|
||||
2. **Recipient ID** (Optional, 8 bytes)
|
||||
* Present only if `HAS_RECIPIENT` flag is set.
|
||||
* Target peer ID for addressed messages.
|
||||
|
||||
3. **Source Route** (Optional, Variable Length)
|
||||
* **Condition:** Present **ONLY** if `HAS_ROUTE` flag is set **AND** `Version >= 2`.
|
||||
* **Structure:**
|
||||
* `Count` (1 byte): Number of hops (`N`).
|
||||
* `Hops` (`N * 8` bytes): Sequence of 8-byte Peer IDs.
|
||||
* **Note:** The size of this field (`1 + 8*N` bytes) is **NOT** included in the `Payload Length` field in the fixed header. It exists structurally between the Recipient ID and the Payload.
|
||||
|
||||
4. **Payload** (Variable Length)
|
||||
* Size is exactly the value specified in the `Payload Length` field of the fixed header.
|
||||
* Contains the application data (e.g., encrypted message, announcement TLVs).
|
||||
* If `IS_COMPRESSED` flag is set, the first 2 bytes are the original uncompressed size (UInt16), followed by the compressed bytes.
|
||||
|
||||
5. **Signature** (Optional, 64 bytes)
|
||||
* Present only if `HAS_SIGNATURE` flag is set.
|
||||
* Ed25519 signature covering the entire packet (with TTL=0 and Signature excluded).
|
||||
**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data).
|
||||
|
||||
---
|
||||
|
||||
## Route Field Encoding
|
||||
## 1. Protocol Versioning & Layering
|
||||
|
||||
- Presence: Signaled by the `HAS_ROUTE (0x08)` bit in `flags` **AND** `version >= 2`.
|
||||
- Layout (immediately after optional `RecipientID`):
|
||||
- `count`: 1 byte (0..255)
|
||||
- `hops`: concatenation of `count` peer IDs, each encoded as exactly 8 bytes
|
||||
- Peer ID encoding (8 bytes): same as used elsewhere in BitChat (16 hex chars → 8 bytes; left-to-right conversion; pad with `0x00` if shorter). This matches the on‑wire `senderID`/`recipientID` encoding.
|
||||
- Size impact: `1 + 8*N` bytes, where `N = count`.
|
||||
- Empty route: `HAS_ROUTE` with `count = 0` is treated as no route (relays ignore it).
|
||||
To support source routing and larger payloads, the packet format has been upgraded to **Version 2**.
|
||||
|
||||
## Sender Behavior
|
||||
* **Version 1 (Legacy):** 2-byte payload length limit. Ignores routing flags.
|
||||
* **Version 2 (Current):** 4-byte payload length limit. Supports Source Routing.
|
||||
|
||||
- Applicability: Intended for addressed packets (i.e., where `recipientID` is set and is not the broadcast ID). For broadcast packets, omit the route.
|
||||
- Path computation: Use Dijkstra’s shortest path (unit weights) on your internal mesh topology to find a route from the sender (your peerID) to the recipient (the destination peerID). The `BitchatPacket` already contains dedicated `senderID` and `recipientID` fields. The `Route` field's `hops` list **SHOULD** contain the sequence of intermediate peer IDs that the packet should traverse. It **SHOULD NOT** duplicate the `senderID` or `recipientID` if they are already present in the `BitchatPacket`'s dedicated fields. Instead, the `hops` list represents the explicit path *between* the sender and recipient, starting from the first relay and ending with the last relay before the recipient.
|
||||
- Encoding: Ensure the packet `version` is set to 2 or higher. Set `HAS_ROUTE`, write `count = path.length`, then the 8‑byte hop IDs in order. Keep `count <= 255`.
|
||||
- Signing: The route is covered by the Ed25519 signature (recommended):
|
||||
- Signature input is the canonical encoding with `signature` omitted and `ttl = 0` (TTL excluded to allow relay decrement) — same rule as base protocol.
|
||||
**Key Rule:** The `HAS_ROUTE (0x08)` flag is **only valid** if the packet `version >= 2`. Relays receiving a v1 packet must ignore this flag even if set.
|
||||
|
||||
## Relay Behavior
|
||||
---
|
||||
|
||||
When receiving a packet that is not addressed to you:
|
||||
## 2. Packet Structure Comparison
|
||||
|
||||
1) If `HAS_ROUTE` is not set, or the route is empty, or the packet `version < 2`, relay using your normal broadcast logic (subject to TTL/probability policies).
|
||||
2) If `HAS_ROUTE` is set AND `version >= 2`:
|
||||
- **Route Sanity Check**: Before processing, the relay **MUST** validate the route. If the route contains duplicate hops (i.e., the same peer ID appears more than once), the packet **MUST** be dropped to prevent loops.
|
||||
- If your peer ID appears at index `i` in the hop list:
|
||||
- If there is a next hop at `i+1`, attempt a targeted unicast to that next hop if you have a direct connection to it.
|
||||
- If successful, do NOT broadcast this packet further.
|
||||
- If not directly connected (or the send fails), fall back to broadcast relaying.
|
||||
- If you are the last hop (no `i+1`), the packet has reached the end of its explicit route. The relay should then attempt to deliver it to the final `recipientID` if directly connected, but SHOULD NOT relay it further as a broadcast.
|
||||
The following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet.
|
||||
|
||||
TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whether targeted or broadcast). If TTL reaches 0, do not relay.
|
||||
### V1 Packet (Legacy)
|
||||
```text
|
||||
+-------------------+---------------------------------------------------------+
|
||||
| Fixed Header (14) | Variable Sections |
|
||||
+-------------------+----------+-------------+------------------+-------------+
|
||||
| Ver: 1 (1B) | SenderID | RecipientID | Payload | Signature |
|
||||
| Type, TTL, etc. | (8B) | (8B) | (Length in Head) | (64B) |
|
||||
| Len: 2 Bytes | | (Optional) | | (Optional) |
|
||||
+-------------------+----------+-------------+------------------+-------------+
|
||||
```
|
||||
|
||||
## Receiver Behavior (Destination)
|
||||
### V2 Packet (Source Routed)
|
||||
```text
|
||||
+-------------------+-----------------------------------------------------------------------------+
|
||||
| Fixed Header (16) | Variable Sections |
|
||||
+-------------------+----------+-------------+-----------------------+------------------+-------------+
|
||||
| Ver: 2 (1B) | SenderID | RecipientID | SOURCE ROUTE | Payload | Signature |
|
||||
| Type, TTL, etc. | (8B) | (8B) | (Variable) | (Length in Head) | (64B) |
|
||||
| Len: 4 Bytes | | (Required*) | Only if HAS_ROUTE=1 | | (Optional) |
|
||||
+-------------------+----------+-------------+-----------------------+------------------+-------------+
|
||||
```
|
||||
|
||||
- This extension does not change how addressed packets are handled by the final recipient. If the packet is addressed to you (`recipientID == myPeerID`), process it normally (e.g., decrypt Noise payload, verify signatures, etc.).
|
||||
- Signature verification MUST include the route field when present; route tampering will invalidate the signature.
|
||||
**(*) Note:** A `Route` can be attached to **any** packet type that has a `RecipientID` (flag `HAS_RECIPIENT` set).
|
||||
|
||||
## Compatibility
|
||||
### Fixed Header Differences
|
||||
|
||||
- Omission: If `HAS_ROUTE` is omitted, legacy behavior applies. Relays that don’t implement this feature will ignore the route entirely.
|
||||
- Version Constraint: Implementations MUST NOT parse or act on the `Route` field in v1 packets, even if the `HAS_ROUTE` flag is set. This prevents potential parsing ambiguities with legacy clients.
|
||||
- Partial support: If any relay on the path cannot directly reach the next hop, it will fall back to broadcast relaying; delivery is still probabilistic like the base protocol.
|
||||
| Field | Size (v1) | Size (v2) | Description |
|
||||
|---|---|---|---|
|
||||
| **Version** | 1 byte | 1 byte | `0x01` vs `0x02` |
|
||||
| **Payload Length** | **2 bytes** | **4 bytes** | `UInt32` in v2 to support large files. **Excludes** route/IDs/sig. |
|
||||
| **Total Size** | **14 bytes** | **16 bytes** | V2 header is 2 bytes larger. |
|
||||
|
||||
## Minimal Example (conceptual)
|
||||
---
|
||||
|
||||
- Header (fixed 13 bytes): unchanged.
|
||||
- Variable sections (ordered):
|
||||
- `SenderID(8)`
|
||||
- `RecipientID(8)` (if present)
|
||||
- `HAS_ROUTE` set → `count=1`, `hops = [H1]` where `H1` is 8 bytes
|
||||
- Payload (optionally compressed)
|
||||
- Signature (64)
|
||||
## 3. Source Route Specification
|
||||
|
||||
In this example, `SENDER_ID` is the sender, `RECIPIENT_ID` is the final recipient, and `H1` is the single intermediate relay. The `hops` list explicitly defines the path *between* the sender and recipient. The receiver verifies the signature over the packet encoding (with `ttl = 0` and `signature` omitted), which includes the `hops` when `HAS_ROUTE` is set.
|
||||
The `Source Route` field is a variable-length list of **intermediate hops** that the packet must traverse.
|
||||
|
||||
## Operational Notes
|
||||
* **Location:** Immediately follows `RecipientID`.
|
||||
* **Structure:**
|
||||
* `Count` (1 byte): Number of intermediate hops (`N`).
|
||||
* `Hops` (`N * 8` bytes): Sequence of Peer IDs.
|
||||
|
||||
- Routing optimality depends on the freshness and completeness of the topology your implementation has learned (e.g., via gossip of direct neighbors). Recompute routes as needed.
|
||||
- Route length should be kept small to reduce overhead and the probability of missing a direct link at some hop.
|
||||
- Implementations may introduce policy controls (e.g., disable source routing, cap max route length).
|
||||
### Intermediate Hops Only
|
||||
The route list MUST contain **only** the intermediate relays between the sender and the recipient.
|
||||
* **DO NOT** include the `SenderID` (it is already in the packet).
|
||||
* **DO NOT** include the `RecipientID` (it is already in the packet).
|
||||
|
||||
**Example:**
|
||||
Topology: `Alice (Sender) -> Bob -> Charlie -> Dave (Recipient)`
|
||||
* Packet `SenderID`: Alice
|
||||
* Packet `RecipientID`: Dave
|
||||
* Packet `Route`: `[Bob, Charlie]` (Count = 2)
|
||||
|
||||
---
|
||||
|
||||
## 4. Topology Discovery (Gossip)
|
||||
|
||||
To calculate routes, nodes need a view of the network topology. This is achieved via a **Neighbor List** extension to the `IdentityAnnouncement` packet.
|
||||
|
||||
* **Mechanism:** `IdentityAnnouncement` packets contain a TLV (Type-Length-Value) payload.
|
||||
* **New TLV Type:** `0x04` (Direct Neighbors).
|
||||
* **Content:** A list of Peer IDs that the announcing node is directly connected to.
|
||||
|
||||
**TLV Structure (Type 0x04):**
|
||||
```text
|
||||
[Type: 0x04] [Length: 1B] [Count: 1B] [NeighborID1 (8B)] [NeighborID2 (8B)] ...
|
||||
```
|
||||
Nodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fragmentation & Source Routing
|
||||
|
||||
When a large source-routed packet (e.g., File Transfer) exceeds the MTU and requires fragmentation:
|
||||
|
||||
1. **Version Inheritance:** All fragments MUST be marked as **Version 2**.
|
||||
2. **Route Inheritance:** All fragments MUST contain the **exact same Route field** as the parent packet.
|
||||
|
||||
**Why?** If fragments were sent as v1 packets or without routes, they would fall back to flooding, negating the bandwidth benefits of source routing for large data transfers.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security & Signing
|
||||
|
||||
Source routing is fully secured by the existing Ed25519 signature scheme.
|
||||
|
||||
* **Scope:** The signature covers the **entire packet structure** (Header + Sender + Recipient + Route + Payload).
|
||||
* **Verification:** The receiver verifies the signature against the `SenderID`'s public key.
|
||||
* **Integrity:** Any tampering with the route list by malicious relays will invalidate the signature, causing the packet to be dropped by the destination.
|
||||
|
||||
**Signature Input Construction:**
|
||||
Serialize the packet exactly as transmitted, but temporarily set `TTL = 0` and remove the `Signature` bytes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Relay Logic
|
||||
|
||||
When a node receives a packet **not** addressed to itself:
|
||||
|
||||
1. **Check Route:**
|
||||
* Is `Version >= 2`?
|
||||
* Is `HAS_ROUTE` flag set?
|
||||
* Is the route list non-empty?
|
||||
2. **If YES (Source Routed):**
|
||||
* Find local Peer ID in the route list at index `i`.
|
||||
* **Next Hop:** The peer at `i + 1`.
|
||||
* **Last Hop:** If `i` is the last index, the Next Hop is the `RecipientID`.
|
||||
* **Action:** Attempt to unicast (`sendToPeer`) to the Next Hop.
|
||||
* **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery.
|
||||
3. **If NO (Standard):**
|
||||
* Flood the packet to all connected neighbors (subject to TTL and probability rules).
|
||||
|
||||
Reference in New Issue
Block a user