crossmate

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

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 }