diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index 00e8bb0c..0cc6f317 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -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..= 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 } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 50a170ff..2d5f0642 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -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..] = [:] + // 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) } } } - 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 = [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 { 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 diff --git a/bitchat/Services/NotificationStreamAssembler.swift b/bitchat/Services/NotificationStreamAssembler.swift index 39948dc6..cbef4506 100644 --- a/bitchat/Services/NotificationStreamAssembler.swift +++ b/bitchat/Services/NotificationStreamAssembler.swift @@ -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 { diff --git a/bitchatTests/Protocol/BinaryProtocolTests.swift b/bitchatTests/Protocol/BinaryProtocolTests.swift index 17d014ff..72def4c8 100644 --- a/bitchatTests/Protocol/BinaryProtocolTests.swift +++ b/bitchatTests/Protocol/BinaryProtocolTests.swift @@ -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)") diff --git a/bitchatTests/Services/MeshTopologyTrackerTests.swift b/bitchatTests/Services/MeshTopologyTrackerTests.swift index 485b290e..1ae0f5f5 100644 --- a/bitchatTests/Services/MeshTopologyTrackerTests.swift +++ b/bitchatTests/Services/MeshTopologyTrackerTests.swift @@ -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 == []) + } + } diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md new file mode 100644 index 00000000..6832a16e --- /dev/null +++ b/docs/SOURCE_ROUTING.md @@ -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).