From 01256f3041b23e1251497d10755628ae754a8b7e Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:38:44 +0100 Subject: [PATCH] Add source-based routing support (#862) * Add source-based routing support * include neighbors in ANNOUNCE --------- Co-authored-by: jack Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- bitchat/Models/BitchatPacket.swift | 8 +- bitchat/Models/PeerID.swift | 14 ++ bitchat/Protocols/BinaryProtocol.swift | 63 +++++++-- bitchat/Protocols/Packets.swift | 27 +++- bitchat/Services/BLE/BLEService.swift | 110 ++++++++++++++-- bitchat/Services/MeshTopologyTracker.swift | 120 ++++++++++++++++++ .../Protocol/BinaryProtocolTests.swift | 80 ++++++++++++ .../Services/MeshTopologyTrackerTests.swift | 86 +++++++++++++ bitchatTests/Utils/PeerIDTests.swift | 22 +++- 9 files changed, 504 insertions(+), 26 deletions(-) create mode 100644 bitchat/Services/MeshTopologyTracker.swift create mode 100644 bitchatTests/Services/MeshTopologyTrackerTests.swift diff --git a/bitchat/Models/BitchatPacket.swift b/bitchat/Models/BitchatPacket.swift index 6473b871..c23d0157 100644 --- a/bitchat/Models/BitchatPacket.swift +++ b/bitchat/Models/BitchatPacket.swift @@ -21,8 +21,9 @@ struct BitchatPacket: Codable { let payload: Data var signature: Data? var ttl: UInt8 + var route: [Data]? - init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) { + init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) { self.version = version self.type = type self.senderID = senderID @@ -31,6 +32,7 @@ struct BitchatPacket: Codable { self.payload = payload self.signature = signature self.ttl = ttl + self.route = route } // Convenience initializer for new binary format @@ -53,6 +55,7 @@ struct BitchatPacket: Codable { self.payload = payload self.signature = nil self.ttl = ttl + self.route = nil } var data: Data? { @@ -81,7 +84,8 @@ struct BitchatPacket: Codable { payload: payload, signature: nil, // Remove signature for signing ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility - version: version + version: version, + route: route ) return BinaryProtocol.encode(unsignedPacket) } diff --git a/bitchat/Models/PeerID.swift b/bitchat/Models/PeerID.swift index d31e2275..837f56aa 100644 --- a/bitchat/Models/PeerID.swift +++ b/bitchat/Models/PeerID.swift @@ -136,6 +136,20 @@ extension PeerID { } } +extension PeerID { + var routingData: Data? { + if let direct = Data(hexString: id), direct.count == 8 { return direct } + if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData } + let short = toShort() + return Data(hexString: short.id) + } + + init?(routingData: Data) { + guard routingData.count == 8 else { return nil } + self.init(hexData: routingData) + } +} + // MARK: - Validation extension PeerID { diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index cde49fd4..00e8bb0c 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -137,6 +137,7 @@ struct BinaryProtocol { static let hasRecipient: UInt8 = 0x01 static let hasSignature: UInt8 = 0x02 static let isCompressed: UInt8 = 0x04 + static let hasRoute: UInt8 = 0x08 } // Encode BitchatPacket to binary format @@ -160,8 +161,21 @@ struct BinaryProtocol { } let lengthFieldBytes = lengthFieldSize(for: version) + let originalRoute = packet.route ?? [] + if originalRoute.contains(where: { $0.isEmpty }) { return nil } + let sanitizedRoute: [Data] = originalRoute.map { hop in + if hop.count == senderIDSize { return hop } + if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) } + var padded = hop + padded.append(Data(repeating: 0, count: senderIDSize - hop.count)) + return padded + } + guard sanitizedRoute.count <= 255 else { return nil } + + let hasRoute = !sanitizedRoute.isEmpty + let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0 let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0 - let payloadDataSize = payload.count + originalSizeFieldBytes + let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes if version == 1 && payloadDataSize > Int(UInt16.max) { return nil } if version == 2 && payloadDataSize > Int(UInt32.max) { return nil } @@ -185,6 +199,7 @@ 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 } data.append(flags) if version == 2 { @@ -212,6 +227,13 @@ struct BinaryProtocol { } } + if hasRoute { + data.append(UInt8(sanitizedRoute.count)) + for hop in sanitizedRoute { + data.append(hop) + } + } + if isCompressed, let originalSize = originalPayloadSize { if version == 2 { let value = UInt32(originalSize) @@ -321,9 +343,27 @@ struct BinaryProtocol { if recipientID == nil { return nil } } + var route: [Data]? = nil + var remainingPayloadBytes = payloadLength + + if (flags & Flags.hasRoute) != 0 { + guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil } + remainingPayloadBytes -= 1 + if routeCount > 0 { + var hops: [Data] = [] + for _ in 0..= senderIDSize, + let hop = readData(senderIDSize) else { return nil } + remainingPayloadBytes -= senderIDSize + hops.append(hop) + } + route = hops + } + } + let payload: Data if isCompressed { - guard payloadLength >= lengthFieldBytes else { return nil } + guard remainingPayloadBytes >= lengthFieldBytes else { return nil } let originalSize: Int if version == 2 { guard let rawSize = read32() else { return nil } @@ -332,16 +372,12 @@ struct BinaryProtocol { guard let rawSize = read16() else { return nil } originalSize = Int(rawSize) } - // Guard to keep decompression bounded to sane BLE payload limits - // Use maxFramedFileBytes to account for TLV overhead in file transfer payloads + remainingPayloadBytes -= lengthFieldBytes guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil } - let compressedSize = payloadLength - lengthFieldBytes - guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil } + let compressedSize = remainingPayloadBytes + guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil } + remainingPayloadBytes = 0 - // Validate compression ratio to prevent zip bomb attacks - // Primary protection: originalSize capped at 1MB (line 336) - // Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation) - guard compressedSize > 0 else { return nil } let compressionRatio = Double(originalSize) / Double(compressedSize) guard compressionRatio <= 50_000.0 else { SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security) @@ -352,7 +388,9 @@ struct BinaryProtocol { decompressed.count == originalSize else { return nil } payload = decompressed } else { - guard let rawPayload = readData(payloadLength) else { return nil } + guard remainingPayloadBytes >= 0, + let rawPayload = readData(remainingPayloadBytes) else { return nil } + remainingPayloadBytes = 0 payload = rawPayload } @@ -372,7 +410,8 @@ struct BinaryProtocol { payload: payload, signature: signature, ttl: ttl, - version: version + version: version, + route: route ) } } diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index e91831a5..8914d4b7 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -6,11 +6,13 @@ struct AnnouncementPacket { let nickname: String let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let signingPublicKey: Data // Ed25519 public key for signing + let directNeighbors: [Data]? // 8-byte peer IDs private enum TLVType: UInt8 { case nickname = 0x01 case noisePublicKey = 0x02 case signingPublicKey = 0x03 + case directNeighbors = 0x04 } func encode() -> Data? { @@ -35,6 +37,16 @@ struct AnnouncementPacket { data.append(TLVType.signingPublicKey.rawValue) data.append(UInt8(signingPublicKey.count)) data.append(signingPublicKey) + + // TLV for direct neighbors (optional) + if let neighbors = directNeighbors, !neighbors.isEmpty { + let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 } + if !neighborsData.isEmpty && neighborsData.count % 8 == 0 { + data.append(TLVType.directNeighbors.rawValue) + data.append(UInt8(neighborsData.count)) + data.append(neighborsData) + } + } return data } @@ -44,6 +56,7 @@ struct AnnouncementPacket { var nickname: String? var noisePublicKey: Data? var signingPublicKey: Data? + var directNeighbors: [Data]? while offset + 2 <= data.count { let typeRaw = data[offset] @@ -63,6 +76,17 @@ struct AnnouncementPacket { noisePublicKey = Data(value) case .signingPublicKey: signingPublicKey = Data(value) + case .directNeighbors: + if length > 0 && length % 8 == 0 { + var neighbors = [Data]() + let count = length / 8 + for i in 0.. MTU) private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 } @@ -569,6 +570,7 @@ final class BLEService: NSObject { peerToPeripheralUUID.removeAll() subscribedCentrals.removeAll() centralToPeerID.removeAll() + meshTopology.reset() } // MARK: Connectivity and peers @@ -726,6 +728,8 @@ final class BLEService: NSObject { version: 2 ) + self.applyRouteIfAvailable(&packet, to: peerID) + if let signed = self.noiseService.signPacket(packet) { packet = signed } @@ -745,7 +749,7 @@ final class BLEService: NSObject { SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session) do { let encrypted = try noiseService.encrypt(payload, for: peerID) - let packet = BitchatPacket( + var packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), @@ -754,6 +758,7 @@ final class BLEService: NSObject { signature: nil, ttl: messageTTL ) + applyRouteIfAvailable(&packet, to: peerID) broadcastPacket(packet) } catch { SecureLogger.error("Failed to send read receipt: \(error)") @@ -1226,7 +1231,7 @@ final class BLEService: NSObject { if noiseService.hasEstablishedSession(with: peerID) { do { let encrypted = try noiseService.encrypt(payload, for: peerID) - let packet = BitchatPacket( + var packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), @@ -1235,6 +1240,7 @@ final class BLEService: NSObject { signature: nil, ttl: messageTTL ) + applyRouteIfAvailable(&packet, to: peerID) broadcastPacket(packet) } catch { SecureLogger.error("Failed to send delivery ACK: \(error)") @@ -1407,10 +1413,15 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification + let connectedPeerIDs: [Data] = collectionsQueue.sync { + peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData } + } + let announcement = AnnouncementPacket( nickname: myNickname, noisePublicKey: noisePub, - signingPublicKey: signingPub + signingPublicKey: signingPub, + directNeighbors: connectedPeerIDs ) guard let payload = announcement.encode() else { @@ -1739,6 +1750,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph peers[peerID] = info } } + clearDirectLink(with: peerID) } // Restart scanning with allow duplicates for faster rediscovery @@ -2026,6 +2038,7 @@ extension BLEService: CBPeripheralDelegate { peripherals[peripheralUUID] = state } peerToPeripheralUUID[senderID] = peripheralUUID + registerDirectLink(with: senderID) } let msgID = makeMessageID(for: packet) @@ -2192,6 +2205,7 @@ extension BLEService: CBPeripheralManagerDelegate { // Clean up mappings centralToPeerID.removeValue(forKey: centralUUID) + clearDirectLink(with: peerID) // Update UI immediately notifyUI { [weak self] in @@ -2308,7 +2322,10 @@ extension BLEService: CBPeripheralManagerDelegate { subscribedCentrals.append(sorted[0].central) } if packet.type == MessageType.announce.rawValue { - if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID } + if packet.ttl == messageTTL { + centralToPeerID[centralUUID] = senderID + registerDirectLink(with: senderID) + } // Record ingress link for last-hop suppression then process let msgID = makeMessageID(for: packet) collectionsQueue.async(flags: .barrier) { [weak self] in @@ -2418,6 +2435,63 @@ extension BLEService { ) } + private func routingData(for peerID: PeerID) -> Data? { + 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 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 routingPeer(from data: Data) -> PeerID? { + PeerID(routingData: data) + } + + 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 { + return true + } + + 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) + return true + } + /// Safely fetch the current direct-link state for a peer using the BLE queue. private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) { let computeState = { () -> (Bool, Bool) in @@ -2451,6 +2525,7 @@ extension BLEService { let fingerprint = noiseService.getIdentityFingerprint() myPeerID = PeerID(str: fingerprint.prefix(16)) myPeerIDData = Data(hexString: myPeerID.id) ?? Data() + meshTopology.reset() } private func restartGossipManager() { @@ -2469,7 +2544,7 @@ extension BLEService { } do { let encrypted = try noiseService.encrypt(typedPayload, for: peerID) - let packet = BitchatPacket( + var packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), @@ -2478,6 +2553,7 @@ extension BLEService { signature: nil, ttl: messageTTL ) + applyRouteIfAvailable(&packet, to: peerID) broadcastPacket(packet) } catch { SecureLogger.error("Failed to send verification payload: \(error)") @@ -2696,7 +2772,7 @@ extension BLEService { } } - let packet = BitchatPacket( + var packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: recipientData, @@ -2705,6 +2781,7 @@ extension BLEService { signature: nil, ttl: messageTTL ) + applyRouteIfAvailable(&packet, to: recipientID) broadcastPacket(packet) @@ -2744,7 +2821,7 @@ extension BLEService { let handshakeData = try noiseService.initiateHandshake(with: peerID) // Send handshake init - let packet = BitchatPacket( + var packet = BitchatPacket( type: MessageType.noiseHandshake.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), @@ -2753,6 +2830,7 @@ extension BLEService { signature: nil, ttl: messageTTL ) + applyRouteIfAvailable(&packet, to: peerID) broadcastPacket(packet) } catch { SecureLogger.error("Failed to initiate handshake: \(error)") @@ -2786,7 +2864,7 @@ extension BLEService { let encrypted = try noiseService.encrypt(messagePayload, for: peerID) - let packet = BitchatPacket( + var packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), @@ -2795,6 +2873,7 @@ extension BLEService { signature: nil, ttl: messageTTL ) + applyRouteIfAvailable(&packet, to: peerID) // We're already on messageQueue from the callback broadcastPacket(packet) @@ -2946,7 +3025,8 @@ extension BLEService { timestamp: packet.timestamp, payload: payload, signature: nil, - ttl: packet.ttl + ttl: packet.ttl, + route: packet.route ) let workItem = DispatchWorkItem { [weak self] in @@ -3161,6 +3241,11 @@ extension BLEService { } return } + + registerRoute(packet.route) + if peerID != myPeerID && packet.ttl == messageTTL { + registerDirectLink(with: peerID) + } // Deduplication (thread-safe) let senderID = PeerID(hexData: packet.senderID) @@ -3245,6 +3330,10 @@ extension BLEService { break } + if forwardAlongRouteIfNeeded(packet) { + return + } + // Relay if TTL > 1 and we're not the original sender // Relay decision and scheduling (extracted via RelayController) do { @@ -3591,7 +3680,7 @@ extension BLEService { do { if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) { // Send response - let responsePacket = BitchatPacket( + var responsePacket = BitchatPacket( type: MessageType.noiseHandshake.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), @@ -3600,6 +3689,7 @@ extension BLEService { signature: nil, ttl: messageTTL ) + applyRouteIfAvailable(&responsePacket, to: peerID) // We're on messageQueue from delegate callback broadcastPacket(responsePacket) } diff --git a/bitchat/Services/MeshTopologyTracker.swift b/bitchat/Services/MeshTopologyTracker.swift new file mode 100644 index 00000000..11a06f56 --- /dev/null +++ b/bitchat/Services/MeshTopologyTracker.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Tracks observed mesh topology and computes hop-by-hop routes. +final class MeshTopologyTracker { + private typealias RoutingID = Data + + private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent) + private let hopSize = 8 + private var adjacency: [RoutingID: Set] = [:] + + func reset() { + queue.sync(flags: .barrier) { + self.adjacency.removeAll() + } + } + + func recordDirectLink(between a: Data?, and b: Data?) { + guard let left = sanitize(a), let right = sanitize(b), left != right else { return } + 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 + } + } + } + + 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 + } + } + } + } + + func recordRoute(_ hops: [Data]) { + let sanitized = hops.compactMap { sanitize($0) } + guard sanitized.count >= 2 else { return } + 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 + } + } + } + + func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? { + guard let source = sanitize(start), let target = sanitize(goal) else { return nil } + if source == target { return [source] } + + let graph = queue.sync { adjacency } + guard graph[source] != nil, graph[target] != nil else { return nil } + + 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 { + queuePaths.append(nextPath) + } + visited.insert(neighbor) + } + } + + return nil + } + + // MARK: - Helpers + + private func sanitize(_ data: Data?) -> Data? { + guard var value = data, !value.isEmpty else { return nil } + if value.count > hopSize { + value = Data(value.prefix(hopSize)) + } else if value.count < hopSize { + value.append(Data(repeating: 0, count: hopSize - value.count)) + } + return value + } +} diff --git a/bitchatTests/Protocol/BinaryProtocolTests.swift b/bitchatTests/Protocol/BinaryProtocolTests.swift index d62b962a..17d014ff 100644 --- a/bitchatTests/Protocol/BinaryProtocolTests.swift +++ b/bitchatTests/Protocol/BinaryProtocolTests.swift @@ -54,6 +54,86 @@ struct BinaryProtocolTests { #expect(decodedPacket.signature != nil) #expect(decodedPacket.signature == TestConstants.testSignature) } + + @Test func packetWithRouteRoundTrip() throws { + let route: [Data] = [ + try #require(Data(hexString: "0102030405060708")), + try #require(Data(hexString: "1112131415161718")), + try #require(Data(hexString: "2122232425262728")) + ] + + var packet = BitchatPacket( + type: 0x01, + senderID: route[0], + recipientID: route.last, + timestamp: 1_720_000_000_000, + payload: Data("route-test".utf8), + signature: nil, + ttl: 6 + ) + packet.route = route + + let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route") + let flagsByte = encoded[BinaryProtocol.Offsets.flags] + #expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0) + + let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route") + let decodedRoute = try #require(decoded.route) + #expect(decodedRoute.count == route.count) + for (expected, actual) in zip(route, decodedRoute) { + #expect(actual == expected) + } + } + + @Test func packetWithRoutePadsShortHop() throws { + let sender = try #require(Data(hexString: "0011223344556677")) + let destination = try #require(Data(hexString: "8899aabbccddeeff")) + let shortHop = Data([0xAA, 0xBB, 0xCC]) + + var packet = BitchatPacket( + type: 0x02, + senderID: sender, + recipientID: destination, + timestamp: 1_730_000_000_000, + payload: Data("pad-test".utf8), + signature: nil, + ttl: 5 + ) + packet.route = [shortHop, destination] + + let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with short hop route") + let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with short hop route") + let decodedRoute = try #require(decoded.route) + let firstHop = try #require(decodedRoute.first) + #expect(firstHop.count == BinaryProtocol.senderIDSize) + #expect(firstHop.prefix(shortHop.count) == shortHop) + let paddingBytes = firstHop.suffix(firstHop.count - shortHop.count) + #expect(paddingBytes.allSatisfy { $0 == 0 }) + } + + @Test func packetWithRouteAndCompressedPayload() throws { + let route: [Data] = [ + try #require(Data(hexString: "0101010101010101")), + try #require(Data(hexString: "0202020202020202")) + ] + let repeatedString = String(repeating: "compress-me", count: 150) + var packet = BitchatPacket( + type: 0x03, + senderID: route[0], + recipientID: route.last, + timestamp: 1_740_000_000_000, + payload: Data(repeatedString.utf8), + signature: nil, + ttl: 7 + ) + packet.route = route + + let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route and compression") + let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route and compression") + #expect(decoded.payload == Data(repeatedString.utf8)) + let decodedRoute = try #require(decoded.route) + #expect(decodedRoute == route) + } // MARK: - Compression Tests diff --git a/bitchatTests/Services/MeshTopologyTrackerTests.swift b/bitchatTests/Services/MeshTopologyTrackerTests.swift new file mode 100644 index 00000000..485b290e --- /dev/null +++ b/bitchatTests/Services/MeshTopologyTrackerTests.swift @@ -0,0 +1,86 @@ +// +// MeshTopologyTrackerTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +struct MeshTopologyTrackerTests { + private func hex(_ value: String) throws -> Data { + try #require(Data(hexString: value)) + } + + @Test func directLinkProducesRoute() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0102030405060708") + let b = try hex("1112131415161718") + + tracker.recordDirectLink(between: a, and: b) + let route = try #require(tracker.computeRoute(from: a, to: b)) + #expect(route == [a, b]) + } + + @Test func multiHopRouteComputation() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0001020304050607") + let b = try hex("1011121314151617") + 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) + + let route = try #require(tracker.computeRoute(from: a, to: d)) + #expect(route == [a, b, c, d]) + } + + @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 { + 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]) + + tracker.removeDirectLink(between: b, and: c) + #expect(tracker.computeRoute(from: a, to: c) == nil) + } + + @Test func removingPeerClearsEdges() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0F0E0D0C0B0A0908") + let b = try hex("0A0B0C0D0E0F0001") + let c = try hex("0011223344556677") + + tracker.recordRoute([a, b, c]) + let initialRoute = try #require(tracker.computeRoute(from: a, to: c)) + #expect(initialRoute == [a, b, c]) + + tracker.removePeer(b) + #expect(tracker.computeRoute(from: a, to: c) == nil) + } + +} diff --git a/bitchatTests/Utils/PeerIDTests.swift b/bitchatTests/Utils/PeerIDTests.swift index 41726572..9bd41706 100644 --- a/bitchatTests/Utils/PeerIDTests.swift +++ b/bitchatTests/Utils/PeerIDTests.swift @@ -217,7 +217,27 @@ struct PeerIDTests { let short = peerID.toShort() #expect(short == peerID) } - + + @Test func routingData_fromShortID() throws { + let peerID = PeerID(str: hex16) + let routing = try #require(peerID.routingData) + #expect(routing.count == 8) + #expect(routing == Data(hexString: hex16)) + } + + @Test func routingData_fromNoiseKey() throws { + let peerID = PeerID(str: hex64) + let routing = try #require(peerID.routingData) + let expectedShort = peerID.toShort() + #expect(routing == Data(hexString: expectedShort.id)) + } + + @Test func routingPeerRoundTrip() throws { + let raw = try #require(Data(hexString: hex16)) + let peerID = try #require(PeerID(routingData: raw)) + #expect(peerID.routingData == raw) + } + // MARK: - Codable @Test func codable_emptyPrefix() throws {