1// MeshManager.swift
2// SophaxChatCore
3//
4// P2P mesh transport via MultipeerConnectivity (Bluetooth LE + WiFi Direct).
5//
6// Security:
7// • encryptionPreference: .required → TLS-level transport encryption (defence-in-depth)
8// • MCPeerID display name = ephemeral random token per session (no stable fingerprint)
9// • Authentication happens at the application layer (X3DH + Ed25519 signatures)
10// • Malformed or undecryptable packets are silently dropped
11
12import Foundation
13@preconcurrency import MultipeerConnectivity
14
15// MARK: - Delegate
16
17public protocol MeshManagerDelegate: AnyObject {
18 func meshManager(_ manager: MeshManager, didDiscoverPeer peerID: String, withName displayName: String)
19 func meshManager(_ manager: MeshManager, didLosePeer peerID: String)
20 func meshManager(_ manager: MeshManager, didConnectToPeer peerID: String)
21 func meshManager(_ manager: MeshManager, didDisconnectFromPeer peerID: String)
22 func meshManager(_ manager: MeshManager, didReceiveMessage message: WireMessage, fromPeer peerID: String)
23 func meshManager(_ manager: MeshManager, sendDidFailForPeer peerID: String, error: Error)
24}
25
26// MARK: - MeshManager
27//
28// MeshManager is the production implementation of the MessageTransport
29// contract defined in MessageTransport.swift. It implements the full
30// start/stop/send/broadcast/isConnected surface using MultipeerConnectivity
31// (Bluetooth LE + WiFi Direct). Future transports (LoRa, audio covert channel)
32// will expose the same surface — see MessageTransport.swift for the protocol.
33
34// Threading contract: MCSessionDelegate and MCNearbyServiceBrowserDelegate callbacks
35// arrive on MPC's internal queue. All delegate calls are re-dispatched to
36// `DispatchQueue.main` before being forwarded to MessageTransportDelegate. Public
37// methods (send, broadcast, start, stop) must be called from the main thread.
38public final class MeshManager: NSObject, @unchecked Sendable {
39
40 // Lowercase, ≤ 15 chars, [a-z0-9\-] only
41 private static let serviceType = "sophax-chat"
42
43 private let localPeerID: MCPeerID
44 private var session: MCSession
45 private var advertiser: MCNearbyServiceAdvertiser
46 private var browser: MCNearbyServiceBrowser
47
48 /// MCPeerID displayName → application-level peerID (learned from Hello messages)
49 private var displayNameToPeerID: [String: String] = [:]
50 /// application-level peerID → MCPeerID
51 private var peerIDToMCPeer: [String: MCPeerID] = [:]
52 /// Set of currently connected MCPeerIDs
53 private var connectedMCPeers: Set<MCPeerID> = []
54
55 private let queue = DispatchQueue(label: "com.sophax.mesh", qos: .userInitiated)
56
57 public weak var delegate: MeshManagerDelegate?
58
59 // MARK: - Init
60
61 public init(localIdentityHash: String) {
62 // Use a fresh random token each session instead of the identity hash prefix.
63 // The identity hash is stable and would let passive BLE/WiFi observers track a
64 // device across time and space without ever completing a session handshake.
65 // Application-layer identity (X3DH + Ed25519) does not depend on this display name.
66 let sessionToken = UUID().uuidString
67 .replacingOccurrences(of: "-", with: "")
68 .prefix(11)
69 .lowercased() // "sx-" (3) + 11 chars = 14 ≤ 15-char MPC limit
70 let mcID = MCPeerID(displayName: "sx-\(sessionToken)")
71 self.localPeerID = mcID
72
73 let sess = MCSession(
74 peer: mcID,
75 securityIdentity: nil,
76 encryptionPreference: .required // TLS transport encryption required
77 )
78 self.session = sess
79 self.advertiser = MCNearbyServiceAdvertiser(
80 peer: mcID,
81 discoveryInfo: ["v": "1"], // Protocol version only — no PII
82 serviceType: Self.serviceType
83 )
84 self.browser = MCNearbyServiceBrowser(peer: mcID, serviceType: Self.serviceType)
85
86 super.init()
87 sess.delegate = self
88 advertiser.delegate = self
89 browser.delegate = self
90 }
91
92 // MARK: - Lifecycle
93
94 public func start() {
95 advertiser.startAdvertisingPeer()
96 browser.startBrowsingForPeers()
97 }
98
99 public func stop() {
100 advertiser.stopAdvertisingPeer()
101 browser.stopBrowsingForPeers()
102 session.disconnect()
103 }
104
105 // MARK: - Sending
106
107 /// Reliable, ordered delivery to a specific directly-connected peer.
108 public func send(_ message: WireMessage, toPeerID peerID: String) throws {
109 guard let mcPeer = peerIDToMCPeer[peerID],
110 connectedMCPeers.contains(mcPeer) else {
111 throw MeshError.peerNotConnected(peerID)
112 }
113 let data = try JSONEncoder().encode(message)
114 try session.send(data, toPeers: [mcPeer], with: .reliable)
115 }
116
117 /// Broadcast to all directly-connected peers, optionally excluding one.
118 /// Used by the relay system to forward envelopes without looping.
119 public func broadcast(_ message: WireMessage, excluding excludedPeerID: String? = nil) throws {
120 let excludedMCPeer: MCPeerID? = excludedPeerID.flatMap { peerIDToMCPeer[$0] }
121 let targets = connectedMCPeers.filter { $0 != excludedMCPeer }
122 guard !targets.isEmpty else { return }
123 let data = try JSONEncoder().encode(message)
124 try session.send(data, toPeers: Array(targets), with: .reliable)
125 }
126
127 /// Whether a specific peer (by peerID) is directly connected.
128 public func isConnected(peerID: String) -> Bool {
129 guard let mc = peerIDToMCPeer[peerID] else { return false }
130 return connectedMCPeers.contains(mc)
131 }
132
133 /// All directly-connected application-level peerIDs.
134 public var connectedPeerIDs: [String] {
135 connectedMCPeers.compactMap { displayNameToPeerID[$0.displayName] }
136 }
137
138 /// Number of directly connected peers.
139 public var directPeerCount: Int { connectedMCPeers.count }
140}
141
142// MARK: - MCSessionDelegate
143
144extension MeshManager: MCSessionDelegate {
145
146 public func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
147 queue.async { [weak self] in
148 guard let self else { return }
149 let appID = self.displayNameToPeerID[peerID.displayName] ?? peerID.displayName
150 switch state {
151 case .connected:
152 self.connectedMCPeers.insert(peerID)
153 DispatchQueue.main.async { self.delegate?.meshManager(self, didConnectToPeer: appID) }
154 case .notConnected:
155 self.connectedMCPeers.remove(peerID)
156 DispatchQueue.main.async { self.delegate?.meshManager(self, didDisconnectFromPeer: appID) }
157 default:
158 break
159 }
160 }
161 }
162
163 public func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
164 queue.async { [weak self] in
165 guard let self else { return }
166 // Reject oversized payloads before decoding to prevent memory DoS.
167 // Matches TCPTransport.maxFrameSize (3 MiB) — both transports must accept
168 // the same message types, so an attacker cannot bypass the TCP limit by
169 // switching to the BLE/WiFi-Direct transport.
170 guard data.count <= 3 * 1024 * 1024 else { return }
171 guard let message = try? JSONDecoder().decode(WireMessage.self, from: data) else {
172 return // Malformed — drop silently
173 }
174 // Learn/update the mapping from MC display name to application peerID
175 self.displayNameToPeerID[peerID.displayName] = message.senderID
176 self.peerIDToMCPeer[message.senderID] = peerID
177
178 let senderID = message.senderID
179 DispatchQueue.main.async {
180 self.delegate?.meshManager(self, didReceiveMessage: message, fromPeer: senderID)
181 }
182 }
183 }
184
185 // Unused delegate stubs
186 public func session(_ session: MCSession, didReceive stream: InputStream,
187 withName streamName: String, fromPeer peerID: MCPeerID) {}
188 public func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String,
189 fromPeer peerID: MCPeerID, with progress: Progress) {}
190 public func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String,
191 fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {}
192}
193
194// MARK: - MCNearbyServiceAdvertiserDelegate
195
196extension MeshManager: MCNearbyServiceAdvertiserDelegate {
197
198 public func advertiser(_ advertiser: MCNearbyServiceAdvertiser,
199 didReceiveInvitationFromPeer peerID: MCPeerID,
200 withContext context: Data?,
201 invitationHandler: @escaping (Bool, MCSession?) -> Void) {
202 // Limit concurrent mesh connections to prevent connection-flood DoS.
203 // Cryptographic authentication still happens at the app layer after acceptance.
204 let accept = session.connectedPeers.count < 50
205 invitationHandler(accept, accept ? session : nil)
206 }
207
208 public func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Error) {
209 #if DEBUG
210 print("[MeshManager] Advertising error: \(error.localizedDescription)")
211 #endif
212 }
213}
214
215// MARK: - MCNearbyServiceBrowserDelegate
216
217extension MeshManager: MCNearbyServiceBrowserDelegate {
218
219 public func browser(_ browser: MCNearbyServiceBrowser,
220 foundPeer peerID: MCPeerID,
221 withDiscoveryInfo info: [String: String]?) {
222 queue.async { [weak self] in
223 guard let self else { return }
224 browser.invitePeer(peerID, to: self.session, withContext: nil, timeout: 10)
225 // Store temporary mapping (MC display name) until Hello arrives with real peerID
226 self.peerIDToMCPeer[peerID.displayName] = peerID
227 DispatchQueue.main.async {
228 self.delegate?.meshManager(self, didDiscoverPeer: peerID.displayName,
229 withName: peerID.displayName)
230 }
231 }
232 }
233
234 public func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
235 queue.async { [weak self] in
236 guard let self else { return }
237 let appID = self.displayNameToPeerID[peerID.displayName] ?? peerID.displayName
238 self.peerIDToMCPeer.removeValue(forKey: appID)
239 self.displayNameToPeerID.removeValue(forKey: peerID.displayName)
240 DispatchQueue.main.async {
241 self.delegate?.meshManager(self, didLosePeer: appID)
242 }
243 }
244 }
245
246 public func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: Error) {
247 #if DEBUG
248 print("[MeshManager] Browse error: \(error.localizedDescription)")
249 #endif
250 }
251}
252
253// MARK: - MeshError
254
255public enum MeshError: Error, LocalizedError {
256 case peerNotConnected(String)
257 case encodingFailed
258
259 public var errorDescription: String? {
260 switch self {
261 case .peerNotConnected(let id): "Peer \(id) is not directly connected"
262 case .encodingFailed: "Message encoding failed"
263 }
264 }
265}