crossmate

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

commit 48fb360b8350cec7e913e5b149da1828d291ae59
parent 51605910df65e7879c46817acc98a753943fcecd
Author: Michael Camilleri <[email protected]>
Date:   Sat, 19 Sep 2026 09:49:32 +0900

Decode App Attest authenticator extensions

Newer iOS versions append Apple validation extensions after the COSE
public key while leaving the WebAuthn extension flag clear. Crossmate
treated both CBOR values as the key, so the push worker rejected fresh
TestFlight installations with 'trailing cbor data'.

This commit separates and validates the extension map before retaining
the COSE key. It accepts TestFlight and App Store validation categories
and rejects malformed or unexpected metadata, with regression coverage
for Apple's flagless extension layout.

Co-Authored-By: Codex GPT 5.6 Terra <[email protected]>

Diffstat:
MTests/Workers/push-worker.test.mjs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MWorkers/push-worker.js | 42+++++++++++++++++++++++++++++++++++++++++-
2 files changed, 103 insertions(+), 2 deletions(-)

diff --git a/Tests/Workers/push-worker.test.mjs b/Tests/Workers/push-worker.test.mjs @@ -1,7 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createHmac } from "node:crypto"; -import { PushRegistry } from "../../Workers/push-worker.js"; +import { + parseAuthenticatorData, + PushRegistry, + validateAppAttestExtensions +} from "../../Workers/push-worker.js"; import { StubStorage } from "./helpers.mjs"; function makeRegistry(env = {}) { @@ -25,6 +29,63 @@ const appAttestKeyBytes = Buffer.from([ const standardAppAttestKeyID = appAttestKeyBytes.toString("base64"); const urlSafeAppAttestKeyID = appAttestKeyBytes.toString("base64url"); +function attestedAuthenticatorData({ flags = 0x40, extensions = new Uint8Array() } = {}) { + const rpIDHash = Buffer.alloc(32); + const signCount = Buffer.alloc(4); + const aaguid = Buffer.alloc(16); + const credentialID = Buffer.alloc(32, 7); + // A P-256 COSE key: { 1: 2, 3: -7, -1: 1, -2: x, -3: y }. + const coseKey = Buffer.concat([ + Buffer.from([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20]), + Buffer.alloc(32, 1), + Buffer.from([0x22, 0x58, 0x20]), + Buffer.alloc(32, 2) + ]); + const credentialLength = Buffer.from([0x00, credentialID.length]); + return Buffer.concat([ + rpIDHash, + Buffer.from([flags]), + signCount, + aaguid, + credentialLength, + credentialID, + coseKey, + extensions + ]); +} + +test("App Attest extensions follow, rather than extend, the COSE key despite a clear ED flag", () => { + // { "apple_bundle_version_01": "886", "apple_validation_category_01": h'02000000' } + const extensions = Buffer.from([ + 0xa2, + 0x77, ...Buffer.from("apple_bundle_version_01"), 0x63, 0x38, 0x38, 0x36, + 0x78, 0x1c, ...Buffer.from("apple_validation_category_01"), 0x44, 0x02, 0x00, 0x00, 0x00 + ]); + const data = attestedAuthenticatorData({ extensions }); + const parsed = parseAuthenticatorData(data, { requireAttestedCredential: true }); + + assert.equal(parsed.cosePublicKey.length, 77); + assert.equal(parsed.extensions.apple_bundle_version_01, "886"); + assert.deepEqual(parsed.extensions.apple_validation_category_01, Buffer.from([2, 0, 0, 0])); + assert.doesNotThrow(() => validateAppAttestExtensions(parsed.extensions)); +}); + +test("App Attest rejects malformed and invalid app extensions", () => { + assert.throws( + () => parseAuthenticatorData(attestedAuthenticatorData({ extensions: Buffer.from([0xf6]) }), { + requireAttestedCredential: true + }), + /invalid authenticator extensions/ + ); + assert.throws( + () => validateAppAttestExtensions({ + apple_validation_category_01: new Uint8Array([3, 0, 0, 0]), + apple_bundle_version_01: "886" + }), + /unexpected App Attest launch category/ + ); +}); + function challengeRequest(keyID, deviceID = "device-1") { return new Request("https://push.example/attest/challenge", { method: "POST", diff --git a/Workers/push-worker.js b/Workers/push-worker.js @@ -337,6 +337,7 @@ export class PushRegistry { if (!isExpectedAppAttestAAGUID(authData.aaguid, appAttestEnvironment)) { throw new Error(`unexpected aaguid for ${appAttestEnvironment}: ${bytesToHex(authData.aaguid)}`); } + validateAppAttestExtensions(authData.extensions); if (!attestation.attStmt || !Array.isArray(attestation.attStmt.x5c) || attestation.attStmt.x5c.length < 2) { throw new Error("missing certificate chain"); } @@ -1445,11 +1446,46 @@ function parseAuthenticatorData(bytes, options = {}) { const credentialEnd = credentialStart + credentialLength; if (credentialEnd > bytes.length) throw new Error("credential id truncated"); result.credentialID = bytes.slice(credentialStart, credentialEnd); - result.cosePublicKey = bytes.slice(credentialEnd); + // Newer iOS releases append a separate CBOR extensions map after the COSE + // public-key item. Apple currently does this without setting WebAuthn's + // ED flag, so decode one COSE item rather than treating all remaining + // authData as the key: COSE parsing must not see the extension map as + // trailing data. + const coseReader = new CBORReader(bytes.slice(credentialEnd)); + coseReader.read(); + const coseEnd = credentialEnd + coseReader.offset; + result.cosePublicKey = bytes.slice(credentialEnd, coseEnd); + + if (coseEnd !== bytes.length) { + const extensionReader = new CBORReader(bytes.slice(coseEnd)); + const extensions = extensionReader.read(); + if (!extensionReader.done || !extensions || Array.isArray(extensions) || typeof extensions !== "object") { + throw new Error("invalid authenticator extensions"); + } + result.extensions = extensions; + } } return result; } +function validateAppAttestExtensions(extensions) { + // Extensions are absent from older attestations. When present, Apple's + // current format includes the distributed-binary category and bundle + // version; accept only the two Crossmate distribution channels. + if (extensions === undefined) return; + const categoryBytes = extensions.apple_validation_category_01; + const bundleVersion = extensions.apple_bundle_version_01; + if (!(categoryBytes instanceof Uint8Array) || categoryBytes.length !== 4 + || typeof bundleVersion !== "string" || bundleVersion.length === 0) { + throw new Error("invalid App Attest app extensions"); + } + const category = categoryBytes[0] + + (categoryBytes[1] << 8) + + (categoryBytes[2] << 16) + + (categoryBytes[3] * 0x1000000); + if (![2, 4].includes(category)) throw new Error("unexpected App Attest launch category"); +} + function isExpectedAppAttestAAGUID(aaguid, environment) { if (!aaguid || aaguid.length !== 16) return false; const production = new Uint8Array(16); @@ -1720,3 +1756,7 @@ function timingSafeEqual(a, b) { } return diff === 0; } + +// Exported only for the Worker unit suite; Cloudflare ignores non-binding +// module exports at runtime. +export { parseAuthenticatorData, validateAppAttestExtensions };