PushClient.swift (27394B)
1 import CryptoKit 2 import Foundation 3 4 /// Uploads this device's APNs token to the Crossmate push worker and keeps 5 /// the registration in sync with the current iCloud authorID. The worker 6 /// itself is idempotent — re-posting an unchanged triple is a no-op — so the 7 /// only client-side state is a small dedup cache to avoid redundant network 8 /// chatter on every cold launch. 9 @MainActor 10 final class PushClient { 11 enum Environment: String { 12 case sandbox 13 case production 14 } 15 16 private let baseURL: URL 17 private let deviceID: String 18 private let environment: Environment 19 private let session: URLSession 20 private let log: (String) -> Void 21 private let authenticator: PushRequestAuthenticator 22 23 private var apnsToken: String? 24 private var authorID: String? 25 /// The addresses this device should be reachable at, each bound to its 26 /// game's shared push credential. One per shared game the account 27 /// participates in, plus the account-scoped sibling address (nil 28 /// credential). The worker keys game addresses under their `credID`. 29 private var bindings: Set<PushAddressBinding> = [] 30 /// Push kinds this device has muted in notification settings. Registered 31 /// with the worker as a denylist so muted pushes are dropped before APNs. 32 private var mutedKinds: Set<String> = [] 33 private var lastRegistered: Registration? 34 35 /// Resolves (minting if needed) the shared push credential for a game. 36 /// Set by AppServices to read from the GameStore. Publishes use it to sign 37 /// the request the worker verifies against the credential it holds. 38 var gameCredentialResolver: (@MainActor (UUID) -> GamePushCredentials?)? 39 40 /// Resolves (minting if needed) the per-game content key used to encrypt the 41 /// structured payload. Set by AppServices to read from the GameStore. When 42 /// nil (or unresolved), a publish ships its generic cleartext body with no 43 /// encrypted payload — degraded but never leaking personal text. 44 var contentKeyResolver: (@MainActor (UUID) -> SymmetricKey?)? 45 46 /// The cleartext alert body the worker forwards to APNs in place of the real 47 /// notification text. The personal wording is encrypted into the payload and 48 /// recomposed on the device by the notification service extension; this is 49 /// all the worker (and a recipient whose NSE can't decrypt) ever sees. 50 static let genericAlertBody = "New activity in one of your puzzles" 51 52 /// Game credentials already registered with the worker this session 53 /// (credID → secret), so `/games/:credID/register` is posted at most once 54 /// per credential value. 55 private var registeredGameCredentials: [UUID: String] = [:] 56 57 /// The `(token, bindings, mutedKinds)` triple last successfully reconciled 58 /// with the worker. All must match for a reconcile to be a no-op, so a 59 /// token rotation re-binds every address, a binding change (including a 60 /// credID rotation) registers the delta, and a notification-preference 61 /// change re-registers every address with the new denylist. 62 private struct Registration: Equatable { 63 let token: String 64 let bindings: Set<PushAddressBinding> 65 let mutedKinds: Set<String> 66 } 67 68 /// `nil` when the worker isn't configured (e.g. a fresh checkout without a 69 /// `Local.xcconfig`) or when the bundle's APNs environment is missing or 70 /// unrecognised. The rest of the app treats a nil PushClient as "push 71 /// notifications are disabled" rather than crashing. 72 /// Dedicated session for worker traffic. `waitsForConnectivity` lets a send 73 /// issued right at app-foreground — before the radio/path is back up — wait 74 /// for connectivity instead of failing immediately (a common source of the 75 /// `-1005`/`-1009` registration failures), bounded by the resource timeout. 76 /// It governs connection establishment, not a mid-transfer drop, so it pairs 77 /// with the transport retry in `sendAuthorized` rather than replacing it. 78 /// A dedicated session also keeps the worker's connection pool off `.shared`, 79 /// so unrelated traffic doesn't churn the pooled connections it reuses. 80 static func makeWorkerSession() -> URLSession { 81 let config = URLSessionConfiguration.default 82 config.waitsForConnectivity = true 83 config.timeoutIntervalForRequest = 30 84 config.timeoutIntervalForResource = 60 85 return URLSession(configuration: config) 86 } 87 88 init?( 89 deviceID: String = RecordSerializer.localDeviceID, 90 session: URLSession = PushClient.makeWorkerSession(), 91 log: @escaping (String) -> Void = { _ in } 92 ) { 93 guard 94 let rawBase = Bundle.main.object(forInfoDictionaryKey: "CrossmatePushBaseURL") as? String, 95 case let trimmedBase = rawBase.trimmingCharacters(in: .whitespacesAndNewlines), 96 !trimmedBase.isEmpty, 97 let base = URL(string: trimmedBase) 98 else { return nil } 99 self.baseURL = base 100 self.deviceID = deviceID 101 self.session = session 102 self.log = log 103 self.authenticator = PushRequestAuthenticator( 104 baseURL: base, 105 deviceID: deviceID, 106 session: session 107 ) 108 // CrossmateAPSEnvironment carries the same APS_ENVIRONMENT build 109 // setting that fills the aps-environment entitlement, so the 110 // environment registered with the worker always matches the 111 // environment the APNs token was issued for. 112 switch Bundle.main.object(forInfoDictionaryKey: "CrossmateAPSEnvironment") as? String { 113 case "development": 114 self.environment = .sandbox 115 case "production": 116 self.environment = .production 117 case let other: 118 log("Push disabled: unrecognised APNs environment \(other ?? "<missing>")") 119 return nil 120 } 121 } 122 123 func updateAPNsToken(_ data: Data) { 124 let hex = data.map { String(format: "%02x", $0) }.joined() 125 if hex == apnsToken { return } 126 apnsToken = hex 127 Task { await reconcile() } 128 } 129 130 /// Records the current account identity. Used only as the `fromAuthorID` 131 /// display field on outgoing pushes — the worker no longer keys anything 132 /// on identity, so an account switch is reflected purely through the 133 /// address set changing (see `setAddresses`). 134 func updateAuthorID(_ newAuthorID: String?) { 135 let normalized = newAuthorID?.trimmingCharacters(in: .whitespaces) 136 authorID = (normalized?.isEmpty == false) ? normalized : nil 137 } 138 139 /// Sets the full set of address→credential bindings this device should be 140 /// registered under. The caller (AccountPushCoordinator) recomputes this 141 /// from Core Data on launch, when a shared game appears, and on account 142 /// switch. Bindings that drop out are unregistered so a left/old-account 143 /// game stops delivering here. 144 func setAddresses(_ next: Set<PushAddressBinding>) { 145 if next == bindings { return } 146 bindings = next 147 Task { await reconcile() } 148 } 149 150 /// Sets the push kinds this device's notification settings have muted. 151 /// The caller (AccountPushCoordinator) mirrors this from preferences on 152 /// launch and on every settings change. 153 func setMutedKinds(_ next: Set<String>) { 154 if next == mutedKinds { return } 155 mutedKinds = next 156 Task { await reconcile() } 157 } 158 159 private func reconcile() async { 160 guard let apnsToken else { return } 161 let desired = bindings 162 let signature = Registration( 163 token: apnsToken, 164 bindings: desired, 165 mutedKinds: mutedKinds 166 ) 167 if lastRegistered == signature { return } 168 let toRemove = (lastRegistered?.bindings ?? []).subtracting(desired) 169 do { 170 // Register each game's shared credential first, so the worker 171 // accepts the addresses bound to it. 172 for creds in Set(desired.compactMap(\.credentials)) { 173 await registerGameCredential(creds) 174 } 175 if !desired.isEmpty { 176 try await register( 177 token: apnsToken, 178 bindings: Array(desired), 179 mutedKinds: signature.mutedKinds.sorted() 180 ) 181 } 182 if !toRemove.isEmpty { 183 await unregister(bindings: Array(toRemove)) 184 } 185 lastRegistered = signature 186 } catch { 187 // Worker is idempotent; the next token delivery or address change 188 // will retry. Bubble the failure into diagnostics rather than 189 // surfacing a user-facing error. 190 log("Push register failed: \(error.localizedDescription)") 191 } 192 } 193 194 /// One push addressee, identified by the recipient's per-(account, game) 195 /// `pushAddress` capability rather than an identity. `body`, if set, 196 /// overrides the top-level broadcast `body` for this recipient only — the 197 /// receiver-side notification text can then be personalised (e.g. the 198 /// pause-summary counts that depend on when that specific peer last read 199 /// the puzzle). 200 struct Addressee: Sendable, Equatable { 201 let address: String 202 let body: String? 203 /// Structured semantics for this recipient, encoded into the wire 204 /// `payload` field. The worker forwards it opaquely; the notification 205 /// service extension decodes it (e.g. to decide the badge). 206 let payload: PushPayload? 207 208 init(address: String, body: String? = nil, payload: PushPayload? = nil) { 209 self.address = address 210 self.body = body 211 self.payload = payload 212 } 213 } 214 215 /// Fire-and-forget publish. The worker maps each addressee's `address` to 216 /// that account's registered device tokens and fans the push out to all 217 /// of them. Failures are logged but never surfaced — pushes are advisory 218 /// and the next event will retry the underlying state on its own. 219 /// When `broadcast` is true the worker fans the push out to every device 220 /// registered under the game's credential — the whole room — instead of an 221 /// explicit `addressees` list, so the sender needn't know who the 222 /// participants are (their Player records may not have synced yet). The 223 /// uniform `broadcastPayload` and `body` are delivered to all of them, and 224 /// `excludeAddress` (the sender's own derived game address) keeps the 225 /// sender's other devices from being notified. Broadcast is only meaningful 226 /// for a game-credentialed push. 227 /// Stable `apns-collapse-id` for a game's notification tile. All alert 228 /// pushes for one game share it, so APNs keeps a single replacing tile in 229 /// Notification Center (the receiver's NSE then folds successive `pause` 230 /// summaries into it). 41 chars — well under APNs' 64-byte limit. 231 static func gameCollapseID(_ gameID: UUID) -> String { 232 "game-\(gameID.uuidString)" 233 } 234 235 func publish( 236 kind: String, 237 gameID: UUID, 238 addressees: [Addressee], 239 title: String, 240 puzzleTitle: String? = nil, 241 background: Bool = false, 242 gameCredentialed: Bool = true, 243 broadcast: Bool = false, 244 excludeAddress: String? = nil, 245 broadcastPayload: PushPayload? = nil, 246 collapseID: String? = nil, 247 extra: [String: Any] = [:], 248 body: String 249 ) async { 250 guard broadcast || !addressees.isEmpty else { return } 251 // A game push must prove participation: resolve (minting if needed) the 252 // game's shared credential, register it with the worker, and sign the 253 // request below. Account-scoped publishes (sibling-device hints) carry 254 // no game and stay on the App-Attest-only path. 255 var credential: GamePushCredentials? 256 if gameCredentialed { 257 guard let creds = gameCredentialResolver?(gameID) else { 258 log("push(\(kind)): skipped (no game credential)") 259 return 260 } 261 await registerGameCredential(creds) 262 credential = creds 263 } 264 log(broadcast 265 ? "push(\(kind)): broadcasting to room" 266 : "push(\(kind)): publishing to \(addressees.count) addressee(s)") 267 // Stamp the puzzle title onto each addressee's structured payload so the 268 // receiver's extension can recompose the body from components (swapping 269 // in its private nickname) rather than editing the sender's text. 270 // Injected here so the call sites pass it once, not per addressee. 271 // Resolve (and mint) the game's content key only when there is a payload 272 // to seal, so account-scoped pushes that carry none don't mint one. 273 let needsContentKey = broadcastPayload != nil || addressees.contains { $0.payload != nil } 274 let contentKey = needsContentKey ? contentKeyResolver?(gameID) : nil 275 // Encrypt each addressee's structured payload under the content key and 276 // ship it as `enc`. The per-recipient cleartext `body` (personalised 277 // pause counts) is deliberately *not* sent: those counts live in the 278 // sealed payload's event, and the receiver's NSE recomposes the body — 279 // so the worker never sees the wording. An addressee whose payload can't 280 // be sealed (no key resolved) still gets the push, with the generic body. 281 let addresseePayloads: [[String: Any]] = addressees.map { addressee in 282 var entry: [String: Any] = ["address": addressee.address] 283 if var payload = addressee.payload { 284 if let puzzleTitle { payload.puzzleTitle = puzzleTitle } 285 if let contentKey, let sealed = PushPayloadCipher.seal(payload, key: contentKey) { 286 entry["enc"] = sealed 287 } 288 } 289 return entry 290 } 291 var payload: [String: Any] = [ 292 "kind": kind, 293 "gameID": gameID.uuidString, 294 "fromAuthorID": authorID ?? "", 295 "senderDeviceID": deviceID, 296 "title": title, 297 // Generic wording only; the real body is sealed into the payload and 298 // recomposed on-device. A bodyless (background) push stays bodyless. 299 "alertBody": body.isEmpty ? "" : Self.genericAlertBody, 300 "addressees": addresseePayloads, 301 "background": background 302 ] 303 // The credID names which registered credential the worker verifies the 304 // game signature against and scopes delivery to. Covered by the body 305 // hash (App Attest), so it can't be swapped without breaking auth. 306 if let credential { 307 payload["credID"] = credential.credID.uuidString 308 } 309 // Forwarded verbatim into the APNs `apns-collapse-id` header by the 310 // worker (alert pushes only). Opaque to the worker — the coalescing 311 // policy it encodes lives here, not in the worker. 312 if let collapseID { 313 payload["collapseID"] = collapseID 314 } 315 // Broadcast: the worker resolves targets from the credential's whole 316 // address set, so the empty `addressees` above is ignored. Carry the 317 // uniform payload at top level (the per-addressee slot is unused) and 318 // name the sender's own address so its other devices are skipped. 319 if broadcast { 320 payload["broadcast"] = true 321 if let excludeAddress { payload["excludeAddress"] = excludeAddress } 322 if var broadcastPayload { 323 if let puzzleTitle { broadcastPayload.puzzleTitle = puzzleTitle } 324 if let contentKey, let sealed = PushPayloadCipher.seal(broadcastPayload, key: contentKey) { 325 payload["enc"] = sealed 326 } 327 } 328 } 329 for (key, value) in extra { 330 payload[key] = value 331 } 332 var request = URLRequest(url: baseURL.appendingPathComponent("publish")) 333 request.httpMethod = "POST" 334 do { 335 let body = try JSONSerialization.data(withJSONObject: payload) 336 request.httpBody = body 337 let (data, response) = try await sendAuthorized( 338 request, 339 method: "POST", 340 path: "/publish", 341 body: body, 342 gameCredential: credential 343 ) 344 guard response.statusCode == 200 else { 345 throw URLError(.badServerResponse) 346 } 347 log("push(\(kind)): worker accepted\(Self.deliverySummary(from: data))") 348 } catch { 349 log("push(\(kind)) failed: \(error.localizedDescription)") 350 } 351 } 352 353 func publishAccountEvent( 354 kind: String, 355 gameID: UUID, 356 address: String, 357 readAt: Date? = nil 358 ) async { 359 var extra: [String: Any] = [:] 360 if let readAt { 361 extra["readAt"] = Self.iso8601.string(from: readAt) 362 } 363 await publish( 364 kind: kind, 365 gameID: gameID, 366 addressees: [Addressee(address: address)], 367 title: "", 368 background: true, 369 gameCredentialed: false, 370 extra: extra, 371 body: "" 372 ) 373 } 374 375 /// Formats the worker's publish response counts for the diagnostics log, 376 /// e.g. " (delivered=2 muted=1 removed=0 failed=0)". Production delivery 377 /// is only observable through the on-device log, so this is where "why 378 /// didn't that push arrive?" gets answered — `muted` in particular means 379 /// a recipient device turned that notification kind off in settings. 380 /// Returns "" for an unparseable body (e.g. a worker predating a count). 381 nonisolated static func deliverySummary(from data: Data) -> String { 382 guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { 383 return "" 384 } 385 let counts = ["delivered", "muted", "removed", "failed"].compactMap { key in 386 (json[key] as? Int).map { "\(key)=\($0)" } 387 } 388 return counts.isEmpty ? "" : " (\(counts.joined(separator: " ")))" 389 } 390 391 private static let iso8601: ISO8601DateFormatter = { 392 let formatter = ISO8601DateFormatter() 393 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 394 formatter.timeZone = TimeZone(secondsFromGMT: 0) 395 return formatter 396 }() 397 398 /// Encodes the bindings as the worker's `[{address, credID?}]` wire shape. 399 /// A nil credential (the account-scoped address) omits `credID`, so the 400 /// worker stores it on the legacy address-only key. 401 private func addressPayload(for bindings: [PushAddressBinding]) -> [[String: Any]] { 402 bindings.map { binding in 403 var entry: [String: Any] = ["address": binding.address] 404 if let credID = binding.credentials?.credID { 405 entry["credID"] = credID.uuidString 406 } 407 return entry 408 } 409 } 410 411 private func register( 412 token: String, 413 bindings: [PushAddressBinding], 414 mutedKinds: [String] 415 ) async throws { 416 var request = URLRequest(url: baseURL.appendingPathComponent("register")) 417 request.httpMethod = "POST" 418 let body: [String: Any] = [ 419 "deviceID": deviceID, 420 "token": token, 421 "environment": environment.rawValue, 422 "addresses": addressPayload(for: bindings), 423 "mutedKinds": mutedKinds 424 ] 425 let data = try JSONSerialization.data(withJSONObject: body) 426 request.httpBody = data 427 let (_, response) = try await sendAuthorized( 428 request, 429 method: "POST", 430 path: "/register", 431 body: data 432 ) 433 try assert204(response) 434 } 435 436 private func unregister(bindings: [PushAddressBinding]) async { 437 var request = URLRequest(url: baseURL.appendingPathComponent("register")) 438 request.httpMethod = "DELETE" 439 let data = (try? JSONSerialization.data(withJSONObject: [ 440 "deviceID": deviceID, 441 "addresses": addressPayload(for: bindings) 442 ])) ?? Data() 443 request.httpBody = data 444 do { 445 let (_, response) = try await sendAuthorized( 446 request, 447 method: "DELETE", 448 path: "/register", 449 body: data 450 ) 451 try assert204(response) 452 } catch { 453 log("Push unregister failed: \(error.localizedDescription)") 454 } 455 } 456 457 /// Registers a game's shared push credential with the worker (first-write- 458 /// wins) so it will verify publishes signed with that secret. Idempotent 459 /// and deduped per session; mirrors `EngagementHost.registerRoom`. A 409 460 /// means a different secret is already registered under this credID — only 461 /// possible on an (astronomically unlikely) credID collision, so it is 462 /// logged rather than retried. 463 private func registerGameCredential(_ credentials: GamePushCredentials) async { 464 if registeredGameCredentials[credentials.credID] == credentials.secret { return } 465 let path = "/games/\(credentials.credID.uuidString)/register" 466 var request = URLRequest( 467 url: baseURL 468 .appendingPathComponent("games") 469 .appendingPathComponent(credentials.credID.uuidString) 470 .appendingPathComponent("register") 471 ) 472 request.httpMethod = "POST" 473 let data = (try? JSONSerialization.data(withJSONObject: ["secret": credentials.secret])) ?? Data() 474 request.httpBody = data 475 do { 476 let (_, response) = try await sendAuthorized( 477 request, 478 method: "POST", 479 path: path, 480 body: data 481 ) 482 switch response.statusCode { 483 case 200..<300: 484 registeredGameCredentials[credentials.credID] = credentials.secret 485 case 409: 486 log("Push game-credential rejected (secret mismatch) for \(credentials.credID)") 487 default: 488 log("Push game-credential register failed: HTTP \(response.statusCode)") 489 } 490 } catch { 491 log("Push game-credential register failed: \(error.localizedDescription)") 492 } 493 } 494 495 private func sendAuthorized( 496 _ request: URLRequest, 497 method: String, 498 path: String, 499 body: Data, 500 gameCredential: GamePushCredentials? = nil 501 ) async throws -> (Data, HTTPURLResponse) { 502 // Transport-level retry. `-1005 networkConnectionLost` / `-1001 timedOut` 503 // against the Cloudflare worker are usually a keep-alive connection-reuse 504 // race — the edge closed a pooled connection the device then reused — 505 // rather than a real outage (CloudKit keeps syncing through them). They're 506 // retryable: a fresh attempt opens a new connection. Each attempt re-signs 507 // via the full overload below — `signedHeaders` mints a fresh nonce and a 508 // new App Attest assertion whose counter is monotonic — so a retry can't 509 // replay a stale assertion and regress the worker's stored counter. Every 510 // worker write reached through here is idempotent, so re-sending is safe. 511 // Bounded: a genuinely offline device exhausts the attempts and the next 512 // reconcile trigger re-registers. 513 let maxAttempts = 3 514 var attempt = 0 515 while true { 516 do { 517 return try await sendAuthorized( 518 request, 519 method: method, 520 path: path, 521 body: body, 522 gameCredential: gameCredential, 523 retryAfterRegistrationReset: true 524 ) 525 } catch let error as URLError 526 where Self.isRetryableTransport(error.code) && attempt + 1 < maxAttempts { 527 attempt += 1 528 // Short backoff (200ms, 400ms) so an instantaneous reuse race 529 // isn't hammered and a momentarily-busy edge gets a beat. 530 try? await Task.sleep(for: .milliseconds(200 * attempt)) 531 } 532 } 533 } 534 535 /// Transport failures worth retrying for an idempotent worker write. 536 /// Deliberately excludes `.notConnectedToInternet`/`.cancelled` — a truly 537 /// offline or cancelled send should fail fast and let the next reconcile 538 /// re-register rather than spin through the backoff. 539 private static func isRetryableTransport(_ code: URLError.Code) -> Bool { 540 switch code { 541 case .networkConnectionLost, .timedOut, .cannotConnectToHost, 542 .cannotFindHost, .dnsLookupFailed, .secureConnectionFailed: 543 return true 544 default: 545 return false 546 } 547 } 548 549 private func sendAuthorized( 550 _ request: URLRequest, 551 method: String, 552 path: String, 553 body: Data, 554 gameCredential: GamePushCredentials?, 555 retryAfterRegistrationReset: Bool 556 ) async throws -> (Data, HTTPURLResponse) { 557 var request = request 558 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 559 let headers = try await authenticator.signedHeaders( 560 method: method, 561 path: path, 562 body: body 563 ) 564 for (key, value) in headers { 565 request.setValue(value, forHTTPHeaderField: key) 566 } 567 // Prove game participation by signing the App Attest request's own 568 // body hash / timestamp / nonce with the game secret. The worker 569 // re-derives this from the secret it holds for the body's credID. 570 if let gameCredential, 571 let bodyHash = headers["X-Crossmate-Body-SHA256"], 572 let timestamp = headers["X-Crossmate-Timestamp"], 573 let nonce = headers["X-Crossmate-Nonce"] { 574 let payload = GamePushSigner.signaturePayload( 575 credID: gameCredential.credID, 576 bodyHash: bodyHash, 577 timestamp: timestamp, 578 nonce: nonce 579 ) 580 if let signature = try? GamePushSigner.signature( 581 payload: payload, 582 secret: gameCredential.secret 583 ) { 584 request.setValue(signature, forHTTPHeaderField: "X-Crossmate-Game-Signature") 585 } 586 } 587 let (data, response) = try await session.data(for: request) 588 guard let http = response as? HTTPURLResponse else { 589 throw URLError(.badServerResponse) 590 } 591 if http.statusCode == 401, retryAfterRegistrationReset { 592 await authenticator.resetRegistration() 593 return try await sendAuthorized( 594 request, 595 method: method, 596 path: path, 597 body: body, 598 gameCredential: gameCredential, 599 retryAfterRegistrationReset: false 600 ) 601 } 602 return (data, http) 603 } 604 605 private func assert204(_ response: HTTPURLResponse) throws { 606 guard response.statusCode == 204 else { 607 throw URLError(.badServerResponse) 608 } 609 } 610 }