mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Merge pull request #938 from permissionlesstech/source-routing-packet-format
feat: Source routing v2
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == [])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user