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