commit a4ecc2c17f7e9bd0cb0c93e3ca346f6c58667d28
parent 8fdf9e81d5ff5a172cab3b093f8d143a606d2da5
Author: Michael Camilleri <[email protected]>
Date: Fri, 12 Jun 2026 05:27:32 +0900
Fix App Attest assertion verification in the push worker
The push worker verified assertion signatures over the concatenation of
the authenticator data and client data hash, treating the App Attest
nonce as the ECDSA digest. The Secure Enclave actually signs the nonce
as a message, so the digest under the signature is the SHA-256 of the
nonce, and every assertion was rejected. The worker now hashes the
concatenation once before handing it to WebCrypto, which applies the
second hash. Temporary diagnostic logging around enrollment and
assertion checks stays in place until the rollout settles.
Challenge and nonce expiry relied on Durable Object storage honoring
expirationTtl, an option only KV supports, so neither ever expired.
Challenge age is now checked when an attestation is consumed, request
nonces are only treated as replays within the nonce TTL (older replays
already fail the timestamp skew check), and stale per-device keys are
pruned as new ones are written.
Two protocol adjustments round this out. The assertion counter is now
tracked as a high-water mark instead of enforced, since concurrent
requests from one device can arrive out of counter order and replay
protection is already covered by the nonce and timestamp checks. And
clients now fetch attestation challenges with a POST that carries the
device and key IDs in the body, keeping those identifiers out of logged
request URLs.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
2 files changed, 410 insertions(+), 53 deletions(-)
diff --git a/Crossmate/Services/PushRequestAuthenticator.swift b/Crossmate/Services/PushRequestAuthenticator.swift
@@ -114,18 +114,15 @@ actor PushRequestAuthenticator {
}
private func fetchChallenge(for keyID: String) async throws -> String {
- var components = URLComponents(
- url: baseURL.appendingPathComponent("attest").appendingPathComponent("challenge"),
- resolvingAgainstBaseURL: false
+ var request = URLRequest(
+ url: baseURL.appendingPathComponent("attest").appendingPathComponent("challenge")
)
- components?.queryItems = [
- URLQueryItem(name: "deviceID", value: deviceID),
- URLQueryItem(name: "keyID", value: keyID)
- ]
- guard let url = components?.url else {
- throw PushRequestAuthError.invalidResponse
- }
- let (data, response) = try await session.data(from: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(
+ ChallengeRequest(deviceID: deviceID, keyID: keyID)
+ )
+ let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw PushRequestAuthError.missingChallenge
}
@@ -225,6 +222,11 @@ actor PushRequestAuthenticator {
].joined(separator: "\n")
}
+ private struct ChallengeRequest: Encodable {
+ var deviceID: String
+ var keyID: String
+ }
+
private struct ChallengeResponse: Decodable {
var challenge: String
}
diff --git a/Workers/push-worker.js b/Workers/push-worker.js
@@ -9,8 +9,8 @@ export class PushRegistry {
async fetch(request) {
const url = new URL(request.url);
- if (url.pathname === "/attest/challenge" && request.method === "GET") {
- return this.handleAttestationChallenge(url);
+ if (url.pathname === "/attest/challenge" && request.method === "POST") {
+ return this.handleAttestationChallenge(request);
}
if (url.pathname === "/attest/register" && request.method === "POST") {
return this.handleAttestationRegister(request);
@@ -19,6 +19,16 @@ export class PushRegistry {
const bodyText = await request.text();
const auth = await this.authenticate(request, bodyText);
if (!auth.ok) {
+ console.warn("Push worker auth failed", {
+ method: request.method,
+ path: url.pathname,
+ status: auth.status,
+ message: auth.message,
+ authVersion: request.headers.get("X-Crossmate-Auth-Version") || "",
+ deviceIDLength: (request.headers.get("X-Crossmate-Device-ID") || "").length,
+ keyIDLength: (request.headers.get("X-Crossmate-Key-ID") || "").length,
+ bodyLength: bodyText.length
+ });
return new Response(auth.message, { status: auth.status });
}
@@ -89,8 +99,10 @@ export class PushRegistry {
return { ok: false, status: 401, message: "Unknown App Attest key" };
}
+ const nonceTTLSeconds = Number(this.env.REQUEST_NONCE_TTL_SECONDS || "300");
const nonceKey = `request-nonce:${deviceID}:${nonce}`;
- if (await this.state.storage.get(nonceKey)) {
+ const nonceUsedAt = await this.state.storage.get(nonceKey);
+ if (nonceUsedAt && Date.now() - nonceUsedAt <= nonceTTLSeconds * 1000) {
return { ok: false, status: 401, message: "Nonce already used" };
}
@@ -111,49 +123,151 @@ export class PushRegistry {
if (!bytesEqual(authData.rpIDHash, expectedAppIDHash)) {
return { ok: false, status: 401, message: "Bad App Attest app id" };
}
- if (authData.signCount <= (registration.signCount || 0)) {
- return { ok: false, status: 401, message: "Stale App Attest counter" };
- }
-
const signedBytes = concatBytes(assertion.authenticatorData, clientDataHash);
- const publicKey = await crypto.subtle.importKey(
- "jwk",
- registration.publicKeyJWK,
- { name: "ECDSA", namedCurve: "P-256" },
- false,
- ["verify"]
- );
+ // The Secure Enclave signs nonce = SHA256(authenticatorData || clientDataHash)
+ // as an ECDSA-SHA256 *message*, so the digest under the signature is
+ // SHA256(nonce). WebCrypto applies that second hash.
+ const assertionNonce = await sha256Bytes(signedBytes);
+ const keySource = registration.publicKeySPKI ? "spki" : "jwk";
+ const publicKey = await this.importAppAttestPublicKey(registration);
const verified = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
publicKey,
derECDSASignatureToRaw(assertion.signature),
- signedBytes
+ assertionNonce
);
if (!verified) {
+ const diagnostic = {
+ signatureLength: assertion.signature.length,
+ authenticatorDataLength: assertion.authenticatorData.length,
+ clientDataHashLength: clientDataHash.length,
+ signedBytesLength: signedBytes.length,
+ keySource,
+ hasPublicKeySPKI: Boolean(registration.publicKeySPKI),
+ signCount: authData.signCount,
+ storedSignCount: registration.signCount || 0,
+ rawOverDoubleHash: false,
+ derOverCanonicalHash: false,
+ rawOverCanonicalString: false,
+ rawSha384OverCanonicalHash: false,
+ rawSha512OverCanonicalHash: false,
+ rawOverPrehashedNonce: false,
+ rawOverClientDataHashOnly: false,
+ rawOverAuthenticatorDataOnly: false
+ };
+ try {
+ diagnostic.rawOverDoubleHash = await crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-256" },
+ publicKey,
+ derECDSASignatureToRaw(assertion.signature),
+ concatBytes(assertion.authenticatorData, await sha256Bytes(clientDataHash))
+ );
+ } catch {
+ diagnostic.rawOverDoubleHash = "error";
+ }
+ try {
+ diagnostic.derOverCanonicalHash = await crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-256" },
+ publicKey,
+ assertion.signature,
+ signedBytes
+ );
+ } catch {
+ diagnostic.derOverCanonicalHash = "error";
+ }
+ try {
+ diagnostic.rawOverCanonicalString = await crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-256" },
+ publicKey,
+ derECDSASignatureToRaw(assertion.signature),
+ concatBytes(assertion.authenticatorData, new TextEncoder().encode(canonical))
+ );
+ } catch {
+ diagnostic.rawOverCanonicalString = "error";
+ }
+ try {
+ diagnostic.rawSha384OverCanonicalHash = await crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-384" },
+ publicKey,
+ derECDSASignatureToRaw(assertion.signature),
+ signedBytes
+ );
+ } catch {
+ diagnostic.rawSha384OverCanonicalHash = "error";
+ }
+ try {
+ diagnostic.rawSha512OverCanonicalHash = await crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-512" },
+ publicKey,
+ derECDSASignatureToRaw(assertion.signature),
+ signedBytes
+ );
+ } catch {
+ diagnostic.rawSha512OverCanonicalHash = "error";
+ }
+ try {
+ diagnostic.rawOverPrehashedNonce = verifyP256ECDSADigest(
+ registration.publicKeyJWK,
+ derECDSASignatureToRaw(assertion.signature),
+ await sha256Bytes(signedBytes)
+ );
+ } catch {
+ diagnostic.rawOverPrehashedNonce = "error";
+ }
+ try {
+ diagnostic.rawOverClientDataHashOnly = await crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-256" },
+ publicKey,
+ derECDSASignatureToRaw(assertion.signature),
+ clientDataHash
+ );
+ } catch {
+ diagnostic.rawOverClientDataHashOnly = "error";
+ }
+ try {
+ diagnostic.rawOverAuthenticatorDataOnly = await crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-256" },
+ publicKey,
+ derECDSASignatureToRaw(assertion.signature),
+ assertion.authenticatorData
+ );
+ } catch {
+ diagnostic.rawOverAuthenticatorDataOnly = "error";
+ }
+ console.warn("App Attest assertion verification failed", diagnostic);
return { ok: false, status: 401, message: "Bad App Attest assertion" };
}
- registration.signCount = authData.signCount;
+ // Concurrent requests from one device can arrive out of counter order, and
+ // replay is already blocked by the nonce and timestamp checks, so the
+ // counter is only tracked (for clone diagnostics), never enforced.
+ registration.signCount = Math.max(registration.signCount || 0, authData.signCount);
registration.updatedAt = Date.now();
await this.state.storage.put(registrationKey, registration);
- await this.state.storage.put(nonceKey, Date.now(), {
- expirationTtl: Number(this.env.REQUEST_NONCE_TTL_SECONDS || "300")
- });
+ // Durable Object storage has no expirationTtl, so prune stale nonces here.
+ await this.pruneExpired(`request-nonce:${deviceID}:`, nonceTTLSeconds);
+ await this.state.storage.put(nonceKey, Date.now());
return { ok: true, deviceID };
}
- async handleAttestationChallenge(url) {
- const deviceID = url.searchParams.get("deviceID") || "";
- const keyID = url.searchParams.get("keyID") || "";
+ async handleAttestationChallenge(request) {
+ const body = await readJSONText(await request.text());
+ if (!body) return badRequest("Body must be JSON");
+ const deviceID = body.deviceID || "";
+ const keyID = body.keyID || "";
if (!deviceID || !keyID) {
return badRequest("deviceID and keyID required");
}
+ const ttlSeconds = this.appAttestChallengeTTLSeconds();
const challenge = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
- await this.state.storage.put(
- this.appAttestChallengeKey(deviceID, keyID, challenge),
- Date.now(),
- { expirationTtl: Number(this.env.APP_ATTEST_CHALLENGE_TTL_SECONDS || "300") }
- );
+ await this.pruneExpired(`appattest-challenge:${deviceID}:`, ttlSeconds);
+ await this.state.storage.put(this.appAttestChallengeKey(deviceID, challenge), Date.now());
+ console.log("App Attest challenge issued", {
+ deviceIDLength: deviceID.length,
+ keyIDLength: keyID.length,
+ challengeLength: challenge.length,
+ ttlSeconds
+ });
return Response.json({ challenge });
}
@@ -164,8 +278,20 @@ export class PushRegistry {
if (!deviceID || !keyID || !challenge || !attestationObject) {
return badRequest("deviceID, keyID, challenge, attestationObject required");
}
- const challengeKey = this.appAttestChallengeKey(deviceID, keyID, challenge);
- if (!(await this.state.storage.get(challengeKey))) {
+ const challengeKey = this.appAttestChallengeKey(deviceID, challenge);
+ const challengeIssuedAt = await this.state.storage.get(challengeKey);
+ const challengeExpired = challengeIssuedAt
+ ? Date.now() - challengeIssuedAt > this.appAttestChallengeTTLSeconds() * 1000
+ : false;
+ if (!challengeIssuedAt || challengeExpired) {
+ if (challengeExpired) await this.state.storage.delete(challengeKey);
+ console.warn("App Attest registration failed", {
+ error: challengeExpired ? "expired challenge" : "unknown challenge",
+ deviceIDLength: deviceID.length,
+ keyIDLength: keyID.length,
+ challengeLength: challenge.length,
+ attestationObjectLength: attestationObject.length
+ });
return new Response("Unknown App Attest challenge", { status: 401 });
}
@@ -178,15 +304,38 @@ export class PushRegistry {
});
await this.state.storage.put(this.appAttestRegistrationKey(deviceID, keyID), registration);
await this.state.storage.delete(challengeKey);
+ console.log("App Attest registration accepted", {
+ expectedEnvironment: this.env.APP_ATTEST_ENVIRONMENT || "production",
+ appBundleID: this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || "",
+ rootCertConfigured: Boolean(this.env.APP_ATTEST_ROOT_CERT_PEM),
+ rootCertLength: (this.env.APP_ATTEST_ROOT_CERT_PEM || "").length,
+ deviceIDLength: deviceID.length,
+ keyIDLength: keyID.length,
+ signCount: registration.signCount
+ });
return new Response(null, { status: 204 });
} catch (error) {
+ console.error("App Attest registration failed", {
+ error: error.message,
+ expectedEnvironment: this.env.APP_ATTEST_ENVIRONMENT || "production",
+ appTeamIDConfigured: Boolean(this.env.APP_TEAM_ID),
+ appBundleID: this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || "",
+ rootCertConfigured: Boolean(this.env.APP_ATTEST_ROOT_CERT_PEM),
+ rootCertLength: (this.env.APP_ATTEST_ROOT_CERT_PEM || "").length,
+ deviceIDLength: deviceID.length,
+ keyIDLength: keyID.length,
+ challengeLength: challenge.length,
+ attestationObjectLength: attestationObject.length
+ });
return new Response(`Bad App Attest attestation: ${error.message}`, { status: 401 });
}
}
async verifyAttestation({ deviceID, keyID, challenge, attestationObject }) {
const attestation = decodeAttestationObject(attestationObject);
- const authData = parseAuthenticatorData(attestation.authData);
+ const authData = parseAuthenticatorData(attestation.authData, {
+ requireAttestedCredential: true
+ });
const expectedAppIDHash = await this.expectedAppIDHash();
if (!bytesEqual(authData.rpIDHash, expectedAppIDHash)) {
throw new Error("app id hash mismatch");
@@ -194,8 +343,9 @@ export class PushRegistry {
if (!authData.credentialID || !bytesEqual(authData.credentialID, base64URLDecodeFlexible(keyID))) {
throw new Error("credential id mismatch");
}
- if (!isExpectedAppAttestAAGUID(authData.aaguid, this.env.APP_ATTEST_ENVIRONMENT || "production")) {
- throw new Error("unexpected aaguid");
+ const appAttestEnvironment = this.env.APP_ATTEST_ENVIRONMENT || "production";
+ if (!isExpectedAppAttestAAGUID(authData.aaguid, appAttestEnvironment)) {
+ throw new Error(`unexpected aaguid for ${appAttestEnvironment}: ${bytesToHex(authData.aaguid)}`);
}
if (!attestation.attStmt || !Array.isArray(attestation.attStmt.x5c) || attestation.attStmt.x5c.length < 2) {
throw new Error("missing certificate chain");
@@ -225,11 +375,31 @@ export class PushRegistry {
return {
publicKeyJWK: coseEC2PublicKeyToJWK(authData.cosePublicKey),
+ publicKeySPKI: base64URLEncode(leaf.subjectPublicKeyInfo),
signCount: authData.signCount,
createdAt: Date.now()
};
}
+ async importAppAttestPublicKey(registration) {
+ if (registration.publicKeySPKI) {
+ return crypto.subtle.importKey(
+ "spki",
+ base64URLDecode(registration.publicKeySPKI),
+ { name: "ECDSA", namedCurve: "P-256" },
+ false,
+ ["verify"]
+ );
+ }
+ return crypto.subtle.importKey(
+ "jwk",
+ registration.publicKeyJWK,
+ { name: "ECDSA", namedCurve: "P-256" },
+ false,
+ ["verify"]
+ );
+ }
+
async expectedAppIDHash() {
const teamID = this.env.APP_TEAM_ID || "";
const bundleID = this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || "";
@@ -239,8 +409,27 @@ export class PushRegistry {
return sha256Bytes(new TextEncoder().encode(`${teamID}.${bundleID}`));
}
- appAttestChallengeKey(deviceID, keyID, challenge) {
- return `appattest-challenge:${deviceID}:${keyID}:${challenge}`;
+ appAttestChallengeKey(deviceID, challenge) {
+ return `appattest-challenge:${deviceID}:${challenge}`;
+ }
+
+ appAttestChallengeTTLSeconds() {
+ return Number(this.env.APP_ATTEST_CHALLENGE_TTL_SECONDS || "300");
+ }
+
+ // Deletes per-device keys whose stored timestamp is older than ttlSeconds.
+ // Durable Object storage ignores KV's expirationTtl option, so expiry has to
+ // be enforced manually.
+ async pruneExpired(prefix, ttlSeconds) {
+ const entries = await this.state.storage.list({ prefix });
+ const cutoff = Date.now() - ttlSeconds * 1000;
+ const expired = [];
+ for (const [key, storedAt] of entries) {
+ if (typeof storedAt !== "number" || storedAt < cutoff) expired.push(key);
+ }
+ if (expired.length > 0) {
+ await this.state.storage.delete(expired);
+ }
}
appAttestRegistrationKey(deviceID, keyID) {
@@ -505,6 +694,11 @@ function bytesEqual(left, right) {
return diff === 0;
}
+function bytesToHex(bytes) {
+ if (!bytes) return "";
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
+}
+
function base64URLDecode(string) {
let base64 = string.replace(/-/g, "+").replace(/_/g, "/");
base64 += "=".repeat((4 - (base64.length % 4)) % 4);
@@ -643,7 +837,7 @@ class CBORReader {
}
}
-function parseAuthenticatorData(bytes) {
+function parseAuthenticatorData(bytes, options = {}) {
if (bytes.length < 37) throw new Error("authenticator data too short");
const signCount = (
(bytes[33] * 0x1000000) +
@@ -656,7 +850,11 @@ function parseAuthenticatorData(bytes) {
flags: bytes[32],
signCount
};
- if ((result.flags & 0x40) !== 0) {
+ const hasAttestedCredentialData = (result.flags & 0x40) !== 0;
+ if (options.requireAttestedCredential && !hasAttestedCredentialData) {
+ throw new Error("attested credential data missing");
+ }
+ if (options.requireAttestedCredential || options.parseAttestedCredential) {
if (bytes.length < 55) throw new Error("attested credential data missing");
result.aaguid = bytes.slice(37, 53);
const credentialLength = (bytes[53] << 8) | bytes[54];
@@ -702,6 +900,7 @@ function parseCertificate(bytes) {
throw new Error("invalid certificate");
}
const tbs = cert.children[0];
+ const signatureAlgorithm = parseAlgorithmIdentifier(cert.children[1]);
const signatureValue = cert.children[2];
if (signatureValue.tag !== 0x03) throw new Error("invalid certificate signature");
@@ -722,6 +921,7 @@ function parseCertificate(bytes) {
return {
tbs: tbs.raw,
+ signatureAlgorithm,
subjectPublicKeyInfo,
signature: signatureValue.value.slice(1),
extensions
@@ -777,22 +977,71 @@ function decodeOID(bytes) {
}
async function verifyCertificateSignature(cert, issuerSPKI) {
+ const issuerKey = parseSubjectPublicKeyInfo(issuerSPKI);
+ const hash = certificateSignatureHash(cert.signatureAlgorithm);
const publicKey = await crypto.subtle.importKey(
"spki",
issuerSPKI,
- { name: "ECDSA", namedCurve: "P-256" },
+ { name: "ECDSA", namedCurve: issuerKey.namedCurve },
false,
["verify"]
);
const ok = await crypto.subtle.verify(
- { name: "ECDSA", hash: "SHA-256" },
+ { name: "ECDSA", hash },
publicKey,
- derECDSASignatureToRaw(cert.signature),
+ derECDSASignatureToRaw(cert.signature, issuerKey.coordinateLength),
cert.tbs
);
if (!ok) throw new Error("certificate signature verification failed");
}
+function parseAlgorithmIdentifier(node) {
+ if (!node || node.tag !== 0x30 || !node.children[0]) {
+ throw new Error("invalid algorithm identifier");
+ }
+ const algorithm = {
+ oid: decodeOID(node.children[0].value)
+ };
+ if (node.children[1]) {
+ if (node.children[1].tag === 0x06) {
+ algorithm.parametersOID = decodeOID(node.children[1].value);
+ } else {
+ algorithm.parameters = node.children[1].raw;
+ }
+ }
+ return algorithm;
+}
+
+function parseSubjectPublicKeyInfo(spki) {
+ const node = parseDER(spki);
+ if (node.tag !== 0x30 || !node.children[0]) {
+ throw new Error("invalid subject public key info");
+ }
+ const algorithm = parseAlgorithmIdentifier(node.children[0]);
+ if (algorithm.oid !== "1.2.840.10045.2.1") {
+ throw new Error(`unsupported certificate public key algorithm ${algorithm.oid}`);
+ }
+ switch (algorithm.parametersOID) {
+ case "1.2.840.10045.3.1.7":
+ return { namedCurve: "P-256", coordinateLength: 32 };
+ case "1.3.132.0.34":
+ return { namedCurve: "P-384", coordinateLength: 48 };
+ default:
+ throw new Error(`unsupported certificate EC curve ${algorithm.parametersOID || ""}`);
+ }
+}
+
+function certificateSignatureHash(signatureAlgorithm) {
+ switch (signatureAlgorithm.oid) {
+ case "1.2.840.10045.4.3.2":
+ return "SHA-256";
+ case "1.2.840.10045.4.3.3":
+ return "SHA-384";
+ default:
+ throw new Error(`unsupported certificate signature algorithm ${signatureAlgorithm.oid}`);
+ }
+}
+
function certificateAppAttestNonce(cert) {
const extension = cert.extensions.find((entry) => entry.oid === "1.2.840.113635.100.8.2");
if (!extension) throw new Error("missing app attest nonce extension");
@@ -811,14 +1060,14 @@ function findOctetString(node, length) {
return null;
}
-function derECDSASignatureToRaw(bytes) {
+function derECDSASignatureToRaw(bytes, coordinateLength = 32) {
const sequence = parseDER(bytes);
if (sequence.tag !== 0x30 || sequence.children.length !== 2) {
throw new Error("invalid ecdsa signature");
}
return concatBytes(
- derIntegerToFixed(sequence.children[0].value, 32),
- derIntegerToFixed(sequence.children[1].value, 32)
+ derIntegerToFixed(sequence.children[0].value, coordinateLength),
+ derIntegerToFixed(sequence.children[1].value, coordinateLength)
);
}
@@ -833,6 +1082,112 @@ function derIntegerToFixed(bytes, length) {
return result;
}
+const P256 = {
+ p: 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn,
+ n: 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n,
+ a: -3n,
+ gx: 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296n,
+ gy: 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5n
+};
+
+function verifyP256ECDSADigest(publicKeyJWK, rawSignature, digest) {
+ if (!publicKeyJWK || publicKeyJWK.crv !== "P-256" || rawSignature.length !== 64 || digest.length !== 32) {
+ return false;
+ }
+ const r = bytesToBigInt(rawSignature.slice(0, 32));
+ const s = bytesToBigInt(rawSignature.slice(32));
+ if (r <= 0n || r >= P256.n || s <= 0n || s >= P256.n) return false;
+ const q = {
+ x: bytesToBigInt(base64URLDecode(publicKeyJWK.x)),
+ y: bytesToBigInt(base64URLDecode(publicKeyJWK.y))
+ };
+ if (!p256IsOnCurve(q)) return false;
+ const z = bytesToBigInt(digest) % P256.n;
+ const w = modInverse(s, P256.n);
+ const u1 = mod(z * w, P256.n);
+ const u2 = mod(r * w, P256.n);
+ const point = p256Add(
+ p256Multiply({ x: P256.gx, y: P256.gy }, u1),
+ p256Multiply(q, u2)
+ );
+ return Boolean(point) && mod(point.x, P256.n) === r;
+}
+
+function p256IsOnCurve(point) {
+ if (!point || point.x < 0n || point.x >= P256.p || point.y < 0n || point.y >= P256.p) {
+ return false;
+ }
+ const left = mod(point.y * point.y, P256.p);
+ const right = mod(point.x * point.x * point.x + P256.a * point.x + p256B(), P256.p);
+ return left === right;
+}
+
+function p256B() {
+ return 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
+}
+
+function p256Add(left, right) {
+ if (!left) return right;
+ if (!right) return left;
+ if (left.x === right.x) {
+ if (mod(left.y + right.y, P256.p) === 0n) return null;
+ return p256Double(left);
+ }
+ const slope = mod((right.y - left.y) * modInverse(right.x - left.x, P256.p), P256.p);
+ const x = mod(slope * slope - left.x - right.x, P256.p);
+ const y = mod(slope * (left.x - x) - left.y, P256.p);
+ return { x, y };
+}
+
+function p256Double(point) {
+ if (!point || point.y === 0n) return null;
+ const slope = mod((3n * point.x * point.x + P256.a) * modInverse(2n * point.y, P256.p), P256.p);
+ const x = mod(slope * slope - 2n * point.x, P256.p);
+ const y = mod(slope * (point.x - x) - point.y, P256.p);
+ return { x, y };
+}
+
+function p256Multiply(point, scalar) {
+ let result = null;
+ let addend = point;
+ let value = scalar;
+ while (value > 0n) {
+ if ((value & 1n) === 1n) result = p256Add(result, addend);
+ addend = p256Double(addend);
+ value >>= 1n;
+ }
+ return result;
+}
+
+function mod(value, modulus) {
+ const result = value % modulus;
+ return result >= 0n ? result : result + modulus;
+}
+
+function modInverse(value, modulus) {
+ let low = mod(value, modulus);
+ let high = modulus;
+ let lowCoefficient = 1n;
+ let highCoefficient = 0n;
+ while (low > 1n) {
+ const ratio = high / low;
+ [low, high] = [high - low * ratio, low];
+ [lowCoefficient, highCoefficient] = [
+ highCoefficient - lowCoefficient * ratio,
+ lowCoefficient
+ ];
+ }
+ return mod(lowCoefficient, modulus);
+}
+
+function bytesToBigInt(bytes) {
+ let value = 0n;
+ for (const byte of bytes) {
+ value = (value << 8n) + BigInt(byte);
+ }
+ return value;
+}
+
async function signProviderJWT({ keyPEM, keyID, teamID, issuedAt }) {
if (!keyPEM || !keyID || !teamID) {
throw new Error("APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID must all be set");