Merge pull request #938 from permissionlesstech/source-routing-packet-format

feat: Source routing v2
This commit is contained in:
jack
2026-01-12 10:21:28 -10:00
committed by GitHub
7 changed files with 539 additions and 168 deletions
+18 -19
View File
@@ -161,7 +161,9 @@ struct BinaryProtocol {
}
let lengthFieldBytes = lengthFieldSize(for: version)
let originalRoute = packet.route ?? []
// Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)
let originalRoute = (version >= 2) ? (packet.route ?? []) : []
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
let sanitizedRoute: [Data] = originalRoute.map { hop in
if hop.count == senderIDSize { return hop }
@@ -175,13 +177,14 @@ struct BinaryProtocol {
let hasRoute = !sanitizedRoute.isEmpty
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
// payloadLength in header is payload-only (does NOT include route bytes)
let payloadDataSize = payload.count + originalSizeFieldBytes
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
guard let headerSize = headerSize(for: version) else { return nil }
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength
let estimatedPayload = payloadDataSize
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
var data = Data()
@@ -199,7 +202,8 @@ struct BinaryProtocol {
if packet.recipientID != nil { flags |= Flags.hasRecipient }
if packet.signature != nil { flags |= Flags.hasSignature }
if isCompressed { flags |= Flags.isCompressed }
if hasRoute { flags |= Flags.hasRoute }
// HAS_ROUTE is only valid for v2+ packets
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
data.append(flags)
if version == 2 {
@@ -323,6 +327,8 @@ struct BinaryProtocol {
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
let payloadLength: Int
if version == 2 {
@@ -343,27 +349,24 @@ struct BinaryProtocol {
if recipientID == nil { return nil }
}
// Route (optional, v2+ only): route bytes are NOT included in payloadLength
var route: [Data]? = nil
var remainingPayloadBytes = payloadLength
if (flags & Flags.hasRoute) != 0 {
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
remainingPayloadBytes -= 1
if hasRoute {
guard let routeCount = read8() else { return nil }
if routeCount > 0 {
var hops: [Data] = []
for _ in 0..<Int(routeCount) {
guard remainingPayloadBytes >= senderIDSize,
let hop = readData(senderIDSize) else { return nil }
remainingPayloadBytes -= senderIDSize
guard let hop = readData(senderIDSize) else { return nil }
hops.append(hop)
}
route = hops
}
}
// Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)
let payload: Data
if isCompressed {
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
guard payloadLength >= lengthFieldBytes else { return nil }
let originalSize: Int
if version == 2 {
guard let rawSize = read32() else { return nil }
@@ -372,11 +375,9 @@ struct BinaryProtocol {
guard let rawSize = read16() else { return nil }
originalSize = Int(rawSize)
}
remainingPayloadBytes -= lengthFieldBytes
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
let compressedSize = remainingPayloadBytes
let compressedSize = payloadLength - lengthFieldBytes
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
remainingPayloadBytes = 0
let compressionRatio = Double(originalSize) / Double(compressedSize)
guard compressionRatio <= 50_000.0 else {
@@ -388,9 +389,7 @@ struct BinaryProtocol {
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard remainingPayloadBytes >= 0,
let rawPayload = readData(remainingPayloadBytes) else { return nil }
remainingPayloadBytes = 0
guard let rawPayload = readData(payloadLength) else { return nil }
payload = rawPayload
}
+104 -47
View File
@@ -27,6 +27,7 @@ final class BLEService: NSObject {
// Default per-fragment chunk size when link limits are unknown
private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
private let bleMaxMTU = 512
private let maxMessageLength = InputValidator.Limits.maxMessageLength
private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
// Flood/battery controls
@@ -731,8 +732,6 @@ final class BLEService: NSObject {
version: 2
)
self.applyRouteIfAvailable(&packet, to: peerID)
if let signed = self.noiseService.signPacket(packet) {
packet = signed
}
@@ -761,7 +760,6 @@ final class BLEService: NSObject {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
broadcastPacket(packet)
} catch {
SecureLogger.error("Failed to send read receipt: \(error)")
@@ -780,21 +778,29 @@ final class BLEService: NSObject {
// MARK: - Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
// Apply route if recipient exists (centralized route application)
let packetToSend: BitchatPacket
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
packetToSend = applyRouteIfAvailable(packet, to: recipientPeerID)
} else {
packetToSend = packet
}
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = padPolicy(for: packet.type)
if packet.type == MessageType.fileTransfer.rawValue {
sendFragmentedPacket(packet, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId)
let padForBLE = padPolicy(for: packetToSend.type)
if packetToSend.type == MessageType.fileTransfer.rawValue {
sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId)
return
}
guard let data = packet.toBinaryData(padding: padForBLE) else {
guard let data = packetToSend.toBinaryData(padding: padForBLE) else {
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
return
}
if packet.type == MessageType.noiseEncrypted.rawValue {
sendEncrypted(packet, data: data, pad: padForBLE)
if packetToSend.type == MessageType.noiseEncrypted.rawValue {
sendEncrypted(packetToSend, data: data, pad: padForBLE)
return
}
sendGenericBroadcast(packet, data: data, pad: padForBLE)
sendGenericBroadcast(packetToSend, data: data, pad: padForBLE)
}
// MARK: - Broadcast helpers (single responsibility)
@@ -1243,7 +1249,6 @@ final class BLEService: NSObject {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
broadcastPacket(packet)
} catch {
SecureLogger.error("Failed to send delivery ACK: \(error)")
@@ -1753,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 {
@@ -2041,7 +2047,7 @@ extension BLEService: CBPeripheralDelegate {
peripherals[peripheralUUID] = state
}
peerToPeripheralUUID[senderID] = peripheralUUID
registerDirectLink(with: senderID)
refreshLocalTopology()
}
let msgID = makeMessageID(for: packet)
@@ -2208,7 +2214,7 @@ extension BLEService: CBPeripheralManagerDelegate {
// Clean up mappings
centralToPeerID.removeValue(forKey: centralUUID)
clearDirectLink(with: peerID)
refreshLocalTopology()
// Update UI immediately
notifyUI { [weak self] in
@@ -2327,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)
@@ -2442,27 +2448,39 @@ 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]? {
meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID))
}
private func applyRouteIfAvailable(_ packet: inout BitchatPacket, to recipient: PeerID) {
guard let route = computeRoute(to: recipient), route.count >= 2 else { return }
packet.route = route
meshTopology.recordRoute(route)
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
guard let route = computeRoute(to: recipient), route.count >= 1 else {
return packet
}
// Create new packet with route applied and version upgraded to 2
let routedPacket = BitchatPacket(
type: packet.type,
senderID: packet.senderID,
recipientID: packet.recipientID,
timestamp: packet.timestamp,
payload: packet.payload,
signature: nil, // Will be re-signed below
ttl: packet.ttl,
version: 2,
route: route
)
// Re-sign the packet since route and version changed
guard let signedPacket = noiseService.signPacket(routedPacket) else {
SecureLogger.error("❌ Failed to re-sign packet with route", category: .security)
return packet // Return original packet if signing fails
}
return signedPacket
}
private func routingPeer(from data: Data) -> PeerID? {
@@ -2472,23 +2490,46 @@ extension BLEService {
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
guard let route = packet.route, !route.isEmpty else { return false }
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
guard let selfData = myRoutingData,
let index = route.firstIndex(of: selfData) else { return false }
// No further hops: respect explicit route termination
if index == route.count - 1 {
guard let selfData = myRoutingData else { return false }
// Route contains only intermediate hops (start and end excluded)
// If we're not in the route, we're the sender - forward to first hop
guard let index = route.firstIndex(of: selfData) else {
// We're the sender, forward to first intermediate hop
guard packet.ttl > 1 else { return true }
let firstHopData = route[0]
guard let nextPeer = routingPeer(from: firstHopData),
isPeerConnected(nextPeer) else {
return false
}
var relayPacket = packet
relayPacket.ttl = packet.ttl - 1
sendPacketDirected(relayPacket, to: nextPeer)
return true
}
guard packet.ttl > 1 else { return true }
// We're an intermediate node in the route
// If we're the last intermediate hop, forward to destination
if index == route.count - 1 {
guard packet.ttl > 1 else { return true }
guard let destinationPeer = PeerID(hexData: packet.recipientID),
isPeerConnected(destinationPeer) else {
return false
}
var relayPacket = packet
relayPacket.ttl = packet.ttl - 1
sendPacketDirected(relayPacket, to: destinationPeer)
return true
}
// Forward to next intermediate hop
guard packet.ttl > 1 else { return true }
let nextHopData = route[index + 1]
guard let nextPeer = routingPeer(from: nextHopData),
isPeerConnected(nextPeer) else {
return false
}
registerDirectLink(with: nextPeer)
var relayPacket = packet
relayPacket.ttl = packet.ttl - 1
sendPacketDirected(relayPacket, to: nextPeer)
@@ -2556,7 +2597,6 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
broadcastPacket(packet)
} catch {
SecureLogger.error("Failed to send verification payload: \(error)")
@@ -2784,7 +2824,6 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: recipientID)
broadcastPacket(packet)
@@ -2833,7 +2872,6 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
broadcastPacket(packet)
} catch {
SecureLogger.error("Failed to initiate handshake: \(error)")
@@ -2876,7 +2914,6 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
// We're already on messageQueue from the callback
broadcastPacket(packet)
@@ -2970,7 +3007,21 @@ extension BLEService {
}
// Fragment the unpadded frame; each fragment will be encoded independently
let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
let chunk = context.maxChunk ?? defaultFragmentSize
// Dynamic Fragment Sizing (Source Routing v2)
// See docs/SOURCE_ROUTING.md Section 5.1
var fragmentVersion: UInt8 = 1
var calculatedChunk = defaultFragmentSize
if let route = packet.route, !route.isEmpty {
fragmentVersion = 2
// RouteSize = 1 + (Hops * 8)
let routeSize = 1 + (route.count * 8)
// Overhead = HeaderV2(16) + SenderID(8) + RecipientID(8) + RouteSize + FragmentHeader(13) + PaddingBuffer(16)
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
calculatedChunk = max(64, bleMaxMTU - overhead)
}
let chunk = context.maxChunk ?? calculatedChunk
let safeChunk = max(64, chunk)
let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in
Data(fullData[offset..<min(offset + safeChunk, fullData.count)])
@@ -3029,6 +3080,7 @@ extension BLEService {
payload: payload,
signature: nil,
ttl: packet.ttl,
version: fragmentVersion,
route: packet.route
)
@@ -3245,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)
@@ -3378,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)
@@ -3692,7 +3745,6 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&responsePacket, to: peerID)
// We're on messageQueue from delegate callback
broadcastPacket(responsePacket)
}
@@ -3977,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() {
+64 -71
View File
@@ -6,104 +6,97 @@ final class MeshTopologyTracker {
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
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() {
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)
}
}
}
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [source] }
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<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]
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 { return nextPath }
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
@@ -62,6 +62,7 @@ struct NotificationStreamAssembler {
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0
let lengthOffset = 12
let payloadLength: Int
@@ -80,6 +81,15 @@ struct NotificationStreamAssembler {
var frameLength = framePrefix + payloadLength
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
if hasRoute {
let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)
let routeCountIndex = buffer.startIndex + routeCountOffset
guard buffer.count > routeCountOffset else { break }
let routeCount = Int(buffer[routeCountIndex])
frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)
}
if isCompressed {
let rawLengthFieldBytes = (version == 2) ? 4 : 2
if payloadLength < rawLengthFieldBytes {
+152 -3
View File
@@ -55,6 +55,8 @@ struct BinaryProtocolTests {
#expect(decodedPacket.signature == TestConstants.testSignature)
}
// MARK: - Source-Based Routing Tests (v2 only)
@Test func packetWithRouteRoundTrip() throws {
let route: [Data] = [
try #require(Data(hexString: "0102030405060708")),
@@ -62,6 +64,7 @@ struct BinaryProtocolTests {
try #require(Data(hexString: "2122232425262728"))
]
// Route is only supported for v2+ packets
var packet = BitchatPacket(
type: 0x01,
senderID: route[0],
@@ -69,7 +72,8 @@ struct BinaryProtocolTests {
timestamp: 1_720_000_000_000,
payload: Data("route-test".utf8),
signature: nil,
ttl: 6
ttl: 6,
version: 2
)
packet.route = route
@@ -78,6 +82,7 @@ struct BinaryProtocolTests {
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route")
#expect(decoded.version == 2)
let decodedRoute = try #require(decoded.route)
#expect(decodedRoute.count == route.count)
for (expected, actual) in zip(route, decodedRoute) {
@@ -90,6 +95,7 @@ struct BinaryProtocolTests {
let destination = try #require(Data(hexString: "8899aabbccddeeff"))
let shortHop = Data([0xAA, 0xBB, 0xCC])
// Route is only supported for v2+ packets
var packet = BitchatPacket(
type: 0x02,
senderID: sender,
@@ -97,7 +103,8 @@ struct BinaryProtocolTests {
timestamp: 1_730_000_000_000,
payload: Data("pad-test".utf8),
signature: nil,
ttl: 5
ttl: 5,
version: 2
)
packet.route = [shortHop, destination]
@@ -117,6 +124,7 @@ struct BinaryProtocolTests {
try #require(Data(hexString: "0202020202020202"))
]
let repeatedString = String(repeating: "compress-me", count: 150)
// Route is only supported for v2+ packets
var packet = BitchatPacket(
type: 0x03,
senderID: route[0],
@@ -124,7 +132,8 @@ struct BinaryProtocolTests {
timestamp: 1_740_000_000_000,
payload: Data(repeatedString.utf8),
signature: nil,
ttl: 7
ttl: 7,
version: 2
)
packet.route = route
@@ -135,6 +144,146 @@ struct BinaryProtocolTests {
#expect(decodedRoute == route)
}
@Test func v1PacketIgnoresRouteOnEncode() throws {
// v1 packets should NOT include route even if route is set on the packet object
let route: [Data] = [
try #require(Data(hexString: "0102030405060708")),
try #require(Data(hexString: "1112131415161718"))
]
var packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: route.last,
timestamp: 1_720_000_000_000,
payload: Data("v1-no-route".utf8),
signature: nil,
ttl: 6
// version defaults to 1 (v1 packet)
)
packet.route = route // route is set but should be ignored for v1
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v1 packet")
// HAS_ROUTE flag should NOT be set for v1 packets
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v1 packet should not have HAS_ROUTE flag set")
// Decoded packet should have no route
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v1 packet")
#expect(decoded.version == 1)
#expect(decoded.route == nil, "v1 packet should decode with nil route")
#expect(decoded.payload == Data("v1-no-route".utf8))
}
@Test func v2PacketIncludesRouteOnEncode() throws {
// v2 packets SHOULD include route when route is set
let route: [Data] = [
try #require(Data(hexString: "0102030405060708")),
try #require(Data(hexString: "1112131415161718"))
]
var packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: route.last,
timestamp: 1_720_000_000_000,
payload: Data("v2-with-route".utf8),
signature: nil,
ttl: 6,
version: 2
)
packet.route = route
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet")
// HAS_ROUTE flag SHOULD be set for v2 packets with route
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0, "v2 packet should have HAS_ROUTE flag set")
// Decoded packet should have route
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet")
#expect(decoded.version == 2)
let decodedRoute = try #require(decoded.route, "v2 packet should decode with route")
#expect(decodedRoute.count == route.count)
#expect(decoded.payload == Data("v2-with-route".utf8))
}
@Test func v2PacketWithoutRouteDecodesCorrectly() throws {
// v2 packet without route should still work
let sender = try #require(Data(hexString: "0011223344556677"))
let recipient = try #require(Data(hexString: "8899aabbccddeeff"))
let packet = BitchatPacket(
type: 0x02,
senderID: sender,
recipientID: recipient,
timestamp: 1_750_000_000_000,
payload: Data("v2-no-route".utf8),
signature: nil,
ttl: 5,
version: 2
)
// route is nil by default
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet without route")
// HAS_ROUTE flag should NOT be set when no route
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v2 packet without route should not have HAS_ROUTE flag")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet without route")
#expect(decoded.version == 2)
#expect(decoded.route == nil)
#expect(decoded.payload == Data("v2-no-route".utf8))
}
@Test func v1AndV2PayloadLengthDifference() throws {
// Verify that payloadLength does NOT include route bytes
// by comparing encoded sizes
let route: [Data] = [
try #require(Data(hexString: "0102030405060708"))
]
let payloadData = Data("test-payload".utf8)
// v1 packet (route ignored)
var v1Packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: nil,
timestamp: 1_720_000_000_000,
payload: payloadData,
signature: nil,
ttl: 6
// version defaults to 1
)
v1Packet.route = route // will be ignored for v1
// v2 packet with same payload but route included
var v2Packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: nil,
timestamp: 1_720_000_000_000,
payload: payloadData,
signature: nil,
ttl: 6,
version: 2
)
v2Packet.route = route
let v1Encoded = try #require(BinaryProtocol.encode(v1Packet, padding: false))
let v2Encoded = try #require(BinaryProtocol.encode(v2Packet, padding: false))
// v2 should be larger by: 2 bytes (header length field difference) + 1 byte (route count) + 8 bytes (one hop)
// Header: v1=14, v2=16 -> +2 bytes
// Route: 1 + 8 = 9 bytes
// Total expected difference: 11 bytes
let expectedDiff = 2 + 1 + 8 // header diff + route count + one hop
#expect(v2Encoded.count - v1Encoded.count == expectedDiff,
"v2 packet should be \(expectedDiff) bytes larger than v1 (actual diff: \(v2Encoded.count - v1Encoded.count))")
}
// MARK: - Compression Tests
@Test("Create a large, compressible payload above current threshold (2048B)")
@@ -20,9 +20,13 @@ 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))
#expect(route == [a, b])
// Direct connection returns empty route (no intermediate hops)
#expect(route == [])
}
@Test func multiHopRouteComputation() throws {
@@ -32,41 +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))
#expect(route == [a, b, c, 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))
#expect(route.first == a)
#expect(route.last == c)
}
@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))
#expect(initialRoute == [a, b, c])
// 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 {
@@ -75,12 +76,28 @@ 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))
#expect(initialRoute == [a, b, c])
#expect(initialRoute == [b])
tracker.removePeer(b)
#expect(tracker.computeRoute(from: a, to: c) == nil)
}
@Test func sameStartAndEndReturnsEmptyRoute() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0102030405060708")
let b = try hex("1112131415161718")
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 == [])
}
}
+146
View File
@@ -0,0 +1,146 @@
# 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.
The `ANNOUNCE` packet payload now consists of a sequence of TLVs. The standard identity information is followed by an optional Gossip TLV.
* **Mechanism:** Appended to the `IdentityAnnouncement` 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] [NeighborID1 (8B)] [NeighborID2 (8B)] ...
```
The `Length` field indicates the total size of the neighbor IDs in bytes (N * 8). There is no explicit count field.
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).