From 08ffde618a00b3b277aa668b6fa26cfca218ad91 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:03:24 +0200 Subject: [PATCH 01/18] wip mesh graph --- .../android/mesh/BluetoothMeshService.kt | 36 ++++++- .../bitchat/android/mesh/MessageHandler.kt | 7 ++ .../android/services/meshgraph/GossipTLV.kt | 76 +++++++++++++++ .../services/meshgraph/MeshGraphService.kt | 96 +++++++++++++++++++ .../android/ui/debug/DebugSettingsSheet.kt | 73 ++++++++++++++ 5 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt create mode 100644 app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index da3d1226..6c584e74 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -694,11 +694,20 @@ class BluetoothMeshService(private val context: Context) { // Create iOS-compatible IdentityAnnouncement with TLV encoding val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) - val tlvPayload = announcement.encode() + var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode announcement as TLV") return@launch } + + // Append gossip TLV containing up to 10 direct neighbors (compact IDs) + try { + val directPeers = getDirectPeerIDsForGossip() + if (directPeers.isNotEmpty()) { + val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers) + tlvPayload = tlvPayload + gossip + } + } catch (_: Exception) { } val announcePacket = BitchatPacket( type = MessageType.ANNOUNCE.value, @@ -741,11 +750,20 @@ class BluetoothMeshService(private val context: Context) { // Create iOS-compatible IdentityAnnouncement with TLV encoding val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) - val tlvPayload = announcement.encode() + var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode peer announcement as TLV") return } + + // Append gossip TLV containing up to 10 direct neighbors (compact IDs) + try { + val directPeers = getDirectPeerIDsForGossip() + if (directPeers.isNotEmpty()) { + val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers) + tlvPayload = tlvPayload + gossip + } + } catch (_: Exception) { } val packet = BitchatPacket( type = MessageType.ANNOUNCE.value, @@ -764,6 +782,20 @@ class BluetoothMeshService(private val context: Context) { Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)") } + /** + * Collect up to 10 direct neighbors for gossip TLV. + */ + private fun getDirectPeerIDsForGossip(): List { + return try { + // Prefer verified peers that are currently marked as direct + val verified = peerManager.getVerifiedPeers() + val direct = verified.filter { it.value.isDirectConnection }.keys.toList() + direct.take(10) + } catch (_: Exception) { + emptyList() + } + } + /** * Send leave announcement */ diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 76fea7a1..7e0bc729 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -240,6 +240,13 @@ class MessageHandler(private val myPeerID: String) { previousPeerID = null ) + // Update mesh graph from gossip neighbors (if present) + try { + val neighbors = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload) + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(peerID, nickname, neighbors, packet.timestamp) + } catch (_: Exception) { } + Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID") return isFirstAnnounce } diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt new file mode 100644 index 00000000..e684f215 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt @@ -0,0 +1,76 @@ +package com.bitchat.android.services.meshgraph + +import android.util.Log + +/** + * Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads. + * Uses compact TLV: [type=0x04][len=1 byte][value=N*8 bytes of peerIDs] + */ +object GossipTLV { + // TLV type for a compact list of direct neighbor peerIDs (each 8 bytes) + const val DIRECT_NEIGHBORS_TYPE: UByte = 0x04u + + /** + * Encode up to 10 unique peerIDs (hex string up to 16 chars) as TLV value. + */ + fun encodeNeighbors(peerIDs: List): ByteArray { + val unique = peerIDs.distinct().take(10) + val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray() + if (valueBytes.size > 255) { + // Safety check, though 10*8 = 80 bytes, so well under 255 + Log.w("GossipTLV", "Neighbors value exceeds 255, truncating") + } + return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes + } + + /** + * Scan a TLV-encoded announce payload and extract neighbor peerIDs if present. + */ + fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List { + val result = mutableListOf() + var offset = 0 + while (offset + 2 <= payload.size) { + val type = payload[offset].toUByte() + val len = payload[offset + 1].toUByte().toInt() + offset += 2 + if (offset + len > payload.size) break + val value = payload.sliceArray(offset until offset + len) + offset += len + + if (type == DIRECT_NEIGHBORS_TYPE) { + // Value is N*8 bytes of peer IDs + var pos = 0 + while (pos + 8 <= value.size) { + val idBytes = value.sliceArray(pos until pos + 8) + result.add(bytesToPeerIdHex(idBytes)) + pos += 8 + } + break // only one neighbors TLV expected + } + } + return result + } + + private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray { + val clean = hexString.lowercase().take(16) + val result = ByteArray(8) { 0 } + var idx = 0 + var out = 0 + while (idx + 1 < clean.length && out < 8) { + val byteStr = clean.substring(idx, idx + 2) + val b = byteStr.toIntOrNull(16)?.toByte() ?: 0 + result[out++] = b + idx += 2 + } + return result + } + + private fun bytesToPeerIdHex(bytes: ByteArray): String { + val sb = StringBuilder() + for (b in bytes.take(8)) { + sb.append(String.format("%02x", b)) + } + return sb.toString() + } +} + diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt new file mode 100644 index 00000000..7decb692 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt @@ -0,0 +1,96 @@ +package com.bitchat.android.services.meshgraph + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Maintains an internal undirected graph of the mesh based on gossip. + * Nodes are peers (peerID), edges are direct connections. + */ +class MeshGraphService private constructor() { + data class GraphNode(val peerID: String, val nickname: String?) + data class GraphEdge(val a: String, val b: String) + data class GraphSnapshot(val nodes: List, val edges: List) + + // Map peerID -> nickname (may be null if unknown) + private val nicknames = ConcurrentHashMap() + // Adjacency (undirected): peerID -> set of neighbor peerIDs + private val adjacency = ConcurrentHashMap>() + // Latest announcement timestamp per peer (ULong from packet) + private val lastUpdate = ConcurrentHashMap() + + private val _graphState = MutableStateFlow(GraphSnapshot(emptyList(), emptyList())) + val graphState: StateFlow = _graphState.asStateFlow() + + /** + * Update graph from a verified announcement. + * Replaces previous neighbors for origin if this is newer (by timestamp). + */ + fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighbors: List, timestamp: ULong) { + // Newer-only replacement per origin + val prevTs = lastUpdate[originPeerID] + if (prevTs != null && prevTs >= timestamp) return + lastUpdate[originPeerID] = timestamp + + synchronized(this) { + // Update nickname + if (originNickname != null) nicknames[originPeerID] = originNickname + + // Remove old symmetric edges contributed by this origin + val prevNeighbors = adjacency[originPeerID]?.toSet().orEmpty() + prevNeighbors.forEach { n -> + adjacency[n]?.remove(originPeerID) + } + + // Replace origin's adjacency with new set + val newSet = neighbors.distinct().take(10).filter { it != originPeerID }.toMutableSet() + adjacency[originPeerID] = newSet + // Ensure nodes exist for neighbors + newSet.forEach { n -> + adjacency.putIfAbsent(n, mutableSetOf()) + // Add symmetric edge for undirected graph visualization + adjacency[n]?.add(originPeerID) + } + + // Compute snapshot + publishSnapshot() + } + } + + fun updateNickname(peerID: String, nickname: String?) { + if (nickname == null) return + nicknames[peerID] = nickname + publishSnapshot() + } + + private fun publishSnapshot() { + val nodes = mutableSetOf() + adjacency.forEach { (a, neighbors) -> + nodes.add(a) + nodes.addAll(neighbors) + } + // Merge in nicknames-only nodes + nodes.addAll(nicknames.keys) + + val nodeList = nodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID } + val edgeSet = mutableSetOf>() + adjacency.forEach { (a, ns) -> + ns.forEach { b -> + val (x, y) = if (a <= b) a to b else b to a + edgeSet.add(x to y) + } + } + val edges = edgeSet.map { GraphEdge(it.first, it.second) }.sortedWith(compareBy({ it.a }, { it.b })) + _graphState.value = GraphSnapshot(nodeList, edges) + } + + companion object { + @Volatile private var INSTANCE: MeshGraphService? = null + fun getInstance(): MeshGraphService = INSTANCE ?: synchronized(this) { + INSTANCE ?: MeshGraphService().also { INSTANCE = it } + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 33fde064..f060fe9d 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.draw.rotate import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.services.meshgraph.MeshGraphService import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -91,6 +92,11 @@ fun DebugSettingsSheet( .padding(bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + item { + // Mesh topology visualization + MeshTopologySection() + } + item { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500)) @@ -247,10 +253,77 @@ fun DebugSettingsSheet( Text("${maxVal.toInt()}", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopStart).padding(start = 4.dp, top = 2.dp)) // Y-axis unit label (vertical) Text("p/s", fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)) + } + } + } +} + +@Composable +fun MeshTopologySection() { + val colorScheme = MaterialTheme.colorScheme + val graphService = remember { MeshGraphService.getInstance() } + val snapshot by graphService.graphState.collectAsState() + + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) + Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + val nodes = snapshot.nodes + val edges = snapshot.edges + val empty = nodes.isEmpty() + if (empty) { + Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(220.dp).background(colorScheme.surface.copy(alpha = 0.4f))) { + val w = size.width + val h = size.height + val cx = w / 2f + val cy = h / 2f + val radius = (minOf(w, h) * 0.36f) + val n = nodes.size + if (n == 1) { + // Single node centered + drawCircle(color = Color(0xFF00C851), radius = 12f, center = androidx.compose.ui.geometry.Offset(cx, cy)) + } else { + // Circular layout + val positions = nodes.mapIndexed { i, node -> + val angle = (2 * Math.PI * i.toDouble()) / n + val x = cx + (radius * Math.cos(angle)).toFloat() + val y = cy + (radius * Math.sin(angle)).toFloat() + node.peerID to androidx.compose.ui.geometry.Offset(x, y) + }.toMap() + + // Draw edges + edges.forEach { e -> + val p1 = positions[e.a] + val p2 = positions[e.b] + if (p1 != null && p2 != null) { + drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f) + } + } + + // Draw nodes + nodes.forEach { node -> + val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy) + drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos) } } } + // Label list for clarity under the canvas + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) { + items(nodes) { node -> + val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}" + Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f)) + } + } } + } + } +} + + // Connected devices item { From f4b33b5a09814ae6d8a27513190218cc581afd55 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:11:07 +0200 Subject: [PATCH 02/18] gossip fix --- .../android/ui/debug/DebugSettingsSheet.kt | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index f060fe9d..6881494d 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -27,6 +27,71 @@ import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.services.meshgraph.MeshGraphService import kotlinx.coroutines.launch +@Composable +fun MeshTopologySection() { + val colorScheme = MaterialTheme.colorScheme + val graphService = remember { MeshGraphService.getInstance() } + val snapshot by graphService.graphState.collectAsState() + + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) + Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + val nodes = snapshot.nodes + val edges = snapshot.edges + val empty = nodes.isEmpty() + if (empty) { + Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(220.dp).background(colorScheme.surface.copy(alpha = 0.4f))) { + val w = size.width + val h = size.height + val cx = w / 2f + val cy = h / 2f + val radius = (minOf(w, h) * 0.36f) + val n = nodes.size + if (n == 1) { + // Single node centered + drawCircle(color = Color(0xFF00C851), radius = 12f, center = androidx.compose.ui.geometry.Offset(cx, cy)) + } else { + // Circular layout + val positions = nodes.mapIndexed { i, node -> + val angle = (2 * Math.PI * i.toDouble()) / n + val x = cx + (radius * Math.cos(angle)).toFloat() + val y = cy + (radius * Math.sin(angle)).toFloat() + node.peerID to androidx.compose.ui.geometry.Offset(x, y) + }.toMap() + + // Draw edges + edges.forEach { e -> + val p1 = positions[e.a] + val p2 = positions[e.b] + if (p1 != null && p2 != null) { + drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f) + } + } + + // Draw nodes + nodes.forEach { node -> + val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy) + drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos) + } + } + } + // Label list for clarity under the canvas + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) { + items(nodes) { node -> + val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}" + Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f)) + } + } + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun DebugSettingsSheet( From 5769e70ea4ee562aedb0ae5968875fa7f0ab836a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:29:46 +0200 Subject: [PATCH 03/18] gossip works --- .../android/mesh/BluetoothMeshService.kt | 10 ++++++ .../bitchat/android/mesh/MessageHandler.kt | 6 ++-- .../android/services/meshgraph/GossipTLV.kt | 11 ++++--- .../services/meshgraph/MeshGraphService.kt | 32 +++++++++++-------- .../android/ui/debug/DebugSettingsSheet.kt | 29 ++++++++++++++--- 5 files changed, 62 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 6c584e74..2ddb6139 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -707,6 +707,11 @@ class BluetoothMeshService(private val context: Context) { val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers) tlvPayload = tlvPayload + gossip } + // Always update our own node in the mesh graph with the neighbor list we used + try { + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong()) + } catch (_: Exception) { } } catch (_: Exception) { } val announcePacket = BitchatPacket( @@ -763,6 +768,11 @@ class BluetoothMeshService(private val context: Context) { val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers) tlvPayload = tlvPayload + gossip } + // Always update our own node in the mesh graph with the neighbor list we used + try { + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong()) + } catch (_: Exception) { } } catch (_: Exception) { } val packet = BitchatPacket( diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 7e0bc729..2ebac4db 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -240,11 +240,11 @@ class MessageHandler(private val myPeerID: String) { previousPeerID = null ) - // Update mesh graph from gossip neighbors (if present) + // Update mesh graph from gossip neighbors (only if TLV present) try { - val neighbors = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload) + val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload) com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() - .updateFromAnnouncement(peerID, nickname, neighbors, packet.timestamp) + .updateFromAnnouncement(peerID, nickname, neighborsOrNull, packet.timestamp) } catch (_: Exception) { } Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID") diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt index e684f215..85ea6c9c 100644 --- a/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt @@ -24,9 +24,10 @@ object GossipTLV { } /** - * Scan a TLV-encoded announce payload and extract neighbor peerIDs if present. + * Scan a TLV-encoded announce payload and extract neighbor peerIDs. + * Returns null if the TLV is not present at all; returns an empty list if present with length 0. */ - fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List { + fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List? { val result = mutableListOf() var offset = 0 while (offset + 2 <= payload.size) { @@ -45,10 +46,11 @@ object GossipTLV { result.add(bytesToPeerIdHex(idBytes)) pos += 8 } - break // only one neighbors TLV expected + return result // present (possibly empty) } } - return result + // Not present + return null } private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray { @@ -73,4 +75,3 @@ object GossipTLV { return sb.toString() } } - diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt index 7decb692..ceb87a91 100644 --- a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt @@ -28,33 +28,40 @@ class MeshGraphService private constructor() { * Update graph from a verified announcement. * Replaces previous neighbors for origin if this is newer (by timestamp). */ - fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighbors: List, timestamp: ULong) { - // Newer-only replacement per origin - val prevTs = lastUpdate[originPeerID] - if (prevTs != null && prevTs >= timestamp) return - lastUpdate[originPeerID] = timestamp - + fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighborsOrNull: List?, timestamp: ULong) { synchronized(this) { - // Update nickname + // Always update nickname if provided if (originNickname != null) nicknames[originPeerID] = originNickname + // If no neighbors TLV present, do not modify edges or timestamps + if (neighborsOrNull == null) { + publishSnapshot() + return + } + + // Newer-only replacement per origin (based on TLV-bearing announcements only) + val prevTs = lastUpdate[originPeerID] + if (prevTs != null && prevTs >= timestamp) { + // Older or equal TLV-bearing update: ignore + return + } + lastUpdate[originPeerID] = timestamp + // Remove old symmetric edges contributed by this origin val prevNeighbors = adjacency[originPeerID]?.toSet().orEmpty() prevNeighbors.forEach { n -> adjacency[n]?.remove(originPeerID) } - // Replace origin's adjacency with new set - val newSet = neighbors.distinct().take(10).filter { it != originPeerID }.toMutableSet() + // Replace origin's adjacency with new set (may be empty) + val newSet = neighborsOrNull.distinct().take(10).filter { it != originPeerID }.toMutableSet() adjacency[originPeerID] = newSet - // Ensure nodes exist for neighbors + // Ensure undirected edges newSet.forEach { n -> adjacency.putIfAbsent(n, mutableSetOf()) - // Add symmetric edge for undirected graph visualization adjacency[n]?.add(originPeerID) } - // Compute snapshot publishSnapshot() } } @@ -93,4 +100,3 @@ class MeshGraphService private constructor() { } } } - diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 6881494d..3bd0ada3 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -26,6 +26,9 @@ import androidx.compose.ui.draw.rotate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.services.meshgraph.MeshGraphService import kotlinx.coroutines.launch +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas @Composable fun MeshTopologySection() { @@ -78,6 +81,22 @@ fun MeshTopologySection() { val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy) drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos) } + + // Draw labels near nodes (nickname or short ID) + val labelColor = colorScheme.onSurface.toArgb() + val textSizePx = 10.sp.toPx() + drawIntoCanvas { canvas -> + val paint = android.graphics.Paint().apply { + isAntiAlias = true + color = labelColor + textSize = textSizePx + } + nodes.forEach { node -> + val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy) + val label = (node.nickname?.takeIf { it.isNotBlank() } ?: node.peerID.take(8)) + canvas.nativeCanvas.drawText(label, pos.x + 12f, pos.y - 12f, paint) + } + } } } // Label list for clarity under the canvas @@ -157,11 +176,6 @@ fun DebugSettingsSheet( .padding(bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - item { - // Mesh topology visualization - MeshTopologySection() - } - item { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500)) @@ -195,6 +209,11 @@ fun DebugSettingsSheet( } } + // Mesh topology visualization (moved below verbose logging) + item { + MeshTopologySection() + } + // GATT controls item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { From a5928987fe2c7b21278cbd145ab28daa25adcdd9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:46:34 +0200 Subject: [PATCH 04/18] source-based routing wip --- .../mesh/BluetoothConnectionManager.kt | 10 +++ .../android/mesh/BluetoothMeshService.kt | 27 ++++++-- .../mesh/BluetoothPacketBroadcaster.kt | 40 +++++++++++ .../bitchat/android/mesh/PacketProcessor.kt | 4 ++ .../android/mesh/PacketRelayManager.kt | 41 +++++++++++- .../android/protocol/BinaryProtocol.kt | 49 +++++++++++++- .../services/meshgraph/RoutePlanner.kt | 66 +++++++++++++++++++ 7 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt 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 0cc7f967..23539dcb 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -247,6 +247,16 @@ class BluetoothConnectionManager( serverManager.getCharacteristic() ) } + + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + if (!isActive) return false + return packetBroadcaster.sendToPeer( + peerID, + routed, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } // Expose role controls for debug UI diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 2ddb6139..a636042a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -401,6 +401,10 @@ class BluetoothMeshService(private val context: Context) { override fun relayPacket(routed: RoutedPacket) { connectionManager.broadcastPacket(routed) } + + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + return connectionManager.sendToPeer(peerID, routed) + } } // BluetoothConnectionManager delegates @@ -987,21 +991,36 @@ class BluetoothMeshService(private val context: Context) { */ private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { return try { + // Optionally compute and attach a source route for addressed packets + val withRoute = try { + val rec = packet.recipientID + if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) { + val dest = rec.joinToString("") { b -> "%02x".format(b) } + val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest) + if (path != null && path.size >= 3) { + // Exclude first (sender) and last (recipient); only intermediates + val intermediates = path.subList(1, path.size - 1) + val hopsBytes = intermediates.map { hexStringToByteArray(it) } + packet.copy(route = hopsBytes) + } else packet.copy(route = null) + } else packet + } catch (_: Exception) { packet } + // Get the canonical packet data for signing (without signature) - val packetDataForSigning = packet.toBinaryDataForSigning() + val packetDataForSigning = withRoute.toBinaryDataForSigning() if (packetDataForSigning == null) { Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned") - return packet + return withRoute } // Sign the packet data using our signing key val signature = encryptionService.signData(packetDataForSigning) if (signature != null) { Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)") - packet.copy(signature = signature) + withRoute.copy(signature = signature) } else { Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned") - packet + withRoute } } catch (e: Exception) { Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned") 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 1fc52bb4..b5c5484f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -159,6 +159,46 @@ class BluetoothPacketBroadcaster( } } } + + /** + * Targeted send to a specific peer (by peerID) if directly connected. + * Returns true if sent to at least one matching connection. + */ + fun sendToPeer( + targetPeerID: String, + routed: RoutedPacket, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + val packet = routed.packet + val data = packet.toBinaryData() ?: return false + val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() + val senderPeerID = routed.peerID ?: packet.senderID.toHexString() + val incomingAddr = routed.relayAddress + val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] } + val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) } + + // Try server-side connections first + val targetDevice = connectionTracker.getSubscribedDevices() + .firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID } + if (targetDevice != null) { + if (notifyDevice(targetDevice, data, gattServer, characteristic)) { + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl) + return true + } + } + + // Try client-side connections next + val targetConn = connectionTracker.getConnectedDevices().values + .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } + if (targetConn != null) { + if (writeToDeviceConn(targetConn, data)) { + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl) + return true + } + } + return false + } /** * Internal broadcast implementation - runs in serialized actor context diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 54ecffbe..315308e2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -112,6 +112,9 @@ class PacketProcessor(private val myPeerID: String) { override fun broadcastPacket(routed: RoutedPacket) { delegate?.relayPacket(routed) } + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + return delegate?.sendToPeer(peerID, routed) ?: false + } } } @@ -310,4 +313,5 @@ interface PacketProcessorDelegate { fun sendAnnouncementToPeer(peerID: String) fun sendCachedMessages(peerID: String) fun relayPacket(routed: RoutedPacket) + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index f292534b..246960bf 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -65,9 +65,35 @@ class PacketRelayManager(private val myPeerID: String) { val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte()) Log.d(TAG, "Decremented TTL from ${'$'}{packet.ttl} to ${'$'}{relayPacket.ttl}") + // Source-based routing: if route is set and includes us, try targeted next-hop forwarding + val route = relayPacket.route + if (!route.isNullOrEmpty()) { + val myIdBytes = hexStringToPeerBytes(myPeerID) + val index = route.indexOfFirst { it.contentEquals(myIdBytes) } + if (index >= 0) { + val nextHopIdHex: String? = run { + val nextIndex = index + 1 + if (nextIndex < route.size) { + route[nextIndex].toHexString() + } else { + // We are the last intermediate; try final recipient as next hop + relayPacket.recipientID?.toHexString() + } + } + if (nextHopIdHex != null) { + val success = try { delegate?.sendToPeer(nextHopIdHex, RoutedPacket(relayPacket, peerID, routed.relayAddress)) } catch (_: Exception) { false } ?: false + if (success) { + Log.i(TAG, "📦 Source-route relay: ${myPeerID.take(8)} -> ${nextHopIdHex.take(8)} (type ${'$'}{packet.type}, TTL ${'$'}{relayPacket.ttl})") + return + } else { + Log.w(TAG, "Source-route next hop ${nextHopIdHex.take(8)} not directly connected; falling back to broadcast") + } + } + } + } + // Apply relay logic based on packet type and debug switch val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID) - if (shouldRelay) { relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress)) } else { @@ -206,4 +232,17 @@ interface PacketRelayManagerDelegate { // Packet operations fun broadcastPacket(routed: RoutedPacket) + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean +} + +private fun hexStringToPeerBytes(hex: String): ByteArray { + val result = ByteArray(8) + var idx = 0 + var out = 0 + while (idx + 1 < hex.length && out < 8) { + val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0 + result[out++] = b + idx += 2 + } + return result } diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 3dcc47a2..0a0c466a 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -57,7 +57,8 @@ data class BitchatPacket( val timestamp: ULong, val payload: ByteArray, var signature: ByteArray? = null, // Changed from val to var for packet signing - var ttl: UByte + var ttl: UByte, + var route: List? = null // Optional source route: ordered list of peerIDs (8 bytes each), including sender and final recipient ) : Parcelable { constructor( @@ -95,7 +96,8 @@ data class BitchatPacket( timestamp = timestamp, payload = payload, signature = null, // Remove signature for signing - ttl = 0u // Use fixed TTL=0 for signing to ensure relay compatibility + ttl = 0u, // Use fixed TTL=0 for signing to ensure relay compatibility + route = route ) return BinaryProtocol.encode(unsignedPacket) } @@ -147,6 +149,11 @@ data class BitchatPacket( if (!signature.contentEquals(other.signature)) return false } else if (other.signature != null) return false if (ttl != other.ttl) return false + if (route != null || other.route != null) { + val a = route?.map { it.toList() } ?: emptyList() + val b = other.route?.map { it.toList() } ?: emptyList() + if (a != b) return false + } return true } @@ -160,6 +167,7 @@ data class BitchatPacket( result = 31 * result + payload.contentHashCode() result = 31 * result + (signature?.contentHashCode() ?: 0) result = 31 * result + ttl.hashCode() + result = 31 * result + (route?.fold(1) { acc, bytes -> 31 * acc + bytes.contentHashCode() } ?: 0) return result } } @@ -177,6 +185,7 @@ object BinaryProtocol { const val HAS_RECIPIENT: UByte = 0x01u const val HAS_SIGNATURE: UByte = 0x02u const val IS_COMPRESSED: UByte = 0x04u + const val HAS_ROUTE: UByte = 0x08u } fun encode(packet: BitchatPacket): ByteArray? { @@ -215,6 +224,9 @@ object BinaryProtocol { if (isCompressed) { flags = flags or Flags.IS_COMPRESSED } + if (!packet.route.isNullOrEmpty()) { + flags = flags or Flags.HAS_ROUTE + } buffer.put(flags.toByte()) // Payload length (2 bytes, big-endian) - includes original size if compressed @@ -236,6 +248,14 @@ object BinaryProtocol { buffer.put(ByteArray(RECIPIENT_ID_SIZE - recipientBytes.size)) } } + + // Route (optional): 1 byte count + N*8 bytes + packet.route?.let { routeList -> + val cleaned = routeList.map { bytes -> bytes.take(SENDER_ID_SIZE).toByteArray().let { if (it.size < SENDER_ID_SIZE) it + ByteArray(SENDER_ID_SIZE - it.size) else it } } + val count = cleaned.size.coerceAtMost(255) + buffer.put(count.toByte()) + cleaned.take(count).forEach { hop -> buffer.put(hop) } + } // Payload (with original size prepended if compressed) if (isCompressed) { @@ -302,6 +322,7 @@ object BinaryProtocol { val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte() val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte() val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte() + val hasRoute = (flags and Flags.HAS_ROUTE) != 0u.toUByte() // Payload length val payloadLength = buffer.getShort().toUShort() @@ -309,6 +330,15 @@ object BinaryProtocol { // Calculate expected total size var expectedSize = HEADER_SIZE + SENDER_ID_SIZE + payloadLength.toInt() if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE + 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() + } + expectedSize += 1 + (routeCount * SENDER_ID_SIZE) + } if (hasSignature) expectedSize += SIGNATURE_SIZE if (raw.size < expectedSize) return null @@ -324,6 +354,18 @@ object BinaryProtocol { recipientBytes } else null + // Route (optional) + val route: List? = if (hasRoute) { + val count = buffer.get().toUByte().toInt() + val hops = mutableListOf() + repeat(count) { + val hop = ByteArray(SENDER_ID_SIZE) + buffer.get(hop) + hops.add(hop) + } + hops + } else null + // Payload val payload = if (isCompressed) { // First 2 bytes are original size @@ -357,7 +399,8 @@ object BinaryProtocol { timestamp = timestamp, payload = payload, signature = signature, - ttl = ttl + ttl = ttl, + route = route ) } catch (e: Exception) { diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt new file mode 100644 index 00000000..ae279405 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt @@ -0,0 +1,66 @@ +package com.bitchat.android.services.meshgraph + +import android.util.Log +import java.util.PriorityQueue + +/** + * Computes shortest paths on the current mesh graph snapshot using Dijkstra. + * Assumes unit edge weights. + */ +object RoutePlanner { + private const val TAG = "RoutePlanner" + + /** + * Return full path [src, ..., dst] if reachable, else null. + */ + fun shortestPath(src: String, dst: String): List? { + if (src == dst) return listOf(src) + val snapshot = MeshGraphService.getInstance().graphState.value + val neighbors = mutableMapOf>() + snapshot.edges.forEach { e -> + neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b) + neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a) + } + // Ensure nodes known even if isolated + snapshot.nodes.forEach { n -> neighbors.putIfAbsent(n.peerID, mutableSetOf()) } + + if (!neighbors.containsKey(src) || !neighbors.containsKey(dst)) return null + + val dist = mutableMapOf() + val prev = mutableMapOf() + val pq = PriorityQueue>(compareBy { it.second }) + + neighbors.keys.forEach { v -> + dist[v] = if (v == src) 0 else Int.MAX_VALUE + prev[v] = null + } + pq.add(src to 0) + + while (pq.isNotEmpty()) { + val (u, d) = pq.poll() + if (d > (dist[u] ?: Int.MAX_VALUE)) continue + if (u == dst) break + neighbors[u]?.forEach { v -> + val alt = d + 1 + if (alt < (dist[v] ?: Int.MAX_VALUE)) { + dist[v] = alt + prev[v] = u + pq.add(v to alt) + } + } + } + + if ((dist[dst] ?: Int.MAX_VALUE) == Int.MAX_VALUE) return null + + val path = mutableListOf() + var cur: String? = dst + while (cur != null) { + path.add(cur) + cur = prev[cur] + } + path.reverse() + Log.d(TAG, "Computed path $path") + return path + } +} + From 11353afd722039d46394fe53cddcca0727129433 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 5 Sep 2025 23:47:19 +0200 Subject: [PATCH 05/18] log --- .../main/java/com/bitchat/android/mesh/BluetoothMeshService.kt | 1 + .../main/java/com/bitchat/android/protocol/BinaryProtocol.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index a636042a..54539988 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -1001,6 +1001,7 @@ class BluetoothMeshService(private val context: Context) { // Exclude first (sender) and last (recipient); only intermediates val intermediates = path.subList(1, path.size - 1) val hopsBytes = intermediates.map { hexStringToByteArray(it) } + Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)") packet.copy(route = hopsBytes) } else packet.copy(route = null) } else packet diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 0a0c466a..fee7aaad 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -58,7 +58,7 @@ data class BitchatPacket( val payload: ByteArray, var signature: ByteArray? = null, // Changed from val to var for packet signing var ttl: UByte, - var route: List? = null // Optional source route: ordered list of peerIDs (8 bytes each), including sender and final recipient + var route: List? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient ) : Parcelable { constructor( From f2dd90e77e5f367c6c07c0b545463ae51c1e17eb Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:26:08 +0200 Subject: [PATCH 06/18] update spec to be explicit about intermediate hops only --- docs/SOURCE_ROUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index f6d101e2..72705aa3 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -30,7 +30,7 @@ Unknown flags are ignored by older implementations (they will simply not see a r ## Sender Behavior - 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 `src` (your peerID) to `dst` (recipient peerID). The hop list SHOULD include the full path `[src, ..., dst]`. +- 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: 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. @@ -64,11 +64,11 @@ TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whe - Variable sections (ordered): - `SenderID(8)` - `RecipientID(8)` (if present) - - `HAS_ROUTE` set → `count=3`, `hops = [H0 H1 H2]` where each `Hk` is 8 bytes + - `HAS_ROUTE` set → `count=1`, `hops = [H1]` where `H1` is 8 bytes - Payload (optionally compressed) - Signature (64) -Where `H0` is the sender’s peer ID, `H2` is the recipient’s peer ID, and `H1` is an intermediate relay. The receiver verifies the signature over the packet encoding (with `ttl = 0` and `signature` omitted), which includes the `hops` when `HAS_ROUTE` is set. +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. ## Operational Notes From b60c0da1dac86c97b3db22d8a96f58d75b0b9cab Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:39:01 +0200 Subject: [PATCH 07/18] drop duplicate hops --- .../main/java/com/bitchat/android/mesh/PacketRelayManager.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index 246960bf..5a1abee0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -68,6 +68,11 @@ class PacketRelayManager(private val myPeerID: String) { // Source-based routing: if route is set and includes us, try targeted next-hop forwarding val route = relayPacket.route if (!route.isNullOrEmpty()) { + // Check for duplicate hops to prevent routing loops + if (route.map { it.toHexString() }.toSet().size < route.size) { + Log.w(TAG, "Packet with duplicate hops dropped") + return + } val myIdBytes = hexStringToPeerBytes(myPeerID) val index = route.indexOfFirst { it.contentEquals(myIdBytes) } if (index >= 0) { From 94fbadb570b0a263249e5671d8d1dd90bb6fcbcb Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:40:38 +0200 Subject: [PATCH 08/18] add to spec --- docs/SOURCE_ROUTING.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index 72705aa3..9bb4529b 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -40,11 +40,13 @@ Unknown flags are ignored by older implementations (they will simply not see a r When receiving a packet that is not addressed to you: 1) If `HAS_ROUTE` is not set, or the route is empty, relay using your normal broadcast logic (subject to TTL/probability policies). -2) If `HAS_ROUTE` is set and 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`), proceed with standard handling (e.g., if not addressed to you, do not relay further). +2) If `HAS_ROUTE` is set: + - **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. TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whether targeted or broadcast). If TTL reaches 0, do not relay. From 69d18aae3c2bf15370fc239205acbab0d0eedc8e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:45:01 +0200 Subject: [PATCH 09/18] test --- .../android/mesh/PacketRelayManagerTest.kt | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt new file mode 100644 index 00000000..ae896e53 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt @@ -0,0 +1,117 @@ + +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi +class PacketRelayManagerTest { + + private lateinit var packetRelayManager: PacketRelayManager + private val delegate: PacketRelayManagerDelegate = mock() + + private val myPeerID = "1111111111111111" + private val otherPeerID = "2222222222222222" + private val nextHopPeerID = "3333333333333333" + private val finalRecipientID = "4444444444444444" + + @Before + fun setUp() { + packetRelayManager = PacketRelayManager(myPeerID) + packetRelayManager.delegate = delegate + whenever(delegate.getNetworkSize()).thenReturn(10) + whenever(delegate.getBroadcastRecipient()).thenReturn(byteArrayOf(0,0,0,0,0,0,0,0)) + } + + private fun createPacket(route: List?, recipient: String? = null): BitchatPacket { + return BitchatPacket( + type = MessageType.MESSAGE.value, + senderID = hexStringToPeerBytes(otherPeerID), + recipientID = recipient?.let { hexStringToPeerBytes(it) }, + timestamp = System.currentTimeMillis().toULong(), + payload = "hello".toByteArray(), + ttl = 5u, + route = route + ) + } + + @Test + fun `packet with duplicate hops is dropped`() = runTest { + val route = listOf( + hexStringToPeerBytes(nextHopPeerID), + hexStringToPeerBytes(nextHopPeerID) + ) + val packet = createPacket(route) + val routedPacket = RoutedPacket(packet, otherPeerID) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate, never()).sendToPeer(any(), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `valid source-routed packet is relayed to next hop`() = runTest { + val route = listOf( + hexStringToPeerBytes(myPeerID), + hexStringToPeerBytes(nextHopPeerID) + ) + val packet = createPacket(route, finalRecipientID) + val routedPacket = RoutedPacket(packet, otherPeerID) + whenever(delegate.sendToPeer(any(), any())).thenReturn(true) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate).sendToPeer(org.mockito.kotlin.eq(nextHopPeerID), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `last hop does not relay further`() = runTest { + val route = listOf( + hexStringToPeerBytes(myPeerID) + ) + val packet = createPacket(route, finalRecipientID) + val routedPacket = RoutedPacket(packet, otherPeerID) + whenever(delegate.sendToPeer(any(), any())).thenReturn(true) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate).sendToPeer(org.mockito.kotlin.eq(finalRecipientID), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `packet with empty route is broadcast`() = runTest { + val packet = createPacket(null) + val routedPacket = RoutedPacket(packet, otherPeerID) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate, never()).sendToPeer(any(), any()) + verify(delegate).broadcastPacket(any()) + } + + private fun hexStringToPeerBytes(hex: String): ByteArray { + val result = ByteArray(8) + var idx = 0 + var out = 0 + while (idx + 1 < hex.length && out < 8) { + val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0 + result[out++] = b + idx += 2 + } + return result + } +} From 016f4b553eeb2aa15ff967c58fde3519b1ac0d34 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:04:04 +0100 Subject: [PATCH 10/18] forgot comma --- .../main/java/com/bitchat/android/protocol/BinaryProtocol.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index c4a241bf..9d825e7b 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -98,7 +98,7 @@ data class BitchatPacket( timestamp = timestamp, payload = payload, signature = null, // Remove signature for signing - route = route + route = route, ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility ) return BinaryProtocol.encode(unsignedPacket) From aabd0011dfebebede195ad5bed36c6cc6839e260 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 7 Jan 2026 07:17:44 +0700 Subject: [PATCH 11/18] source routing v2 --- .../android/protocol/BinaryProtocol.kt | 20 +++--- docs/SOURCE_ROUTING.md | 64 ++++++++++++++++--- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 9d825e7b..78a51b7e 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -240,7 +240,8 @@ object BinaryProtocol { if (isCompressed) { flags = flags or Flags.IS_COMPRESSED } - if (!packet.route.isNullOrEmpty()) { + // HAS_ROUTE is only supported for v2+ packets + if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) { flags = flags or Flags.HAS_ROUTE } buffer.put(flags.toByte()) @@ -269,12 +270,14 @@ object BinaryProtocol { } } - // Route (optional): 1 byte count + N*8 bytes - packet.route?.let { routeList -> - val cleaned = routeList.map { bytes -> bytes.take(SENDER_ID_SIZE).toByteArray().let { if (it.size < SENDER_ID_SIZE) it + ByteArray(SENDER_ID_SIZE - it.size) else it } } - val count = cleaned.size.coerceAtMost(255) - buffer.put(count.toByte()) - cleaned.take(count).forEach { hop -> buffer.put(hop) } + // Route (optional, v2+ only): 1 byte count + N*8 bytes + if (packet.version >= 2u.toUByte()) { + packet.route?.let { routeList -> + val cleaned = routeList.map { bytes -> bytes.take(SENDER_ID_SIZE).toByteArray().let { if (it.size < SENDER_ID_SIZE) it + ByteArray(SENDER_ID_SIZE - it.size) else it } } + val count = cleaned.size.coerceAtMost(255) + buffer.put(count.toByte()) + cleaned.take(count).forEach { hop -> buffer.put(hop) } + } } // Payload (with original size prepended if compressed) @@ -344,7 +347,8 @@ object BinaryProtocol { val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte() val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte() val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte() - val hasRoute = (flags and Flags.HAS_ROUTE) != 0u.toUByte() + // HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1 + val hasRoute = (version >= 2u.toUByte()) && (flags and Flags.HAS_ROUTE) != 0u.toUByte() // Payload length - version-dependent (2 or 4 bytes) val payloadLength = if (version >= 2u.toUByte()) { diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index 9bb4529b..f1ee0853 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -7,19 +7,66 @@ 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)`. +- 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`): `count` (1 byte) + `count * 8` bytes hop IDs + 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 (they will simply not see a route and continue broadcasting as before). +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). + +--- ## Route Field Encoding -- Presence: Signaled by the `HAS_ROUTE (0x08)` bit in `flags`. +- 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 @@ -31,7 +78,7 @@ Unknown flags are ignored by older implementations (they will simply not see a r - 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: Set `HAS_ROUTE`, write `count = path.length`, then the 8‑byte hop IDs in order. Keep `count <= 255`. +- 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. @@ -39,8 +86,8 @@ Unknown flags are ignored by older implementations (they will simply not see a r When receiving a packet that is not addressed to you: -1) If `HAS_ROUTE` is not set, or the route is empty, relay using your normal broadcast logic (subject to TTL/probability policies). -2) If `HAS_ROUTE` is set: +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. @@ -57,7 +104,8 @@ TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whe ## Compatibility -- Omission: If `HAS_ROUTE` is omitted, legacy behavior applies. Relays that don’t implement this feature will ignore the route entirely, because they won’t set or check `HAS_ROUTE`. +- 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. ## Minimal Example (conceptual) From cc09d35e405c9838f0796f7275f81b7e8d736333 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:32:38 +0700 Subject: [PATCH 12/18] add compression bomb protection --- .../android/protocol/BinaryProtocol.kt | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 78a51b7e..dc9dce8a 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -202,12 +202,12 @@ object BinaryProtocol { try { // Try to compress payload if beneficial var payload = packet.payload - var originalPayloadSize: UShort? = null + var originalPayloadSize: Int? = null var isCompressed = false if (CompressionUtil.shouldCompress(payload)) { CompressionUtil.compress(payload)?.let { compressedPayload -> - originalPayloadSize = payload.size.toUShort() + originalPayloadSize = payload.size payload = compressedPayload isCompressed = true } @@ -217,7 +217,8 @@ object BinaryProtocol { val headerSize = getHeaderSize(packet.version) val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0 val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0 - val payloadBytes = payload.size + if (isCompressed) 2 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 buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) } @@ -247,7 +248,7 @@ object BinaryProtocol { buffer.put(flags.toByte()) // Payload length (2 or 4 bytes, big-endian) - includes original size if compressed - val payloadDataSize = payload.size + if (isCompressed) 2 else 0 + val payloadDataSize = payload.size + sizeFieldBytes if (packet.version >= 2u.toUByte()) { buffer.putInt(payloadDataSize) // 4 bytes for v2+ } else { @@ -284,7 +285,11 @@ object BinaryProtocol { if (isCompressed) { val originalSize = originalPayloadSize if (originalSize != null) { - buffer.putShort(originalSize.toShort()) + if (packet.version >= 2u.toUByte()) { + buffer.putInt(originalSize.toInt()) + } else { + buffer.putShort(originalSize.toShort()) + } } } buffer.put(payload) @@ -398,13 +403,28 @@ object BinaryProtocol { // Payload val payload = if (isCompressed) { - // First 2 bytes are original size - if (payloadLength.toInt() < 2) return null - val originalSize = buffer.getShort().toInt() + val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2 + if (payloadLength.toInt() < lengthFieldBytes) return null + + val originalSize = if (version >= 2u.toUByte()) { + buffer.getInt() + } else { + buffer.getShort().toUShort().toInt() + } // Compressed payload - val compressedPayload = ByteArray(payloadLength.toInt() - 2) + val compressedSize = payloadLength.toInt() - lengthFieldBytes + val compressedPayload = ByteArray(compressedSize) buffer.get(compressedPayload) + + // Security check: Compression bomb protection + if (compressedSize > 0) { + val ratio = originalSize.toDouble() / compressedSize.toDouble() + if (ratio > 50_000.0) { + Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1") + return null + } + } // Decompress CompressionUtil.decompress(compressedPayload, originalSize) ?: return null From a74e587123d58cc3714a1357f67b9967cf9e2f2a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:01:37 +0700 Subject: [PATCH 13/18] v2 source routing --- .../main/java/com/bitchat/android/mesh/BluetoothMeshService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index dc73a642..600ff170 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -1334,7 +1334,8 @@ class BluetoothMeshService(private val context: Context) { val intermediates = path.subList(1, path.size - 1) val hopsBytes = intermediates.map { hexStringToByteArray(it) } Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)") - packet.copy(route = hopsBytes) + // Attach route and upgrade to v2 (required for HAS_ROUTE flag) + packet.copy(route = hopsBytes, version = 2u) } else packet.copy(route = null) } else packet } catch (_: Exception) { packet } From fe441f8a0f63f41ebc3b5b194529fc6348fc0d52 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:05:27 +0700 Subject: [PATCH 14/18] fragmented packets inherit route --- app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt | 3 +++ 1 file changed, 3 insertions(+) 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..8a6d20e4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt @@ -98,13 +98,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 ) From baa2d66fd60d0692a4d72398e35471c43bcd080e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:13:25 +0700 Subject: [PATCH 15/18] update spec --- docs/SOURCE_ROUTING.md | 219 +++++++++++++++++++++-------------------- 1 file changed, 113 insertions(+), 106 deletions(-) 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). 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 16/18] 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). From 605c05d85ab8cd2a276c17baf59bfdc802c3d68b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:11:57 +0700 Subject: [PATCH 17/18] feat(gossip): implement two-way handshake for source routing edges - Update MeshGraphService to track directed announcements - Require bidirectional announcements for a 'confirmed' edge - Update RoutePlanner to strictly use confirmed edges - Update Mesh Topology debug view to show confirmed vs unconfirmed edges (solid vs dotted) --- .../services/meshgraph/MeshGraphService.kt | 73 +++++++++++-------- .../services/meshgraph/RoutePlanner.kt | 5 +- .../android/ui/debug/DebugSettingsSheet.kt | 24 +++++- 3 files changed, 67 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt index ceb87a91..8d036b4b 100644 --- a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt @@ -6,18 +6,18 @@ import kotlinx.coroutines.flow.asStateFlow import java.util.concurrent.ConcurrentHashMap /** - * Maintains an internal undirected graph of the mesh based on gossip. + * Maintains an internal graph of the mesh based on gossip. * Nodes are peers (peerID), edges are direct connections. */ class MeshGraphService private constructor() { data class GraphNode(val peerID: String, val nickname: String?) - data class GraphEdge(val a: String, val b: String) + data class GraphEdge(val a: String, val b: String, val isConfirmed: Boolean, val confirmedBy: String? = null) data class GraphSnapshot(val nodes: List, val edges: List) // Map peerID -> nickname (may be null if unknown) private val nicknames = ConcurrentHashMap() - // Adjacency (undirected): peerID -> set of neighbor peerIDs - private val adjacency = ConcurrentHashMap>() + // Announcements: peerID -> set of neighbor peerIDs that *this* peer claims to see + private val announcements = ConcurrentHashMap>() // Latest announcement timestamp per peer (ULong from packet) private val lastUpdate = ConcurrentHashMap() @@ -47,20 +47,10 @@ class MeshGraphService private constructor() { } lastUpdate[originPeerID] = timestamp - // Remove old symmetric edges contributed by this origin - val prevNeighbors = adjacency[originPeerID]?.toSet().orEmpty() - prevNeighbors.forEach { n -> - adjacency[n]?.remove(originPeerID) - } - - // Replace origin's adjacency with new set (may be empty) - val newSet = neighborsOrNull.distinct().take(10).filter { it != originPeerID }.toMutableSet() - adjacency[originPeerID] = newSet - // Ensure undirected edges - newSet.forEach { n -> - adjacency.putIfAbsent(n, mutableSetOf()) - adjacency[n]?.add(originPeerID) - } + // Update what originPeerID announces + // Filter out self-loops just in case + val newSet = neighborsOrNull.distinct().take(10).filter { it != originPeerID }.toSet() + announcements[originPeerID] = newSet publishSnapshot() } @@ -73,24 +63,43 @@ class MeshGraphService private constructor() { } private fun publishSnapshot() { - val nodes = mutableSetOf() - adjacency.forEach { (a, neighbors) -> - nodes.add(a) - nodes.addAll(neighbors) + // Collect all known nodes from nicknames and announcements + val allNodes = mutableSetOf() + allNodes.addAll(nicknames.keys) + announcements.forEach { (origin, neighbors) -> + allNodes.add(origin) + allNodes.addAll(neighbors) } - // Merge in nicknames-only nodes - nodes.addAll(nicknames.keys) - val nodeList = nodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID } - val edgeSet = mutableSetOf>() - adjacency.forEach { (a, ns) -> - ns.forEach { b -> - val (x, y) = if (a <= b) a to b else b to a - edgeSet.add(x to y) + val nodeList = allNodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID } + + val edges = mutableListOf() + val processedPairs = mutableSetOf>() + + // We only care about connections that exist in at least one direction. + // So iterating through all entries in `announcements` covers every declared edge. + announcements.forEach { (source, targets) -> + targets.forEach { target -> + val pair = if (source <= target) source to target else target to source + if (processedPairs.add(pair)) { + // This is a new pair we haven't evaluated yet + val (a, b) = pair + val aAnnouncesB = announcements[a]?.contains(b) == true + val bAnnouncesA = announcements[b]?.contains(a) == true + + if (aAnnouncesB && bAnnouncesA) { + edges.add(GraphEdge(a, b, isConfirmed = true)) + } else if (aAnnouncesB) { + edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = a)) + } else if (bAnnouncesA) { + edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = b)) + } + } } } - val edges = edgeSet.map { GraphEdge(it.first, it.second) }.sortedWith(compareBy({ it.a }, { it.b })) - _graphState.value = GraphSnapshot(nodeList, edges) + + val sortedEdges = edges.sortedWith(compareBy({ it.a }, { it.b })) + _graphState.value = GraphSnapshot(nodeList, sortedEdges) } companion object { diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt index ae279405..78624f87 100644 --- a/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt @@ -17,7 +17,9 @@ object RoutePlanner { if (src == dst) return listOf(src) val snapshot = MeshGraphService.getInstance().graphState.value val neighbors = mutableMapOf>() - snapshot.edges.forEach { e -> + + // Only consider confirmed edges for routing + snapshot.edges.filter { it.isConfirmed }.forEach { e -> neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b) neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a) } @@ -63,4 +65,3 @@ object RoutePlanner { return path } } - diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 25be4a21..760bdb12 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -80,7 +80,29 @@ fun MeshTopologySection() { val p1 = positions[e.a] val p2 = positions[e.b] if (p1 != null && p2 != null) { - drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f) + if (e.isConfirmed) { + drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f) + } else { + // Unconfirmed: draw "solid" from declarer, "dashed" from other + val start = if (e.confirmedBy == e.a) p1 else p2 + val end = if (e.confirmedBy == e.a) p2 else p1 + + val midX = (start.x + end.x) / 2 + val midY = (start.y + end.y) / 2 + val mid = androidx.compose.ui.geometry.Offset(midX, midY) + + // Solid half + drawLine(color = Color(0xFF4A90E2), start = start, end = mid, strokeWidth = 2f) + + // Dotted half + drawLine( + color = Color(0xFF4A90E2), + start = mid, + end = end, + strokeWidth = 2f, + pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect(floatArrayOf(5f, 5f), 0f) + ) + } } } From 01c803cc62ab7fb95d83ebf137c853c2cdf094a9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:13:27 +0700 Subject: [PATCH 18/18] docs: update SOURCE_ROUTING.md with two-way handshake requirement --- docs/SOURCE_ROUTING.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index bfb285ca..1fdd2c6f 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -91,6 +91,13 @@ To calculate routes, nodes need a view of the network topology. This is achieved ``` Nodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors. +### Edge Verification (Two-Way Handshake) + +To prevent spoofing and routing through stale connections, the Mesh Graph service implements a strict two-way handshake verification: + +* **Unconfirmed Edge:** If Peer A announces Peer B, but Peer B does *not* announce Peer A, the connection is treated as **unconfirmed**. Unconfirmed edges are visualized as dotted lines in debug tools but are **excluded** from route calculations. +* **Confirmed Edge:** An edge is only valid for routing when **both** peers explicitly announce each other in their neighbor lists. This ensures that the connection is bidirectional and currently active from both perspectives. + --- ## 5. Fragmentation & Source Routing