EngagementHost.swift (13771B)
1 import CryptoKit 2 import Foundation 3 import Security 4 5 @MainActor 6 final class EngagementHost: NSObject { 7 enum Event { 8 case channelOpen(engagementID: UUID) 9 case channelMessage(engagementID: UUID, message: Data) 10 case channelClose(engagementID: UUID) 11 case diagnostic(engagementID: UUID?, message: String) 12 case error(engagementID: UUID?, message: String) 13 } 14 15 var onEvent: ((Event) -> Void)? 16 17 private static let keepaliveInterval: Duration = .seconds(25) 18 19 private var sockets: [UUID: URLSessionWebSocketTask] = [:] 20 private var engagementIDsByTask: [ObjectIdentifier: UUID] = [:] 21 private var pingTasks: [UUID: Task<Void, Never>] = [:] 22 // A `.default` URLSession cannot hold a socket open while the app is 23 // suspended — iOS aborts it (`Software caused connection abort`) on the way 24 // to the background. This is by design: the live channel is a foreground-only 25 // luxury, and the durable Moves/CloudKit path is the source of truth. Do NOT 26 // try to paper over the aborts with reconnect retries; a backgrounded 27 // reconnect just storms until the next suspension. The fix lives upstream — 28 // `reconcileEngagement` refuses to connect unless the app is foreground. 29 private lazy var session = URLSession( 30 configuration: .default, 31 delegate: self, 32 delegateQueue: nil 33 ) 34 35 func connect( 36 engagementID: UUID, 37 room: EngagementRoomCredentials, 38 authorID: String, 39 deviceID: String 40 ) async throws { 41 disconnect(engagementID: engagementID) 42 // Register (idempotently) before every connect. The worker only 43 // accepts sockets for rooms whose secret it already holds, and any 44 // legitimate creds-holder may register first — which also resurrects 45 // a room whose idle expiry wiped the worker-side secret. 46 try await registerRoom(room) 47 guard let url = try Self.socketURL(room: room, authorID: authorID, deviceID: deviceID) else { 48 throw EngagementHostError.missingEndpoint 49 } 50 let task = session.webSocketTask(with: url) 51 // The relay is untrusted; refuse oversized frames at the transport 52 // before they are buffered into the app at all. `EngagementMessage 53 // .decode` re-checks the same bound, but this one costs nothing. 54 task.maximumMessageSize = EngagementMessage.maxEncodedFrameBytes 55 sockets[engagementID] = task 56 engagementIDsByTask[ObjectIdentifier(task)] = engagementID 57 task.resume() 58 receiveNext(engagementID: engagementID, task: task) 59 startPing(engagementID: engagementID) 60 onEvent?(.diagnostic(engagementID: engagementID, message: "socket connecting \(room.roomID.uuidString)")) 61 } 62 63 /// Sends an app-level "ping" on a timer so an idle foreground socket 64 /// survives NAT / Cloudflare edge idle timeouts. The room Worker answers 65 /// "pong" via `setWebSocketAutoResponse` without waking the Durable Object; 66 /// both strings are filtered out of the inbound stream in `receiveNext` 67 /// (they are not engagement messages). Stops on the first failed send or 68 /// when the socket is gone — the close path then tears the channel down. 69 private func startPing(engagementID: UUID) { 70 pingTasks[engagementID]?.cancel() 71 pingTasks[engagementID] = Task { @MainActor [weak self] in 72 while !Task.isCancelled { 73 try? await Task.sleep(for: Self.keepaliveInterval) 74 guard !Task.isCancelled, let self, let task = self.sockets[engagementID] else { return } 75 do { 76 try await task.send(.string("ping")) 77 } catch { 78 return 79 } 80 } 81 } 82 } 83 84 private func stopPing(engagementID: UUID) { 85 pingTasks.removeValue(forKey: engagementID)?.cancel() 86 } 87 88 func send(engagementID: UUID, message: Data) async throws { 89 guard let task = sockets[engagementID] else { 90 throw EngagementHostError.missingSocket 91 } 92 try await task.send(.data(message)) 93 } 94 95 func disconnect(engagementID: UUID) { 96 stopPing(engagementID: engagementID) 97 guard let task = sockets.removeValue(forKey: engagementID) else { return } 98 engagementIDsByTask.removeValue(forKey: ObjectIdentifier(task)) 99 task.cancel(with: .goingAway, reason: nil) 100 onEvent?(.channelClose(engagementID: engagementID)) 101 } 102 103 private func receiveNext(engagementID: UUID, task: URLSessionWebSocketTask) { 104 task.receive { [weak self, weak task] result in 105 Task { @MainActor in 106 guard let self, let task, self.sockets[engagementID] === task else { return } 107 switch result { 108 case .success(.data(let data)): 109 self.onEvent?(.channelMessage(engagementID: engagementID, message: data)) 110 self.receiveNext(engagementID: engagementID, task: task) 111 case .success(.string(let string)): 112 // Keepalive frames (see `startPing`) are not engagement 113 // messages; swallow them and keep listening. 114 guard string != "ping", string != "pong" else { 115 self.receiveNext(engagementID: engagementID, task: task) 116 return 117 } 118 let data = Data(string.utf8) 119 self.onEvent?(.channelMessage(engagementID: engagementID, message: data)) 120 self.receiveNext(engagementID: engagementID, task: task) 121 case .failure(let error): 122 self.stopPing(engagementID: engagementID) 123 self.sockets.removeValue(forKey: engagementID) 124 self.engagementIDsByTask.removeValue(forKey: ObjectIdentifier(task)) 125 self.onEvent?(.error(engagementID: engagementID, message: error.localizedDescription)) 126 self.onEvent?(.channelClose(engagementID: engagementID)) 127 @unknown default: 128 self.receiveNext(engagementID: engagementID, task: task) 129 } 130 } 131 } 132 } 133 134 static func socketURL( 135 room: EngagementRoomCredentials, 136 authorID: String, 137 deviceID: String, 138 baseURL: URL? = endpointURL 139 ) throws -> URL? { 140 guard let baseURL else { return nil } 141 guard baseURL.scheme == "ws" || baseURL.scheme == "wss" else { 142 throw EngagementHostError.invalidEndpoint 143 } 144 let timestamp = String(Int(Date().timeIntervalSince1970)) 145 let nonce = UUID().uuidString 146 let signaturePayload = EngagementSocketAuthenticator.signaturePayload( 147 roomID: room.roomID, 148 authorID: authorID, 149 deviceID: deviceID, 150 timestamp: timestamp, 151 nonce: nonce 152 ) 153 let signature = try EngagementSocketAuthenticator.signature( 154 payload: signaturePayload, 155 secret: room.secret 156 ) 157 158 let socketURL = baseURL 159 .appendingPathComponent("rooms") 160 .appendingPathComponent(room.roomID.uuidString) 161 .appendingPathComponent("socket") 162 var components = URLComponents(url: socketURL, resolvingAgainstBaseURL: false) 163 // The room secret never rides in the URL: the worker verifies the 164 // signature against the secret registered via `registrationRequest`. 165 components?.queryItems = [ 166 URLQueryItem(name: "authorID", value: authorID), 167 URLQueryItem(name: "deviceID", value: deviceID), 168 URLQueryItem(name: "timestamp", value: timestamp), 169 URLQueryItem(name: "nonce", value: nonce), 170 URLQueryItem(name: "signature", value: signature) 171 ] 172 return components?.url 173 } 174 175 static func registrationRequest( 176 room: EngagementRoomCredentials, 177 baseURL: URL? = endpointURL 178 ) throws -> URLRequest? { 179 guard let baseURL else { return nil } 180 guard baseURL.scheme == "ws" || baseURL.scheme == "wss" else { 181 throw EngagementHostError.invalidEndpoint 182 } 183 let registerURL = baseURL 184 .appendingPathComponent("rooms") 185 .appendingPathComponent(room.roomID.uuidString) 186 .appendingPathComponent("register") 187 var components = URLComponents(url: registerURL, resolvingAgainstBaseURL: false) 188 components?.scheme = baseURL.scheme == "ws" ? "http" : "https" 189 guard let url = components?.url else { return nil } 190 var request = URLRequest(url: url) 191 request.httpMethod = "POST" 192 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 193 request.httpBody = try JSONEncoder().encode(["secret": room.secret]) 194 return request 195 } 196 197 private func registerRoom(_ room: EngagementRoomCredentials) async throws { 198 guard let request = try Self.registrationRequest(room: room) else { 199 throw EngagementHostError.missingEndpoint 200 } 201 let (_, response) = try await session.data(for: request) 202 let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 203 switch statusCode { 204 case 200..<300: 205 return 206 case 409: 207 // The worker holds a different secret for this room ID. Retrying 208 // with the same creds can never succeed; the lifecycle clears the 209 // advertised creds so a fresh room is minted. 210 throw EngagementHostError.roomSecretMismatch 211 default: 212 throw EngagementHostError.registrationFailed(statusCode: statusCode) 213 } 214 } 215 216 private static var endpointURL: URL? { 217 guard let raw = Bundle.main.object(forInfoDictionaryKey: "CrossmateEngagementSocketURL") as? String, 218 !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, 219 let url = URL(string: raw) 220 else { return nil } 221 return url 222 } 223 } 224 225 extension EngagementHost: EngagementTransporting, @unchecked Sendable {} 226 227 extension EngagementHost: URLSessionWebSocketDelegate { 228 nonisolated func urlSession( 229 _ session: URLSession, 230 webSocketTask: URLSessionWebSocketTask, 231 didOpenWithProtocol protocol: String? 232 ) { 233 Task { @MainActor in 234 guard let engagementID = engagementIDsByTask[ObjectIdentifier(webSocketTask)] else { return } 235 onEvent?(.channelOpen(engagementID: engagementID)) 236 } 237 } 238 239 nonisolated func urlSession( 240 _ session: URLSession, 241 webSocketTask: URLSessionWebSocketTask, 242 didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, 243 reason: Data? 244 ) { 245 Task { @MainActor in 246 guard let engagementID = engagementIDsByTask.removeValue(forKey: ObjectIdentifier(webSocketTask)) else { 247 return 248 } 249 stopPing(engagementID: engagementID) 250 sockets.removeValue(forKey: engagementID) 251 onEvent?(.channelClose(engagementID: engagementID)) 252 } 253 } 254 } 255 256 enum EngagementHostError: LocalizedError, Equatable { 257 case missingEndpoint 258 case missingSocket 259 case invalidEndpoint 260 case invalidSecret 261 case roomSecretMismatch 262 case registrationFailed(statusCode: Int) 263 264 var errorDescription: String? { 265 switch self { 266 case .missingEndpoint: 267 "CrossmateEngagementSocketURL is not configured." 268 case .missingSocket: 269 "The engagement socket is not connected." 270 case .invalidEndpoint: 271 "CrossmateEngagementSocketURL must use ws or wss." 272 case .invalidSecret: 273 "The engagement room secret is invalid." 274 case .roomSecretMismatch: 275 "The engagement room is registered with a different secret." 276 case .registrationFailed(let statusCode): 277 "Engagement room registration failed (HTTP \(statusCode))." 278 } 279 } 280 } 281 282 enum EngagementSocketAuthenticator { 283 static func signaturePayload( 284 roomID: UUID, 285 authorID: String, 286 deviceID: String, 287 timestamp: String, 288 nonce: String 289 ) -> String { 290 [ 291 roomID.uuidString, 292 authorID, 293 deviceID, 294 timestamp, 295 nonce 296 ].joined(separator: "|") 297 } 298 299 static func signature(payload: String, secret: String) throws -> String { 300 guard let secretData = Data(base64URLEncoded: secret) else { 301 throw EngagementHostError.invalidSecret 302 } 303 let key = SymmetricKey(data: secretData) 304 let mac = HMAC<SHA256>.authenticationCode(for: Data(payload.utf8), using: key) 305 return Data(mac).base64URLEncodedString() 306 } 307 } 308 309 extension Data { 310 init?(base64URLEncoded string: String) { 311 var base64 = string 312 .replacingOccurrences(of: "-", with: "+") 313 .replacingOccurrences(of: "_", with: "/") 314 let padding = (4 - base64.count % 4) % 4 315 base64.append(String(repeating: "=", count: padding)) 316 self.init(base64Encoded: base64) 317 } 318 319 func base64URLEncodedString() -> String { 320 base64EncodedString() 321 .replacingOccurrences(of: "+", with: "-") 322 .replacingOccurrences(of: "/", with: "_") 323 .replacingOccurrences(of: "=", with: "") 324 } 325 326 static func secureRandom(count: Int) throws -> Data { 327 var bytes = [UInt8](repeating: 0, count: count) 328 let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) 329 guard status == errSecSuccess else { 330 throw EngagementHostError.invalidSecret 331 } 332 return Data(bytes) 333 } 334 }