Receipt Validation – Parse and Decode a Receipt with Swift
The aim of this guide is to help you parse a receipt and decode it so that you have readable pieces of metadata to inspect and finalize all of the receipt validation steps.
This is a continuation of my receipt validation series. I’m assuming that…
You’ve brought in a cryptography library like OpenSSL to be able to work with the PKCS #7 container that acts as the “envelope” for the receipt. Perhaps you’ve even done it the “easy way” with CocoaPods.
After finishing this guide, you’ll still have to compute the GUID hash of your app to compare with the hash that’s found within the receipt. You’ll also have to inspect the receipt data to perform any app-specific verification steps. But in order to do either, you’ll need the parsed receipt metadata.
Want to understand the final ReceiptParser? Let’s get to it!
The final goal: A parsed receipt
The final goal of this guide is a parsed receipt.
What do you say we start things off by defining what one looks like?
At the end of the day, what we’d like back from the parsing process is a simple struct that contains the various pieces of metadata that are found within the extracted the PKCS #7 container. Things like…
You may be wondering, “How’d he know what values are encoded within the extracted receipt payload?”. Apple has a very handy list of all the values that are encoded, so I listed each property out in my struct according to their documentation.
Parsing the receipt produces the most valuable piece of the whole process. Sure, it’s necessary to go through all of the other validation steps, but having a decoded receipt with actual human-readable values is, to me, a huge step.
Full disclaimer though: parsing the receipt is not very… Swifty.
We’re going to be working with all kinds of ugly things like UnsafeMutablePointers, and cryptically-named C Types.
Let’s take it one step at a time though…
Visualizing the encoded receipt’s structure
Up to now, we’ve been working only with the PKCS #7 container for the receipt. Now it’s time to dig into the container and see what it actually contains.
If you crack open the container, what you find is a long series of bytes that encode the actual structure of the receipt.
From beginning to end, the bytes should encode what’s called an “ASN.1 Set”. In fact, if you open the PKCS #7 container and it doesn’t encode an ASN.1 Set, that’d warrant a receipt validation failure…more about handling that in a minute.
Here’s a visual representation of an ASN.1 Set:
Since we’ve just got a bunch of bytes encoding things, there’s got to be some way to say, “This byte, or these series of bytes, represent [this human understandable thing]”.
That’s exactly what we’ve got, as you can see by the visual representation.
The first byte in the receipt payload (the green box in the visualization) signals that the bytes that follow encode an ASN.1 Set.
The next bytes in the series (the blue box) encode how long the ASN.1 Set is, so that as you’re going along parsing and decoding the contents of the Set, you know when to stop.
The final series of bytes (the yellow boxes) encode chunks of information that can be decoded to give you human readable receipt attributes. Those chunks, themselves, are encoded as ASN.1 Sequences.
So what does an ASN.1 Sequence look like? Here’s a visual:
When it comes to app receipts, ASN.1 Sequences are used to say, “Hey, this series of bytes encodes the bundle identifier or the original app version or some other receipt attribute.”
Each ASN.1 Sequence has a flag (the pink box in the visualization) to signal that the bytes that follow do, in fact, encode an ASN.1 Sequence.
Then, just like an ASN.1 Set, the next bytes in line (the blue box) encode how long the Sequence is. Then comes what we’re really after in all this Set/Sequence talk:
The type of attribute (bundle identifier, for example) is encoded next in the series of bytes as an ASN.1 Integer (note that this isn’t a Swift Int…yet…we’ll decode it soon). Each attribute type has a unique ASN.1 Integer value, kind of like an ID. Apple’s documentation is helpful in figuring out which ASN.1 Integer value maps to which receipt attribute.
After the attribute type comes some bytes that encode an “attribute version”, also as an ASN.1 Integer. At the time of this guide’s publication, “attribute version” isn’t used for anything. Nonetheless, the series of bytes right after the attribute type within the ASN.1 Sequence is reserved and will always represent the “attribute version”.
The remaining bytes in the ASN.1 Sequence encode the actual value of the attribute as an ASN.1 Octet String (don’t let the word “Octet String” trick you into thinking that it’s actually a String… they’re bytes that we’ll have to decode shortly…)
Knowing how the receipt payload is structured will help us formulate a strategy around parsing it. Let’s imagine a simple algorithm to do it now.
Receipt parsing strategy
Let’s take it step by step. What if we approach parsing the receipt like this:
1) Do some preliminary checks to ensure that the receipt payload is in the correct structural format (it should be an ASN.1 Set, for example).
2) For each ASN.1 Sequence within the ASN.1 Set, check to see what type of attribute it is.
3) Decode its Octet String value into actual, human-readable values. The decoded values would be represented by Swift Types (Int, String, Date are sufficient to cover all of the possibilities for receipts). The final decoded value depends on what type of attribute it is.
4) Create and return a ParsedReceipt instance as the final product.
If at any point the receipt payload fails to live up to the expected structure, receipt validation will fail, and we can signal that by throwing an error.
A note on in-app purchase receipts
As we follow the receipt parsing strategy steps that I just described, there’s going to come a point where we run into the ASN.1 Sequence that encodes the in-app purchase receipts.
These are special.
In-app purchase receipts are encoded as ASN.1 Sets (with ASN.1 Sequences within) inside the primary ASN.1 Set receipt payload. In other words, they’re nested ASN.1 Sets within the overall ASN.1 Set that encodes the whole receipt. The nested Set contains the in-app purchase receipt attributes.
So in order to decode these, we’ll have to apply the receipt parsing strategy within the receipt parsing strategy. Fun, huh? We’ll only have to do it for the in-app purchase receipt attributes though.
Preparation step: Helper decoding functions
If you saw the ParsedReceipt struct that I proposed earlier in the guide, you’ll notice that there are essentially four Swift Types that the receipt attributes (and in-app purchase receipt attributes) get decoded into:
Int?
String?
NSData?
Date?
NSData has a constructor that can work with UnsafeRawPointers directly, but Int?, String?, and Date? need some help converting from the ASN.1 versions of those Types to the Swift versions of those Types.
Let me put the code before you and follow up with what I’m doing here:
funcDecodeASN1Integer(startOfIntintPointer:inoutUnsafePointer<UInt8>?,length:Int)->Int?{// These will be set by ASN1_get_objectvartype=Int32(0)varxclass=Int32(0)varintLength=0ASN1_get_object(&intPointer,&intLength,&type,&xclass,length)guardtype==V_ASN1_INTEGERelse{returnnil}letinteger=c2i_ASN1_INTEGER(nil,&intPointer,intLength)letresult=ASN1_INTEGER_get(integer)ASN1_INTEGER_free(integer)returnresult}funcDecodeASN1String(startOfStringstringPointer:inoutUnsafePointer<UInt8>?,length:Int)->String?{// These will be set by ASN1_get_objectvartype=Int32(0)varxclass=Int32(0)varstringLength=0ASN1_get_object(&stringPointer,&stringLength,&type,&xclass,length)iftype==V_ASN1_UTF8STRING{letmutableStringPointer=UnsafeMutableRawPointer(mutating:stringPointer!)returnString(bytesNoCopy:mutableStringPointer,length:stringLength,encoding:String.Encoding.utf8,freeWhenDone:false)}iftype==V_ASN1_IA5STRING{letmutableStringPointer=UnsafeMutableRawPointer(mutating:stringPointer!)returnString(bytesNoCopy:mutableStringPointer,length:stringLength,encoding:String.Encoding.ascii,freeWhenDone:false)}returnnil}funcDecodeASN1Date(startOfDatedatePointer:inoutUnsafePointer<UInt8>?,length:Int)->Date?{// Date formatter code from https://www.objc.io/issues/17-security/receipt-validation/#parsing-the-receiptletdateFormatter=DateFormatter()dateFormatter.locale=Locale(identifier:"en_US_POSIX")dateFormatter.dateFormat="yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'"dateFormatter.timeZone=TimeZone(secondsFromGMT:0)ifletdateString=DecodeASN1String(startOfString:&datePointer,length:length){returndateFormatter.date(from:dateString)}returnnil}
Each of these decoding functions are dealing with the receipt attribute value portion of the ASN.1 Sequence that we’re working on at the time. Recall the structure:
So we take in a pointer that’s pointing to the start of one of the attribute values (a yellow box). The yellow box’s ASN.1 Octet String encodes either an integer, a string, or a date. (Okay, technically I guess you could include NSData, but this doesn’t need to be “decoded” really. And the in-app purchase receipts will be parsed and decoded into the stated Types as well, so it all boils down to the three I just mentioned…thus the reason for only three helper functions).
The strategy for the first two functions is basically to take what we’re pointing to, and call ASN1_get_object.
This function call gets us enough information to decode the bytes from the start of the object to the end of the object into either an Int? or a String?. If it fails, nil is returned.
Decoding dates simply involves initializing a DateFormatter with the appropriate locale and date format. The datePointer parameter actually points to an encoded string, so the strategy is to use the DecodeASN1String function we made, and pass the date string to the date formatter.
So long as the string can be decoded, the date formatter instance is used to create an actual Date? instance and return it. Otherwise, nil is returned.
Handling error conditions
The kinds of errors that can occur when parsing the receipt payload all have to do with unexpected structure.
For example, if we’re expecting to be stepping through an ASN.1 Payload or an ASN.1 Sequence but instead find that it’s not what we expect, this is a situation where reeipt validation should fail, because there’s no way to decode the receipt attributes if the bytes of the payload don’t conform to the expected structure.
In situations where the receipt payload or one of its in-app purchase receipt payloads is “malformed” in some way, we can throw an Error.
I’ve highlighted two new ReceiptValidationError cases here:
structReceiptParser{funcparse(_PKCS7Container:UnsafeMutablePointer<PKCS7>)throws->ParsedReceipt{varbundleIdentifier:String?varbundleIdData:NSData?varappVersion:String?varopaqueValue:NSData?varsha1Hash:NSData?varinAppPurchaseReceipts=[ParsedInAppPurchaseReceipt]()varoriginalAppVersion:String?varreceiptCreationDate:Date?varexpirationDate:Date?// Parse the receipt, setting each variablereturnParsedReceipt(bundleIdentifier:bundleIdentifier,bundleIdData:bundleIdData,appVersion:appVersion,opaqueValue:opaqueValue,sha1Hash:sha1Hash,inAppPurchaseReceipts:inAppPurchaseReceipts,originalAppVersion:originalAppVersion,receiptCreationDate:receiptCreationDate,expirationDate:expirationDate)}funcparseInAppPurchaseReceipt(currentInAppPurchaseASN1PayloadLocation:inoutUnsafePointer<UInt8>?,payloadLength:Int)throws->ParsedInAppPurchaseReceipt{varquantity:Int?varproductIdentifier:String?vartransactionIdentifier:String?varoriginalTransactionIdentifier:String?varpurchaseDate:Date?varoriginalPurchaseDate:Date?varsubscriptionExpirationDate:Date?varcancellationDate:Date?varwebOrderLineItemId:Int?// Parse the in-app purchase receipt, setting each variablereturnParsedInAppPurchaseReceipt(quantity:quantity,productIdentifier:productIdentifier,transactionIdentifier:transactionIdentifier,originalTransactionIdentifier:originalTransactionIdentifier,purchaseDate:purchaseDate,originalPurchaseDate:originalPurchaseDate,subscriptionExpirationDate:subscriptionExpirationDate,cancellationDate:cancellationDate,webOrderLineItemId:webOrderLineItemId)}
So a total of two functions: one to parse the overall receipt, and one to parse each in-app purchase receipt nested within the overall receipt.
Now comes the hard part. Actually doing all the decoding. Don’t forget the strategy we’re going to take! That’ll help you walk through this code without getting insanely overwhelmed (hopefully).
parse function implementation
First, the implementation of parse(_:) with comments throughout to help you find where each step of the strategy is being implemented:
funcparse(_PKCS7Container:UnsafeMutablePointer<PKCS7>)throws->ParsedReceipt{varbundleIdentifier:String?varbundleIdData:NSData?varappVersion:String?varopaqueValue:NSData?varsha1Hash:NSData?varinAppPurchaseReceipts=[ParsedInAppPurchaseReceipt]()varoriginalAppVersion:String?varreceiptCreationDate:Date?varexpirationDate:Date?// Strategy Step 1: Preliminary structure checks// Must have data to work withguardletcontents=PKCS7Container.pointee.d.sign.pointee.contents,letoctets=contents.pointee.d.dataelse{throwReceiptValidationError.malformedReceipt}// Determine the start and end of the receipt payloadvarcurrentASN1PayloadLocation=UnsafePointer(octets.pointee.data)letendOfPayload=currentASN1PayloadLocation!.advanced(by:Int(octets.pointee.length))vartype=Int32(0)varxclass=Int32(0)varlength=0ASN1_get_object(¤tASN1PayloadLocation,&length,&type,&xclass,Int(octets.pointee.length))// Payload must be an ASN1 Setguardtype==V_ASN1_SETelse{throwReceiptValidationError.malformedReceipt}// Decode Payload// Strategy Step 2: Walk through payload (ASN1 Set) and parse each ASN1 Sequence // within (ASN1 Sets contain one or more ASN1 Sequences)whilecurrentASN1PayloadLocation!<endOfPayload{// Get next ASN1 SequenceASN1_get_object(&currentASN1PayloadLocation,&length,&type,&xclass,currentASN1PayloadLocation!.distance(to:endOfPayload))// ASN1 Object type must be an ASN1 Sequenceguardtype==V_ASN1_SEQUENCEelse{throwReceiptValidationError.malformedReceipt}// Attribute type of ASN1 Sequence must be an IntegerguardletattributeType=DecodeASN1Integer(startOfInt:&currentASN1PayloadLocation,length:currentASN1PayloadLocation!.distance(to:endOfPayload))else{throwReceiptValidationError.malformedReceipt}// Attribute version of ASN1 Sequence must be an IntegerguardDecodeASN1Integer(startOfInt:&currentASN1PayloadLocation,length:currentASN1PayloadLocation!.distance(to:endOfPayload))!=nilelse{throwReceiptValidationError.malformedReceipt}// Get ASN1 Sequence valueASN1_get_object(&currentASN1PayloadLocation,&length,&type,&xclass,currentASN1PayloadLocation!.distance(to:endOfPayload))// ASN1 Sequence value must be an ASN1 Octet Stringguardtype==V_ASN1_OCTET_STRINGelse{throwReceiptValidationError.malformedReceipt}// Strategy Step 3: Decode attributesswitchattributeType{case2:varstartOfBundleId=currentASN1PayloadLocationbundleIdData=NSData(bytes:startOfBundleId,length:length)bundleIdentifier=DecodeASN1String(startOfString:&startOfBundleId,length:length)case3:varstartOfAppVersion=currentASN1PayloadLocationappVersion=DecodeASN1String(startOfString:&startOfAppVersion,length:length)case4:letstartOfOpaqueValue=currentASN1PayloadLocationopaqueValue=NSData(bytes:startOfOpaqueValue,length:length)case5:letstartOfSha1Hash=currentASN1PayloadLocationsha1Hash=NSData(bytes:startOfSha1Hash,length:length)case17:varstartOfInAppPurchaseReceipt=currentASN1PayloadLocationletiapReceipt=tryparseInAppPurchaseReceipt(currentInAppPurchaseASN1PayloadLocation:&startOfInAppPurchaseReceipt,payloadLength:length)inAppPurchaseReceipts.append(iapReceipt)case12:varstartOfReceiptCreationDate=currentASN1PayloadLocationreceiptCreationDate=DecodeASN1Date(startOfDate:&startOfReceiptCreationDate,length:length)case19:varstartOfOriginalAppVersion=currentASN1PayloadLocationoriginalAppVersion=DecodeASN1String(startOfString:&startOfOriginalAppVersion,length:length)case21:varstartOfExpirationDate=currentASN1PayloadLocationexpirationDate=DecodeASN1Date(startOfDate:&startOfExpirationDate,length:length)default:break}currentASN1PayloadLocation=currentASN1PayloadLocation?.advanced(by:length)}// Strategy Step 4: Return ParsedReceiptreturnParsedReceipt(bundleIdentifier:bundleIdentifier,bundleIdData:bundleIdData,appVersion:appVersion,opaqueValue:opaqueValue,sha1Hash:sha1Hash,inAppPurchaseReceipts:inAppPurchaseReceipts,originalAppVersion:originalAppVersion,receiptCreationDate:receiptCreationDate,expirationDate:expirationDate)}
Aside from the work with pointers and the Open SSL function names, the strategy is pretty straight-forward when you look it from a bird’s-eye point of view.
Once again, if you’re curious about how I knew to map each case within the switch to the correct receipt attribute, I simply got them from Apple’s documentation.
parseInAppPurchaseRectipt function implementation
Now it’s time to see how to parse an in-app purchase receipt payload. Take a look:
funcparseInAppPurchaseReceipt(currentInAppPurchaseASN1PayloadLocation:inoutUnsafePointer<UInt8>?,payloadLength:Int)throws->ParsedInAppPurchaseReceipt{varquantity:Int?varproductIdentifier:String?vartransactionIdentifier:String?varoriginalTransactionIdentifier:String?varpurchaseDate:Date?varoriginalPurchaseDate:Date?varsubscriptionExpirationDate:Date?varcancellationDate:Date?varwebOrderLineItemId:Int?// Find the end of the in-app purchase receipt payloadletendOfPayload=currentInAppPurchaseASN1PayloadLocation!.advanced(by:payloadLength)vartype=Int32(0)varxclass=Int32(0)varlength=0ASN1_get_object(¤tInAppPurchaseASN1PayloadLocation,&length,&type,&xclass,payloadLength)// Payload must be an ASN1 Setguardtype==V_ASN1_SETelse{throwReceiptValidationError.malformedInAppPurchaseReceipt}// Decode Payload// Step through payload (ASN1 Set) and parse each ASN1 Sequence within (ASN1 Sets contain one or more ASN1 Sequences)whilecurrentInAppPurchaseASN1PayloadLocation!<endOfPayload{// Get next ASN1 SequenceASN1_get_object(&currentInAppPurchaseASN1PayloadLocation,&length,&type,&xclass,currentInAppPurchaseASN1PayloadLocation!.distance(to:endOfPayload))// ASN1 Object type must be an ASN1 Sequenceguardtype==V_ASN1_SEQUENCEelse{throwReceiptValidationError.malformedInAppPurchaseReceipt}// Attribute type of ASN1 Sequence must be an IntegerguardletattributeType=DecodeASN1Integer(startOfInt:&currentInAppPurchaseASN1PayloadLocation,length:currentInAppPurchaseASN1PayloadLocation!.distance(to:endOfPayload))else{throwReceiptValidationError.malformedInAppPurchaseReceipt}// Attribute version of ASN1 Sequence must be an IntegerguardDecodeASN1Integer(startOfInt:&currentInAppPurchaseASN1PayloadLocation,length:currentInAppPurchaseASN1PayloadLocation!.distance(to:endOfPayload))!=nilelse{throwReceiptValidationError.malformedInAppPurchaseReceipt}// Get ASN1 Sequence valueASN1_get_object(&currentInAppPurchaseASN1PayloadLocation,&length,&type,&xclass,currentInAppPurchaseASN1PayloadLocation!.distance(to:endOfPayload))// ASN1 Sequence value must be an ASN1 Octet Stringguardtype==V_ASN1_OCTET_STRINGelse{throwReceiptValidationError.malformedInAppPurchaseReceipt}// Decode attributesswitchattributeType{case1701:varstartOfQuantity=currentInAppPurchaseASN1PayloadLocationquantity=DecodeASN1Integer(startOfInt:&startOfQuantity,length:length)case1702:varstartOfProductIdentifier=currentInAppPurchaseASN1PayloadLocationproductIdentifier=DecodeASN1String(startOfString:&startOfProductIdentifier,length:length)case1703:varstartOfTransactionIdentifier=currentInAppPurchaseASN1PayloadLocationtransactionIdentifier=DecodeASN1String(startOfString:&startOfTransactionIdentifier,length:length)case1705:varstartOfOriginalTransactionIdentifier=currentInAppPurchaseASN1PayloadLocationoriginalTransactionIdentifier=DecodeASN1String(startOfString:&startOfOriginalTransactionIdentifier,length:length)case1704:varstartOfPurchaseDate=currentInAppPurchaseASN1PayloadLocationpurchaseDate=DecodeASN1Date(startOfDate:&startOfPurchaseDate,length:length)case1706:varstartOfOriginalPurchaseDate=currentInAppPurchaseASN1PayloadLocationoriginalPurchaseDate=DecodeASN1Date(startOfDate:&startOfOriginalPurchaseDate,length:length)case1708:varstartOfSubscriptionExpirationDate=currentInAppPurchaseASN1PayloadLocationsubscriptionExpirationDate=DecodeASN1Date(startOfDate:&startOfSubscriptionExpirationDate,length:length)case1712:varstartOfCancellationDate=currentInAppPurchaseASN1PayloadLocationcancellationDate=DecodeASN1Date(startOfDate:&startOfCancellationDate,length:length)case1711:varstartOfWebOrderLineItemId=currentInAppPurchaseASN1PayloadLocationwebOrderLineItemId=DecodeASN1Integer(startOfInt:&startOfWebOrderLineItemId,length:length)default:break}currentInAppPurchaseASN1PayloadLocation=currentInAppPurchaseASN1PayloadLocation!.advanced(by:length)}returnParsedInAppPurchaseReceipt(quantity:quantity,productIdentifier:productIdentifier,transactionIdentifier:transactionIdentifier,originalTransactionIdentifier:originalTransactionIdentifier,purchaseDate:purchaseDate,originalPurchaseDate:originalPurchaseDate,subscriptionExpirationDate:subscriptionExpirationDate,cancellationDate:cancellationDate,webOrderLineItemId:webOrderLineItemId)}
As you can see, parsing an in-app purchase receipt uses the same strategy as parsing the overall receipt does.
A receipt may contain zero or more in-app purchase receipts, so this function may get called zero, one, or many times, depending on what your app offers as in-app purchases, and of course, what your users have actually purchased.
Final ReceiptParser
I realize that breaking the code apart like I’ve done is good for teaching purposes, but perhaps not so much for “I just wanna copy-paste and use this” purposes.
enumReceiptValidationResult{casesuccess(ParsedReceipt)// Now has ParsedReceipt for an associated valuecaseerror(ReceiptValidationError)}structReceiptValidator{letreceiptLoader=ReceiptLoader()letreceiptExtractor=ReceiptExtractor()letreceiptSignatureValidator=ReceiptSignatureValidator()letreceiptParser=ReceiptParser()funcvalidateReceipt()->ReceiptValidationResult{do{letreceiptData=tryreceiptLoader.loadReceipt()letreceiptContainer=tryreceiptExtractor.extractPKCS7Container(receiptData)tryreceiptSignatureValidator.checkSignaturePresence(receiptContainer)tryreceiptSignatureValidator.checkSignatureAuthenticity(receiptContainer)letparsedReceipt=tryreceiptParser.parse(receiptContainer)return.success(parsedReceipt)}catch{return.error(erroras!ReceiptValidationError)}}}
Preparing to finish receipt validation!
What a journey this has been! We’re almost done with this receipt validation process.