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.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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.