crossmate

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

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 };