In-App Purchases: Receipt Validation Tutorial
In this tutorial, you’ll learn how receipts for In-App Purchases work and how to validate them to ensure your users have paid for the goodies you give them. By Bill Morefield.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
In-App Purchases: Receipt Validation Tutorial
30 mins
Reading In-App Purchases
The attribute for in-app purchases requires more complex processing. Instead of a single integer or string, in-app purchases are another ASN.1 set within this set. IAPReceipt.swift contains an IAPReceipt
to store the contents. The set is formatted the same as the one containing it and the code to read it is very similar. Add the following initializer to IAPReceipt
:
init?(with pointer: inout UnsafePointer<UInt8>?, payloadLength: Int) {
let endPointer = pointer!.advanced(by: payloadLength)
var type: Int32 = 0
var xclass: Int32 = 0
var length = 0
ASN1_get_object(&pointer, &length, &type, &xclass, payloadLength)
guard type == V_ASN1_SET else {
return nil
}
while pointer! < endPointer {
ASN1_get_object(&pointer, &length, &type, &xclass, pointer!.distance(to: endPointer))
guard type == V_ASN1_SEQUENCE else {
return nil
}
guard let attributeType = readASN1Integer(ptr: &pointer,
maxLength: pointer!.distance(to: endPointer))
else {
return nil
}
// Attribute version must be an integer, but not using the value
guard let _ = readASN1Integer(ptr: &pointer,
maxLength: pointer!.distance(to: endPointer))
else {
return nil
}
ASN1_get_object(&pointer, &length, &type, &xclass, pointer!.distance(to: endPointer))
guard type == V_ASN1_OCTET_STRING else {
return nil
}
switch attributeType {
case 1701:
var p = pointer
quantity = readASN1Integer(ptr: &p, maxLength: length)
case 1702:
var p = pointer
productIdentifier = readASN1String(ptr: &p, maxLength: length)
case 1703:
var p = pointer
transactionIdentifer = readASN1String(ptr: &p, maxLength: length)
case 1705:
var p = pointer
originalTransactionIdentifier = readASN1String(ptr: &p, maxLength: length)
case 1704:
var p = pointer
purchaseDate = readASN1Date(ptr: &p, maxLength: length)
case 1706:
var p = pointer
originalPurchaseDate = readASN1Date(ptr: &p, maxLength: length)
case 1708:
var p = pointer
subscriptionExpirationDate = readASN1Date(ptr: &p, maxLength: length)
case 1712:
var p = pointer
subscriptionCancellationDate = readASN1Date(ptr: &p, maxLength: length)
case 1711:
var p = pointer
webOrderLineId = readASN1Integer(ptr: &p, maxLength: length)
default:
break
}
pointer = pointer!.advanced(by: length)
}
}
The only difference from the code reading the initial set comes from the different type values found in an in-app purchase. If at any point in the initialization it finds an unexpected value, it returns nil
and stops.
Back in Receipt.swift, replace the switch case 17: // IAP Receipt
in readReceipt(_:)
with the following to use the new objects:
case 17: // IAP Receipt
var iapStartPtr = ptr
let parsedReceipt = IAPReceipt(with: &iapStartPtr, payloadLength: length)
if let newReceipt = parsedReceipt {
inAppReceipts.append(newReceipt)
}
You pass the current pointer to init()
to read the set containing the IAP. If a valid receipt item comes back, it's added to the array. Note that for consumable and non-renewing subscriptions, in-app purchases only appear once at the time of purchase. They are not included in future receipt updates. Non-consumable and auto-renewing subscriptions will always show in the receipt.
Validating the Receipt
With the receipt payload read, you can finish validating the receipt. Add this code to init()
in Receipt
:
validateReceipt()
Add a new method to Receipt
:
private func validateReceipt() {
guard
let idString = bundleIdString,
let version = bundleVersionString,
let _ = opaqueData,
let hash = hashData
else {
receiptStatus = .missingComponent
return
}
}
This code ensures the receipt contains the elements required for validation. If any are missing, validation fails. Add the following code at the end of validateReceipt()
:
// Check the bundle identifier
guard let appBundleId = Bundle.main.bundleIdentifier else {
receiptStatus = .unknownFailure
return
}
guard idString == appBundleId else {
receiptStatus = .invalidBundleIdentifier
return
}
This code gets the bundle identifier of your app and compares it to the bundle identifier in the receipt. If they don't match, the receipt is likely copied from another app and not valid.
Add the following code after the validation of the identifier:
// Check the version
guard let appVersionString =
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String else {
receiptStatus = .unknownFailure
return
}
guard version == appVersionString else {
receiptStatus = .invalidVersionIdentifier
return
}
You compare the version stored in the receipt to the current version of your app. If the values don't match, the receipt likely was copied from another version of the app, as the receipt should be updated with the app.
The final validation check validates that the receipt was created for the current device. To do this, you need the device identifier, an alphanumeric string that uniquely identifies a device for your app.
Add the following method to Receipt
:
private func getDeviceIdentifier() -> Data {
let device = UIDevice.current
var uuid = device.identifierForVendor!.uuid
let addr = withUnsafePointer(to: &uuid) { (p) -> UnsafeRawPointer in
UnsafeRawPointer(p)
}
let data = Data(bytes: addr, count: 16)
return data
}
This method gets the device identifier as a Data
object.
You validate the device using a hash function. A hash function is easy to compute in one direction but difficult to reverse. A hash is commonly used to allow confirmation of a value without the need to store the value itself. For example, passwords are normally stored as hashed values instead of the actual password. Several values can be hashed together, and if the end result is the same, you can feel confident that the original values were the same.
Add the following method at the end of the Receipt
class:
private func computeHash() -> Data {
let identifierData = getDeviceIdentifier()
var ctx = SHA_CTX()
SHA1_Init(&ctx)
let identifierBytes: [UInt8] = .init(identifierData)
SHA1_Update(&ctx, identifierBytes, identifierData.count)
let opaqueBytes: [UInt8] = .init(opaqueData!)
SHA1_Update(&ctx, opaqueBytes, opaqueData!.count)
let bundleBytes: [UInt8] = .init(bundleIdData!)
SHA1_Update(&ctx, bundleBytes, bundleIdData!.count)
var hash: [UInt8] = .init(repeating: 0, count: 20)
SHA1_Final(&hash, &ctx)
return Data(bytes: hash, count: 20)
}
You compute a SHA-1 hash to validate the device. The OpenSSL libraries again can compute the SHA-1 hash you need. You combine the opaque value from the receipt, the bundle identifier in the receipt, and the device identifier. Apple knows these values at the time of purchase and your app knows them at the time of verification. By computing the hash and checking against the one in the receipt, you validate the receipt was created for the current device.
Add the following code to the end of validateReceipt()
:
// Check the GUID hash
let guidHash = computeHash()
guard hash == guidHash else {
receiptStatus = .invalidHash
return
}
This code compares the calculated hash to the value in the receipt. If they do not match, the receipt likely was copied from another device and is invalid.
The final check for a receipt only applies to apps allowing Volume Purchase Program (VPP) purchases. These purchases include an expiration date in the receipt. Add the following code to finish out validateReceipt()
:
// Check the expiration attribute if it's present
let currentDate = Date()
if let expirationDate = expirationDate {
if expirationDate < currentDate {
receiptStatus = .invalidExpired
return
}
}
// All checks passed so validation is a success
receiptStatus = .validationSuccess
If there is a non-nil expiration date, then your app should check that the expiration falls after the current date. If it's before the current date, the receipt is no longer valid. If no expiration date exists, then the validation does not fail.
At last, having completed all these checks without any failure, you can mark the receipt as valid.