crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }