Verifying App Attest in a Cloudflare Worker: four things Apple's docs don't quite say
Nib's server is a Cloudflare Worker, and Apple doesn't ship a JavaScript library for verifying StoreKit signatures or App Attest assertions there. So I wrote the verification myself — no dependencies, WebCrypto only. It mostly went fine. These are the four places it didn't.
Apple signs a hash, and ECDSA hashes it again
An App Attest assertion is an ECDSA P-256 signature you verify against the device's stored public key. The obvious reading of the flow — sign over authenticatorData || clientDataHash — produces code that looks exactly right and fails on every single legitimate assertion, with nothing more helpful than bad signature.
The part that's easy to miss: Apple's message is already a hash. The device signs nonce = SHA256(authData || clientDataHash), and then ECDSA-SHA256 — as WebCrypto implements it — hashes the message again before verifying. So the digest that has to match is effectively SHA256(SHA256(authData || clientDataHash)), and the value you hand to crypto.subtle.verify must be the nonce, not the concatenation:
// Apple は nonce = SHA256(authData || clientDataHash) を「メッセージ」として
// ECDSA-SHA256 署名する(実ダイジェストは SHA256(nonce) の二重ハッシュ)。
// authData||clientDataHash を直接 verify に渡すと1段足りず必ず失敗する。
const nonce = await sha256(concat(authData, clientDataHash));
const ok = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
key,
derSigToRaw(signature, 32),
nonce
);
What makes this one expensive is that there is no partial failure mode to triangulate from. Wrong app ID hash? Distinct error. Counter replay? Distinct error. Wrong signing message? Indistinguishable from a forged request. I only found it with a real device end to end — and it now has a regression test that reproduces Apple's signing procedure with a homemade P-256 key, including an explicit case asserting that the old, wrong shape is rejected. Signature code deserves tests for the bug you had, not just the behavior you want.
A security check that rejects everything looks exactly like one that works
To stop "any Apple-issued certificate" from acting as an issuer, the verifier byte-scans certificates for Apple's marker OIDs — the WWDR intermediate carries 1.2.840.113635.100.6.2.1. I encoded that OID by hand:
const OID_WWDR_INTERMEDIATE = Uint8Array.from(
[0x06, 0x0a, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x63, 0x64, 0x06, 0x02, 0x01]
); // 1.2.840.113635.100.6.2.1(長さ0x0a=10バイト。0x09だと実物に一切マッチしない)
The length byte in that header is 0x0a — ten bytes of OID body. For ten days, the code shipped with 0x09. A needle with a wrong length byte matches nothing, ever — including every genuine Apple certificate — so the check rejected the real WWDR intermediate along with everything else, and the first sandbox purchase died with jws: intermediate is not Apple WWDR.
The general lesson stuck with me more than the specific byte: a fail-closed security check that rejects everything is indistinguishable from a working one until real data arrives. No type system knows your DER is wrong. No unit test fires unless you thought to test against a real certificate — which is exactly what the fix added, verifying the needle against Apple's actual AppleWWDRCAG6.cer. Hand-rolled byte-scanning is fine; hand-rolled byte-scanning without a fixture from production is a time bomb with a quiet fuse.
Pinning Apple's root is not enough
My first chain verifier did what most write-ups suggest: walk the signatures, and pin the root's fingerprint to Apple Root CA G3. That sounds complete. It isn't — because it never asks whether the certificates in the middle are allowed to be issuers.
Without that question, any end-entity certificate that chains to Apple's root — a developer certificate, say — could be presented as an "intermediate," and the chain still walks: signatures verify, root pins. The fix is boring X.509 diligence, enforced in code:
* 署名連鎖・ルートピン留めに加え、(1) issuer/subject の名前連鎖、(2) 発行者証明書が
* CA:TRUE かつ keyCertSign を持つこと、を強制する。これが無いと Apple が署名した任意の
* end-entity 証明書を中間 CA に成りすませてしまう(marker OID のバイト走査だけでは防げない)。
Every certificate used as an issuer must have basicConstraints CA:TRUE and keyUsage keyCertSign, and each link's issuer name must equal the next certificate's subject name, byte for byte. None of this is exotic — it's what full TLS stacks do — but when you hand-roll verification, the checks you didn't write don't exist. I keep this one filed under "why you were told not to hand-roll X.509," written by someone who did it anyway and got to meet the reason personally.
App Attest doesn't exist on macOS — design the waiver as a ratchet
The awkward operational fact: DCAppAttestService is iOS-only. A Mac app can send you a StoreKit JWS, but it can never attest. So the Worker has to accept a "macOS waiver" — and the platform claim arrives in a client-controlled header, which means an iOS attacker can claim to be a Mac.
Two design decisions keep the waiver from becoming the hole:
The waiver is a ratchet. The first time a device registers an attest key, it has proven it's iOS — and it is never allowed to downgrade to the macOS waiver again. The claim is client-asserted; the history isn't.
While attestation is optional, its failure must not be fatal. An attacker who can't produce a valid assertion simply omits the header — returning 401 on a bad assertion blocks no one malicious and breaks only legitimate users with desynced state (a reinstall, a restored backup). So during rollout, failures are logged, not punished, until the enforcement flag flips for everyone. Half-enforced security that punishes honesty is worse than none.
What I'd tell myself
All four findings share a property: the failure is invisible from the code alone. The double hash reads correctly. The wrong length byte compiles into a working scanner. The unpinned intermediate check walks real chains happily. The fatal-on-failure waiver passes every test you'd write on day one. What surfaced each of them was contact with production reality — a real device, a real purchase, a real threat model, a real reinstall.
If you're doing this in a Worker yourself: budget the time for an end-to-end run with real hardware before you trust any green checkmark, and turn every bug you find into a fixture. The 400 lines are the easy part.
Written while building Nib — a monochrome notepad for Mac, iPhone and iPad where you press record inside a note and your words become searchable text as you speak, transcribed on-device. Transcription and search are free with no monthly cap.
See Nib
← All posts