Add AppReceiptVerifier for signature validation of legacy app receipts - #208
Draft
emindeniz99 wants to merge 2 commits into
Draft
Add AppReceiptVerifier for signature validation of legacy app receipts#208emindeniz99 wants to merge 2 commits into
emindeniz99 wants to merge 2 commits into
Conversation
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>
This was referenced Aug 21, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 toReceiptUtility: 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:
_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 certificatesSignedDataVerifiertakes. The CMS signature is then verified against the public keyverify_chainitself returns, so the signing key provably comes from the validated chain.verify_chain's existingeffective_dateparameter), so old receipts survive certificate rotations — in line with Apple's Dec 2022 signing-certificate notice.enable_online_checksmoves evaluation to the current date and enables revocation checking, matchingSignedDataVerifier's semantics.cryptographyoffers no CMS signature verification and pyOpenSSL no longer ships a PKCS#7 API. The existingasn1dependency (whichReceiptUtilitynavigates 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,asn1cryptokeeps raw bytes and handles this BER shape (our own library's Python package uses it) — that variant is implemented and green (same 235 tests) onapp-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.VerificationExceptionwith existingVerificationStatusvalues — no additions. In the Xcode and LocalTesting environments the signature checks are skipped but the bundle id and environment are still validated, mirroringSignedDataVerifier.AppReceiptandInAppPurchaseReceiptare attrs models in the existing style (camelCase fields, epoch-millisecond dates, likeAppTransaction). 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_idhasextract_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
ReceiptUtilitytakes 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