Validating App Store Receipts without verifyReceipt
A fascinating look into how the App Store and StoreKit operate.
Apple receipts are not a black box. Since receipt files have been around, I’ve almost always treated them as tokens to be sent off to a service for validation, rather than actual data. I recently discovered that the App Store receipt’s contents are a full snapshot a user’s in-app-purchase (IAP) history and that Apple documents how to validate and extract this information. It is far from simple, but it provides a fascinating look into how the App Store and StoreKit operate.
App Store Receipts
Starting with iOS 7, Apple introduced an alternative method for accessing a user’s IAP history. Previously developers had to rely on the SKPaymentQueue
as the source of truth, listening to transactions and keeping track. With iOS 7, Apple introduced NSBundle.appStoreReceiptURL
that gave developers a disk location they could find this new “receipt” file. The intention was to have developers use this file as the source of truth for IAPs, rather than the StoreKit purchase queue, which can be difficult to build around.
Most developers’ experience with App Store receipts is very brief. It entails grabbing the URL, reading the file’s contents, encoding it in base 64, and sending it off to a server for validation via Apple. The data itself is a black box to most, more of a token than an actual container of information. However, this couldn’t be further from the truth. The receipt is a complex but decently documented file that contains almost all of the information that you would normally get by validating via Apple’s /verifyReceipt
endpoint.
Validating the Receipt Signature
The file itself uses an old file encoding called ASN.1, a binary file encoding protocol similar to Protobuf or Apache Thrift. Using ASN.1, Apple wraps the actual receipt data (also an ASN.1 blob) inside of a cryptographic container known as a PKCS#7 container. It is a container that stores signed data, a signature, and certificates required to verify the included signature.
We are using Python to decode and validate the receipt. First, we parse the PKCS#7 container and extract the essential parts: the certificates, the signature, and the receipt data.
1# Load the contents of the receipt file
2receipt_file = open('./receipt_data.bin', 'rb').read()
3
4# Use asn1crypto's cms definitions to parse the PKCS#7 format
5from asn1crypto.cms import ContentInfo
6pkcs_container = ContentInfo.load(receipt_file)
7
8# Extract the certificates, signature, and receipt_data
9certificates = pkcs_container['content']['certificates']
10signer_info = pkcs_container['content']['signer_infos'][0]
11receipt_data = pkcs_container['content']['encap_content_info']['content']
Apple relies on cryptographic signatures to guarantee that the contents of the receipt are valid. (It may be useful to brush up on asymmetric key cryptography if you aren’t familiar with the concept at a basic level.) Apple hashes the receipt data, then encrypts that hash with their private key. This ensures that the only way to decrypt the encrypted hash is using Apple’s published public key. This process is called signing, and the encrypted hash is called a signature.
Apple hashes and encrypts the receipt data to create a signature, then packages the signature, certificates, and receipt data together into the receipt file.
Our job is to ensure the receipt data we have is the same data that Apple signed. Since Apple generated the signature with their private key (which only Apple can access), we can trust the data in the receipt file without talking to Apple directly. To do this, we use Apple’s public key, also known as a certificate. We first decrypt the signature to get back to the hash and then compute the hash ourselves using the included receipt data and the same hashing function. If our computed hash matches the one we’ve decrypted from Apple, we’ve proven that the receipt data contained in the receipt file is the same receipt data that Apple signed.
For security reasons, Apple doesn’t like to use their root certificate to generate such simple signatures. If the private key for Apple’s root certificate were on every iTunes server, the chance of compromise would be much higher. Instead, Apple uses a certificate chain of trust to grant signing privileges to other, less critical certificates. Apple typically includes three certificates in the receipt file: the Apple Root Certificate, a World Wide Developer Relations Intermediate Certificate, and the iTunes App Store Certificate. The iTunes App Store Certificate is the one used to generate the signature of the receipt data.
1from OpenSSL.crypto import load_certificate, FILETYPE_ASN1
2
3# Pull out and parse the X.509 certificates included in the receipt
4itunes_cert_data = certificates[0].chosen.dump()
5itunes_cert = load_certificate(FILETYPE_ASN1, itunes_cert_data)
6itunes_cert_signature = certificates[0].chosen.signature
7
8wwdr_cert_data = certificates[1].chosen.dump()
9wwdr_cert = load_certificate(FILETYPE_ASN1, wwdr_cert_data)
10wwdr_cert_signature = certificates[1].chosen.signature
11
12untrusted_root_data = certificates[2].chosen.dump()
13untrusted_root = load_certificate(FILETYPE_ASN1, untrusted_root_data)
14untrusted_root_signature = certificates[2].chosen.signature
To bootstrap our trust chain, we need to obtain a trusted copy of the Apple Root Certificate. We can download a trusted Apple Root Certificate from Apple. Assuming your machine hasn’t been man-in-the-middled by some very tenacious IAP cracker, you can trust this certificate. Having a known trusted certificate, we can bootstrap trust to the other included certificates included in the receipt file.
1import urllib.request
2trusted_root_data = urllib.request.urlopen("https://www.apple.com/appleca/AppleIncRootCertificate.cer").read()
3trusted_root = load_certificate(FILETYPE_ASN1, trusted_root_data)
The trusted version of the Apple Root Certificate can be used to validate the signature on the WWDR Certificate. Once we’ve validated that the WWDR Certificate included in the receipt is trusted, we can, in turn, use it to validate the iTunes App Store Certificate. Fortunately, the OpenSSL Python wrapper provides tools for doing this.
1from OpenSSL.crypto import X509Store, X509StoreContext, X509StoreContextError
2trusted_store = X509Store()
3trusted_store.add_cert(trusted_root)
4
5try:
6 X509StoreContext(trusted_store, wwdr_cert).verify_certificate()
7 trusted_store.add_cert(wwdr_cert)
8except X509StoreContextError as e:
9 print("WWDR certificate invalid")
10 exit()
11
12try:
13 X509StoreContext(trusted_store, itunes_cert).verify_certificate()
14except X509StoreContextError as e:
15 print("iTunes certificate invalid")
16 exit()
Now we have the iTunes App Store Certificate extracted and validated; we can use it to validate the signature on the receipt data.
1from OpenSSL.crypto import verify
2
3try:
4 verify(itunes_cert, signer_info['signature'].native, receipt_data.native, 'sha1')
5 print("The receipt data signature is valid.")
6except Exception as e:
7 print("The receipt data is invalid: %s" % e)
We’ve now unpacked the PKCS#7 container, extracted the certificates, validated them using a trusted root cert, and used them to validate the signature on our receipt data. We can now trust that no one has modified the contents of the receipt data since Apple generated the signature. The next phase is unpacking the receipt data and reading the actual information about a user’s purchases.
Extracting the Receipt Fields
The signed receipt data itself is an ASN.1 container. However, this one doesn’t use a public specification such as PKCS#7 or X.509. Instead, it is a custom format created by Apple exclusively for App Store receipts. You can see the documented fields here (it’s likely you’ve used this page for reading Apple JSON responses). The following ASN.1 specification describes the contents of the receipt data:
1ReceiptModule DEFINITIONS ::=
2BEGIN
3
4ReceiptAttribute ::= SEQUENCE {
5 type INTEGER,
6 version INTEGER,
7 value OCTET STRING
8}
9
10Payload ::= SET OF ReceiptAttribute
11
12END
The definition is relatively loose, a record called a ReceiptAttribute
that is a tuple of(Integer type, Integer version, Octet String value) and a set of these that we call Payload. The type field is used in conjunction with Apple’s documentation to tell you how to parse the value field, which is a binary blob. The asn1crypto module lets us define custom types to automate some of the parsing work.
1from asn1crypto.core import Any, Integer, ObjectIdentifier, OctetString, Sequence, SetOf, UTF8String, IA5String
2attribute_types = [
3 (2, 'bundle_id', UTF8String),
4 (3, 'application_version', UTF8String) ,
5 (4, 'opaque_value', OctetString),
6 (5, 'sha1_hash', OctetString),
7 (12, 'creation_date', IA5String),
8 (17, 'in_app', OctetString),
9 (19, 'original_application_version', UTF8String),
10 (21, 'expiration_date', IA5String)
11]
12
13class ReceiptAttributeType(Integer):
14 _map = {type_code: name for type_code, name, _ in attribute_types}
15
16class ReceiptAttribute(Sequence):
17 _fields = [
18 ('type', ReceiptAttributeType),
19 ('version', Integer),
20 ('value', OctetString)
21 ]
22
23class Receipt(SetOf):
24 _child_spec = ReceiptAttribute
25
26
27receipt = Receipt.load(receipt_data.native)
We can now go over all the ReceiptAttribute tuples and move them into a dictionary since there should only be one of each type for all but one of the types. We also convert them to native Python types. There are, however, multiple instances of the in_app type. We will store these for later.
1receipt_attributes = {}
2attribute_types_to_class = {name: type_class for _, name, type_class in attribute_types}
3
4in_apps = []
5for attr in receipt:
6 attr_type = attr['type'].native
7
8 # Just store the in_apps for now
9 if attr_type == 'in_app':
10 in_apps.append(attr['value'])
11 continue
12
13 if attr_type in attribute_types_to_class:
14 if attribute_types_to_class[attr_type] is not None:
15 receipt_attributes[attr_type] = attribute_types_to_class[attr_type].load(attr['value'].native).native
16 else:
17 receipt_attributes[attr_type] = attr['value'].native
The dictionary receipt_attributes now has some of the same data you are accustomed to seeing in response from Apple’s /verifyReceipt
endpoint.
1The receipt data signature is valid.
2{'application_version': '1',
3 'bundle_id': 'com.jacobeiting.subtester',
4 'creation_date': '2017-10-03T00:58:43Z',
5 'opaque_value': b'\xdb\x81(\xb1=\xce.<i\xbdkS\xc6\xbe%\x08',
6 'original_application_version': '1.0',
7 'sha1_hash': b',\x88\x7f\x9f4\x1b\xc2\xa9W\x18\x1d\x81\x1c%\xbb\x91"*\xe97'}
8in_apps count = 8
Extracting In-App Purchases
The data we’ve extracted so far has to do with the app purchase or download, but we are usually more interested in the IAPs. To see what IAPs there are, we look at the in_app attributes that we siphoned off when going through the receipt’s attributes. The value field of in_app attributes is itself another ASN.1 container with a similar definition as the top-level receipt.
1InAppAttribute ::= SEQUENCE {
2 type INTEGER,
3 version INTEGER,
4 value OCTET STRING
5}
6
7InAppReceipt ::= SET OF InAppAttribute
We can perform the same operation on the contents of the individual in_app receipts to extract the fields as native Python objects.
1in_app_attribute_types = {
2 (1701, 'quantity', Integer),
3 (1702, 'product_id', UTF8String),
4 (1703, 'transaction_id', UTF8String),
5 (1705, 'original_transaction_id', UTF8String),
6 (1704, 'purchase_date', IA5String),
7 (1706, 'original_purchase_date', IA5String),
8 (1708, 'expires_date', IA5String),
9 (1719, 'is_in_intro_offer_period', Integer),
10 (1712, 'cancellation_date', IA5String),
11 (1711, 'web_order_line_item_id', Integer)
12}
13
14class InAppAttributeType(Integer):
15 _map = {type_code: name for (type_code, name, _) in in_app_attribute_types}
16
17class InAppAttribute(Sequence):
18 _fields = [
19 ('type', InAppAttributeType),
20 ('version', Integer),
21 ('value', OctetString)
22 ]
23
24class InAppPayload(SetOf):
25 _child_spec = InAppAttribute
26
27in_app_attribute_types_to_class = {name: type_class for _, name, type_class in in_app_attribute_types}
28
29in_apps_parsed = []
30
31for in_app_data in in_apps:
32 in_app = {}
33
34 for attr in InAppPayload.load(in_app_data.native):
35 attr_type = attr['type'].native
36
37 if attr_type in in_app_attribute_types_to_class:
38 in_app[attr_type] = in_app_attribute_types_to_class[attr_type].load(attr['value'].native).native
39
40 in_apps_parsed.append(in_app)
41
42receipt_attributes['in_app'] = in_apps_parsed
43
44from pprint import pprint as pp
45pp(receipt_attributes)
And that’s the way the news goes! We’ve successfully unpacked and validated the information from an Apple receipt without sending it off to Apple.
1{'application_version': '1',
2 'bundle_id': 'com.jacobeiting.subtester',
3 'creation_date': '2017-10-03T00:58:43Z',
4 'in_app': [{'cancellation_date': '',
5 'expires_date': '2017-10-02T23:16:23Z',
6 'original_purchase_date': '2017-10-02T23:08:25Z',
7 'original_transaction_id': '1000000339835086',
8 'product_id': 'onemonth_freetrial',
9 'purchase_date': '2017-10-02T23:11:23Z',
10 'quantity': 1,
11 'transaction_id': '1000000339835183',
12 'web_order_line_item_id': 1000000036447552},
13 {'cancellation_date': '',
14 'expires_date': '2017-10-02T23:21:23Z',
15 'original_purchase_date': '2017-10-02T23:08:25Z',
16 'original_transaction_id': '1000000339835086',
17 'product_id': 'onemonth_freetrial',
18 'purchase_date': '2017-10-02T23:16:23Z',
19 'quantity': 1,
20 'transaction_id': '1000000339835291',
21 'web_order_line_item_id': 1000000036447572},
22 {'cancellation_date': '',
23 'expires_date': '2017-10-02T23:11:23Z',
24 'original_purchase_date': '2017-10-02T23:08:25Z',
25 'original_transaction_id': '1000000339835086',
26 'product_id': 'onemonth_freetrial',
27 'purchase_date': '2017-10-02T23:08:23Z',
28 'quantity': 1,
29 'transaction_id': '1000000339835086',
30 'web_order_line_item_id': 1000000036447550}],
31 'opaque_value': b'\xdb\x81(\xb1=\xce.<i\xbdkS\xc6\xbe%\x08',
32 'original_application_version': '1.0',
33 'sha1_hash': b',\x88\x7f\x9f4\x1b\xc2\xa9W\x18\x1d\x81\x1c%\xbb\x91"*\xe97'}
Here is the script in its entirety.
Limitations
There are limitations to validating Apple receipts this way. The biggest being that you won’t get access to fields associated with subscriptions: latest_receipt_info and pending_renewal_info. The receipt file you unpack is just a snapshot, and you potentially need to be able to update a user’s subscriptions even if they haven’t sent you the most recent receipt. Additionally, validating receipts yourself is error-prone and could be equated with “rolling your own crypto” – risky and usually unneeded. For instance, DO NOT USE THIS CODE to validate your receipts.
We skipped over some important things like checking certificate expirations. You are better off using Apple’s /verifyReceipt
service or something like RevenueCat if you don’t want to handle the risk. RevenueCat is the best way to implement subscriptions in your mobile app. We handle all the complicated parts so you can get back to building.
You might also like
- Blog post
How we built the RevenueCat SDK for Kotlin Multiplatform
Explore the architecture and key decisions behind building the RevenueCat Kotlin Multiplatform SDK, designed to streamline in-app purchases across platforms.
- Blog post
Inside RevenueCat’s engineering strategy: Scaling beyond 32,000+ apps
The strategies and principles that guide our global team to build reliable, developer-loved software
- Blog post
RevenueCat Ship-a-ton
The hackathon that’s all about shipping… a ton.