source routing v2

This commit is contained in:
callebtc
2026-01-12 10:18:04 +07:00
parent a37243e780
commit eb3bbfd861
4 changed files with 255 additions and 125 deletions
+21 -24
View File
@@ -17,7 +17,7 @@ final class BLEService: NSObject {
// MARK: - Constants // MARK: - Constants
#if DEBUG #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 #else
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
#endif #endif
@@ -1758,8 +1758,9 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
peers[peerID] = info peers[peerID] = info
} }
} }
clearDirectLink(with: peerID) refreshLocalTopology()
} }
// Restart scanning with allow duplicates for faster rediscovery // Restart scanning with allow duplicates for faster rediscovery
if centralManager?.state == .poweredOn { if centralManager?.state == .poweredOn {
@@ -2046,7 +2047,7 @@ extension BLEService: CBPeripheralDelegate {
peripherals[peripheralUUID] = state peripherals[peripheralUUID] = state
} }
peerToPeripheralUUID[senderID] = peripheralUUID peerToPeripheralUUID[senderID] = peripheralUUID
registerDirectLink(with: senderID) refreshLocalTopology()
} }
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
@@ -2213,7 +2214,7 @@ extension BLEService: CBPeripheralManagerDelegate {
// Clean up mappings // Clean up mappings
centralToPeerID.removeValue(forKey: centralUUID) centralToPeerID.removeValue(forKey: centralUUID)
clearDirectLink(with: peerID) refreshLocalTopology()
// Update UI immediately // Update UI immediately
notifyUI { [weak self] in notifyUI { [weak self] in
@@ -2332,7 +2333,7 @@ extension BLEService: CBPeripheralManagerDelegate {
if packet.type == MessageType.announce.rawValue { if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL { if packet.ttl == messageTTL {
centralToPeerID[centralUUID] = senderID centralToPeerID[centralUUID] = senderID
registerDirectLink(with: senderID) refreshLocalTopology()
} }
// Record ingress link for last-hop suppression then process // Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
@@ -2447,17 +2448,11 @@ extension BLEService {
peerID.toShort().routingData peerID.toShort().routingData
} }
private func registerDirectLink(with peerID: PeerID) { private func refreshLocalTopology() {
meshTopology.recordDirectLink(between: myPeerIDData, and: routingData(for: peerID)) let neighbors: [Data] = collectionsQueue.sync {
} peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData }
}
private func clearDirectLink(with peerID: PeerID) { meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors)
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 computeRoute(to peerID: PeerID) -> [Data]? { private func computeRoute(to peerID: PeerID) -> [Data]? {
@@ -2468,7 +2463,6 @@ extension BLEService {
guard let route = computeRoute(to: recipient), route.count >= 1 else { guard let route = computeRoute(to: recipient), route.count >= 1 else {
return packet return packet
} }
meshTopology.recordRoute(route)
// Create new packet with route applied and version upgraded to 2 // Create new packet with route applied and version upgraded to 2
let routedPacket = BitchatPacket( let routedPacket = BitchatPacket(
type: packet.type, type: packet.type,
@@ -2508,7 +2502,6 @@ extension BLEService {
isPeerConnected(nextPeer) else { isPeerConnected(nextPeer) else {
return false return false
} }
registerDirectLink(with: nextPeer)
var relayPacket = packet var relayPacket = packet
relayPacket.ttl = packet.ttl - 1 relayPacket.ttl = packet.ttl - 1
sendPacketDirected(relayPacket, to: nextPeer) sendPacketDirected(relayPacket, to: nextPeer)
@@ -2523,7 +2516,6 @@ extension BLEService {
isPeerConnected(destinationPeer) else { isPeerConnected(destinationPeer) else {
return false return false
} }
registerDirectLink(with: destinationPeer)
var relayPacket = packet var relayPacket = packet
relayPacket.ttl = packet.ttl - 1 relayPacket.ttl = packet.ttl - 1
sendPacketDirected(relayPacket, to: destinationPeer) sendPacketDirected(relayPacket, to: destinationPeer)
@@ -2538,7 +2530,6 @@ extension BLEService {
return false return false
} }
registerDirectLink(with: nextPeer)
var relayPacket = packet var relayPacket = packet
relayPacket.ttl = packet.ttl - 1 relayPacket.ttl = packet.ttl - 1
sendPacketDirected(relayPacket, to: nextPeer) sendPacketDirected(relayPacket, to: nextPeer)
@@ -3306,10 +3297,6 @@ extension BLEService {
return return
} }
registerRoute(packet.route)
if peerID != myPeerID && packet.ttl == messageTTL {
registerDirectLink(with: peerID)
}
// Deduplication (thread-safe) // Deduplication (thread-safe)
let senderID = PeerID(hexData: packet.senderID) let senderID = PeerID(hexData: packet.senderID)
@@ -3439,6 +3426,11 @@ extension BLEService {
return 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 // 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. // This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey) let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey)
@@ -4037,6 +4029,11 @@ extension BLEService {
self.delegate?.didUpdatePeerList(currentPeerIDs) 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() { private func performCleanup() {
+62 -73
View File
@@ -6,70 +6,46 @@ final class MeshTopologyTracker {
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent) private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
private let hopSize = 8 private let hopSize = 8
private var adjacency: [RoutingID: Set<RoutingID>] = [:] // Directed claims: Key claims to see Value (neighbors)
private var claims: [RoutingID: Set<RoutingID>] = [:]
// Last time we received an update from a node
private var lastSeen: [RoutingID: Date] = [:]
func reset() { func reset() {
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
self.adjacency.removeAll() self.claims.removeAll()
self.lastSeen.removeAll()
} }
} }
func recordDirectLink(between a: Data?, and b: Data?) { /// Update the topology with a node's self-reported neighbor list
guard let left = sanitize(a), let right = sanitize(b), left != right else { return } 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) { queue.sync(flags: .barrier) {
var setA = self.adjacency[left] ?? [] self.claims[source] = validNeighbors
setA.insert(right) self.lastSeen[source] = Date()
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
}
} }
} }
func removePeer(_ data: Data?) { func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return } guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return } self.claims.removeValue(forKey: peer)
for neighbor in neighbors { self.lastSeen.removeValue(forKey: peer)
if var set = self.adjacency[neighbor] {
set.remove(peer)
self.adjacency[neighbor] = set.isEmpty ? nil : set
}
}
} }
} }
func recordRoute(_ hops: [Data]) { /// Prune nodes that haven't updated their topology in `age` seconds
let sanitized = hops.compactMap { sanitize($0) } func prune(olderThan age: TimeInterval) {
guard sanitized.count >= 2 else { return } let deadline = Date().addingTimeInterval(-age)
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
for idx in 0..<(sanitized.count - 1) { let stale = self.lastSeen.filter { $0.value < deadline }
let left = sanitized[idx] for (peer, _) in stale {
let right = sanitized[idx + 1] self.claims.removeValue(forKey: peer)
guard left != right else { continue } self.lastSeen.removeValue(forKey: peer)
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
} }
} }
} }
@@ -78,36 +54,49 @@ final class MeshTopologyTracker {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil } guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [] } // Direct connection, no intermediate hops if source == target { return [] } // Direct connection, no intermediate hops
let graph = queue.sync { adjacency } return queue.sync {
guard graph[source] != nil, graph[target] != nil else { return nil } // BFS
var visited: Set<RoutingID> = [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<RoutingID> = [source] for neighbor in neighbors {
var queuePaths: [[RoutingID]] = [[source]] if visited.contains(neighbor) { continue }
var index = 0
// CONFIRMED EDGE CHECK:
while index < queuePaths.count { // 'last' claims 'neighbor' (checked above)
let path = queuePaths[index] // Does 'neighbor' claim 'last'?
index += 1 guard let neighborClaims = claims[neighbor],
guard path.count <= maxHops else { continue } neighborClaims.contains(last) else {
guard let last = path.last, let neighbors = graph[last] else { continue } continue
}
for neighbor in neighbors {
if visited.contains(neighbor) { continue } var nextPath = path
var nextPath = path nextPath.append(neighbor)
nextPath.append(neighbor)
if neighbor == target { if neighbor == target {
// Remove start and end, return only intermediate hops // Return only intermediate hops
guard nextPath.count >= 2 else { return [] } // Path: [Source, I1, I2, Target] -> [I1, I2]
return Array(nextPath.dropFirst().dropLast()) return Array(nextPath.dropFirst().dropLast())
} }
if nextPath.count <= maxHops {
visited.insert(neighbor)
queuePaths.append(nextPath) queuePaths.append(nextPath)
} }
visited.insert(neighbor)
} }
return nil
} }
return nil
} }
// MARK: - Helpers // MARK: - Helpers
@@ -20,7 +20,10 @@ struct MeshTopologyTrackerTests {
let a = try hex("0102030405060708") let a = try hex("0102030405060708")
let b = try hex("1112131415161718") 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)) let route = try #require(tracker.computeRoute(from: a, to: b))
// Direct connection returns empty route (no intermediate hops) // Direct connection returns empty route (no intermediate hops)
#expect(route == []) #expect(route == [])
@@ -33,43 +36,38 @@ struct MeshTopologyTrackerTests {
let c = try hex("2021222324252627") let c = try hex("2021222324252627")
let d = try hex("3031323334353637") let d = try hex("3031323334353637")
tracker.recordDirectLink(between: a, and: b) // Bidirectional announcements for A-B, B-C, C-D
tracker.recordDirectLink(between: b, and: c) tracker.updateNeighbors(for: a, neighbors: [b])
tracker.recordDirectLink(between: c, and: d) 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)) let route = try #require(tracker.computeRoute(from: a, to: d))
// Route should only contain intermediate hops (b, c), excluding start (a) and end (d) // Route should only contain intermediate hops (b, c), excluding start (a) and end (d)
#expect(route == [b, c]) #expect(route == [b, c])
} }
@Test func recordRouteAddsEdges() throws { @Test func unconfirmedEdgeDoesNotRoute() 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 {
let tracker = MeshTopologyTracker() let tracker = MeshTopologyTracker()
let a = try hex("0101010101010101") let a = try hex("0101010101010101")
let b = try hex("0202020202020202") let b = try hex("0202020202020202")
let c = try hex("0303030303030303") let c = try hex("0303030303030303")
tracker.recordDirectLink(between: a, and: b) // A announces B (confirmed)
tracker.recordDirectLink(between: b, and: c) // B announces A, C (confirmed A-B, unconfirmed B-C)
let initialRoute = try #require(tracker.computeRoute(from: a, to: c)) // C does NOT announce B
// Route should only contain intermediate hops (b), excluding start (a) and end (c) tracker.updateNeighbors(for: a, neighbors: [b])
#expect(initialRoute == [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) #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 { @Test func removingPeerClearsEdges() throws {
@@ -78,9 +76,11 @@ struct MeshTopologyTrackerTests {
let b = try hex("0A0B0C0D0E0F0001") let b = try hex("0A0B0C0D0E0F0001")
let c = try hex("0011223344556677") 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)) 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]) #expect(initialRoute == [b])
tracker.removePeer(b) tracker.removePeer(b)
@@ -92,7 +92,9 @@ struct MeshTopologyTrackerTests {
let a = try hex("0102030405060708") let a = try hex("0102030405060708")
let b = try hex("1112131415161718") 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) // When start == end, route should be empty (no intermediate hops needed)
let route = try #require(tracker.computeRoute(from: a, to: a)) let route = try #require(tracker.computeRoute(from: a, to: a))
#expect(route == []) #expect(route == [])
+142
View File
@@ -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).