crossmate

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

push-worker.js (45003B)


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