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