push-worker.js (66216B)
1 // Single source for every rate bucket's defaults (env-overridable via 2 // `<PREFIX>_LIMIT` / `<PREFIX>_WINDOW_SECONDS`), shared by the enforcement 3 // call sites and the sweep horizon so the two cannot drift. 4 const RATE_LIMIT_DEFAULTS = { 5 APP_ATTEST_CHALLENGE_IP: { limit: 60, windowSeconds: 60 * 60 }, 6 APP_ATTEST_CHALLENGE_DEVICE: { limit: 10, windowSeconds: 5 * 60 }, 7 APP_ATTEST_REGISTER_IP: { limit: 30, windowSeconds: 60 * 60 }, 8 APP_ATTEST_REGISTER_DEVICE: { limit: 5, windowSeconds: 10 * 60 }, 9 PUBLISH_CRED: { limit: 60, windowSeconds: 60 }, 10 PUBLISH_ADDRESS: { limit: 30, windowSeconds: 60 } 11 }; 12 13 // Ingress bounds: a single authenticated request must not be able to buy 14 // unbounded buffering, storage scanning, or APNs fanout, so every body, list, 15 // and string is capped before the work it would trigger. These counts and 16 // byte budgets are env-overridable (same convention as the rate limits); the 17 // per-field character caps below are structural and fixed. 18 const INGRESS_LIMIT_DEFAULTS = { 19 MAX_BODY_BYTES: 131072, 20 MAX_ADDRESS_COUNT: 64, 21 MAX_REGISTRATIONS_PER_CRED: 128, 22 MAX_BROADCAST_TARGETS: 128 23 }; 24 25 // Key-forming identifiers (addresses, credIDs, device IDs, APNs tokens, 26 // nonces) are base64url/hex/UUID strings; anything longer or outside that 27 // alphabet cannot be genuine and would otherwise flow into storage keys and 28 // the APNs request URL. 29 const MAX_ID_CHARS = 128; 30 // App Attest key IDs are opaque Base64 supplied by Apple, not identifiers 31 // minted by Crossmate. Accept both standard and URL-safe spellings, then use 32 // one canonical base64url spelling anywhere the value becomes a storage key. 33 const MAX_APP_ATTEST_KEY_ID_CHARS = 256; 34 const MAX_KIND_CHARS = 64; 35 const MAX_MUTED_KIND_COUNT = 32; 36 const MAX_TEXT_CHARS = 512; // title / alert body 37 const MAX_OPAQUE_CHARS = 4096; // forwarded base64 `payload` / `enc` blobs 38 const MAX_COLLAPSE_ID_CHARS = 64; // APNs' own apns-collapse-id ceiling 39 const MAX_SECRET_CHARS = 256; // clients mint 32 bytes → 43 base64url chars 40 const MAX_ATTESTATION_OBJECT_CHARS = 32768; // genuine objects are ~8 KB base64 41 const MAX_APNS_PAYLOAD_BYTES = 4096; // APNs payload ceiling (alert + background) 42 43 export class PushRegistry { 44 constructor(state, env) { 45 this.state = state; 46 this.env = env; 47 this.cachedJWT = null; 48 this.cachedJWTExpiresAt = 0; 49 } 50 51 async fetch(request) { 52 const url = new URL(request.url); 53 54 // Bound the body before any route (attestation included) buffers it: an 55 // oversized request is refused for a few header bytes instead of being 56 // materialized just to fail JSON or auth validation. 57 const body = await readBodyWithinLimit(request, this.ingressLimit("MAX_BODY_BYTES")); 58 if (!body.ok) { 59 return new Response("Body too large", { status: 413 }); 60 } 61 const bodyText = body.text; 62 63 if (url.pathname === "/attest/challenge" && request.method === "POST") { 64 return this.handleAttestationChallenge(request, bodyText); 65 } 66 if (url.pathname === "/attest/register" && request.method === "POST") { 67 return this.handleAttestationRegister(request, bodyText); 68 } 69 70 const auth = await this.authenticate(request, bodyText); 71 if (!auth.ok) { 72 console.warn("Push worker auth failed", { 73 method: request.method, 74 path: url.pathname, 75 status: auth.status, 76 message: auth.message, 77 authVersion: request.headers.get("X-Crossmate-Auth-Version") || "", 78 deviceIDLength: (request.headers.get("X-Crossmate-Device-ID") || "").length, 79 keyIDLength: (request.headers.get("X-Crossmate-Key-ID") || "").length, 80 bodyLength: bodyText.length 81 }); 82 return new Response(auth.message, { status: auth.status }); 83 } 84 85 if (url.pathname === "/register" && request.method === "POST") { 86 return this.handleRegister(request, bodyText, auth); 87 } 88 if (url.pathname === "/register" && request.method === "DELETE") { 89 return this.handleUnregister(bodyText, auth); 90 } 91 if (url.pathname === "/publish" && request.method === "POST") { 92 return this.handlePublish(request, bodyText, auth); 93 } 94 const gameRegister = url.pathname.match(/^\/games\/([^/]+)\/register$/); 95 if (gameRegister && request.method === "POST") { 96 return this.handleGameRegister(gameRegister[1], bodyText); 97 } 98 return new Response("Not found", { status: 404 }); 99 } 100 101 async authenticate(request, bodyText) { 102 try { 103 return await this.authenticateAppAttest(request, bodyText); 104 } catch (error) { 105 return { ok: false, status: 401, message: `Bad App Attest auth: ${error.message}` }; 106 } 107 } 108 109 async authenticateAppAttest(request, bodyText) { 110 if ((request.headers.get("X-Crossmate-Auth-Version") || "") !== "appattest-v1") { 111 return { ok: false, status: 401, message: "Missing App Attest auth" }; 112 } 113 const deviceID = request.headers.get("X-Crossmate-Device-ID") || ""; 114 const keyID = request.headers.get("X-Crossmate-Key-ID") || ""; 115 const timestamp = request.headers.get("X-Crossmate-Timestamp") || ""; 116 const nonce = request.headers.get("X-Crossmate-Nonce") || ""; 117 const bodyHash = request.headers.get("X-Crossmate-Body-SHA256") || ""; 118 const assertionBase64 = request.headers.get("X-Crossmate-Assertion") || ""; 119 if (!deviceID || !keyID || !timestamp || !nonce || !bodyHash || !assertionBase64) { 120 return { ok: false, status: 401, message: "Incomplete App Attest auth" }; 121 } 122 // These three form storage keys below; bound them before any storage touch. 123 const canonicalKeyID = canonicalAppAttestKeyID(keyID); 124 if (!isValidID(deviceID) || !canonicalKeyID || !isValidID(nonce)) { 125 return { ok: false, status: 401, message: "Malformed App Attest auth" }; 126 } 127 128 const nowSeconds = Math.floor(Date.now() / 1000); 129 const timestampSeconds = Number(timestamp); 130 const maxSkewSeconds = Number(this.env.MAX_AUTH_SKEW_SECONDS || "120"); 131 if (!Number.isFinite(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > maxSkewSeconds) { 132 return { ok: false, status: 401, message: "Stale auth timestamp" }; 133 } 134 135 const computedBodyHash = base64URLEncode(await sha256Bytes(new TextEncoder().encode(bodyText))); 136 if (!timingSafeEqual(bodyHash, computedBodyHash)) { 137 return { ok: false, status: 401, message: "Bad body hash" }; 138 } 139 140 const registrationKey = this.appAttestRegistrationKey(deviceID, canonicalKeyID); 141 const registration = await this.loadAppAttestRegistration(deviceID, keyID, canonicalKeyID); 142 if (!registration) { 143 return { ok: false, status: 401, message: "Unknown App Attest key" }; 144 } 145 146 const nonceTTLSeconds = Number(this.env.REQUEST_NONCE_TTL_SECONDS || "300"); 147 const nonceKey = `request-nonce:${deviceID}:${nonce}`; 148 const nonceUsedAt = await this.state.storage.get(nonceKey); 149 if (nonceUsedAt && Date.now() - nonceUsedAt <= nonceTTLSeconds * 1000) { 150 return { ok: false, status: 401, message: "Nonce already used" }; 151 } 152 153 const path = new URL(request.url).pathname; 154 const canonical = canonicalPushRequest({ 155 method: request.method, 156 path, 157 bodyHash, 158 timestamp, 159 nonce, 160 deviceID, 161 keyID 162 }); 163 const clientDataHash = await sha256Bytes(new TextEncoder().encode(canonical)); 164 const assertion = decodeAssertion(base64URLDecode(assertionBase64)); 165 const authData = parseAuthenticatorData(assertion.authenticatorData); 166 const expectedAppIDHash = await this.expectedAppIDHash(); 167 if (!bytesEqual(authData.rpIDHash, expectedAppIDHash)) { 168 return { ok: false, status: 401, message: "Bad App Attest app id" }; 169 } 170 const signedBytes = concatBytes(assertion.authenticatorData, clientDataHash); 171 // The Secure Enclave signs nonce = SHA256(authenticatorData || clientDataHash) 172 // as an ECDSA-SHA256 *message*, so the digest under the signature is 173 // SHA256(nonce). WebCrypto applies that second hash. 174 const assertionNonce = await sha256Bytes(signedBytes); 175 const publicKey = await this.importAppAttestPublicKey(registration); 176 const verified = await crypto.subtle.verify( 177 { name: "ECDSA", hash: "SHA-256" }, 178 publicKey, 179 derECDSASignatureToRaw(assertion.signature), 180 assertionNonce 181 ); 182 if (!verified) { 183 return { ok: false, status: 401, message: "Bad App Attest assertion" }; 184 } 185 186 // Concurrent requests from one device can arrive out of counter order, and 187 // replay is already blocked by the nonce and timestamp checks, so the 188 // counter is only tracked (for clone diagnostics), never enforced. 189 registration.signCount = Math.max(registration.signCount || 0, authData.signCount); 190 registration.updatedAt = Date.now(); 191 await this.state.storage.put(registrationKey, registration); 192 // Durable Object storage has no expirationTtl, so prune stale nonces here. 193 await this.pruneExpired(`request-nonce:${deviceID}:`, nonceTTLSeconds); 194 await this.state.storage.put(nonceKey, Date.now()); 195 return { ok: true, deviceID }; 196 } 197 198 async loadAppAttestRegistration(deviceID, keyID, canonicalKeyID) { 199 const registrationKey = this.appAttestRegistrationKey(deviceID, canonicalKeyID); 200 let registration = await this.state.storage.get(registrationKey); 201 // TEMPORARY BUILD-885 COMPATIBILITY: keys enrolled before 07aae65 were 202 // stored using Apple's original spelling. Migrate that record on first 203 // successful lookup. Remove this fallback with the other build-885 wire 204 // compatibility after its supported upgrade window closes. 205 if (!registration && keyID !== canonicalKeyID) { 206 const legacyRegistrationKey = `appattest-key:${deviceID}:${keyID}`; 207 registration = await this.state.storage.get(legacyRegistrationKey); 208 if (registration) { 209 await this.state.storage.put(registrationKey, registration); 210 await this.state.storage.delete(legacyRegistrationKey); 211 } 212 } 213 return registration; 214 } 215 216 async handleAttestationChallenge(request, bodyText) { 217 const body = await readJSONText(bodyText); 218 if (!body) return badRequest("Body must be JSON"); 219 const deviceID = body.deviceID || ""; 220 const keyID = body.keyID || ""; 221 if (!deviceID || !keyID) { 222 return badRequest("deviceID and keyID required"); 223 } 224 // Apple owns the key-ID format. Validate it as bounded Base64 rather than 225 // applying Crossmate's narrower storage-identifier alphabet. 226 if (!isValidID(deviceID) || !canonicalAppAttestKeyID(keyID)) { 227 return badRequest("Malformed deviceID or keyID"); 228 } 229 const limited = await this.checkAttestationRateLimit(request, "challenge", deviceID); 230 if (limited) return limited; 231 const ttlSeconds = this.appAttestChallengeTTLSeconds(); 232 const challenge = base64URLEncode(crypto.getRandomValues(new Uint8Array(32))); 233 await this.pruneExpired(`appattest-challenge:${deviceID}:`, ttlSeconds); 234 await this.state.storage.put(this.appAttestChallengeKey(deviceID, challenge), Date.now()); 235 console.log("App Attest challenge issued", { 236 deviceIDLength: deviceID.length, 237 keyIDLength: keyID.length, 238 challengeLength: challenge.length, 239 ttlSeconds 240 }); 241 return Response.json({ challenge }); 242 } 243 244 async handleAttestationRegister(request, bodyText) { 245 const body = await readJSONText(bodyText); 246 if (!body) return badRequest("Body must be JSON"); 247 const { deviceID, keyID, challenge, attestationObject } = body; 248 if (!deviceID || !keyID || !challenge || !attestationObject) { 249 return badRequest("deviceID, keyID, challenge, attestationObject required"); 250 } 251 const canonicalKeyID = canonicalAppAttestKeyID(keyID); 252 if (!isValidID(deviceID) || !canonicalKeyID || !isValidID(challenge)) { 253 return badRequest("Malformed deviceID, keyID, or challenge"); 254 } 255 // Attestation runs pre-auth, so keep the base64/CBOR/certificate work it 256 // can demand tighter than the general body cap. 257 if (typeof attestationObject !== "string" || attestationObject.length > MAX_ATTESTATION_OBJECT_CHARS) { 258 return badRequest("attestationObject too large"); 259 } 260 // Registration is idempotent. If the first 204 was lost, the client 261 // resends the same persisted attestation after its challenge has already 262 // been consumed. Confirm the existing canonical binding so that recovery 263 // does not force it to discard a successfully enrolled Secure Enclave key. 264 const registrationKey = this.appAttestRegistrationKey(deviceID, canonicalKeyID); 265 if (await this.state.storage.get(registrationKey)) { 266 return new Response(null, { status: 204 }); 267 } 268 const limited = await this.checkAttestationRateLimit(request, "register", deviceID); 269 if (limited) return limited; 270 const challengeKey = this.appAttestChallengeKey(deviceID, challenge); 271 const challengeIssuedAt = await this.state.storage.get(challengeKey); 272 const challengeExpired = challengeIssuedAt 273 ? Date.now() - challengeIssuedAt > this.appAttestChallengeTTLSeconds() * 1000 274 : false; 275 if (!challengeIssuedAt || challengeExpired) { 276 if (challengeExpired) await this.state.storage.delete(challengeKey); 277 console.warn("App Attest registration failed", { 278 error: challengeExpired ? "expired challenge" : "unknown challenge", 279 deviceIDLength: deviceID.length, 280 keyIDLength: keyID.length, 281 challengeLength: challenge.length, 282 attestationObjectLength: attestationObject.length 283 }); 284 return new Response("Unknown App Attest challenge", { status: 401 }); 285 } 286 287 try { 288 const registration = await this.verifyAttestation({ 289 deviceID, 290 keyID, 291 canonicalKeyID, 292 challenge, 293 attestationObject: base64URLDecode(attestationObject) 294 }); 295 await this.state.storage.put(registrationKey, registration); 296 await this.state.storage.delete(challengeKey); 297 console.log("App Attest registration accepted", { 298 expectedEnvironment: this.env.APP_ATTEST_ENVIRONMENT || "production", 299 appBundleID: this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || "", 300 rootCertConfigured: Boolean(this.env.APP_ATTEST_ROOT_CERT_PEM), 301 rootCertLength: (this.env.APP_ATTEST_ROOT_CERT_PEM || "").length, 302 deviceIDLength: deviceID.length, 303 keyIDLength: keyID.length, 304 signCount: registration.signCount 305 }); 306 return new Response(null, { status: 204 }); 307 } catch (error) { 308 console.error("App Attest registration failed", { 309 error: error.message, 310 expectedEnvironment: this.env.APP_ATTEST_ENVIRONMENT || "production", 311 appTeamIDConfigured: Boolean(this.env.APP_TEAM_ID), 312 appBundleID: this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || "", 313 rootCertConfigured: Boolean(this.env.APP_ATTEST_ROOT_CERT_PEM), 314 rootCertLength: (this.env.APP_ATTEST_ROOT_CERT_PEM || "").length, 315 deviceIDLength: deviceID.length, 316 keyIDLength: keyID.length, 317 challengeLength: challenge.length, 318 attestationObjectLength: attestationObject.length 319 }); 320 return new Response(`Bad App Attest attestation: ${error.message}`, { status: 401 }); 321 } 322 } 323 324 async verifyAttestation({ deviceID, keyID, canonicalKeyID, challenge, attestationObject }) { 325 const attestation = decodeAttestationObject(attestationObject); 326 const authData = parseAuthenticatorData(attestation.authData, { 327 requireAttestedCredential: true 328 }); 329 const expectedAppIDHash = await this.expectedAppIDHash(); 330 if (!bytesEqual(authData.rpIDHash, expectedAppIDHash)) { 331 throw new Error("app id hash mismatch"); 332 } 333 if (!authData.credentialID || !bytesEqual(authData.credentialID, base64URLDecode(canonicalKeyID))) { 334 throw new Error("credential id mismatch"); 335 } 336 const appAttestEnvironment = this.env.APP_ATTEST_ENVIRONMENT || "production"; 337 if (!isExpectedAppAttestAAGUID(authData.aaguid, appAttestEnvironment)) { 338 throw new Error(`unexpected aaguid for ${appAttestEnvironment}: ${bytesToHex(authData.aaguid)}`); 339 } 340 if (!attestation.attStmt || !Array.isArray(attestation.attStmt.x5c) || attestation.attStmt.x5c.length < 2) { 341 throw new Error("missing certificate chain"); 342 } 343 344 const leaf = parseCertificate(attestation.attStmt.x5c[0]); 345 const intermediate = parseCertificate(attestation.attStmt.x5c[1]); 346 await verifyCertificateSignature(leaf, intermediate.subjectPublicKeyInfo); 347 const rootPEM = this.env.APP_ATTEST_ROOT_CERT_PEM || ""; 348 if (!rootPEM) { 349 throw new Error("worker missing APP_ATTEST_ROOT_CERT_PEM"); 350 } 351 const root = parseCertificate(pemToDer(rootPEM)); 352 await verifyCertificateSignature(intermediate, root.subjectPublicKeyInfo); 353 354 // Build 2026.885 signs Apple's original key-ID spelling, which may be 355 // padded standard Base64. Corrected clients send canonicalKeyID instead. 356 // TEMPORARY COMPATIBILITY: after build 2026.885 is outside the supported 357 // upgrade window, require `keyID === canonicalKeyID` at ingress and hash 358 // canonicalKeyID here; standard Base64 parsing can then remain only as a 359 // bounded normalization helper where Apple-originated values are read. 360 const clientDataHash = await sha256Bytes(new TextEncoder().encode([ 361 "crossmate-appattest-v1", 362 challenge, 363 deviceID, 364 keyID 365 ].join("\n"))); 366 const expectedNonce = await sha256Bytes(concatBytes(attestation.authData, clientDataHash)); 367 const certNonce = certificateAppAttestNonce(leaf); 368 if (!bytesEqual(certNonce, expectedNonce)) { 369 throw new Error("certificate nonce mismatch"); 370 } 371 372 return { 373 publicKeyJWK: coseEC2PublicKeyToJWK(authData.cosePublicKey), 374 publicKeySPKI: base64URLEncode(leaf.subjectPublicKeyInfo), 375 signCount: authData.signCount, 376 createdAt: Date.now() 377 }; 378 } 379 380 async importAppAttestPublicKey(registration) { 381 if (registration.publicKeySPKI) { 382 return crypto.subtle.importKey( 383 "spki", 384 base64URLDecode(registration.publicKeySPKI), 385 { name: "ECDSA", namedCurve: "P-256" }, 386 false, 387 ["verify"] 388 ); 389 } 390 return crypto.subtle.importKey( 391 "jwk", 392 registration.publicKeyJWK, 393 { name: "ECDSA", namedCurve: "P-256" }, 394 false, 395 ["verify"] 396 ); 397 } 398 399 async expectedAppIDHash() { 400 const teamID = this.env.APP_TEAM_ID || ""; 401 const bundleID = this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || ""; 402 if (!teamID || !bundleID) { 403 throw new Error("APP_TEAM_ID and APP_BUNDLE_ID/APNS_TOPIC are required"); 404 } 405 return sha256Bytes(new TextEncoder().encode(`${teamID}.${bundleID}`)); 406 } 407 408 appAttestChallengeKey(deviceID, challenge) { 409 return `appattest-challenge:${deviceID}:${challenge}`; 410 } 411 412 appAttestChallengeTTLSeconds() { 413 return Number(this.env.APP_ATTEST_CHALLENGE_TTL_SECONDS || "300"); 414 } 415 416 // Deletes per-device keys whose stored timestamp is older than ttlSeconds. 417 // Durable Object storage ignores KV's expirationTtl option, so expiry has to 418 // be enforced manually. 419 async pruneExpired(prefix, ttlSeconds) { 420 const entries = await this.state.storage.list({ prefix }); 421 const cutoff = Date.now() - ttlSeconds * 1000; 422 const expired = []; 423 for (const [key, storedAt] of entries) { 424 if (typeof storedAt !== "number" || storedAt < cutoff) expired.push(key); 425 } 426 if (expired.length > 0) { 427 await this.state.storage.delete(expired); 428 } 429 } 430 431 appAttestRegistrationKey(deviceID, keyID) { 432 const canonicalKeyID = canonicalAppAttestKeyID(keyID); 433 if (!canonicalKeyID) throw new Error("invalid App Attest key ID"); 434 return `appattest-key:${deviceID}:${canonicalKeyID}`; 435 } 436 437 async handleRegister(request, bodyText, auth) { 438 const body = await readJSONText(bodyText); 439 if (!body) return badRequest("Body must be JSON"); 440 const { deviceID, token, environment, addresses, mutedKinds } = body; 441 if (!deviceID || !token || !Array.isArray(addresses)) { 442 return badRequest("deviceID, token, addresses required"); 443 } 444 if (auth.deviceID !== deviceID) { 445 return new Response("Authenticated device mismatch", { status: 403 }); 446 } 447 if (environment !== "sandbox" && environment !== "production") { 448 return badRequest("environment must be 'sandbox' or 'production'"); 449 } 450 if (!isValidID(deviceID) || !isValidID(token)) { 451 return badRequest("Malformed deviceID or token"); 452 } 453 if (addresses.length > this.ingressLimit("MAX_ADDRESS_COUNT")) { 454 return badRequest("Too many addresses"); 455 } 456 if (Array.isArray(mutedKinds) && mutedKinds.length > MAX_MUTED_KIND_COUNT) { 457 return badRequest("Too many mutedKinds"); 458 } 459 // Notification preferences ride along as a denylist of `kind` strings the 460 // device does not want delivered. The worker only string-matches them at 461 // publish time — it never interprets them — so a missing field (older 462 // clients) and a kind invented after registration both mean "deliver". 463 const muted = Array.isArray(mutedKinds) 464 ? mutedKinds.filter((kind) => typeof kind === "string" && kind.length > 0 && kind.length <= MAX_KIND_CHARS) 465 : []; 466 // A game-scoped binding is a subscription to that game's pushes, so it 467 // must prove current participation the same way a publish does: a game 468 // signature over this request, verified against the secret registered 469 // under the entry's credID. App Attest alone only proves "some enrolled 470 // installation" — without the secret proof, anyone who ever learned a 471 // credID (e.g. a departed participant, before rotation replaces it) could 472 // re-subscribe to the room. The request carries one signature, so all 473 // credID entries must name a single credID; the client registers each 474 // game's bindings in its own signed request. An unknown credID fails 475 // verification outright (`verifyGameSignature` requires the stored 476 // secret). Unproven credID entries are dropped rather than failing the 477 // request so a legacy client that still batches account + game entries in 478 // one unsigned request keeps its account binding. 479 const credIDs = new Set( 480 addresses 481 .filter((entry) => entry && typeof entry === "object" && isValidID(entry.credID)) 482 .map((entry) => entry.credID) 483 ); 484 let verifiedCredID = null; 485 if (credIDs.size === 1) { 486 const credID = credIDs.values().next().value; 487 const verification = await this.verifyGameSignature(request, credID); 488 if (verification.ok) verifiedCredID = credID; 489 } 490 // A room's registration set is exactly what a broadcast fans out to, so 491 // cap it at write time — that also bounds the broadcast storage scan. 492 // Checked before any write so a refused request stores nothing; 493 // re-registering an existing binding always succeeds. 494 if (verifiedCredID) { 495 const credPrefix = `addr:${verifiedCredID}:`; 496 const existing = await this.state.storage.list({ prefix: credPrefix }); 497 const incoming = new Set(); 498 for (const entry of addresses) { 499 const key = addressStorageKey(entry, deviceID); 500 if (key && key.startsWith(credPrefix) && !existing.has(key)) incoming.add(key); 501 } 502 if (existing.size + incoming.size > this.ingressLimit("MAX_REGISTRATIONS_PER_CRED")) { 503 return badRequest("Too many registrations for game"); 504 } 505 } 506 // Bind this device's APNs token to each address it knows. A game address 507 // carries the game's `credID` and is stored under it so a publish can only 508 // reach it when signed with that game's secret; the account-scoped sibling 509 // address has no credID and uses the bare key. Identity never reaches the 510 // worker — the (credID-scoped) address is the only lookup key. 511 const updatedAt = Date.now(); 512 for (const entry of addresses) { 513 const key = addressStorageKey(entry, deviceID); 514 if (!key) continue; 515 const entryCredID = entry && typeof entry === "object" && typeof entry.credID === "string" 516 ? entry.credID 517 : ""; 518 if (entryCredID && entryCredID !== verifiedCredID) continue; 519 const registration = { token, environment, updatedAt }; 520 if (muted.length > 0) registration.mutedKinds = muted; 521 await this.state.storage.put(key, registration); 522 } 523 return new Response(null, { status: 204 }); 524 } 525 526 async handleUnregister(bodyText, auth) { 527 const body = await readJSONText(bodyText); 528 if (!body) return badRequest("Body must be JSON"); 529 const { deviceID, addresses } = body; 530 if (!deviceID || !Array.isArray(addresses)) { 531 return badRequest("deviceID and addresses required"); 532 } 533 if (auth.deviceID !== deviceID) { 534 return new Response("Authenticated device mismatch", { status: 403 }); 535 } 536 if (!isValidID(deviceID)) { 537 return badRequest("Malformed deviceID"); 538 } 539 if (addresses.length > this.ingressLimit("MAX_ADDRESS_COUNT")) { 540 return badRequest("Too many addresses"); 541 } 542 for (const entry of addresses) { 543 const key = addressStorageKey(entry, deviceID); 544 if (!key) continue; 545 await this.state.storage.delete(key); 546 } 547 return new Response(null, { status: 204 }); 548 } 549 550 // Stores a game's shared push credential (first-write-wins), keyed by the 551 // unguessable credID minted into the Game record. The client (any 552 // participant) registers idempotently before publishing; a different secret 553 // under the same credID is refused with 409 (only reachable on a credID 554 // collision). Mirrors the room worker's `register`. 555 async handleGameRegister(credID, bodyText) { 556 const body = await readJSONText(bodyText); 557 if (!body) return badRequest("Body must be JSON"); 558 if (!isValidID(credID)) return badRequest("Malformed credID"); 559 const secret = typeof body.secret === "string" && body.secret.length <= MAX_SECRET_CHARS 560 ? body.secret 561 : ""; 562 if (!isAcceptableSecret(secret)) return badRequest("Invalid secret"); 563 const key = `gamecred:${credID}`; 564 const stored = await this.state.storage.get(key); 565 if (stored) { 566 if (!timingSafeEqual(stored.secret, secret)) { 567 return new Response("Game credential mismatch", { status: 409 }); 568 } 569 return new Response(null, { status: 204 }); 570 } 571 await this.state.storage.put(key, { secret, createdAt: Date.now() }); 572 return new Response(null, { status: 201 }); 573 } 574 575 // Authorization model: a game push must prove participation. The publish 576 // names the game's `credID` (minted into the participant-only Game record) 577 // and is signed with that game's secret; this verifies the signature 578 // against the secret registered under that credID and then resolves targets 579 // only among addresses registered under the same credID. So a caller can 580 // only reach a game's participants if it holds that game's secret — i.e. it 581 // is a participant. Account-scoped sibling pushes (accountJoined/accountSeen) 582 // carry no credID: their addresses derive from the account secret in the 583 // private CloudKit database, so they were never participant-spoofable. 584 async handlePublish(request, bodyText, auth) { 585 const body = await readJSONText(bodyText); 586 if (!body) return badRequest("Body must be JSON"); 587 const { 588 kind, 589 addressees, 590 gameID, 591 credID, 592 fromAuthorID, 593 senderDeviceID, 594 readAt, 595 title, 596 alertBody, 597 background, 598 broadcast, 599 excludeAddress, 600 collapseID, 601 payload, 602 enc 603 } = body; 604 if (!kind || typeof kind !== "string" || kind.length > MAX_KIND_CHARS) { 605 return badRequest("kind required"); 606 } 607 // Bound every field before the signature, rate-limit, storage, and APNs 608 // work it would otherwise buy. Forwarded metadata only needs a length cap; 609 // the credID additionally forms storage keys and gets the ID alphabet. 610 if (!isAbsentOrBounded(gameID, MAX_ID_CHARS) 611 || !isAbsentOrBounded(fromAuthorID, MAX_ID_CHARS) 612 || !isAbsentOrBounded(senderDeviceID, MAX_ID_CHARS) 613 || !isAbsentOrBounded(readAt, MAX_ID_CHARS) 614 || !isAbsentOrBounded(excludeAddress, MAX_ID_CHARS) 615 || !isAbsentOrBounded(title, MAX_TEXT_CHARS) 616 || !isAbsentOrBounded(alertBody, MAX_TEXT_CHARS) 617 || !isAbsentOrBounded(payload, MAX_OPAQUE_CHARS) 618 || !isAbsentOrBounded(enc, MAX_OPAQUE_CHARS)) { 619 return badRequest("Field too large"); 620 } 621 if (credID != null && credID !== "" && !isValidID(credID)) { 622 return badRequest("Malformed credID"); 623 } 624 // The collapse ID travels as an APNs header (64-byte APNs ceiling), so it 625 // must also stay printable ASCII. 626 if (collapseID != null 627 && !(typeof collapseID === "string" 628 && collapseID.length <= MAX_COLLAPSE_ID_CHARS 629 && /^[\x20-\x7e]*$/.test(collapseID))) { 630 return badRequest("Malformed collapseID"); 631 } 632 // A broadcast fans out to every device registered under the game's credID 633 // (the whole room), so it carries no addressees but must be game-scoped — 634 // the credID is both the delivery scope and, via its signature, the 635 // participation proof. A non-broadcast publish names its recipients. 636 if (broadcast === true) { 637 if (!credID) return badRequest("broadcast requires credID"); 638 } else { 639 if (!Array.isArray(addressees) || addressees.length === 0) { 640 return badRequest("non-empty addressees required"); 641 } 642 if (addressees.length > this.ingressLimit("MAX_ADDRESS_COUNT")) { 643 return badRequest("Too many addressees"); 644 } 645 for (const addressee of addressees) { 646 if (!addressee || typeof addressee !== "object" || !isValidID(addressee.address)) { 647 return badRequest("Malformed addressee"); 648 } 649 if (!isAbsentOrBounded(addressee.body, MAX_TEXT_CHARS) 650 || !isAbsentOrBounded(addressee.payload, MAX_OPAQUE_CHARS) 651 || !isAbsentOrBounded(addressee.enc, MAX_OPAQUE_CHARS)) { 652 return badRequest("Addressee field too large"); 653 } 654 } 655 } 656 657 // APNs enforces its payload ceiling only after the worker has spent 658 // signature, storage, and delivery work; measure the exact payload 659 // `sendOne` would build and refuse oversized publishes up front instead. 660 const encoder = new TextEncoder(); 661 const fits = (body, forwardedPayload, forwardedEnc) => 662 encoder.encode(JSON.stringify(buildAPNsPayload({ 663 kind, 664 gameID, 665 fromAuthorID, 666 senderDeviceID, 667 readAt, 668 title, 669 body, 670 payload: forwardedPayload, 671 enc: forwardedEnc, 672 background: background === true 673 }))).length <= MAX_APNS_PAYLOAD_BYTES; 674 const oversized = broadcast === true 675 ? !fits(alertBody, payload, enc) 676 : addressees.some((addressee) => !fits(addressee.body || alertBody, addressee.payload, addressee.enc)); 677 if (oversized) { 678 return new Response("Notification payload too large", { status: 413 }); 679 } 680 681 if (credID) { 682 const verification = await this.verifyGameSignature(request, credID); 683 if (!verification.ok) { 684 return new Response(verification.message, { status: verification.status }); 685 } 686 } 687 688 const limited = await this.checkPublishRateLimit({ credID, addressees, broadcast }); 689 if (limited) return limited; 690 691 const targets = broadcast === true 692 ? await this.resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload, enc) 693 : await this.resolveTargets(addressees, senderDeviceID, credID); 694 if (targets.length === 0) { 695 return Response.json({ delivered: 0, removed: 0, muted: 0, failed: 0 }); 696 } 697 698 let delivered = 0; 699 let removed = 0; 700 let muted = 0; 701 let failed = 0; 702 for (const target of targets) { 703 // Honor the target device's registered notification preferences: a 704 // muted kind is dropped here, before APNs, so the device never sees it. 705 if (Array.isArray(target.mutedKinds) && target.mutedKinds.includes(kind)) { 706 muted += 1; 707 continue; 708 } 709 const result = await this.sendOne(target, { 710 kind, 711 gameID, 712 fromAuthorID, 713 senderDeviceID, 714 readAt, 715 title, 716 body: target.body || alertBody, 717 payload: target.payload, 718 enc: target.enc, 719 collapseID: typeof collapseID === "string" ? collapseID : undefined, 720 background: background === true 721 }); 722 if (result === "ok") delivered += 1; 723 else if (result === "drop") { 724 // Delete by the exact key the target was resolved from — game 725 // addresses are stored credID-scoped (`addr:<credID>:<address>:<dev>`), 726 // so reconstructing a bare `addr:<address>:<dev>` key would miss them. 727 await this.state.storage.delete(target.storageKey); 728 removed += 1; 729 } else { 730 failed += 1; 731 } 732 } 733 return Response.json({ delivered, removed, muted, failed }); 734 } 735 736 async checkAttestationRateLimit(request, endpoint, deviceID) { 737 const ip = request.headers.get("CF-Connecting-IP") || "unknown"; 738 const ipLimit = endpoint === "register" 739 ? this.rateLimitConfig("APP_ATTEST_REGISTER_IP") 740 : this.rateLimitConfig("APP_ATTEST_CHALLENGE_IP"); 741 const deviceLimit = endpoint === "register" 742 ? this.rateLimitConfig("APP_ATTEST_REGISTER_DEVICE") 743 : this.rateLimitConfig("APP_ATTEST_CHALLENGE_DEVICE"); 744 745 const ipResult = await this.checkRateLimit(`attest:${endpoint}:ip`, ip, ipLimit); 746 if (!ipResult.ok) return rateLimitedResponse(ipResult); 747 const deviceResult = await this.checkRateLimit(`attest:${endpoint}:device`, deviceID, deviceLimit); 748 if (!deviceResult.ok) return rateLimitedResponse(deviceResult); 749 return null; 750 } 751 752 async checkPublishRateLimit({ credID, addressees, broadcast }) { 753 if (credID) { 754 const result = await this.checkRateLimit( 755 "publish:cred", 756 credID, 757 this.rateLimitConfig("PUBLISH_CRED") 758 ); 759 return result.ok ? null : rateLimitedResponse(result); 760 } 761 762 if (broadcast === true) { 763 return badRequest("broadcast requires credID"); 764 } 765 766 const seen = new Set(); 767 for (const addressee of addressees || []) { 768 const address = addressee && typeof addressee.address === "string" ? addressee.address : ""; 769 if (!address || seen.has(address)) continue; 770 seen.add(address); 771 const result = await this.checkRateLimit( 772 "publish:address", 773 address, 774 this.rateLimitConfig("PUBLISH_ADDRESS") 775 ); 776 if (!result.ok) return rateLimitedResponse(result); 777 } 778 return null; 779 } 780 781 rateLimitConfig(prefix) { 782 const defaults = RATE_LIMIT_DEFAULTS[prefix]; 783 const limit = Number(this.env[`${prefix}_LIMIT`] || String(defaults.limit)); 784 const windowSeconds = Number(this.env[`${prefix}_WINDOW_SECONDS`] || String(defaults.windowSeconds)); 785 return { 786 limit: Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : defaults.limit, 787 windowSeconds: Number.isFinite(windowSeconds) && windowSeconds > 0 788 ? Math.floor(windowSeconds) 789 : defaults.windowSeconds 790 }; 791 } 792 793 async checkRateLimit(bucket, identity, config) { 794 const now = Date.now(); 795 const windowMillis = config.windowSeconds * 1000; 796 const cutoff = now - windowMillis; 797 const key = await rateLimitStorageKey(bucket, identity, this.rateLimitKeySecret()); 798 const stored = await this.state.storage.get(key); 799 const recent = Array.isArray(stored) 800 ? stored.filter((timestamp) => typeof timestamp === "number" && timestamp > cutoff) 801 : []; 802 if (recent.length >= config.limit) { 803 const retryAfterSeconds = Math.max(1, Math.ceil((recent[0] + windowMillis - now) / 1000)); 804 await this.state.storage.put(key, recent); 805 await this.ensureRateSweepScheduled(); 806 return { ok: false, retryAfterSeconds }; 807 } 808 recent.push(now); 809 await this.state.storage.put(key, recent); 810 await this.ensureRateSweepScheduled(); 811 return { ok: true }; 812 } 813 814 // Rate keys are written per rotatable identity (IP, deviceID, credID, 815 // address) and would otherwise persist forever once that identity stops 816 // appearing. Arm-if-unarmed keeps the sweep at most one per horizon even 817 // under constant traffic — the same pattern as the room worker's 818 // EngagementRegisterLimiter. 819 async ensureRateSweepScheduled() { 820 const scheduled = await this.state.storage.getAlarm(); 821 if (scheduled === null) { 822 await this.state.storage.setAlarm(Date.now() + this.rateSweepHorizonMillis()); 823 } 824 } 825 826 // The sweep horizon is the largest configured window across all buckets: a 827 // key untouched for that long has expired in every bucket, whatever its own 828 // window, so one horizon safely serves keys whose bucket (and window) can't 829 // be recovered from the HMAC-digested storage key. 830 rateSweepHorizonMillis() { 831 const windows = Object.keys(RATE_LIMIT_DEFAULTS).map( 832 (prefix) => this.rateLimitConfig(prefix).windowSeconds 833 ); 834 return Math.max(...windows) * 1000; 835 } 836 837 async alarm() { 838 const cutoff = Date.now() - this.rateSweepHorizonMillis(); 839 const entries = await this.state.storage.list({ prefix: "rate:" }); 840 let liveKeys = 0; 841 for (const [key, stored] of entries) { 842 const newest = Array.isArray(stored) 843 ? stored.reduce((max, timestamp) => (typeof timestamp === "number" && timestamp > max ? timestamp : max), 0) 844 : 0; 845 if (newest <= cutoff) { 846 await this.state.storage.delete(key); 847 } else { 848 liveKeys += 1; 849 } 850 } 851 if (liveKeys > 0) { 852 await this.state.storage.setAlarm(Date.now() + this.rateSweepHorizonMillis()); 853 } 854 } 855 856 rateLimitKeySecret() { 857 return this.env.RATE_LIMIT_HASH_KEY || this.env.APNS_KEY || "crossmate-rate-limit-v1"; 858 } 859 860 // Resolves an env-overridable ingress cap, falling back to the table default 861 // on a missing or malformed override — same convention as the rate limits. 862 ingressLimit(name) { 863 const parsed = Number(this.env[name] || ""); 864 return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : INGRESS_LIMIT_DEFAULTS[name]; 865 } 866 867 // Verifies the game-participation signature: HMAC, under the secret 868 // registered for `credID`, over the App Attest request's own body hash, 869 // timestamp, and nonce (already validated by `authenticate`, so they are 870 // bound to this exact request and need no separate freshness check here). 871 async verifyGameSignature(request, credID) { 872 const cred = await this.state.storage.get(`gamecred:${credID}`); 873 if (!cred) { 874 return { ok: false, status: 403, message: "Game not registered" }; 875 } 876 const signature = request.headers.get("X-Crossmate-Game-Signature") || ""; 877 if (!signature) { 878 return { ok: false, status: 401, message: "Missing game signature" }; 879 } 880 const bodyHash = request.headers.get("X-Crossmate-Body-SHA256") || ""; 881 const timestamp = request.headers.get("X-Crossmate-Timestamp") || ""; 882 const nonce = request.headers.get("X-Crossmate-Nonce") || ""; 883 const payload = [ 884 "crossmate-push-game-v1", 885 credID, 886 bodyHash, 887 timestamp, 888 nonce 889 ].join("\n"); 890 const expected = await hmacSHA256(cred.secret, payload); 891 if (!timingSafeEqual(signature, expected)) { 892 return { ok: false, status: 401, message: "Invalid game signature" }; 893 } 894 return { ok: true }; 895 } 896 897 async resolveTargets(addressees, senderDeviceID, credID) { 898 const targets = []; 899 for (const addressee of addressees) { 900 if (!addressee || !addressee.address) continue; 901 const body = typeof addressee.body === "string" ? addressee.body : undefined; 902 // Opaque, app-encoded semantics. `enc` is the encrypted (sealed) payload 903 // current clients send; `payload` is the legacy cleartext base64 JSON an 904 // older client may still send. Either way the worker never inspects it — 905 // it just forwards it into the APNs userInfo for the notification service 906 // extension to decode. Keeping it opaque is what lets the app evolve 907 // notification meaning (and now encrypt it) without a worker deploy. 908 const payload = typeof addressee.payload === "string" ? addressee.payload : undefined; 909 const enc = typeof addressee.enc === "string" ? addressee.enc : undefined; 910 // Game pushes resolve only among addresses registered under the same 911 // credID; account pushes use the bare address key. 912 const prefix = credID 913 ? `addr:${credID}:${addressee.address}:` 914 : `addr:${addressee.address}:`; 915 const map = await this.state.storage.list({ prefix }); 916 for (const [key, value] of map) { 917 const deviceID = key.slice(prefix.length); 918 if (senderDeviceID && deviceID === senderDeviceID) continue; 919 targets.push({ 920 address: addressee.address, 921 deviceID, 922 storageKey: key, 923 body, 924 payload, 925 enc, 926 ...value 927 }); 928 } 929 } 930 return targets; 931 } 932 933 // Resolves every device registered under a game's credID — the whole room — 934 // for a broadcast publish. Keys are `addr:<credID>:<address>:<deviceID>`; 935 // addresses (base64url / `acct-…`) and device IDs (hex) never contain a 936 // colon, so the first colon after the prefix splits address from deviceID. 937 // The sender's own device and (via `excludeAddress`) its account's other 938 // devices are skipped, and the uniform `body`/`payload`/`enc` ride every target. 939 async resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload, enc) { 940 const body = typeof alertBody === "string" ? alertBody : undefined; 941 const forwarded = typeof payload === "string" ? payload : undefined; 942 const forwardedEnc = typeof enc === "string" ? enc : undefined; 943 const prefix = `addr:${credID}:`; 944 const map = await this.state.storage.list({ prefix }); 945 // The register-time per-credential cap bounds this scan; this cap bounds 946 // the sequential APNs sends if an over-cap room predates that gate. 947 const maxTargets = this.ingressLimit("MAX_BROADCAST_TARGETS"); 948 const targets = []; 949 for (const [key, value] of map) { 950 if (targets.length >= maxTargets) break; 951 const rest = key.slice(prefix.length); 952 const sep = rest.indexOf(":"); 953 if (sep < 0) continue; 954 const address = rest.slice(0, sep); 955 const deviceID = rest.slice(sep + 1); 956 if (senderDeviceID && deviceID === senderDeviceID) continue; 957 if (excludeAddress && address === excludeAddress) continue; 958 targets.push({ 959 address, 960 deviceID, 961 storageKey: key, 962 body, 963 payload: forwarded, 964 enc: forwardedEnc, 965 ...value 966 }); 967 } 968 return targets; 969 } 970 971 async sendOne(target, message) { 972 const topic = this.env.APNS_TOPIC || "net.inqk.crossmate"; 973 const host = target.environment === "sandbox" 974 ? "api.sandbox.push.apple.com" 975 : "api.push.apple.com"; 976 const jwt = await this.providerJWT(); 977 const apnsPayload = buildAPNsPayload(message); 978 979 // A "nudge" rouse is ephemeral: deliver now or discard, since "come play" 980 // delivered hours later is stale noise. `accountSeen` is also 981 // background-only, but it withdraws already-read notifications from sibling 982 // devices; give APNs a short store-and-forward window so a briefly- 983 // unreachable device can still converge. Other alert kinds (win/resign/ 984 // pause) are one-time meaningful events and keep the longer window so a 985 // recipient who is offline at send time still gets the banner. 986 const expirationSeconds = 987 message.kind === "accountSeen" ? 15 * 60 : 988 message.background || message.kind === "nudge" ? 0 : 989 4 * 60 * 60; 990 const expiration = expirationSeconds === 0 991 ? "0" 992 : String(Math.floor(Date.now() / 1000) + expirationSeconds); 993 994 const headers = { 995 authorization: `bearer ${jwt}`, 996 "apns-topic": topic, 997 "apns-push-type": message.background ? "background" : "alert", 998 "apns-priority": message.background ? "5" : "10", 999 "apns-expiration": expiration, 1000 "content-type": "application/json" 1001 }; 1002 // Coalesce alert pushes for one game into a single Notification Center tile 1003 // (the app picks the id; the receiver's NSE folds successive summaries into 1004 // it). Meaningless on a background push, which displays nothing. 1005 if (message.collapseID && !message.background) { 1006 headers["apns-collapse-id"] = message.collapseID; 1007 } 1008 1009 const response = await fetch(`https://${host}/3/device/${target.token}`, { 1010 method: "POST", 1011 headers, 1012 body: JSON.stringify(apnsPayload) 1013 }); 1014 1015 if (response.status === 200) return "ok"; 1016 if (response.status === 410) return "drop"; 1017 if (response.status === 400) { 1018 const text = await response.text(); 1019 if (text.includes("BadDeviceToken") || text.includes("DeviceTokenNotForTopic")) { 1020 return "drop"; 1021 } 1022 } 1023 return "fail"; 1024 } 1025 1026 async providerJWT() { 1027 const nowSeconds = Math.floor(Date.now() / 1000); 1028 if (this.cachedJWT && nowSeconds < this.cachedJWTExpiresAt - 60) { 1029 return this.cachedJWT; 1030 } 1031 const jwt = await signProviderJWT({ 1032 keyPEM: this.env.APNS_KEY, 1033 keyID: this.env.APNS_KEY_ID, 1034 teamID: this.env.APNS_TEAM_ID, 1035 issuedAt: nowSeconds 1036 }); 1037 this.cachedJWT = jwt; 1038 // Refresh well before APNs' 1-hour ceiling; the rate-limit floor is ~20 min. 1039 this.cachedJWTExpiresAt = nowSeconds + 40 * 60; 1040 return jwt; 1041 } 1042 } 1043 1044 export default { 1045 async fetch(request, env) { 1046 const url = new URL(request.url); 1047 if (url.pathname === "/health") { 1048 return new Response("ok"); 1049 } 1050 const id = env.PUSH_REGISTRY.idFromName("registry"); 1051 return env.PUSH_REGISTRY.get(id).fetch(request); 1052 } 1053 }; 1054 1055 // Buffers a request body only up to maxBytes: a Content-Length that already 1056 // exceeds the cap is refused for free, and a stream that grows past it is 1057 // cancelled mid-read instead of being materialized. 1058 async function readBodyWithinLimit(request, maxBytes) { 1059 const declared = Number(request.headers.get("content-length") || ""); 1060 if (Number.isFinite(declared) && declared > maxBytes) { 1061 return { ok: false }; 1062 } 1063 if (!request.body) { 1064 return { ok: true, text: "" }; 1065 } 1066 const reader = request.body.getReader(); 1067 const chunks = []; 1068 let total = 0; 1069 for (;;) { 1070 const { done, value } = await reader.read(); 1071 if (done) break; 1072 total += value.byteLength; 1073 if (total > maxBytes) { 1074 try { 1075 await reader.cancel(); 1076 } catch { 1077 // The stream is already errored/closed; nothing left to release. 1078 } 1079 return { ok: false }; 1080 } 1081 chunks.push(value); 1082 } 1083 return { ok: true, text: new TextDecoder().decode(concatBytes(...chunks)) }; 1084 } 1085 1086 // Key-forming identifier: bounded and confined to the base64url/hex/UUID 1087 // alphabet every genuine address, credID, device ID, token, and nonce uses — 1088 // so it can never smuggle a `:` storage-key separator or APNs URL syntax. 1089 function isValidID(value) { 1090 return typeof value === "string" 1091 && value.length > 0 1092 && value.length <= MAX_ID_CHARS 1093 && /^[A-Za-z0-9._-]+$/.test(value); 1094 } 1095 1096 // Returns the canonical, unpadded base64url spelling for an Apple App Attest 1097 // key ID. Comparing the round-trip spelling rejects malformed Base64 and 1098 // non-canonical trailing bits while deliberately treating standard Base64, 1099 // URL-safe Base64, and optional padding as the same opaque byte string. 1100 function canonicalAppAttestKeyID(value) { 1101 const mixesAlphabets = typeof value === "string" 1102 && (value.includes("+") || value.includes("/")) 1103 && (value.includes("-") || value.includes("_")); 1104 if (typeof value !== "string" 1105 || value.length === 0 1106 || value.length > MAX_APP_ATTEST_KEY_ID_CHARS 1107 || mixesAlphabets 1108 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(value)) { 1109 return null; 1110 } 1111 let bytes; 1112 try { 1113 bytes = base64URLDecodeFlexible(value); 1114 } catch { 1115 return null; 1116 } 1117 if (bytes.length === 0 || bytes.length > MAX_ID_CHARS) return null; 1118 const canonical = base64URLEncode(bytes); 1119 const suppliedCanonical = value 1120 .replace(/\+/g, "-") 1121 .replace(/\//g, "_") 1122 .replace(/=+$/g, ""); 1123 return canonical === suppliedCanonical ? canonical : null; 1124 } 1125 1126 // Forwarded metadata: absent (or null) is fine, anything present must be a 1127 // string within the cap. 1128 function isAbsentOrBounded(value, maxChars) { 1129 return value == null || (typeof value === "string" && value.length <= maxChars); 1130 } 1131 1132 // The exact APNs payload for one target, shared by the publish-time size 1133 // check and `sendOne` so the two can never disagree. 1134 function buildAPNsPayload(message) { 1135 const alert = {}; 1136 if (message.title) alert.title = message.title; 1137 if (message.body) alert.body = message.body; 1138 const apnsPayload = { 1139 aps: message.background 1140 ? { "content-available": 1 } 1141 : { alert, sound: "default", "mutable-content": 1 }, 1142 kind: message.kind 1143 }; 1144 if (message.gameID) apnsPayload.gameID = message.gameID; 1145 if (message.fromAuthorID) apnsPayload.fromAuthorID = message.fromAuthorID; 1146 if (message.senderDeviceID) apnsPayload.senderDeviceID = message.senderDeviceID; 1147 if (message.readAt) apnsPayload.readAt = message.readAt; 1148 // Forward the opaque app payload verbatim when present. `enc` is the 1149 // encrypted payload current clients send; `payload` is the legacy cleartext 1150 // form an older client may still send. Both are absent for older app builds, 1151 // which the extension handles by falling back to `kind`. 1152 if (message.enc) apnsPayload.enc = message.enc; 1153 if (message.payload) apnsPayload.payload = message.payload; 1154 return apnsPayload; 1155 } 1156 1157 async function readJSONText(text) { 1158 try { 1159 return JSON.parse(text || "{}"); 1160 } catch { 1161 return null; 1162 } 1163 } 1164 1165 function badRequest(message) { 1166 return new Response(message, { status: 400 }); 1167 } 1168 1169 function rateLimitedResponse(result) { 1170 return new Response("Rate limit exceeded", { 1171 status: 429, 1172 headers: { 1173 "Retry-After": String(result.retryAfterSeconds) 1174 } 1175 }); 1176 } 1177 1178 async function rateLimitStorageKey(bucket, identity, secret) { 1179 const key = await crypto.subtle.importKey( 1180 "raw", 1181 new TextEncoder().encode(secret), 1182 { name: "HMAC", hash: "SHA-256" }, 1183 false, 1184 ["sign"] 1185 ); 1186 const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(String(identity || ""))); 1187 const digest = new Uint8Array(signature); 1188 return `rate:${bucket}:${base64URLEncode(digest)}`; 1189 } 1190 1191 // Storage key for a device's registration under one address. A game address 1192 // arrives as `{address, credID}` and is keyed under its credID so a publish 1193 // can reach it only when signed with that game's secret; the account-scoped 1194 // address arrives without a credID and uses the bare key. 1195 function addressStorageKey(entry, deviceID) { 1196 const address = entry && typeof entry === "object" ? entry.address : entry; 1197 // The address and credID become `:`-separated storage-key segments, so both 1198 // must pass the ID alphabet or the key's structure could be forged. 1199 if (!isValidID(address)) return null; 1200 const credID = entry && typeof entry === "object" && typeof entry.credID === "string" 1201 ? entry.credID 1202 : ""; 1203 if (credID && !isValidID(credID)) return null; 1204 return credID 1205 ? `addr:${credID}:${address}:${deviceID}` 1206 : `addr:${address}:${deviceID}`; 1207 } 1208 1209 // The secret doubles as the HMAC key for game signatures, so a registered 1210 // value must decode to at least 32 key bytes (clients mint exactly 32). 1211 function isAcceptableSecret(secret) { 1212 if (!secret) return false; 1213 let bytes; 1214 try { 1215 bytes = base64URLDecode(secret); 1216 } catch { 1217 return false; 1218 } 1219 return bytes.length >= 32; 1220 } 1221 1222 async function hmacSHA256(secret, payload) { 1223 const key = await crypto.subtle.importKey( 1224 "raw", 1225 base64URLDecode(secret), 1226 { name: "HMAC", hash: "SHA-256" }, 1227 false, 1228 ["sign"] 1229 ); 1230 const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); 1231 return base64URLEncode(new Uint8Array(signature)); 1232 } 1233 1234 function canonicalPushRequest({ 1235 method, 1236 path, 1237 bodyHash, 1238 timestamp, 1239 nonce, 1240 deviceID, 1241 keyID 1242 }) { 1243 return [ 1244 "crossmate-push-request-v1", 1245 method.toUpperCase(), 1246 path, 1247 bodyHash, 1248 timestamp, 1249 nonce, 1250 deviceID, 1251 keyID 1252 ].join("\n"); 1253 } 1254 1255 async function sha256Bytes(bytes) { 1256 return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); 1257 } 1258 1259 function concatBytes(...arrays) { 1260 let length = 0; 1261 for (const array of arrays) length += array.length; 1262 const result = new Uint8Array(length); 1263 let offset = 0; 1264 for (const array of arrays) { 1265 result.set(array, offset); 1266 offset += array.length; 1267 } 1268 return result; 1269 } 1270 1271 function bytesEqual(left, right) { 1272 if (!left || !right || left.length !== right.length) return false; 1273 let diff = 0; 1274 for (let index = 0; index < left.length; index += 1) { 1275 diff |= left[index] ^ right[index]; 1276 } 1277 return diff === 0; 1278 } 1279 1280 function bytesToHex(bytes) { 1281 if (!bytes) return ""; 1282 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); 1283 } 1284 1285 function base64URLDecode(string) { 1286 let base64 = string.replace(/-/g, "+").replace(/_/g, "/"); 1287 base64 += "=".repeat((4 - (base64.length % 4)) % 4); 1288 const binary = atob(base64); 1289 const bytes = new Uint8Array(binary.length); 1290 for (let index = 0; index < binary.length; index += 1) { 1291 bytes[index] = binary.charCodeAt(index); 1292 } 1293 return bytes; 1294 } 1295 1296 function base64URLDecodeFlexible(string) { 1297 try { 1298 return base64URLDecode(string); 1299 } catch { 1300 const binary = atob(string); 1301 const bytes = new Uint8Array(binary.length); 1302 for (let index = 0; index < binary.length; index += 1) { 1303 bytes[index] = binary.charCodeAt(index); 1304 } 1305 return bytes; 1306 } 1307 } 1308 1309 function pemToDer(pem) { 1310 const stripped = pem 1311 .replace(/-----BEGIN CERTIFICATE-----/g, "") 1312 .replace(/-----END CERTIFICATE-----/g, "") 1313 .replace(/\s+/g, ""); 1314 return base64URLDecodeFlexible(stripped); 1315 } 1316 1317 function decodeAttestationObject(bytes) { 1318 const decoded = cborDecode(bytes); 1319 if (!decoded || decoded.fmt !== "apple-appattest" || !(decoded.authData instanceof Uint8Array)) { 1320 throw new Error("invalid attestation object"); 1321 } 1322 return decoded; 1323 } 1324 1325 function decodeAssertion(bytes) { 1326 const decoded = cborDecode(bytes); 1327 const authData = decoded.authenticatorData || decoded.authData; 1328 if (!(authData instanceof Uint8Array) || !(decoded.signature instanceof Uint8Array)) { 1329 throw new Error("invalid assertion object"); 1330 } 1331 return { 1332 authenticatorData: authData, 1333 signature: decoded.signature 1334 }; 1335 } 1336 1337 function cborDecode(bytes) { 1338 const reader = new CBORReader(bytes); 1339 const value = reader.read(); 1340 if (!reader.done) throw new Error("trailing cbor data"); 1341 return value; 1342 } 1343 1344 class CBORReader { 1345 constructor(bytes) { 1346 this.bytes = bytes; 1347 this.offset = 0; 1348 } 1349 1350 get done() { 1351 return this.offset === this.bytes.length; 1352 } 1353 1354 read() { 1355 const initial = this.readByte(); 1356 const major = initial >> 5; 1357 const additional = initial & 0x1f; 1358 const value = this.readArgument(additional); 1359 switch (major) { 1360 case 0: 1361 return value; 1362 case 1: 1363 return -1 - value; 1364 case 2: 1365 return this.readBytes(value); 1366 case 3: 1367 return new TextDecoder().decode(this.readBytes(value)); 1368 case 4: { 1369 const array = []; 1370 for (let index = 0; index < value; index += 1) { 1371 array.push(this.read()); 1372 } 1373 return array; 1374 } 1375 case 5: { 1376 const object = {}; 1377 for (let index = 0; index < value; index += 1) { 1378 object[this.read()] = this.read(); 1379 } 1380 return object; 1381 } 1382 case 7: 1383 if (additional === 20) return false; 1384 if (additional === 21) return true; 1385 if (additional === 22) return null; 1386 break; 1387 default: 1388 break; 1389 } 1390 throw new Error("unsupported cbor value"); 1391 } 1392 1393 readArgument(additional) { 1394 if (additional < 24) return additional; 1395 if (additional === 24) return this.readByte(); 1396 if (additional === 25) return this.readUInt(2); 1397 if (additional === 26) return this.readUInt(4); 1398 if (additional === 27) return this.readUInt(8); 1399 throw new Error("indefinite cbor values are unsupported"); 1400 } 1401 1402 readUInt(length) { 1403 let value = 0; 1404 for (let index = 0; index < length; index += 1) { 1405 value = (value * 256) + this.readByte(); 1406 } 1407 return value; 1408 } 1409 1410 readByte() { 1411 if (this.offset >= this.bytes.length) throw new Error("truncated cbor"); 1412 return this.bytes[this.offset++]; 1413 } 1414 1415 readBytes(length) { 1416 if (this.offset + length > this.bytes.length) throw new Error("truncated cbor bytes"); 1417 const value = this.bytes.slice(this.offset, this.offset + length); 1418 this.offset += length; 1419 return value; 1420 } 1421 } 1422 1423 function parseAuthenticatorData(bytes, options = {}) { 1424 if (bytes.length < 37) throw new Error("authenticator data too short"); 1425 const signCount = ( 1426 (bytes[33] * 0x1000000) + 1427 (bytes[34] << 16) + 1428 (bytes[35] << 8) + 1429 bytes[36] 1430 ) >>> 0; 1431 const result = { 1432 rpIDHash: bytes.slice(0, 32), 1433 flags: bytes[32], 1434 signCount 1435 }; 1436 const hasAttestedCredentialData = (result.flags & 0x40) !== 0; 1437 if (options.requireAttestedCredential && !hasAttestedCredentialData) { 1438 throw new Error("attested credential data missing"); 1439 } 1440 if (options.requireAttestedCredential || options.parseAttestedCredential) { 1441 if (bytes.length < 55) throw new Error("attested credential data missing"); 1442 result.aaguid = bytes.slice(37, 53); 1443 const credentialLength = (bytes[53] << 8) | bytes[54]; 1444 const credentialStart = 55; 1445 const credentialEnd = credentialStart + credentialLength; 1446 if (credentialEnd > bytes.length) throw new Error("credential id truncated"); 1447 result.credentialID = bytes.slice(credentialStart, credentialEnd); 1448 result.cosePublicKey = bytes.slice(credentialEnd); 1449 } 1450 return result; 1451 } 1452 1453 function isExpectedAppAttestAAGUID(aaguid, environment) { 1454 if (!aaguid || aaguid.length !== 16) return false; 1455 const production = new Uint8Array(16); 1456 production.set(new TextEncoder().encode("appattest"), 0); 1457 const development = new TextEncoder().encode("appattestdevelop"); 1458 if (environment === "development") { 1459 return bytesEqual(aaguid, development); 1460 } 1461 return bytesEqual(aaguid, production); 1462 } 1463 1464 function coseEC2PublicKeyToJWK(bytes) { 1465 const key = cborDecode(bytes); 1466 const x = key[-2]; 1467 const y = key[-3]; 1468 if (key[1] !== 2 || key[-1] !== 1 || !(x instanceof Uint8Array) || !(y instanceof Uint8Array)) { 1469 throw new Error("unsupported cose key"); 1470 } 1471 return { 1472 kty: "EC", 1473 crv: "P-256", 1474 x: base64URLEncode(x), 1475 y: base64URLEncode(y), 1476 ext: true 1477 }; 1478 } 1479 1480 function parseCertificate(bytes) { 1481 const cert = parseDER(bytes); 1482 if (cert.tag !== 0x30 || cert.children.length < 3) { 1483 throw new Error("invalid certificate"); 1484 } 1485 const tbs = cert.children[0]; 1486 const signatureAlgorithm = parseAlgorithmIdentifier(cert.children[1]); 1487 const signatureValue = cert.children[2]; 1488 if (signatureValue.tag !== 0x03) throw new Error("invalid certificate signature"); 1489 1490 const tbsChildren = tbs.children; 1491 let index = tbsChildren[0].tag === 0xa0 ? 1 : 0; 1492 index += 5; // serial, signature, issuer, validity, subject 1493 const subjectPublicKeyInfo = tbsChildren[index].raw; 1494 const extensions = []; 1495 for (const child of tbsChildren.slice(index + 1)) { 1496 if (child.tag === 0xa3 && child.children[0]?.tag === 0x30) { 1497 for (const ext of child.children[0].children) { 1498 const oid = decodeOID(ext.children[0].value); 1499 const valueNode = ext.children.find((node) => node.tag === 0x04); 1500 if (valueNode) extensions.push({ oid, value: valueNode.value }); 1501 } 1502 } 1503 } 1504 1505 return { 1506 tbs: tbs.raw, 1507 signatureAlgorithm, 1508 subjectPublicKeyInfo, 1509 signature: signatureValue.value.slice(1), 1510 extensions 1511 }; 1512 } 1513 1514 function parseDER(bytes, offset = 0) { 1515 const start = offset; 1516 const tag = bytes[offset++]; 1517 let length = bytes[offset++]; 1518 if ((length & 0x80) !== 0) { 1519 const byteCount = length & 0x7f; 1520 length = 0; 1521 for (let index = 0; index < byteCount; index += 1) { 1522 length = (length * 256) + bytes[offset++]; 1523 } 1524 } 1525 const valueStart = offset; 1526 const end = valueStart + length; 1527 if (end > bytes.length) throw new Error("truncated der"); 1528 const constructed = (tag & 0x20) !== 0; 1529 const children = []; 1530 if (constructed) { 1531 let childOffset = valueStart; 1532 while (childOffset < end) { 1533 const child = parseDER(bytes, childOffset); 1534 children.push(child); 1535 childOffset = child.end; 1536 } 1537 } 1538 return { 1539 tag, 1540 start, 1541 valueStart, 1542 end, 1543 raw: bytes.slice(start, end), 1544 value: bytes.slice(valueStart, end), 1545 children 1546 }; 1547 } 1548 1549 function decodeOID(bytes) { 1550 const parts = [Math.floor(bytes[0] / 40), bytes[0] % 40]; 1551 let value = 0; 1552 for (const byte of bytes.slice(1)) { 1553 value = (value << 7) | (byte & 0x7f); 1554 if ((byte & 0x80) === 0) { 1555 parts.push(value); 1556 value = 0; 1557 } 1558 } 1559 return parts.join("."); 1560 } 1561 1562 async function verifyCertificateSignature(cert, issuerSPKI) { 1563 const issuerKey = parseSubjectPublicKeyInfo(issuerSPKI); 1564 const hash = certificateSignatureHash(cert.signatureAlgorithm); 1565 const publicKey = await crypto.subtle.importKey( 1566 "spki", 1567 issuerSPKI, 1568 { name: "ECDSA", namedCurve: issuerKey.namedCurve }, 1569 false, 1570 ["verify"] 1571 ); 1572 const ok = await crypto.subtle.verify( 1573 { name: "ECDSA", hash }, 1574 publicKey, 1575 derECDSASignatureToRaw(cert.signature, issuerKey.coordinateLength), 1576 cert.tbs 1577 ); 1578 if (!ok) throw new Error("certificate signature verification failed"); 1579 } 1580 1581 function parseAlgorithmIdentifier(node) { 1582 if (!node || node.tag !== 0x30 || !node.children[0]) { 1583 throw new Error("invalid algorithm identifier"); 1584 } 1585 const algorithm = { 1586 oid: decodeOID(node.children[0].value) 1587 }; 1588 if (node.children[1]) { 1589 if (node.children[1].tag === 0x06) { 1590 algorithm.parametersOID = decodeOID(node.children[1].value); 1591 } else { 1592 algorithm.parameters = node.children[1].raw; 1593 } 1594 } 1595 return algorithm; 1596 } 1597 1598 function parseSubjectPublicKeyInfo(spki) { 1599 const node = parseDER(spki); 1600 if (node.tag !== 0x30 || !node.children[0]) { 1601 throw new Error("invalid subject public key info"); 1602 } 1603 const algorithm = parseAlgorithmIdentifier(node.children[0]); 1604 if (algorithm.oid !== "1.2.840.10045.2.1") { 1605 throw new Error(`unsupported certificate public key algorithm ${algorithm.oid}`); 1606 } 1607 switch (algorithm.parametersOID) { 1608 case "1.2.840.10045.3.1.7": 1609 return { namedCurve: "P-256", coordinateLength: 32 }; 1610 case "1.3.132.0.34": 1611 return { namedCurve: "P-384", coordinateLength: 48 }; 1612 default: 1613 throw new Error(`unsupported certificate EC curve ${algorithm.parametersOID || ""}`); 1614 } 1615 } 1616 1617 function certificateSignatureHash(signatureAlgorithm) { 1618 switch (signatureAlgorithm.oid) { 1619 case "1.2.840.10045.4.3.2": 1620 return "SHA-256"; 1621 case "1.2.840.10045.4.3.3": 1622 return "SHA-384"; 1623 default: 1624 throw new Error(`unsupported certificate signature algorithm ${signatureAlgorithm.oid}`); 1625 } 1626 } 1627 1628 function certificateAppAttestNonce(cert) { 1629 const extension = cert.extensions.find((entry) => entry.oid === "1.2.840.113635.100.8.2"); 1630 if (!extension) throw new Error("missing app attest nonce extension"); 1631 const nested = parseDER(extension.value); 1632 const octets = findOctetString(nested, 32); 1633 if (!octets) throw new Error("missing app attest nonce"); 1634 return octets; 1635 } 1636 1637 function findOctetString(node, length) { 1638 if (node.tag === 0x04 && node.value.length === length) return node.value; 1639 for (const child of node.children) { 1640 const found = findOctetString(child, length); 1641 if (found) return found; 1642 } 1643 return null; 1644 } 1645 1646 function derECDSASignatureToRaw(bytes, coordinateLength = 32) { 1647 const sequence = parseDER(bytes); 1648 if (sequence.tag !== 0x30 || sequence.children.length !== 2) { 1649 throw new Error("invalid ecdsa signature"); 1650 } 1651 return concatBytes( 1652 derIntegerToFixed(sequence.children[0].value, coordinateLength), 1653 derIntegerToFixed(sequence.children[1].value, coordinateLength) 1654 ); 1655 } 1656 1657 function derIntegerToFixed(bytes, length) { 1658 let value = bytes; 1659 while (value.length > 0 && value[0] === 0) { 1660 value = value.slice(1); 1661 } 1662 if (value.length > length) throw new Error("ecdsa integer too long"); 1663 const result = new Uint8Array(length); 1664 result.set(value, length - value.length); 1665 return result; 1666 } 1667 1668 async function signProviderJWT({ keyPEM, keyID, teamID, issuedAt }) { 1669 if (!keyPEM || !keyID || !teamID) { 1670 throw new Error("APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID must all be set"); 1671 } 1672 const key = await importP8(keyPEM); 1673 const header = base64URLEncode(new TextEncoder().encode(JSON.stringify({ 1674 alg: "ES256", 1675 kid: keyID 1676 }))); 1677 const claims = base64URLEncode(new TextEncoder().encode(JSON.stringify({ 1678 iss: teamID, 1679 iat: issuedAt 1680 }))); 1681 const signingInput = `${header}.${claims}`; 1682 const signature = await crypto.subtle.sign( 1683 { name: "ECDSA", hash: "SHA-256" }, 1684 key, 1685 new TextEncoder().encode(signingInput) 1686 ); 1687 return `${signingInput}.${base64URLEncode(new Uint8Array(signature))}`; 1688 } 1689 1690 async function importP8(pem) { 1691 const stripped = pem 1692 .replace(/-----BEGIN PRIVATE KEY-----/g, "") 1693 .replace(/-----END PRIVATE KEY-----/g, "") 1694 .replace(/\s+/g, ""); 1695 const der = Uint8Array.from(atob(stripped), (char) => char.charCodeAt(0)); 1696 return crypto.subtle.importKey( 1697 "pkcs8", 1698 der, 1699 { name: "ECDSA", namedCurve: "P-256" }, 1700 false, 1701 ["sign"] 1702 ); 1703 } 1704 1705 function base64URLEncode(bytes) { 1706 let binary = ""; 1707 for (const byte of bytes) { 1708 binary += String.fromCharCode(byte); 1709 } 1710 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); 1711 } 1712 1713 function timingSafeEqual(a, b) { 1714 const left = new TextEncoder().encode(a); 1715 const right = new TextEncoder().encode(b); 1716 if (left.length !== right.length) return false; 1717 let diff = 0; 1718 for (let index = 0; index < left.length; index += 1) { 1719 diff |= left[index] ^ right[index]; 1720 } 1721 return diff === 0; 1722 }