diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3c00872c..2d5f0642 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -17,7 +17,7 @@ final class BLEService: NSObject { // MARK: - Constants #if DEBUG - static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // testnet + static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet #else static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet #endif @@ -1758,8 +1758,9 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph peers[peerID] = info } } - clearDirectLink(with: peerID) + refreshLocalTopology() } + // Restart scanning with allow duplicates for faster rediscovery if centralManager?.state == .poweredOn { @@ -2046,7 +2047,7 @@ extension BLEService: CBPeripheralDelegate { peripherals[peripheralUUID] = state } peerToPeripheralUUID[senderID] = peripheralUUID - registerDirectLink(with: senderID) + refreshLocalTopology() } let msgID = makeMessageID(for: packet) @@ -2213,7 +2214,7 @@ extension BLEService: CBPeripheralManagerDelegate { // Clean up mappings centralToPeerID.removeValue(forKey: centralUUID) - clearDirectLink(with: peerID) + refreshLocalTopology() // Update UI immediately notifyUI { [weak self] in @@ -2332,7 +2333,7 @@ extension BLEService: CBPeripheralManagerDelegate { if packet.type == MessageType.announce.rawValue { if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID - registerDirectLink(with: senderID) + refreshLocalTopology() } // Record ingress link for last-hop suppression then process let msgID = makeMessageID(for: packet) @@ -2447,17 +2448,11 @@ extension BLEService { peerID.toShort().routingData } - private func registerDirectLink(with peerID: PeerID) { - meshTopology.recordDirectLink(between: myPeerIDData, and: routingData(for: peerID)) - } - - private func clearDirectLink(with peerID: PeerID) { - meshTopology.removeDirectLink(between: myPeerIDData, and: routingData(for: peerID)) - } - - private func registerRoute(_ route: [Data]?) { - guard let hops = route, !hops.isEmpty else { return } - meshTopology.recordRoute(hops) + private func refreshLocalTopology() { + let neighbors: [Data] = collectionsQueue.sync { + peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData } + } + meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors) } private func computeRoute(to peerID: PeerID) -> [Data]? { @@ -2468,7 +2463,6 @@ extension BLEService { guard let route = computeRoute(to: recipient), route.count >= 1 else { return packet } - meshTopology.recordRoute(route) // Create new packet with route applied and version upgraded to 2 let routedPacket = BitchatPacket( type: packet.type, @@ -2508,7 +2502,6 @@ extension BLEService { isPeerConnected(nextPeer) else { return false } - registerDirectLink(with: nextPeer) var relayPacket = packet relayPacket.ttl = packet.ttl - 1 sendPacketDirected(relayPacket, to: nextPeer) @@ -2523,7 +2516,6 @@ extension BLEService { isPeerConnected(destinationPeer) else { return false } - registerDirectLink(with: destinationPeer) var relayPacket = packet relayPacket.ttl = packet.ttl - 1 sendPacketDirected(relayPacket, to: destinationPeer) @@ -2538,7 +2530,6 @@ extension BLEService { return false } - registerDirectLink(with: nextPeer) var relayPacket = packet relayPacket.ttl = packet.ttl - 1 sendPacketDirected(relayPacket, to: nextPeer) @@ -3306,10 +3297,6 @@ extension BLEService { return } - registerRoute(packet.route) - if peerID != myPeerID && packet.ttl == messageTTL { - registerDirectLink(with: peerID) - } // Deduplication (thread-safe) let senderID = PeerID(hexData: packet.senderID) @@ -3439,6 +3426,11 @@ extension BLEService { return } + // Update topology with their claimed neighbors + if let neighbors = announcement.directNeighbors { + meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors) + } + // Verify that the sender's derived ID from the announced noise public key matches the packet senderID // This helps detect relayed or spoofed announces. Only warn in release; assert in debug. let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey) @@ -4037,6 +4029,11 @@ extension BLEService { self.delegate?.didUpdatePeerList(currentPeerIDs) } } + + // Refresh local topology to keep our own entry fresh and sync any changes + refreshLocalTopology() + // Prune stale topology nodes (using safe retention window) + meshTopology.prune(olderThan: 60.0) } private func performCleanup() { diff --git a/bitchat/Services/MeshTopologyTracker.swift b/bitchat/Services/MeshTopologyTracker.swift index df7f887a..de9e6085 100644 --- a/bitchat/Services/MeshTopologyTracker.swift +++ b/bitchat/Services/MeshTopologyTracker.swift @@ -6,70 +6,46 @@ final class MeshTopologyTracker { private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent) private let hopSize = 8 - private var adjacency: [RoutingID: Set] = [:] + // Directed claims: Key claims to see Value (neighbors) + private var claims: [RoutingID: Set] = [:] + // Last time we received an update from a node + private var lastSeen: [RoutingID: Date] = [:] func reset() { queue.sync(flags: .barrier) { - self.adjacency.removeAll() + self.claims.removeAll() + self.lastSeen.removeAll() } } - func recordDirectLink(between a: Data?, and b: Data?) { - guard let left = sanitize(a), let right = sanitize(b), left != right else { return } + /// Update the topology with a node's self-reported neighbor list + func updateNeighbors(for sourceData: Data?, neighbors: [Data]) { + guard let source = sanitize(sourceData) else { return } + // Sanitize neighbors and exclude self-loops + let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source]) + queue.sync(flags: .barrier) { - var setA = self.adjacency[left] ?? [] - setA.insert(right) - self.adjacency[left] = setA - - var setB = self.adjacency[right] ?? [] - setB.insert(left) - self.adjacency[right] = setB - } - } - - func removeDirectLink(between a: Data?, and b: Data?) { - guard let left = sanitize(a), let right = sanitize(b), left != right else { return } - queue.sync(flags: .barrier) { - if var setA = self.adjacency[left] { - setA.remove(right) - self.adjacency[left] = setA.isEmpty ? nil : setA - } - if var setB = self.adjacency[right] { - setB.remove(left) - self.adjacency[right] = setB.isEmpty ? nil : setB - } + self.claims[source] = validNeighbors + self.lastSeen[source] = Date() } } func removePeer(_ data: Data?) { guard let peer = sanitize(data) else { return } queue.sync(flags: .barrier) { - guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return } - for neighbor in neighbors { - if var set = self.adjacency[neighbor] { - set.remove(peer) - self.adjacency[neighbor] = set.isEmpty ? nil : set - } - } + self.claims.removeValue(forKey: peer) + self.lastSeen.removeValue(forKey: peer) } } - - func recordRoute(_ hops: [Data]) { - let sanitized = hops.compactMap { sanitize($0) } - guard sanitized.count >= 2 else { return } + + /// Prune nodes that haven't updated their topology in `age` seconds + func prune(olderThan age: TimeInterval) { + let deadline = Date().addingTimeInterval(-age) queue.sync(flags: .barrier) { - for idx in 0..<(sanitized.count - 1) { - let left = sanitized[idx] - let right = sanitized[idx + 1] - guard left != right else { continue } - - var setA = self.adjacency[left] ?? [] - setA.insert(right) - self.adjacency[left] = setA - - var setB = self.adjacency[right] ?? [] - setB.insert(left) - self.adjacency[right] = setB + let stale = self.lastSeen.filter { $0.value < deadline } + for (peer, _) in stale { + self.claims.removeValue(forKey: peer) + self.lastSeen.removeValue(forKey: peer) } } } @@ -78,36 +54,49 @@ final class MeshTopologyTracker { guard let source = sanitize(start), let target = sanitize(goal) else { return nil } if source == target { return [] } // Direct connection, no intermediate hops - let graph = queue.sync { adjacency } - guard graph[source] != nil, graph[target] != nil else { return nil } + return queue.sync { + // BFS + var visited: Set = [source] + // Queue stores paths: [Start, Hop1, Hop2, ..., Current] + var queuePaths: [[RoutingID]] = [[source]] + + while !queuePaths.isEmpty { + let path = queuePaths.removeFirst() + // Limit path length (path contains source + maxHops + target) -> maxHops intermediate + // If maxHops = 10, max edges = 11, max nodes = 12. + if path.count > maxHops + 1 { continue } + + guard let last = path.last else { continue } + + // Get neighbors that 'last' claims to see + guard let neighbors = claims[last] else { continue } - var visited: Set = [source] - var queuePaths: [[RoutingID]] = [[source]] - var index = 0 - - while index < queuePaths.count { - let path = queuePaths[index] - index += 1 - guard path.count <= maxHops else { continue } - guard let last = path.last, let neighbors = graph[last] else { continue } - - for neighbor in neighbors { - if visited.contains(neighbor) { continue } - var nextPath = path - nextPath.append(neighbor) - if neighbor == target { - // Remove start and end, return only intermediate hops - guard nextPath.count >= 2 else { return [] } - return Array(nextPath.dropFirst().dropLast()) - } - if nextPath.count <= maxHops { + for neighbor in neighbors { + if visited.contains(neighbor) { continue } + + // CONFIRMED EDGE CHECK: + // 'last' claims 'neighbor' (checked above) + // Does 'neighbor' claim 'last'? + guard let neighborClaims = claims[neighbor], + neighborClaims.contains(last) else { + continue + } + + var nextPath = path + nextPath.append(neighbor) + + if neighbor == target { + // Return only intermediate hops + // Path: [Source, I1, I2, Target] -> [I1, I2] + return Array(nextPath.dropFirst().dropLast()) + } + + visited.insert(neighbor) queuePaths.append(nextPath) } - visited.insert(neighbor) } + return nil } - - return nil } // MARK: - Helpers diff --git a/bitchatTests/Services/MeshTopologyTrackerTests.swift b/bitchatTests/Services/MeshTopologyTrackerTests.swift index 82e9f229..1ae0f5f5 100644 --- a/bitchatTests/Services/MeshTopologyTrackerTests.swift +++ b/bitchatTests/Services/MeshTopologyTrackerTests.swift @@ -20,7 +20,10 @@ struct MeshTopologyTrackerTests { let a = try hex("0102030405060708") let b = try hex("1112131415161718") - tracker.recordDirectLink(between: a, and: b) + // Bidirectional announcement + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a]) + let route = try #require(tracker.computeRoute(from: a, to: b)) // Direct connection returns empty route (no intermediate hops) #expect(route == []) @@ -33,43 +36,38 @@ struct MeshTopologyTrackerTests { let c = try hex("2021222324252627") let d = try hex("3031323334353637") - tracker.recordDirectLink(between: a, and: b) - tracker.recordDirectLink(between: b, and: c) - tracker.recordDirectLink(between: c, and: d) + // Bidirectional announcements for A-B, B-C, C-D + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b, d]) + tracker.updateNeighbors(for: d, neighbors: [c]) let route = try #require(tracker.computeRoute(from: a, to: d)) // Route should only contain intermediate hops (b, c), excluding start (a) and end (d) #expect(route == [b, c]) } - @Test func recordRouteAddsEdges() throws { - let tracker = MeshTopologyTracker() - var a = Data([0xAA, 0xBB, 0xCC]) - let b = try hex("4445464748494A4B") - let c = try hex("5455565758595A5B") - - tracker.recordRoute([a, b, c]) - - a.append(Data(repeating: 0, count: BinaryProtocol.senderIDSize - a.count)) - let route = try #require(tracker.computeRoute(from: a, to: c)) - // Route should only contain intermediate hops (b), excluding start (a) and end (c) - #expect(route == [b]) - } - - @Test func removingDirectLinkBreaksRoute() throws { + @Test func unconfirmedEdgeDoesNotRoute() throws { let tracker = MeshTopologyTracker() let a = try hex("0101010101010101") let b = try hex("0202020202020202") let c = try hex("0303030303030303") - tracker.recordDirectLink(between: a, and: b) - tracker.recordDirectLink(between: b, and: c) - let initialRoute = try #require(tracker.computeRoute(from: a, to: c)) - // Route should only contain intermediate hops (b), excluding start (a) and end (c) - #expect(initialRoute == [b]) + // A announces B (confirmed) + // B announces A, C (confirmed A-B, unconfirmed B-C) + // C does NOT announce B + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + // C is silent or announces empty - tracker.removeDirectLink(between: b, and: c) + // Should NOT find route A->C because B->C is unconfirmed (C didn't announce B) #expect(tracker.computeRoute(from: a, to: c) == nil) + + // Now C announces B + tracker.updateNeighbors(for: c, neighbors: [b]) + // Should find route + let route = try #require(tracker.computeRoute(from: a, to: c)) + #expect(route == [b]) } @Test func removingPeerClearsEdges() throws { @@ -78,9 +76,11 @@ struct MeshTopologyTrackerTests { let b = try hex("0A0B0C0D0E0F0001") let c = try hex("0011223344556677") - tracker.recordRoute([a, b, c]) + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + let initialRoute = try #require(tracker.computeRoute(from: a, to: c)) - // Route should only contain intermediate hops (b), excluding start (a) and end (c) #expect(initialRoute == [b]) tracker.removePeer(b) @@ -92,7 +92,9 @@ struct MeshTopologyTrackerTests { let a = try hex("0102030405060708") let b = try hex("1112131415161718") - tracker.recordDirectLink(between: a, and: b) + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a]) + // When start == end, route should be empty (no intermediate hops needed) let route = try #require(tracker.computeRoute(from: a, to: a)) #expect(route == []) diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md new file mode 100644 index 00000000..1fdd2c6f --- /dev/null +++ b/docs/SOURCE_ROUTING.md @@ -0,0 +1,142 @@ +# Source-Based Routing for BitChat Packets (v2) + +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:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data). + +--- + +## 1. Protocol Versioning & Layering + +To support source routing and larger payloads, the packet format has been upgraded to **Version 2**. + +* **Version 1 (Legacy):** 2-byte payload length limit. Ignores routing flags. +* **Version 2 (Current):** 4-byte payload length limit. Supports Source Routing. + +**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. + +--- + +## 2. Packet Structure Comparison + +The following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet. + +### 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) | ++-------------------+----------+-------------+------------------+-------------+ +``` + +### 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) | ++-------------------+----------+-------------+-----------------------+------------------+-------------+ +``` + +**(*) Note:** A `Route` can be attached to **any** packet type that has a `RecipientID` (flag `HAS_RECIPIENT` set). + +### Fixed Header Differences + +| 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. | + +--- + +## 3. Source Route Specification + +The `Source Route` field is a variable-length list of **intermediate hops** that the packet must traverse. + +* **Location:** Immediately follows `RecipientID`. +* **Structure:** + * `Count` (1 byte): Number of intermediate hops (`N`). + * `Hops` (`N * 8` bytes): Sequence of Peer IDs. + +### 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. + +### 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 + +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).