From 856fe5eb3ed7933ab773b2c5efeb37f654ad57c0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:24:07 +0700 Subject: [PATCH] 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 --- app/proguard-rules.pro | 5 + .../mesh/BluetoothConnectionManager.kt | 27 ++- .../mesh/BluetoothConnectionTracker.kt | 57 ++++- .../mesh/BluetoothGattServerManager.kt | 30 ++- .../mesh/BluetoothPacketBroadcaster.kt | 2 +- .../bitchat/android/mesh/FragmentManager.kt | 30 ++- .../android/protocol/BinaryProtocol.kt | 18 +- .../android/mesh/FragmentManagerTest.kt | 191 +++++++++++++++ docs/SOURCE_ROUTING.md | 219 +++++++++--------- 9 files changed, 444 insertions(+), 135 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d3e004db..df941012 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 ; +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 2f1664e7..f6dc194d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -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) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 7029185f..85e5f452 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -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 { + val servers = connectedDevices.values.filter { !it.isClient } + val excessList = mutableListOf() + + // 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 diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 6a1c6fbf..e06fd008 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -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) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 73103772..582c73c8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -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", "") diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt index 16f6844d..d9120a84 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt @@ -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..= 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) } diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt new file mode 100644 index 00000000..4dce6d7f --- /dev/null +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt @@ -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 + } +} diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index f1ee0853..bfb285ca 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -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).