Validating App Store Receipts

 转载    2023-03-15

Apple 的 Receipts 文件,包括App Attest receipt、App Store receipt等,都是采用PKCS #7容器格式包装的,包含签名、证书链、及使用ASN.1编码的payload。本文从原理和细节上讲解了使用 python 如何解码Receipts文件,如何验证签名、校验证书链、并提取payload。

原文链接:Validating App Store Receipts without verifyReceipt

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.

pkcs7 container

A diagram of the outermost format of the Apple Receipt, a container format commonly used for transporting signed data.

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.

Load the contents of the receipt file
receipt_file = open('./receipt_data.bin', 'rb').read()

# Use asn1crypto's cms definitions to parse the PKCS#7 format
from asn1crypto.cms import ContentInfo
pkcs_container = ContentInfo.load(receipt_file)

# Extract the certificates, signature, and receipt_data
certificates = pkcs_container['content']['certificates']
signer_info = pkcs_container['content']['signer_infos'][0]
receipt_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.

receipt hash 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.

receipt hash recompute

We use the enclosed certificates to recompute the decrypted hash from Apple. We need to validate the included certificates first though.

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.

from OpenSSL.crypto import load_certificate, FILETYPE_ASN1

# Pull out and parse the X.509 certificates included in the receipt
itunes_cert_data = certificates[0].chosen.dump()
itunes_cert = load_certificate(FILETYPE_ASN1, itunes_cert_data)
itunes_cert_signature = certificates[0].chosen.signature

wwdr_cert_data = certificates[1].chosen.dump()
wwdr_cert = load_certificate(FILETYPE_ASN1, wwdr_cert_data)
wwdr_cert_signature = certificates[1].chosen.signature

untrusted_root_data = certificates[2].chosen.dump()
untrusted_root = load_certificate(FILETYPE_ASN1, untrusted_root_data)
untrusted_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.

import urllib.request
trusted_root_data = urllib.request.urlopen("https://www.apple.com/appleca/AppleIncRootCertificate.cer").read()
trusted_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.

from OpenSSL.crypto import X509Store, X509StoreContext, X509StoreContextError
trusted_store = X509Store()
trusted_store.add_cert(trusted_root)

try:
	X509StoreContext(trusted_store, wwdr_cert).verify_certificate()
	trusted_store.add_cert(wwdr_cert)
except X509StoreContextError as e:
	print("WWDR certificate invalid")
	exit()
	
try:
	X509StoreContext(trusted_store, itunes_cert).verify_certificate()
except X509StoreContextError as e:
	print("iTunes certificate invalid")
	exit()

Now we have the iTunes App Store Certificate extracted and validated; we can use it to validate the signature on the receipt data.

from OpenSSL.crypto import verify

try:
	verify(itunes_cert, signer_info['signature'].native, receipt_data.native, 'sha1')
	print("The receipt data signature is valid.")
except Exception as e:
	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:

ReceiptModule DEFINITIONS ::=
BEGIN

ReceiptAttribute ::= SEQUENCE {
	type    INTEGER,
	version INTEGER,
	value   OCTET STRING
}
 
Payload ::= SET OF ReceiptAttribute
 
END

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.

from asn1crypto.core import Any, Integer, ObjectIdentifier, OctetString, Sequence, SetOf, UTF8String, IA5String
attribute_types = [
    (2, 'bundle_id', UTF8String),
    (3, 'application_version', UTF8String) ,
    (4, 'opaque_value', OctetString),
    (5, 'sha1_hash', OctetString),
    (12, 'creation_date', IA5String),
    (17, 'in_app', OctetString),
    (19, 'original_application_version', UTF8String),
    (21, 'expiration_date', IA5String)
]

class ReceiptAttributeType(Integer):
    _map = {type_code: name for type_code, name, _ in attribute_types}

class ReceiptAttribute(Sequence):
    _fields = [
        ('type', ReceiptAttributeType),
        ('version', Integer),
        ('value', OctetString)
    ]

class Receipt(SetOf):
    _child_spec = ReceiptAttribute


receipt = 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.

receipt_attributes = {}
attribute_types_to_class = {name: type_class for _, name, type_class in attribute_types}

in_apps = []
for attr in receipt:
  attr_type = attr['type'].native

  # Just store the in_apps for now
  if attr_type == 'in_app':
    in_apps.append(attr['value'])
    continue

  if attr_type in attribute_types_to_class:
    if attribute_types_to_class[attr_type] is not None:
      receipt_attributes[attr_type] = attribute_types_to_class[attr_type].load(attr['value'].native).native
    else:
      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.

The receipt data signature is valid.
{'application_version': '1',
 'bundle_id': 'com.jacobeiting.subtester',
 'creation_date': '2017-10-03T00:58:43Z',
 'opaque_value': b'\xdb\x81(\xb1=\xce.<i\xbdkS\xc6\xbe%\x08',
 'original_application_version': '1.0',
 'sha1_hash': b',\x88\x7f\x9f4\x1b\xc2\xa9W\x18\x1d\x81\x1c%\xbb\x91"*\xe97'}
in_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.

InAppAttribute ::= SEQUENCE {
    type                   INTEGER,
    version                INTEGER,
    value                  OCTET STRING
}
 
InAppReceipt ::= 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.

in_app_attribute_types = {
    (1701, 'quantity', Integer),
    (1702, 'product_id', UTF8String),
    (1703, 'transaction_id', UTF8String),
    (1705, 'original_transaction_id', UTF8String),
    (1704, 'purchase_date', IA5String),
    (1706, 'original_purchase_date', IA5String),
    (1708, 'expires_date', IA5String),
    (1719, 'is_in_intro_offer_period', Integer),
    (1712, 'cancellation_date', IA5String),
    (1711, 'web_order_line_item_id', Integer)
}

class InAppAttributeType(Integer):
    _map = {type_code: name for (type_code, name, _) in in_app_attribute_types}

class InAppAttribute(Sequence):
    _fields = [
        ('type', InAppAttributeType),
        ('version', Integer),
        ('value', OctetString)
    ]

class InAppPayload(SetOf):
    _child_spec = InAppAttribute

in_app_attribute_types_to_class = {name: type_class for _, name, type_class in in_app_attribute_types}

in_apps_parsed = []

for in_app_data in in_apps:
  in_app = {}

  for attr in InAppPayload.load(in_app_data.native):
    attr_type = attr['type'].native

    if attr_type in in_app_attribute_types_to_class:
      in_app[attr_type] = in_app_attribute_types_to_class[attr_type].load(attr['value'].native).native

  in_apps_parsed.append(in_app)

receipt_attributes['in_app'] = in_apps_parsed

from pprint import pprint as pp
pp(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.

{'application_version': '1',
 'bundle_id': 'com.jacobeiting.subtester',
 'creation_date': '2017-10-03T00:58:43Z',
 'in_app': [{'cancellation_date': '',
             'expires_date': '2017-10-02T23:16:23Z',
             'original_purchase_date': '2017-10-02T23:08:25Z',
             'original_transaction_id': '1000000339835086',
             'product_id': 'onemonth_freetrial',
             'purchase_date': '2017-10-02T23:11:23Z',
             'quantity': 1,
             'transaction_id': '1000000339835183',
             'web_order_line_item_id': 1000000036447552},
            {'cancellation_date': '',
             'expires_date': '2017-10-02T23:21:23Z',
             'original_purchase_date': '2017-10-02T23:08:25Z',
             'original_transaction_id': '1000000339835086',
             'product_id': 'onemonth_freetrial',
             'purchase_date': '2017-10-02T23:16:23Z',
             'quantity': 1,
             'transaction_id': '1000000339835291',
             'web_order_line_item_id': 1000000036447572}],
 'opaque_value': b'\xdb\x81(\xb1=\xce.<i\xbdkS\xc6\xbe%\x08',
 'original_application_version': '1.0',
 'sha1_hash': b',\x88\x7f\x9f4\x1b\xc2\xa9W\x18\x1d\x81\x1c%\xbb\x91"*\xe97'}

Here is the script in its entirety.

In-App Purchases: Receipt Validation Tutorial

相关文章:

iOS NSAttributedString NSHTMLTextDocumentType陷阱
iOS Method Swizzling 使用陷阱
iOS DeviceCheck详解
xcodebuild build failed:Use the $(inherited) flag
WWDC19:2019苹果全球开发者大会

发表留言

您的电子邮箱地址不会被公开,必填项已用*标注。发布的留言可能不会立即公开展示,请耐心等待审核通过。