crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }