EngagementHost.swift (13472B)
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 sockets[engagementID] = task 52 engagementIDsByTask[ObjectIdentifier(task)] = engagementID 53 task.resume() 54 receiveNext(engagementID: engagementID, task: task) 55 startPing(engagementID: engagementID) 56 onEvent?(.diagnostic(engagementID: engagementID, message: "socket connecting \(room.roomID.uuidString)")) 57 } 58 59 /// Sends an app-level "ping" on a timer so an idle foreground socket 60 /// survives NAT / Cloudflare edge idle timeouts. The room Worker answers 61 /// "pong" via `setWebSocketAutoResponse` without waking the Durable Object; 62 /// both strings are filtered out of the inbound stream in `receiveNext` 63 /// (they are not engagement messages). Stops on the first failed send or 64 /// when the socket is gone — the close path then tears the channel down. 65 private func startPing(engagementID: UUID) { 66 pingTasks[engagementID]?.cancel() 67 pingTasks[engagementID] = Task { @MainActor [weak self] in 68 while !Task.isCancelled { 69 try? await Task.sleep(for: Self.keepaliveInterval) 70 guard !Task.isCancelled, let self, let task = self.sockets[engagementID] else { return } 71 do { 72 try await task.send(.string("ping")) 73 } catch { 74 return 75 } 76 } 77 } 78 } 79 80 private func stopPing(engagementID: UUID) { 81 pingTasks.removeValue(forKey: engagementID)?.cancel() 82 } 83 84 func send(engagementID: UUID, message: Data) async throws { 85 guard let task = sockets[engagementID] else { 86 throw EngagementHostError.missingSocket 87 } 88 try await task.send(.data(message)) 89 } 90 91 func disconnect(engagementID: UUID) { 92 stopPing(engagementID: engagementID) 93 guard let task = sockets.removeValue(forKey: engagementID) else { return } 94 engagementIDsByTask.removeValue(forKey: ObjectIdentifier(task)) 95 task.cancel(with: .goingAway, reason: nil) 96 onEvent?(.channelClose(engagementID: engagementID)) 97 } 98 99 private func receiveNext(engagementID: UUID, task: URLSessionWebSocketTask) { 100 task.receive { [weak self, weak task] result in 101 Task { @MainActor in 102 guard let self, let task, self.sockets[engagementID] === task else { return } 103 switch result { 104 case .success(.data(let data)): 105 self.onEvent?(.channelMessage(engagementID: engagementID, message: data)) 106 self.receiveNext(engagementID: engagementID, task: task) 107 case .success(.string(let string)): 108 // Keepalive frames (see `startPing`) are not engagement 109 // messages; swallow them and keep listening. 110 guard string != "ping", string != "pong" else { 111 self.receiveNext(engagementID: engagementID, task: task) 112 return 113 } 114 let data = Data(string.utf8) 115 self.onEvent?(.channelMessage(engagementID: engagementID, message: data)) 116 self.receiveNext(engagementID: engagementID, task: task) 117 case .failure(let error): 118 self.stopPing(engagementID: engagementID) 119 self.sockets.removeValue(forKey: engagementID) 120 self.engagementIDsByTask.removeValue(forKey: ObjectIdentifier(task)) 121 self.onEvent?(.error(engagementID: engagementID, message: error.localizedDescription)) 122 self.onEvent?(.channelClose(engagementID: engagementID)) 123 @unknown default: 124 self.receiveNext(engagementID: engagementID, task: task) 125 } 126 } 127 } 128 } 129 130 static func socketURL( 131 room: EngagementRoomCredentials, 132 authorID: String, 133 deviceID: String, 134 baseURL: URL? = endpointURL 135 ) throws -> URL? { 136 guard let baseURL else { return nil } 137 guard baseURL.scheme == "ws" || baseURL.scheme == "wss" else { 138 throw EngagementHostError.invalidEndpoint 139 } 140 let timestamp = String(Int(Date().timeIntervalSince1970)) 141 let nonce = UUID().uuidString 142 let signaturePayload = EngagementSocketAuthenticator.signaturePayload( 143 roomID: room.roomID, 144 authorID: authorID, 145 deviceID: deviceID, 146 timestamp: timestamp, 147 nonce: nonce 148 ) 149 let signature = try EngagementSocketAuthenticator.signature( 150 payload: signaturePayload, 151 secret: room.secret 152 ) 153 154 let socketURL = baseURL 155 .appendingPathComponent("rooms") 156 .appendingPathComponent(room.roomID.uuidString) 157 .appendingPathComponent("socket") 158 var components = URLComponents(url: socketURL, resolvingAgainstBaseURL: false) 159 // The room secret never rides in the URL: the worker verifies the 160 // signature against the secret registered via `registrationRequest`. 161 components?.queryItems = [ 162 URLQueryItem(name: "authorID", value: authorID), 163 URLQueryItem(name: "deviceID", value: deviceID), 164 URLQueryItem(name: "timestamp", value: timestamp), 165 URLQueryItem(name: "nonce", value: nonce), 166 URLQueryItem(name: "signature", value: signature) 167 ] 168 return components?.url 169 } 170 171 static func registrationRequest( 172 room: EngagementRoomCredentials, 173 baseURL: URL? = endpointURL 174 ) throws -> URLRequest? { 175 guard let baseURL else { return nil } 176 guard baseURL.scheme == "ws" || baseURL.scheme == "wss" else { 177 throw EngagementHostError.invalidEndpoint 178 } 179 let registerURL = baseURL 180 .appendingPathComponent("rooms") 181 .appendingPathComponent(room.roomID.uuidString) 182 .appendingPathComponent("register") 183 var components = URLComponents(url: registerURL, resolvingAgainstBaseURL: false) 184 components?.scheme = baseURL.scheme == "ws" ? "http" : "https" 185 guard let url = components?.url else { return nil } 186 var request = URLRequest(url: url) 187 request.httpMethod = "POST" 188 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 189 request.httpBody = try JSONEncoder().encode(["secret": room.secret]) 190 return request 191 } 192 193 private func registerRoom(_ room: EngagementRoomCredentials) async throws { 194 guard let request = try Self.registrationRequest(room: room) else { 195 throw EngagementHostError.missingEndpoint 196 } 197 let (_, response) = try await session.data(for: request) 198 let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 199 switch statusCode { 200 case 200..<300: 201 return 202 case 409: 203 // The worker holds a different secret for this room ID. Retrying 204 // with the same creds can never succeed; the lifecycle clears the 205 // advertised creds so a fresh room is minted. 206 throw EngagementHostError.roomSecretMismatch 207 default: 208 throw EngagementHostError.registrationFailed(statusCode: statusCode) 209 } 210 } 211 212 private static var endpointURL: URL? { 213 guard let raw = Bundle.main.object(forInfoDictionaryKey: "CrossmateEngagementSocketURL") as? String, 214 !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, 215 let url = URL(string: raw) 216 else { return nil } 217 return url 218 } 219 } 220 221 extension EngagementHost: EngagementTransporting, @unchecked Sendable {} 222 223 extension EngagementHost: URLSessionWebSocketDelegate { 224 nonisolated func urlSession( 225 _ session: URLSession, 226 webSocketTask: URLSessionWebSocketTask, 227 didOpenWithProtocol protocol: String? 228 ) { 229 Task { @MainActor in 230 guard let engagementID = engagementIDsByTask[ObjectIdentifier(webSocketTask)] else { return } 231 onEvent?(.channelOpen(engagementID: engagementID)) 232 } 233 } 234 235 nonisolated func urlSession( 236 _ session: URLSession, 237 webSocketTask: URLSessionWebSocketTask, 238 didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, 239 reason: Data? 240 ) { 241 Task { @MainActor in 242 guard let engagementID = engagementIDsByTask.removeValue(forKey: ObjectIdentifier(webSocketTask)) else { 243 return 244 } 245 stopPing(engagementID: engagementID) 246 sockets.removeValue(forKey: engagementID) 247 onEvent?(.channelClose(engagementID: engagementID)) 248 } 249 } 250 } 251 252 enum EngagementHostError: LocalizedError, Equatable { 253 case missingEndpoint 254 case missingSocket 255 case invalidEndpoint 256 case invalidSecret 257 case roomSecretMismatch 258 case registrationFailed(statusCode: Int) 259 260 var errorDescription: String? { 261 switch self { 262 case .missingEndpoint: 263 "CrossmateEngagementSocketURL is not configured." 264 case .missingSocket: 265 "The engagement socket is not connected." 266 case .invalidEndpoint: 267 "CrossmateEngagementSocketURL must use ws or wss." 268 case .invalidSecret: 269 "The engagement room secret is invalid." 270 case .roomSecretMismatch: 271 "The engagement room is registered with a different secret." 272 case .registrationFailed(let statusCode): 273 "Engagement room registration failed (HTTP \(statusCode))." 274 } 275 } 276 } 277 278 enum EngagementSocketAuthenticator { 279 static func signaturePayload( 280 roomID: UUID, 281 authorID: String, 282 deviceID: String, 283 timestamp: String, 284 nonce: String 285 ) -> String { 286 [ 287 roomID.uuidString, 288 authorID, 289 deviceID, 290 timestamp, 291 nonce 292 ].joined(separator: "|") 293 } 294 295 static func signature(payload: String, secret: String) throws -> String { 296 guard let secretData = Data(base64URLEncoded: secret) else { 297 throw EngagementHostError.invalidSecret 298 } 299 let key = SymmetricKey(data: secretData) 300 let mac = HMAC<SHA256>.authenticationCode(for: Data(payload.utf8), using: key) 301 return Data(mac).base64URLEncodedString() 302 } 303 } 304 305 extension Data { 306 init?(base64URLEncoded string: String) { 307 var base64 = string 308 .replacingOccurrences(of: "-", with: "+") 309 .replacingOccurrences(of: "_", with: "/") 310 let padding = (4 - base64.count % 4) % 4 311 base64.append(String(repeating: "=", count: padding)) 312 self.init(base64Encoded: base64) 313 } 314 315 func base64URLEncodedString() -> String { 316 base64EncodedString() 317 .replacingOccurrences(of: "+", with: "-") 318 .replacingOccurrences(of: "/", with: "_") 319 .replacingOccurrences(of: "=", with: "") 320 } 321 322 static func secureRandom(count: Int) throws -> Data { 323 var bytes = [UInt8](repeating: 0, count: count) 324 let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) 325 guard status == errSecSuccess else { 326 throw EngagementHostError.invalidSecret 327 } 328 return Data(bytes) 329 } 330 }