push-worker.js (45003B)
1 export class PushRegistry { 2 constructor(state, env) { 3 this.state = state; 4 this.env = env; 5 this.cachedJWT = null; 6 this.cachedJWTExpiresAt = 0; 7 } 8 9 async fetch(request) { 10 const url = new URL(request.url); 11 12 if (url.pathname === "/attest/challenge" && request.method === "POST") { 13 return this.handleAttestationChallenge(request); 14 } 15 if (url.pathname === "/attest/register" && request.method === "POST") { 16 return this.handleAttestationRegister(request); 17 } 18 19 const bodyText = await request.text(); 20 const auth = await this.authenticate(request, bodyText); 21 if (!auth.ok) { 22 console.warn("Push worker auth failed", { 23 method: request.method, 24 path: url.pathname, 25 status: auth.status, 26 message: auth.message, 27 authVersion: request.headers.get("X-Crossmate-Auth-Version") || "", 28 deviceIDLength: (request.headers.get("X-Crossmate-Device-ID") || "").length, 29 keyIDLength: (request.headers.get("X-Crossmate-Key-ID") || "").length, 30 bodyLength: bodyText.length 31 }); 32 return new Response(auth.message, { status: auth.status }); 33 } 34 35 if (url.pathname === "/register" && request.method === "POST") { 36 return this.handleRegister(bodyText, auth); 37 } 38 if (url.pathname === "/register" && request.method === "DELETE") { 39 return this.handleUnregister(bodyText, auth); 40 } 41 if (url.pathname === "/publish" && request.method === "POST") { 42 return this.handlePublish(request, bodyText, auth); 43 } 44 const gameRegister = url.pathname.match(/^\/games\/([^/]+)\/register$/); 45 if (gameRegister && request.method === "POST") { 46 return this.handleGameRegister(gameRegister[1], bodyText); 47 } 48 return new Response("Not found", { status: 404 }); 49 } 50 51 async authenticate(request, bodyText) { 52 try { 53 return await this.authenticateAppAttest(request, bodyText); 54 } catch (error) { 55 return { ok: false, status: 401, message: `Bad App Attest auth: ${error.message}` }; 56 } 57 } 58 59 async authenticateAppAttest(request, bodyText) { 60 if ((request.headers.get("X-Crossmate-Auth-Version") || "") !== "appattest-v1") { 61 return { ok: false, status: 401, message: "Missing App Attest auth" }; 62 } 63 const deviceID = request.headers.get("X-Crossmate-Device-ID") || ""; 64 const keyID = request.headers.get("X-Crossmate-Key-ID") || ""; 65 const timestamp = request.headers.get("X-Crossmate-Timestamp") || ""; 66 const nonce = request.headers.get("X-Crossmate-Nonce") || ""; 67 const bodyHash = request.headers.get("X-Crossmate-Body-SHA256") || ""; 68 const assertionBase64 = request.headers.get("X-Crossmate-Assertion") || ""; 69 if (!deviceID || !keyID || !timestamp || !nonce || !bodyHash || !assertionBase64) { 70 return { ok: false, status: 401, message: "Incomplete App Attest auth" }; 71 } 72 73 const nowSeconds = Math.floor(Date.now() / 1000); 74 const timestampSeconds = Number(timestamp); 75 const maxSkewSeconds = Number(this.env.MAX_AUTH_SKEW_SECONDS || "120"); 76 if (!Number.isFinite(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > maxSkewSeconds) { 77 return { ok: false, status: 401, message: "Stale auth timestamp" }; 78 } 79 80 const computedBodyHash = base64URLEncode(await sha256Bytes(new TextEncoder().encode(bodyText))); 81 if (!timingSafeEqual(bodyHash, computedBodyHash)) { 82 return { ok: false, status: 401, message: "Bad body hash" }; 83 } 84 85 const registrationKey = this.appAttestRegistrationKey(deviceID, keyID); 86 const registration = await this.state.storage.get(registrationKey); 87 if (!registration) { 88 return { ok: false, status: 401, message: "Unknown App Attest key" }; 89 } 90 91 const nonceTTLSeconds = Number(this.env.REQUEST_NONCE_TTL_SECONDS || "300"); 92 const nonceKey = `request-nonce:${deviceID}:${nonce}`; 93 const nonceUsedAt = await this.state.storage.get(nonceKey); 94 if (nonceUsedAt && Date.now() - nonceUsedAt <= nonceTTLSeconds * 1000) { 95 return { ok: false, status: 401, message: "Nonce already used" }; 96 } 97 98 const path = new URL(request.url).pathname; 99 const canonical = canonicalPushRequest({ 100 method: request.method, 101 path, 102 bodyHash, 103 timestamp, 104 nonce, 105 deviceID, 106 keyID 107 }); 108 const clientDataHash = await sha256Bytes(new TextEncoder().encode(canonical)); 109 const assertion = decodeAssertion(base64URLDecode(assertionBase64)); 110 const authData = parseAuthenticatorData(assertion.authenticatorData); 111 const expectedAppIDHash = await this.expectedAppIDHash(); 112 if (!bytesEqual(authData.rpIDHash, expectedAppIDHash)) { 113 return { ok: false, status: 401, message: "Bad App Attest app id" }; 114 } 115 const signedBytes = concatBytes(assertion.authenticatorData, clientDataHash); 116 // The Secure Enclave signs nonce = SHA256(authenticatorData || clientDataHash) 117 // as an ECDSA-SHA256 *message*, so the digest under the signature is 118 // SHA256(nonce). WebCrypto applies that second hash. 119 const assertionNonce = await sha256Bytes(signedBytes); 120 const publicKey = await this.importAppAttestPublicKey(registration); 121 const verified = await crypto.subtle.verify( 122 { name: "ECDSA", hash: "SHA-256" }, 123 publicKey, 124 derECDSASignatureToRaw(assertion.signature), 125 assertionNonce 126 ); 127 if (!verified) { 128 return { ok: false, status: 401, message: "Bad App Attest assertion" }; 129 } 130 131 // Concurrent requests from one device can arrive out of counter order, and 132 // replay is already blocked by the nonce and timestamp checks, so the 133 // counter is only tracked (for clone diagnostics), never enforced. 134 registration.signCount = Math.max(registration.signCount || 0, authData.signCount); 135 registration.updatedAt = Date.now(); 136 await this.state.storage.put(registrationKey, registration); 137 // Durable Object storage has no expirationTtl, so prune stale nonces here. 138 await this.pruneExpired(`request-nonce:${deviceID}:`, nonceTTLSeconds); 139 await this.state.storage.put(nonceKey, Date.now()); 140 return { ok: true, deviceID }; 141 } 142 143 async handleAttestationChallenge(request) { 144 const body = await readJSONText(await request.text()); 145 if (!body) return badRequest("Body must be JSON"); 146 const deviceID = body.deviceID || ""; 147 const keyID = body.keyID || ""; 148 if (!deviceID || !keyID) { 149 return badRequest("deviceID and keyID required"); 150 } 151 const ttlSeconds = this.appAttestChallengeTTLSeconds(); 152 const challenge = base64URLEncode(crypto.getRandomValues(new Uint8Array(32))); 153 await this.pruneExpired(`appattest-challenge:${deviceID}:`, ttlSeconds); 154 await this.state.storage.put(this.appAttestChallengeKey(deviceID, challenge), Date.now()); 155 console.log("App Attest challenge issued", { 156 deviceIDLength: deviceID.length, 157 keyIDLength: keyID.length, 158 challengeLength: challenge.length, 159 ttlSeconds 160 }); 161 return Response.json({ challenge }); 162 } 163 164 async handleAttestationRegister(request) { 165 const body = await readJSONText(await request.text()); 166 if (!body) return badRequest("Body must be JSON"); 167 const { deviceID, keyID, challenge, attestationObject } = body; 168 if (!deviceID || !keyID || !challenge || !attestationObject) { 169 return badRequest("deviceID, keyID, challenge, attestationObject required"); 170 } 171 const challengeKey = this.appAttestChallengeKey(deviceID, challenge); 172 const challengeIssuedAt = await this.state.storage.get(challengeKey); 173 const challengeExpired = challengeIssuedAt 174 ? Date.now() - challengeIssuedAt > this.appAttestChallengeTTLSeconds() * 1000 175 : false; 176 if (!challengeIssuedAt || challengeExpired) { 177 if (challengeExpired) await this.state.storage.delete(challengeKey); 178 console.warn("App Attest registration failed", { 179 error: challengeExpired ? "expired challenge" : "unknown challenge", 180 deviceIDLength: deviceID.length, 181 keyIDLength: keyID.length, 182 challengeLength: challenge.length, 183 attestationObjectLength: attestationObject.length 184 }); 185 return new Response("Unknown App Attest challenge", { status: 401 }); 186 } 187 188 try { 189 const registration = await this.verifyAttestation({ 190 deviceID, 191 keyID, 192 challenge, 193 attestationObject: base64URLDecode(attestationObject) 194 }); 195 await this.state.storage.put(this.appAttestRegistrationKey(deviceID, keyID), registration); 196 await this.state.storage.delete(challengeKey); 197 console.log("App Attest registration accepted", { 198 expectedEnvironment: this.env.APP_ATTEST_ENVIRONMENT || "production", 199 appBundleID: this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || "", 200 rootCertConfigured: Boolean(this.env.APP_ATTEST_ROOT_CERT_PEM), 201 rootCertLength: (this.env.APP_ATTEST_ROOT_CERT_PEM || "").length, 202 deviceIDLength: deviceID.length, 203 keyIDLength: keyID.length, 204 signCount: registration.signCount 205 }); 206 return new Response(null, { status: 204 }); 207 } catch (error) { 208 console.error("App Attest registration failed", { 209 error: error.message, 210 expectedEnvironment: this.env.APP_ATTEST_ENVIRONMENT || "production", 211 appTeamIDConfigured: Boolean(this.env.APP_TEAM_ID), 212 appBundleID: this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || "", 213 rootCertConfigured: Boolean(this.env.APP_ATTEST_ROOT_CERT_PEM), 214 rootCertLength: (this.env.APP_ATTEST_ROOT_CERT_PEM || "").length, 215 deviceIDLength: deviceID.length, 216 keyIDLength: keyID.length, 217 challengeLength: challenge.length, 218 attestationObjectLength: attestationObject.length 219 }); 220 return new Response(`Bad App Attest attestation: ${error.message}`, { status: 401 }); 221 } 222 } 223 224 async verifyAttestation({ deviceID, keyID, challenge, attestationObject }) { 225 const attestation = decodeAttestationObject(attestationObject); 226 const authData = parseAuthenticatorData(attestation.authData, { 227 requireAttestedCredential: true 228 }); 229 const expectedAppIDHash = await this.expectedAppIDHash(); 230 if (!bytesEqual(authData.rpIDHash, expectedAppIDHash)) { 231 throw new Error("app id hash mismatch"); 232 } 233 if (!authData.credentialID || !bytesEqual(authData.credentialID, base64URLDecodeFlexible(keyID))) { 234 throw new Error("credential id mismatch"); 235 } 236 const appAttestEnvironment = this.env.APP_ATTEST_ENVIRONMENT || "production"; 237 if (!isExpectedAppAttestAAGUID(authData.aaguid, appAttestEnvironment)) { 238 throw new Error(`unexpected aaguid for ${appAttestEnvironment}: ${bytesToHex(authData.aaguid)}`); 239 } 240 if (!attestation.attStmt || !Array.isArray(attestation.attStmt.x5c) || attestation.attStmt.x5c.length < 2) { 241 throw new Error("missing certificate chain"); 242 } 243 244 const leaf = parseCertificate(attestation.attStmt.x5c[0]); 245 const intermediate = parseCertificate(attestation.attStmt.x5c[1]); 246 await verifyCertificateSignature(leaf, intermediate.subjectPublicKeyInfo); 247 const rootPEM = this.env.APP_ATTEST_ROOT_CERT_PEM || ""; 248 if (!rootPEM) { 249 throw new Error("worker missing APP_ATTEST_ROOT_CERT_PEM"); 250 } 251 const root = parseCertificate(pemToDer(rootPEM)); 252 await verifyCertificateSignature(intermediate, root.subjectPublicKeyInfo); 253 254 const clientDataHash = await sha256Bytes(new TextEncoder().encode([ 255 "crossmate-appattest-v1", 256 challenge, 257 deviceID, 258 keyID 259 ].join("\n"))); 260 const expectedNonce = await sha256Bytes(concatBytes(attestation.authData, clientDataHash)); 261 const certNonce = certificateAppAttestNonce(leaf); 262 if (!bytesEqual(certNonce, expectedNonce)) { 263 throw new Error("certificate nonce mismatch"); 264 } 265 266 return { 267 publicKeyJWK: coseEC2PublicKeyToJWK(authData.cosePublicKey), 268 publicKeySPKI: base64URLEncode(leaf.subjectPublicKeyInfo), 269 signCount: authData.signCount, 270 createdAt: Date.now() 271 }; 272 } 273 274 async importAppAttestPublicKey(registration) { 275 if (registration.publicKeySPKI) { 276 return crypto.subtle.importKey( 277 "spki", 278 base64URLDecode(registration.publicKeySPKI), 279 { name: "ECDSA", namedCurve: "P-256" }, 280 false, 281 ["verify"] 282 ); 283 } 284 return crypto.subtle.importKey( 285 "jwk", 286 registration.publicKeyJWK, 287 { name: "ECDSA", namedCurve: "P-256" }, 288 false, 289 ["verify"] 290 ); 291 } 292 293 async expectedAppIDHash() { 294 const teamID = this.env.APP_TEAM_ID || ""; 295 const bundleID = this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || ""; 296 if (!teamID || !bundleID) { 297 throw new Error("APP_TEAM_ID and APP_BUNDLE_ID/APNS_TOPIC are required"); 298 } 299 return sha256Bytes(new TextEncoder().encode(`${teamID}.${bundleID}`)); 300 } 301 302 appAttestChallengeKey(deviceID, challenge) { 303 return `appattest-challenge:${deviceID}:${challenge}`; 304 } 305 306 appAttestChallengeTTLSeconds() { 307 return Number(this.env.APP_ATTEST_CHALLENGE_TTL_SECONDS || "300"); 308 } 309 310 // Deletes per-device keys whose stored timestamp is older than ttlSeconds. 311 // Durable Object storage ignores KV's expirationTtl option, so expiry has to 312 // be enforced manually. 313 async pruneExpired(prefix, ttlSeconds) { 314 const entries = await this.state.storage.list({ prefix }); 315 const cutoff = Date.now() - ttlSeconds * 1000; 316 const expired = []; 317 for (const [key, storedAt] of entries) { 318 if (typeof storedAt !== "number" || storedAt < cutoff) expired.push(key); 319 } 320 if (expired.length > 0) { 321 await this.state.storage.delete(expired); 322 } 323 } 324 325 appAttestRegistrationKey(deviceID, keyID) { 326 return `appattest-key:${deviceID}:${keyID}`; 327 } 328 329 async handleRegister(bodyText, auth) { 330 const body = await readJSONText(bodyText); 331 if (!body) return badRequest("Body must be JSON"); 332 const { deviceID, token, environment, addresses, mutedKinds } = body; 333 if (!deviceID || !token || !Array.isArray(addresses)) { 334 return badRequest("deviceID, token, addresses required"); 335 } 336 if (auth.deviceID !== deviceID) { 337 return new Response("Authenticated device mismatch", { status: 403 }); 338 } 339 if (environment !== "sandbox" && environment !== "production") { 340 return badRequest("environment must be 'sandbox' or 'production'"); 341 } 342 // Notification preferences ride along as a denylist of `kind` strings the 343 // device does not want delivered. The worker only string-matches them at 344 // publish time — it never interprets them — so a missing field (older 345 // clients) and a kind invented after registration both mean "deliver". 346 const muted = Array.isArray(mutedKinds) 347 ? mutedKinds.filter((kind) => typeof kind === "string" && kind.length > 0) 348 : []; 349 // Bind this device's APNs token to each address it knows. A game address 350 // carries the game's `credID` and is stored under it so a publish can only 351 // reach it when signed with that game's secret; the account-scoped sibling 352 // address has no credID and uses the bare key. Identity never reaches the 353 // worker — the (credID-scoped) address is the only lookup key. 354 const updatedAt = Date.now(); 355 for (const entry of addresses) { 356 const key = addressStorageKey(entry, deviceID); 357 if (!key) continue; 358 const registration = { token, environment, updatedAt }; 359 if (muted.length > 0) registration.mutedKinds = muted; 360 await this.state.storage.put(key, registration); 361 } 362 return new Response(null, { status: 204 }); 363 } 364 365 async handleUnregister(bodyText, auth) { 366 const body = await readJSONText(bodyText); 367 if (!body) return badRequest("Body must be JSON"); 368 const { deviceID, addresses } = body; 369 if (!deviceID || !Array.isArray(addresses)) { 370 return badRequest("deviceID and addresses required"); 371 } 372 if (auth.deviceID !== deviceID) { 373 return new Response("Authenticated device mismatch", { status: 403 }); 374 } 375 for (const entry of addresses) { 376 const key = addressStorageKey(entry, deviceID); 377 if (!key) continue; 378 await this.state.storage.delete(key); 379 } 380 return new Response(null, { status: 204 }); 381 } 382 383 // Stores a game's shared push credential (first-write-wins), keyed by the 384 // unguessable credID minted into the Game record. The client (any 385 // participant) registers idempotently before publishing; a different secret 386 // under the same credID is refused with 409 (only reachable on a credID 387 // collision). Mirrors the room worker's `register`. 388 async handleGameRegister(credID, bodyText) { 389 const body = await readJSONText(bodyText); 390 if (!body) return badRequest("Body must be JSON"); 391 const secret = typeof body.secret === "string" ? body.secret : ""; 392 if (!isAcceptableSecret(secret)) return badRequest("Invalid secret"); 393 const key = `gamecred:${credID}`; 394 const stored = await this.state.storage.get(key); 395 if (stored) { 396 if (!timingSafeEqual(stored.secret, secret)) { 397 return new Response("Game credential mismatch", { status: 409 }); 398 } 399 return new Response(null, { status: 204 }); 400 } 401 await this.state.storage.put(key, { secret, createdAt: Date.now() }); 402 return new Response(null, { status: 201 }); 403 } 404 405 // Authorization model: a game push must prove participation. The publish 406 // names the game's `credID` (minted into the participant-only Game record) 407 // and is signed with that game's secret; this verifies the signature 408 // against the secret registered under that credID and then resolves targets 409 // only among addresses registered under the same credID. So a caller can 410 // only reach a game's participants if it holds that game's secret — i.e. it 411 // is a participant. Account-scoped sibling pushes (accountJoined/accountSeen) 412 // carry no credID: their addresses derive from the account secret in the 413 // private CloudKit database, so they were never participant-spoofable. 414 async handlePublish(request, bodyText, auth) { 415 const body = await readJSONText(bodyText); 416 if (!body) return badRequest("Body must be JSON"); 417 const { 418 kind, 419 addressees, 420 gameID, 421 credID, 422 fromAuthorID, 423 senderDeviceID, 424 readAt, 425 title, 426 alertBody, 427 background, 428 broadcast, 429 excludeAddress, 430 collapseID, 431 payload, 432 enc 433 } = body; 434 if (!kind) { 435 return badRequest("kind required"); 436 } 437 // A broadcast fans out to every device registered under the game's credID 438 // (the whole room), so it carries no addressees but must be game-scoped — 439 // the credID is both the delivery scope and, via its signature, the 440 // participation proof. A non-broadcast publish names its recipients. 441 if (broadcast === true) { 442 if (!credID) return badRequest("broadcast requires credID"); 443 } else if (!Array.isArray(addressees) || addressees.length === 0) { 444 return badRequest("non-empty addressees required"); 445 } 446 447 if (credID) { 448 const verification = await this.verifyGameSignature(request, credID); 449 if (!verification.ok) { 450 return new Response(verification.message, { status: verification.status }); 451 } 452 } 453 454 const targets = broadcast === true 455 ? await this.resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload, enc) 456 : await this.resolveTargets(addressees, senderDeviceID, credID); 457 if (targets.length === 0) { 458 return Response.json({ delivered: 0, removed: 0, muted: 0, failed: 0 }); 459 } 460 461 let delivered = 0; 462 let removed = 0; 463 let muted = 0; 464 let failed = 0; 465 for (const target of targets) { 466 // Honor the target device's registered notification preferences: a 467 // muted kind is dropped here, before APNs, so the device never sees it. 468 if (Array.isArray(target.mutedKinds) && target.mutedKinds.includes(kind)) { 469 muted += 1; 470 continue; 471 } 472 const result = await this.sendOne(target, { 473 kind, 474 gameID, 475 fromAuthorID, 476 senderDeviceID, 477 readAt, 478 title, 479 body: target.body || alertBody, 480 payload: target.payload, 481 enc: target.enc, 482 collapseID: typeof collapseID === "string" ? collapseID : undefined, 483 background: background === true 484 }); 485 if (result === "ok") delivered += 1; 486 else if (result === "drop") { 487 // Delete by the exact key the target was resolved from — game 488 // addresses are stored credID-scoped (`addr:<credID>:<address>:<dev>`), 489 // so reconstructing a bare `addr:<address>:<dev>` key would miss them. 490 await this.state.storage.delete(target.storageKey); 491 removed += 1; 492 } else { 493 failed += 1; 494 } 495 } 496 return Response.json({ delivered, removed, muted, failed }); 497 } 498 499 // Verifies the game-participation signature: HMAC, under the secret 500 // registered for `credID`, over the App Attest request's own body hash, 501 // timestamp, and nonce (already validated by `authenticate`, so they are 502 // bound to this exact request and need no separate freshness check here). 503 async verifyGameSignature(request, credID) { 504 const cred = await this.state.storage.get(`gamecred:${credID}`); 505 if (!cred) { 506 return { ok: false, status: 403, message: "Game not registered" }; 507 } 508 const signature = request.headers.get("X-Crossmate-Game-Signature") || ""; 509 if (!signature) { 510 return { ok: false, status: 401, message: "Missing game signature" }; 511 } 512 const bodyHash = request.headers.get("X-Crossmate-Body-SHA256") || ""; 513 const timestamp = request.headers.get("X-Crossmate-Timestamp") || ""; 514 const nonce = request.headers.get("X-Crossmate-Nonce") || ""; 515 const payload = [ 516 "crossmate-push-game-v1", 517 credID, 518 bodyHash, 519 timestamp, 520 nonce 521 ].join("\n"); 522 const expected = await hmacSHA256(cred.secret, payload); 523 if (!timingSafeEqual(signature, expected)) { 524 return { ok: false, status: 401, message: "Invalid game signature" }; 525 } 526 return { ok: true }; 527 } 528 529 async resolveTargets(addressees, senderDeviceID, credID) { 530 const targets = []; 531 for (const addressee of addressees) { 532 if (!addressee || !addressee.address) continue; 533 const body = typeof addressee.body === "string" ? addressee.body : undefined; 534 // Opaque, app-encoded semantics. `enc` is the encrypted (sealed) payload 535 // current clients send; `payload` is the legacy cleartext base64 JSON an 536 // older client may still send. Either way the worker never inspects it — 537 // it just forwards it into the APNs userInfo for the notification service 538 // extension to decode. Keeping it opaque is what lets the app evolve 539 // notification meaning (and now encrypt it) without a worker deploy. 540 const payload = typeof addressee.payload === "string" ? addressee.payload : undefined; 541 const enc = typeof addressee.enc === "string" ? addressee.enc : undefined; 542 // Game pushes resolve only among addresses registered under the same 543 // credID; account pushes use the bare address key. 544 const prefix = credID 545 ? `addr:${credID}:${addressee.address}:` 546 : `addr:${addressee.address}:`; 547 const map = await this.state.storage.list({ prefix }); 548 for (const [key, value] of map) { 549 const deviceID = key.slice(prefix.length); 550 if (senderDeviceID && deviceID === senderDeviceID) continue; 551 targets.push({ 552 address: addressee.address, 553 deviceID, 554 storageKey: key, 555 body, 556 payload, 557 enc, 558 ...value 559 }); 560 } 561 } 562 return targets; 563 } 564 565 // Resolves every device registered under a game's credID — the whole room — 566 // for a broadcast publish. Keys are `addr:<credID>:<address>:<deviceID>`; 567 // addresses (base64url / `acct-…`) and device IDs (hex) never contain a 568 // colon, so the first colon after the prefix splits address from deviceID. 569 // The sender's own device and (via `excludeAddress`) its account's other 570 // devices are skipped, and the uniform `body`/`payload`/`enc` ride every target. 571 async resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload, enc) { 572 const body = typeof alertBody === "string" ? alertBody : undefined; 573 const forwarded = typeof payload === "string" ? payload : undefined; 574 const forwardedEnc = typeof enc === "string" ? enc : undefined; 575 const prefix = `addr:${credID}:`; 576 const map = await this.state.storage.list({ prefix }); 577 const targets = []; 578 for (const [key, value] of map) { 579 const rest = key.slice(prefix.length); 580 const sep = rest.indexOf(":"); 581 if (sep < 0) continue; 582 const address = rest.slice(0, sep); 583 const deviceID = rest.slice(sep + 1); 584 if (senderDeviceID && deviceID === senderDeviceID) continue; 585 if (excludeAddress && address === excludeAddress) continue; 586 targets.push({ 587 address, 588 deviceID, 589 storageKey: key, 590 body, 591 payload: forwarded, 592 enc: forwardedEnc, 593 ...value 594 }); 595 } 596 return targets; 597 } 598 599 async sendOne(target, message) { 600 const topic = this.env.APNS_TOPIC || "net.inqk.crossmate"; 601 const host = target.environment === "sandbox" 602 ? "api.sandbox.push.apple.com" 603 : "api.push.apple.com"; 604 const jwt = await this.providerJWT(); 605 const alert = {}; 606 if (message.title) alert.title = message.title; 607 if (message.body) alert.body = message.body; 608 const apnsPayload = { 609 aps: message.background 610 ? { "content-available": 1 } 611 : { alert, sound: "default", "mutable-content": 1 }, 612 kind: message.kind 613 }; 614 if (message.gameID) apnsPayload.gameID = message.gameID; 615 if (message.fromAuthorID) apnsPayload.fromAuthorID = message.fromAuthorID; 616 if (message.senderDeviceID) apnsPayload.senderDeviceID = message.senderDeviceID; 617 if (message.readAt) apnsPayload.readAt = message.readAt; 618 // Forward the opaque app payload verbatim when present. `enc` is the 619 // encrypted payload current clients send; `payload` is the legacy cleartext 620 // form an older client may still send. Both are absent for older app builds, 621 // which the extension handles by falling back to `kind`. 622 if (message.enc) apnsPayload.enc = message.enc; 623 if (message.payload) apnsPayload.payload = message.payload; 624 625 // A "nudge" rouse is ephemeral: deliver now or discard, since "come play" 626 // delivered hours later is stale noise. `accountSeen` is also 627 // background-only, but it withdraws already-read notifications from sibling 628 // devices; give APNs a short store-and-forward window so a briefly- 629 // unreachable device can still converge. Other alert kinds (win/resign/ 630 // pause) are one-time meaningful events and keep the longer window so a 631 // recipient who is offline at send time still gets the banner. 632 const expirationSeconds = 633 message.kind === "accountSeen" ? 15 * 60 : 634 message.background || message.kind === "nudge" ? 0 : 635 4 * 60 * 60; 636 const expiration = expirationSeconds === 0 637 ? "0" 638 : String(Math.floor(Date.now() / 1000) + expirationSeconds); 639 640 const headers = { 641 authorization: `bearer ${jwt}`, 642 "apns-topic": topic, 643 "apns-push-type": message.background ? "background" : "alert", 644 "apns-priority": message.background ? "5" : "10", 645 "apns-expiration": expiration, 646 "content-type": "application/json" 647 }; 648 // Coalesce alert pushes for one game into a single Notification Center tile 649 // (the app picks the id; the receiver's NSE folds successive summaries into 650 // it). Meaningless on a background push, which displays nothing. 651 if (message.collapseID && !message.background) { 652 headers["apns-collapse-id"] = message.collapseID; 653 } 654 655 const response = await fetch(`https://${host}/3/device/${target.token}`, { 656 method: "POST", 657 headers, 658 body: JSON.stringify(apnsPayload) 659 }); 660 661 if (response.status === 200) return "ok"; 662 if (response.status === 410) return "drop"; 663 if (response.status === 400) { 664 const text = await response.text(); 665 if (text.includes("BadDeviceToken") || text.includes("DeviceTokenNotForTopic")) { 666 return "drop"; 667 } 668 } 669 return "fail"; 670 } 671 672 async providerJWT() { 673 const nowSeconds = Math.floor(Date.now() / 1000); 674 if (this.cachedJWT && nowSeconds < this.cachedJWTExpiresAt - 60) { 675 return this.cachedJWT; 676 } 677 const jwt = await signProviderJWT({ 678 keyPEM: this.env.APNS_KEY, 679 keyID: this.env.APNS_KEY_ID, 680 teamID: this.env.APNS_TEAM_ID, 681 issuedAt: nowSeconds 682 }); 683 this.cachedJWT = jwt; 684 // Refresh well before APNs' 1-hour ceiling; the rate-limit floor is ~20 min. 685 this.cachedJWTExpiresAt = nowSeconds + 40 * 60; 686 return jwt; 687 } 688 } 689 690 export default { 691 async fetch(request, env) { 692 const url = new URL(request.url); 693 if (url.pathname === "/health") { 694 return new Response("ok"); 695 } 696 const id = env.PUSH_REGISTRY.idFromName("registry"); 697 return env.PUSH_REGISTRY.get(id).fetch(request); 698 } 699 }; 700 701 async function readJSON(request) { 702 return readJSONText(await request.text()); 703 } 704 705 async function readJSONText(text) { 706 try { 707 return JSON.parse(text || "{}"); 708 } catch { 709 return null; 710 } 711 } 712 713 function badRequest(message) { 714 return new Response(message, { status: 400 }); 715 } 716 717 // Storage key for a device's registration under one address. A game address 718 // arrives as `{address, credID}` and is keyed under its credID so a publish 719 // can reach it only when signed with that game's secret; the account-scoped 720 // address arrives without a credID and uses the bare key. 721 function addressStorageKey(entry, deviceID) { 722 const address = entry && typeof entry === "object" ? entry.address : entry; 723 if (typeof address !== "string" || address.length === 0) return null; 724 const credID = entry && typeof entry === "object" && typeof entry.credID === "string" 725 ? entry.credID 726 : ""; 727 return credID 728 ? `addr:${credID}:${address}:${deviceID}` 729 : `addr:${address}:${deviceID}`; 730 } 731 732 // The secret doubles as the HMAC key for game signatures, so a registered 733 // value must decode to at least 32 key bytes (clients mint exactly 32). 734 function isAcceptableSecret(secret) { 735 if (!secret) return false; 736 let bytes; 737 try { 738 bytes = base64URLDecode(secret); 739 } catch { 740 return false; 741 } 742 return bytes.length >= 32; 743 } 744 745 async function hmacSHA256(secret, payload) { 746 const key = await crypto.subtle.importKey( 747 "raw", 748 base64URLDecode(secret), 749 { name: "HMAC", hash: "SHA-256" }, 750 false, 751 ["sign"] 752 ); 753 const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); 754 return base64URLEncode(new Uint8Array(signature)); 755 } 756 757 function canonicalPushRequest({ 758 method, 759 path, 760 bodyHash, 761 timestamp, 762 nonce, 763 deviceID, 764 keyID 765 }) { 766 return [ 767 "crossmate-push-request-v1", 768 method.toUpperCase(), 769 path, 770 bodyHash, 771 timestamp, 772 nonce, 773 deviceID, 774 keyID 775 ].join("\n"); 776 } 777 778 async function sha256Bytes(bytes) { 779 return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); 780 } 781 782 function concatBytes(...arrays) { 783 let length = 0; 784 for (const array of arrays) length += array.length; 785 const result = new Uint8Array(length); 786 let offset = 0; 787 for (const array of arrays) { 788 result.set(array, offset); 789 offset += array.length; 790 } 791 return result; 792 } 793 794 function bytesEqual(left, right) { 795 if (!left || !right || left.length !== right.length) return false; 796 let diff = 0; 797 for (let index = 0; index < left.length; index += 1) { 798 diff |= left[index] ^ right[index]; 799 } 800 return diff === 0; 801 } 802 803 function bytesToHex(bytes) { 804 if (!bytes) return ""; 805 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); 806 } 807 808 function base64URLDecode(string) { 809 let base64 = string.replace(/-/g, "+").replace(/_/g, "/"); 810 base64 += "=".repeat((4 - (base64.length % 4)) % 4); 811 const binary = atob(base64); 812 const bytes = new Uint8Array(binary.length); 813 for (let index = 0; index < binary.length; index += 1) { 814 bytes[index] = binary.charCodeAt(index); 815 } 816 return bytes; 817 } 818 819 function base64URLDecodeFlexible(string) { 820 try { 821 return base64URLDecode(string); 822 } catch { 823 const binary = atob(string); 824 const bytes = new Uint8Array(binary.length); 825 for (let index = 0; index < binary.length; index += 1) { 826 bytes[index] = binary.charCodeAt(index); 827 } 828 return bytes; 829 } 830 } 831 832 function pemToDer(pem) { 833 const stripped = pem 834 .replace(/-----BEGIN CERTIFICATE-----/g, "") 835 .replace(/-----END CERTIFICATE-----/g, "") 836 .replace(/\s+/g, ""); 837 return base64URLDecodeFlexible(stripped); 838 } 839 840 function decodeAttestationObject(bytes) { 841 const decoded = cborDecode(bytes); 842 if (!decoded || decoded.fmt !== "apple-appattest" || !(decoded.authData instanceof Uint8Array)) { 843 throw new Error("invalid attestation object"); 844 } 845 return decoded; 846 } 847 848 function decodeAssertion(bytes) { 849 const decoded = cborDecode(bytes); 850 const authData = decoded.authenticatorData || decoded.authData; 851 if (!(authData instanceof Uint8Array) || !(decoded.signature instanceof Uint8Array)) { 852 throw new Error("invalid assertion object"); 853 } 854 return { 855 authenticatorData: authData, 856 signature: decoded.signature 857 }; 858 } 859 860 function cborDecode(bytes) { 861 const reader = new CBORReader(bytes); 862 const value = reader.read(); 863 if (!reader.done) throw new Error("trailing cbor data"); 864 return value; 865 } 866 867 class CBORReader { 868 constructor(bytes) { 869 this.bytes = bytes; 870 this.offset = 0; 871 } 872 873 get done() { 874 return this.offset === this.bytes.length; 875 } 876 877 read() { 878 const initial = this.readByte(); 879 const major = initial >> 5; 880 const additional = initial & 0x1f; 881 const value = this.readArgument(additional); 882 switch (major) { 883 case 0: 884 return value; 885 case 1: 886 return -1 - value; 887 case 2: 888 return this.readBytes(value); 889 case 3: 890 return new TextDecoder().decode(this.readBytes(value)); 891 case 4: { 892 const array = []; 893 for (let index = 0; index < value; index += 1) { 894 array.push(this.read()); 895 } 896 return array; 897 } 898 case 5: { 899 const object = {}; 900 for (let index = 0; index < value; index += 1) { 901 object[this.read()] = this.read(); 902 } 903 return object; 904 } 905 case 7: 906 if (additional === 20) return false; 907 if (additional === 21) return true; 908 if (additional === 22) return null; 909 break; 910 default: 911 break; 912 } 913 throw new Error("unsupported cbor value"); 914 } 915 916 readArgument(additional) { 917 if (additional < 24) return additional; 918 if (additional === 24) return this.readByte(); 919 if (additional === 25) return this.readUInt(2); 920 if (additional === 26) return this.readUInt(4); 921 if (additional === 27) return this.readUInt(8); 922 throw new Error("indefinite cbor values are unsupported"); 923 } 924 925 readUInt(length) { 926 let value = 0; 927 for (let index = 0; index < length; index += 1) { 928 value = (value * 256) + this.readByte(); 929 } 930 return value; 931 } 932 933 readByte() { 934 if (this.offset >= this.bytes.length) throw new Error("truncated cbor"); 935 return this.bytes[this.offset++]; 936 } 937 938 readBytes(length) { 939 if (this.offset + length > this.bytes.length) throw new Error("truncated cbor bytes"); 940 const value = this.bytes.slice(this.offset, this.offset + length); 941 this.offset += length; 942 return value; 943 } 944 } 945 946 function parseAuthenticatorData(bytes, options = {}) { 947 if (bytes.length < 37) throw new Error("authenticator data too short"); 948 const signCount = ( 949 (bytes[33] * 0x1000000) + 950 (bytes[34] << 16) + 951 (bytes[35] << 8) + 952 bytes[36] 953 ) >>> 0; 954 const result = { 955 rpIDHash: bytes.slice(0, 32), 956 flags: bytes[32], 957 signCount 958 }; 959 const hasAttestedCredentialData = (result.flags & 0x40) !== 0; 960 if (options.requireAttestedCredential && !hasAttestedCredentialData) { 961 throw new Error("attested credential data missing"); 962 } 963 if (options.requireAttestedCredential || options.parseAttestedCredential) { 964 if (bytes.length < 55) throw new Error("attested credential data missing"); 965 result.aaguid = bytes.slice(37, 53); 966 const credentialLength = (bytes[53] << 8) | bytes[54]; 967 const credentialStart = 55; 968 const credentialEnd = credentialStart + credentialLength; 969 if (credentialEnd > bytes.length) throw new Error("credential id truncated"); 970 result.credentialID = bytes.slice(credentialStart, credentialEnd); 971 result.cosePublicKey = bytes.slice(credentialEnd); 972 } 973 return result; 974 } 975 976 function isExpectedAppAttestAAGUID(aaguid, environment) { 977 if (!aaguid || aaguid.length !== 16) return false; 978 const production = new Uint8Array(16); 979 production.set(new TextEncoder().encode("appattest"), 0); 980 const development = new TextEncoder().encode("appattestdevelop"); 981 if (environment === "development") { 982 return bytesEqual(aaguid, development); 983 } 984 return bytesEqual(aaguid, production); 985 } 986 987 function coseEC2PublicKeyToJWK(bytes) { 988 const key = cborDecode(bytes); 989 const x = key[-2]; 990 const y = key[-3]; 991 if (key[1] !== 2 || key[-1] !== 1 || !(x instanceof Uint8Array) || !(y instanceof Uint8Array)) { 992 throw new Error("unsupported cose key"); 993 } 994 return { 995 kty: "EC", 996 crv: "P-256", 997 x: base64URLEncode(x), 998 y: base64URLEncode(y), 999 ext: true 1000 }; 1001 } 1002 1003 function parseCertificate(bytes) { 1004 const cert = parseDER(bytes); 1005 if (cert.tag !== 0x30 || cert.children.length < 3) { 1006 throw new Error("invalid certificate"); 1007 } 1008 const tbs = cert.children[0]; 1009 const signatureAlgorithm = parseAlgorithmIdentifier(cert.children[1]); 1010 const signatureValue = cert.children[2]; 1011 if (signatureValue.tag !== 0x03) throw new Error("invalid certificate signature"); 1012 1013 const tbsChildren = tbs.children; 1014 let index = tbsChildren[0].tag === 0xa0 ? 1 : 0; 1015 index += 5; // serial, signature, issuer, validity, subject 1016 const subjectPublicKeyInfo = tbsChildren[index].raw; 1017 const extensions = []; 1018 for (const child of tbsChildren.slice(index + 1)) { 1019 if (child.tag === 0xa3 && child.children[0]?.tag === 0x30) { 1020 for (const ext of child.children[0].children) { 1021 const oid = decodeOID(ext.children[0].value); 1022 const valueNode = ext.children.find((node) => node.tag === 0x04); 1023 if (valueNode) extensions.push({ oid, value: valueNode.value }); 1024 } 1025 } 1026 } 1027 1028 return { 1029 tbs: tbs.raw, 1030 signatureAlgorithm, 1031 subjectPublicKeyInfo, 1032 signature: signatureValue.value.slice(1), 1033 extensions 1034 }; 1035 } 1036 1037 function parseDER(bytes, offset = 0) { 1038 const start = offset; 1039 const tag = bytes[offset++]; 1040 let length = bytes[offset++]; 1041 if ((length & 0x80) !== 0) { 1042 const byteCount = length & 0x7f; 1043 length = 0; 1044 for (let index = 0; index < byteCount; index += 1) { 1045 length = (length * 256) + bytes[offset++]; 1046 } 1047 } 1048 const valueStart = offset; 1049 const end = valueStart + length; 1050 if (end > bytes.length) throw new Error("truncated der"); 1051 const constructed = (tag & 0x20) !== 0; 1052 const children = []; 1053 if (constructed) { 1054 let childOffset = valueStart; 1055 while (childOffset < end) { 1056 const child = parseDER(bytes, childOffset); 1057 children.push(child); 1058 childOffset = child.end; 1059 } 1060 } 1061 return { 1062 tag, 1063 start, 1064 valueStart, 1065 end, 1066 raw: bytes.slice(start, end), 1067 value: bytes.slice(valueStart, end), 1068 children 1069 }; 1070 } 1071 1072 function decodeOID(bytes) { 1073 const parts = [Math.floor(bytes[0] / 40), bytes[0] % 40]; 1074 let value = 0; 1075 for (const byte of bytes.slice(1)) { 1076 value = (value << 7) | (byte & 0x7f); 1077 if ((byte & 0x80) === 0) { 1078 parts.push(value); 1079 value = 0; 1080 } 1081 } 1082 return parts.join("."); 1083 } 1084 1085 async function verifyCertificateSignature(cert, issuerSPKI) { 1086 const issuerKey = parseSubjectPublicKeyInfo(issuerSPKI); 1087 const hash = certificateSignatureHash(cert.signatureAlgorithm); 1088 const publicKey = await crypto.subtle.importKey( 1089 "spki", 1090 issuerSPKI, 1091 { name: "ECDSA", namedCurve: issuerKey.namedCurve }, 1092 false, 1093 ["verify"] 1094 ); 1095 const ok = await crypto.subtle.verify( 1096 { name: "ECDSA", hash }, 1097 publicKey, 1098 derECDSASignatureToRaw(cert.signature, issuerKey.coordinateLength), 1099 cert.tbs 1100 ); 1101 if (!ok) throw new Error("certificate signature verification failed"); 1102 } 1103 1104 function parseAlgorithmIdentifier(node) { 1105 if (!node || node.tag !== 0x30 || !node.children[0]) { 1106 throw new Error("invalid algorithm identifier"); 1107 } 1108 const algorithm = { 1109 oid: decodeOID(node.children[0].value) 1110 }; 1111 if (node.children[1]) { 1112 if (node.children[1].tag === 0x06) { 1113 algorithm.parametersOID = decodeOID(node.children[1].value); 1114 } else { 1115 algorithm.parameters = node.children[1].raw; 1116 } 1117 } 1118 return algorithm; 1119 } 1120 1121 function parseSubjectPublicKeyInfo(spki) { 1122 const node = parseDER(spki); 1123 if (node.tag !== 0x30 || !node.children[0]) { 1124 throw new Error("invalid subject public key info"); 1125 } 1126 const algorithm = parseAlgorithmIdentifier(node.children[0]); 1127 if (algorithm.oid !== "1.2.840.10045.2.1") { 1128 throw new Error(`unsupported certificate public key algorithm ${algorithm.oid}`); 1129 } 1130 switch (algorithm.parametersOID) { 1131 case "1.2.840.10045.3.1.7": 1132 return { namedCurve: "P-256", coordinateLength: 32 }; 1133 case "1.3.132.0.34": 1134 return { namedCurve: "P-384", coordinateLength: 48 }; 1135 default: 1136 throw new Error(`unsupported certificate EC curve ${algorithm.parametersOID || ""}`); 1137 } 1138 } 1139 1140 function certificateSignatureHash(signatureAlgorithm) { 1141 switch (signatureAlgorithm.oid) { 1142 case "1.2.840.10045.4.3.2": 1143 return "SHA-256"; 1144 case "1.2.840.10045.4.3.3": 1145 return "SHA-384"; 1146 default: 1147 throw new Error(`unsupported certificate signature algorithm ${signatureAlgorithm.oid}`); 1148 } 1149 } 1150 1151 function certificateAppAttestNonce(cert) { 1152 const extension = cert.extensions.find((entry) => entry.oid === "1.2.840.113635.100.8.2"); 1153 if (!extension) throw new Error("missing app attest nonce extension"); 1154 const nested = parseDER(extension.value); 1155 const octets = findOctetString(nested, 32); 1156 if (!octets) throw new Error("missing app attest nonce"); 1157 return octets; 1158 } 1159 1160 function findOctetString(node, length) { 1161 if (node.tag === 0x04 && node.value.length === length) return node.value; 1162 for (const child of node.children) { 1163 const found = findOctetString(child, length); 1164 if (found) return found; 1165 } 1166 return null; 1167 } 1168 1169 function derECDSASignatureToRaw(bytes, coordinateLength = 32) { 1170 const sequence = parseDER(bytes); 1171 if (sequence.tag !== 0x30 || sequence.children.length !== 2) { 1172 throw new Error("invalid ecdsa signature"); 1173 } 1174 return concatBytes( 1175 derIntegerToFixed(sequence.children[0].value, coordinateLength), 1176 derIntegerToFixed(sequence.children[1].value, coordinateLength) 1177 ); 1178 } 1179 1180 function derIntegerToFixed(bytes, length) { 1181 let value = bytes; 1182 while (value.length > 0 && value[0] === 0) { 1183 value = value.slice(1); 1184 } 1185 if (value.length > length) throw new Error("ecdsa integer too long"); 1186 const result = new Uint8Array(length); 1187 result.set(value, length - value.length); 1188 return result; 1189 } 1190 1191 async function signProviderJWT({ keyPEM, keyID, teamID, issuedAt }) { 1192 if (!keyPEM || !keyID || !teamID) { 1193 throw new Error("APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID must all be set"); 1194 } 1195 const key = await importP8(keyPEM); 1196 const header = base64URLEncode(new TextEncoder().encode(JSON.stringify({ 1197 alg: "ES256", 1198 kid: keyID 1199 }))); 1200 const claims = base64URLEncode(new TextEncoder().encode(JSON.stringify({ 1201 iss: teamID, 1202 iat: issuedAt 1203 }))); 1204 const signingInput = `${header}.${claims}`; 1205 const signature = await crypto.subtle.sign( 1206 { name: "ECDSA", hash: "SHA-256" }, 1207 key, 1208 new TextEncoder().encode(signingInput) 1209 ); 1210 return `${signingInput}.${base64URLEncode(new Uint8Array(signature))}`; 1211 } 1212 1213 async function importP8(pem) { 1214 const stripped = pem 1215 .replace(/-----BEGIN PRIVATE KEY-----/g, "") 1216 .replace(/-----END PRIVATE KEY-----/g, "") 1217 .replace(/\s+/g, ""); 1218 const der = Uint8Array.from(atob(stripped), (char) => char.charCodeAt(0)); 1219 return crypto.subtle.importKey( 1220 "pkcs8", 1221 der, 1222 { name: "ECDSA", namedCurve: "P-256" }, 1223 false, 1224 ["sign"] 1225 ); 1226 } 1227 1228 function base64URLEncode(bytes) { 1229 let binary = ""; 1230 for (const byte of bytes) { 1231 binary += String.fromCharCode(byte); 1232 } 1233 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); 1234 } 1235 1236 function timingSafeEqual(a, b) { 1237 const left = new TextEncoder().encode(a); 1238 const right = new TextEncoder().encode(b); 1239 if (left.length !== right.length) return false; 1240 let diff = 0; 1241 for (let index = 0; index < left.length; index += 1) { 1242 diff |= left[index] ^ right[index]; 1243 } 1244 return diff === 0; 1245 }