Skip to content

Add AppReceiptVerifier for signature validation of legacy app receipts - #208

Draft
emindeniz99 wants to merge 2 commits into
apple:mainfrom
emindeniz99:app-receipt-verification
Draft

Add AppReceiptVerifier for signature validation of legacy app receipts#208
emindeniz99 wants to merge 2 commits into
apple:mainfrom
emindeniz99:app-receipt-verification

Conversation

@emindeniz99

@emindeniz99 emindeniz99 commented Aug 21, 2026

Copy link
Copy Markdown

Resolves #207. Companion to apple/app-store-server-library-java#268 — the same design, ported to this library's idiom; the same shape is also open as apple/app-store-server-library-swift#133 and apple/app-store-server-library-node#427.

Adds AppReceiptVerifier, the validating counterpart to ReceiptUtility: it verifies the PKCS#7 app receipt's certificate chain and signature, then decodes the receipt and its in-app purchase attributes. For apps that still support iOS 14 and earlier, where StoreKit 2 isn't available, the app receipt is the only purchase artifact there is — and today the library extracts a transaction ID from it without checking that the App Store ever signed it.

Design notes:

  • Maximum reuse of the existing trust code. The embedded certificates are ordered leaf → intermediate → root and handed to the existing _ChainVerifier, which already enforces the chain length and both Apple marker OIDs (WWDR intermediate 1.2.840.113635.100.6.2.1, receipt-signing leaf 1.2.840.113635.100.6.11.1), against the same caller-supplied Apple root certificates SignedDataVerifier takes. The CMS signature is then verified against the public key verify_chain itself returns, so the signing key provably comes from the validated chain.
  • Chain validity is evaluated at the receipt's creation date (via verify_chain's existing effective_date parameter), so old receipts survive certificate rotations — in line with Apple's Dec 2022 signing-certificate notice. enable_online_checks moves evaluation to the current date and enables revocation checking, matching SignedDataVerifier's semantics.
  • The container is read with a small BER reader (offset-based, ~90 lines, in the new module), because genuine App Store and Xcode receipts use indefinite lengths and segmented OCTET STRINGs — the repo's own committed Xcode fixtures exercise exactly that, and the new tests decode them. The signature is verified over the signed attributes when present, or the payload directly otherwise, which is how genuine receipts sign; both paths are tested. No new dependencies: cryptography offers no CMS signature verification and pyOpenSSL no longer ships a PKCS#7 API. The existing asn1 dependency (which ReceiptUtility navigates receipts with) was considered, but its streaming decoder yields decoded values, not the raw byte ranges verification needs — the exact signed-attributes SET that is hashed, and the embedded certificates' DER — and re-encoding decoded values for signature checking is the classic path to verification bugs on BER input. Hence the small offset-based reader. If a parsing dependency is preferable, asn1crypto keeps raw bytes and handles this BER shape (our own library's Python package uses it) — that variant is implemented and green (same 235 tests) on app-receipt-verification-asn1crypto (diff vs this PR), so switching is a one-commit decision. One nuance the swap surfaced: asn1crypto accepts the indefinite-length constructed OCTET STRING form genuine receipts use but rejects the definite-length constructed form, which is also legal BER and which this PR's reader accepts.
  • No API surface beyond the new classes. Errors raise the existing VerificationException with existing VerificationStatus values — no additions. In the Xcode and LocalTesting environments the signature checks are skipped but the bundle id and environment are still validated, mirroring SignedDataVerifier.
  • Models AppReceipt and InAppPurchaseReceipt are attrs models in the existing style (camelCase fields, epoch-millisecond dates, like AppTransaction). Attribute types the library doesn't model are preserved as raw bytes, so fields Apple adds later stay reachable without a library update.
  • verify_and_extract_transaction_id has extract_transaction_id_from_app_receipt's exact output contract, but only after verification; ReceiptUtility's docstring now cross-references it (doc-only touch there).

Adversarial review pass. After opening this, I had the branch reviewed adversarially (independent per-language passes, each running its own probes, plus 60k fuzzed receipts and mutation testing of the security checks here). No verification bypass was found, and every security check was confirmed load-bearing by mutation: stubbing the digest comparison, the chain-derived key, the trailing-byte check, the re-tag or the digest allowlist is killed by an existing test. What the review did find, and what a later commit here fixes, was denial of service reachable before anything about a receipt has been verified, needing no Apple key material: nested constructed octet strings multiplied the walk (536 KB measured at 38 seconds of one core, where ReceiptUtility takes no measurable time on the same input); an oversized object identifier was decoded by shifting a bignum per byte (400 KB at 13 seconds); and the embedded certificates were parsed and ordered with no bound on their count. All three are now bounded before the expensive step. The reader also now rejects an element that runs past the end of its parent, which it previously read - the buffer bound was enforced but the enclosing element's was not.

Tests generate a throwaway in-memory PKI (tests/receipt_creator.py) and CMS-sign synthetic receipts — no new fixtures, no real receipts or Apple key material — and additionally decode the repo's two existing Xcode fixtures. 34 new tests cover decoding, tampering, foreign roots, missing marker OIDs, chain length, trailing bytes, expired-chain-at-creation-date behavior (with and without online checks), Xcode receipts, segmented content, receipts without signed attributes, digest-algorithm allowlisting, and the extraction contract; each was checked against a hand-run set of code mutations to confirm it fails when the behavior it guards changes. The full suite passes (239 tests, verified on Python 3.10 with the floor dependency versions and on 3.14 with current ones).

The same design is shipped and cross-verified in all four library languages in apple-purchase-receipt-verifier (MIT), against a shared fixture suite — this PR is that implementation rewritten in this library's idiom, reusing its existing trust code instead of carrying its own.

Happy to adjust naming or shape to whatever fits the library best.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Co-Authored-By: Claude Fable 5 noreply@anthropic.com

ReceiptUtility extracts a transaction id from an app receipt without any
validation, so the extracted id is client-chosen input. AppReceiptVerifier is
the validating counterpart: it verifies the receipt's PKCS#7 signature and
certificate chain with the existing chain verification used for JWS signed
data, against the same caller-supplied Apple root certificates
SignedDataVerifier takes, evaluated at the receipt's creation date so old
receipts survive certificate rotations unless online checks are enabled. It
decodes the receipt and its in-app purchase attributes into AppReceipt and
InAppPurchaseReceipt, and offers verify_and_extract_transaction_id with
ReceiptUtility's exact output contract.

The container is read with a small strict BER reader because genuine App
Store and Xcode receipts use indefinite lengths and segmented OCTET STRINGs,
and the signature is verified over the signed attributes when present or the
payload directly otherwise, as genuine receipts sign. No new dependencies.

Tests generate a throwaway PKI and sign synthetic receipts in memory - no
real receipts or Apple key material - and also decode the repo's existing
Xcode fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An adversarial review of the branch found three ways a receipt costs far
more to reject than to send, all reached before anything about it has
been verified and none needing any Apple key material.

Finding the end of an indefinite-length element means walking everything
inside it, and its parent walked it too, so nesting multiplies the work:
a 536 KB receipt of nested constructed octet strings measured at 38
seconds of one core, where ReceiptUtility takes no measurable time on
the same input. Nesting depth is now bounded.

An object identifier is decoded by shifting a bignum per byte, and the
content type is the first field read out of a receipt, so a 400 KB
identifier measured at 13 seconds. Its width is now bounded.

The embedded certificates are parsed and ordered into a chain before
verification, so their count is now bounded first.

The reader also now rejects an element that runs past the end of its
parent, which it previously read: the buffer bound was enforced but the
enclosing element's was not, so a nested element could be interpreted
from bytes outside the element that declared it.

Four tests cover what the review found untested: the three bounds, and
a signer that names its certificate by subject key identifier rather
than by issuer and serial number, which decides which embedded
certificate becomes the leaf of the chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add signature validation for legacy app receipts alongside ReceiptUtility

1 participant