网络及安全

iOS内购IAP(十四) —— IAP的收据验证(一)

2019-01-09  本文已影响173人  刀客传奇

版本记录

版本号 时间
V1.0 2019.01.09

前言

大家都知道,iOS虚拟商品如宝石、金币等都需要走内购,和苹果三七分成,如果这类商品不走内购那么上不去架或者上架以后被发现而被下架。最近有一个项目需要增加内购支付功能,所以最近又重新集成并整理了下,希望对大家有所帮助。感兴趣的可以参考上面几篇。
1. iOS内购IAP(一) —— 基础配置篇(一)
2. iOS内购IAP(二) —— 工程实践(一)
3. iOS内购IAP(三) —— 编程指南之关于内购(一)
4. iOS内购IAP(四) —— 编程指南之设计您的应用程序的产品(一)
5. iOS内购IAP(五) —— 编程指南之检索产品信息(一)
6. iOS内购IAP(六) —— 编程指南之请求支付(一)
7. iOS内购IAP(七) —— 编程指南之促进应用内购买(一)
8. iOS内购IAP(八) —— 编程指南之提供产品(一)
9. iOS内购IAP(九) —— 编程指南之处理订阅(一)
10. iOS内购IAP(十) —— 编程指南之恢复购买的产品(一)
11. iOS内购IAP(十一) —— 编程指南之准备App审核(一)
12. iOS内购IAP(十二) —— 一个详细的内购流程(一)
13. iOS内购IAP(十三) —— 一个详细的内购流程(二)

开始

首先看下写作环境

Swift 4.2, iOS 12, Xcode 10

在本教程中,您将了解应用内购买的收据如何工作以及如何验证它们,以确保您的用户已为您提供的商品付款。

付费软件一直存在一个问题,即某些用户试图在不购买软件的情况下使用该软件或欺诈性地访问应用内购买。 收据提供了确认这些购买的工具。 他们通过提供销售记录来实现这一目标。 每当用户购买应用程序,进行应用内购买或更新应用程序时,App Store都会在应用程序包中生成收据。

在本教程中,您将了解这些收据的工作原理以及它们在设备上的验证方式。 在本教程中,您应该熟悉应用内购买和StoreKit。 您将需要一个iOS开发人员帐户,一个用于测试的真实设备,访问iOS开发人员中心和App Store Connect


What Is a Receipt?

收据包含应用程序包中的单个文件。 该文件采用称为PKCS#7的格式。 这是应用了加密技术的数据的标准格式。 容器包含有效负载(payload),证书链(chain of certificates)和数字签名(digital signature)。 您使用证书链和数字签名来验证Apple是否生成了收据。

有效负载(payload)由一组称为ASN.1的跨平台格式的凭据属性组成。 这些属性中的每一个都包含类型,版本和值(type, version and value)。 这些代表收据的内容。 您的应用使用这些属性来确定收据对设备有效以及用户购买了什么。


Loading the Receipt

打开入门项目。入门项目是支持StoreKit和应用内购买的iPhone应用程序。

要测试收据验证,您必须在真实设备上运行该应用程序,因为它在模拟器中不起作用。您需要开发证书和沙盒帐户。通过XCode测试应用程序时,默认情况下应用程序不会有收据。如果不存在,则starter app会实现请求刷新的证书。

加密代码很复杂,很容易出错。最好使用已知且经过验证的库,而不是尝试编写自己的库。本教程使用OpenSSL库来完成验证加密和解码收据中提供的ASN.1数据的大部分工作。 OpenSSL不是非常Swift友好的,所以在本教程中你将创建一个Swift包装器。

为iPhone编译OpenSSL并不是一个简单的过程。如果您想自己动手,可以在GitHub上找到脚本和说明。入门项目包括OpenSSL文件夹中最新版本的OpenSSL 1.1.1。它被编译为静态库,使修改更加困难。这包括文件夹以及C头文件。该项目还包括使用Swift的OpenSSL库的桥接头。

注意:您可能想知道为什么使用OpenSSL而不是iOS内置的CommonCrypto框架,而且静态OpenSSL库为您的应用程序包添加了大约40MB。 原因是如果用户越狱他们的设备,使用黑客版本替换CommonCrypto将很容易解决这些问题。 bundle中的静态库是一个更难攻击的目标。

入门项目包括一个起始的Receipt类。 它还包含一个静态方法:isReceiptPresent()。 此方法确定是否存在收据文件。 如果没有,它会使用StoreKit在尝试验证之前请求刷新收据。 如果收据不存在,您的应用应该做类似的事情。

打开Receipt.swift。 在类声明结束时为类添加新的自定义初始值设定项:

init() {
  guard let payload = loadReceipt() else {
    return
  }
}

要开始验证,您需要将收据作为Data对象。 将以下新方法添加到init()下面的Receipt以加载收据并返回PKCS#7数据结构:

private func loadReceipt() -> UnsafeMutablePointer<PKCS7>? {
  // Load the receipt into a Data object
  guard 
    let receiptUrl = Bundle.main.appStoreReceiptURL,
    let receiptData = try? Data(contentsOf: receiptUrl) 
    else {
      receiptStatus = .noReceiptPresent
      return nil
  }
}

此代码获取收据的位置,并尝试将其作为Data对象加载。 如果不存在收据或收据不会作为Data对象加载,则验证失败。 如果在验证收据期间的任何时候检查失败,则整个验证失败。 代码将原因存储在类的receiptStatus属性中。

现在您在Data对象中有了收据,您可以使用OpenSSL处理内容。 OpenSSL函数是用C语言编写的,通常使用指针和其他底层方法。 在loadReceipt()的末尾添加以下代码:

// 1
let receiptBIO = BIO_new(BIO_s_mem())
let receiptBytes: [UInt8] = .init(receiptData)
BIO_write(receiptBIO, receiptBytes, Int32(receiptData.count))
// 2
let receiptPKCS7 = d2i_PKCS7_bio(receiptBIO, nil)
BIO_free(receiptBIO)
// 3
guard receiptPKCS7 != nil else {
  receiptStatus = .unknownReceiptFormat
  return nil
}

这段代码的工作原理:

接下来,您需要确保容器包含签名和数据。将以下代码添加到loadReceipt()方法的末尾以执行这些检查:

// Check that the container has a signature
guard OBJ_obj2nid(receiptPKCS7!.pointee.type) == NID_pkcs7_signed else {
  receiptStatus = .invalidPKCS7Signature
  return nil
}

// Check that the container contains data
let receiptContents = receiptPKCS7!.pointee.d.sign.pointee.contents
guard OBJ_obj2nid(receiptContents?.pointee.type) == NID_pkcs7_data else {
  receiptStatus = .invalidPKCS7Type
  return nil
}

return receiptPKCS7

C通常使用结构体处理复杂数据。 与Swift结构不同,C结构仅包含没有方法或其他元素的数据。 对C中结构的引用是对内存位置的引用 - 指向数据结构的指针。

存在各种UnsafePointer类型以允许混合Swift和C代码。 OpenSSL函数需要一个指针,而不是您可能更熟悉的Swift类和结构。 receiptPKCS7是指向保存PKCS#7包络的数据结构的指针。 UnsafePointerpointee属性遵循指向数据结构的指针。

引用C中指针指向的过程通常足以拥有一个特殊的运算符- >。 指针的pointee属性在Swift中执行此引用。

如果检查成功,则该方法返回指向结构体的指针。 现在您的envelope格式正确且包含数据,您应该验证Apple是否已对其进行签名。


Validating Apple Signed the Receipt

PKCS#7容器使用具有两个组件的公钥加密。 一个组件是与每个人共享的公钥。 第二个是私人安全密钥。 Apple可以使用私钥对数据进行数字签名,因此任何拥有相应公钥的人都可以确保拥有私钥的人进行签名。

对于收据,Apple使用其私钥对收据进行签名,并使用Apple的公钥进行验证。 证书包含有关这些密钥的信息。

通常使用证书来签署构成证书链的其他证书。 这样做可以降低损害任何一个证书的风险,因为它只影响链中较低的证书。 这允许链顶部的单个根证书验证签名和中间证书,而无需由根证书直接签名。

OpenSSL可以为您处理此检查。 在init()的末尾添加以下调用:

guard validateSigning(payload) else {
  return
}

现在在Receipt末尾添加一个新方法来时执行检查:

private func validateSigning(_ receipt: UnsafeMutablePointer<PKCS7>?) -> Bool {
  guard 
    let rootCertUrl = Bundle.main
      .url(forResource: "AppleIncRootCertificate", withExtension: "cer"),
    let rootCertData = try? Data(contentsOf: rootCertUrl) 
    else {
      receiptStatus = .invalidAppleRootCertificate
      return false
  }
  
  let rootCertBio = BIO_new(BIO_s_mem())
  let rootCertBytes: [UInt8] = .init(rootCertData)
  BIO_write(rootCertBio, rootCertBytes, Int32(rootCertData.count))
  let rootCertX509 = d2i_X509_bio(rootCertBio, nil)
  BIO_free(rootCertBio)
}

此代码从bundle加载Apple的根证书并将其转换为BIO对象。 请注意,不同的函数调用反映您正在加载X.509格式证书而不是PKCS容器。 添加以下代码以完成validateSigning(_ :)

// 1
let store = X509_STORE_new()
X509_STORE_add_cert(store, rootCertX509)

// 2
OPENSSL_init_crypto(UInt64(OPENSSL_INIT_ADD_ALL_DIGESTS), nil)

// 3
let verificationResult = PKCS7_verify(receipt, nil, store, nil, nil, 0)
guard verificationResult == 1  else {
  receiptStatus = .failedAppleSignature
  return false
}

return true

这段代码的工作原理:


Reading Data in the Receipt

验证Apple签署了收据后,您现在可以阅读收据内容。 如前所述,有效载荷(payload)的内容是一组ASN.1值。 您将使用读取此格式的OpenSSL函数。

Receipt已包含存储payload内容的属性。 在init()的末尾添加以下代码:

readReceipt(payload)

loadReceipt()之后添加以下方法以开始读取收据数据:

private func readReceipt(_ receiptPKCS7: UnsafeMutablePointer<PKCS7>?) {
  // Get a pointer to the start and end of the ASN.1 payload
  let receiptSign = receiptPKCS7?.pointee.d.sign
  let octets = receiptSign?.pointee.contents.pointee.d.data
  var ptr = UnsafePointer(octets?.pointee.data)
  let end = ptr!.advanced(by: Int(octets!.pointee.length))
}

此代码从PKCS7结构获取指向有效负载起点的指针 - 作为ptr。 然后,您将指针放在有效负载的末尾。 将以下代码添加到readReceipt(_ :)以开始解析有效内容:

var type: Int32 = 0
var xclass: Int32 = 0
var length: Int = 0

ASN1_get_object(&ptr, &length, &type, &xclass, ptr!.distance(to: end))
guard type == V_ASN1_SET else {
  receiptStatus = .unexpectedASN1Type
  return
}

存储有关每个ASN.1对象的信息有三个变量。 ASN1_get_object(_:_:_:_:_ :)读取缓冲区以获取第一个对象。指针更新到下一个对象。

C函数通常使用指向变量的指针从函数返回多个值,并直接更新这些对象。这类似于Swift中的inout参数。 符号获取指向对象的指针。该函数返回数据的长度(length)ASN.1对象类型(type)和ASN.1 tag值(xclass)

最后一个参数是要读取的最长长度。提供此功能可防止因读取超出存储区末尾而导致的安全问题。

然后验证有效内容中第一个项的类型是否为ASN.1集。如果不是,则有效载荷无效。否则,您可以开始阅读该集的内容。您将对ASN1_get_object(_:_:_:_:_ :)使用类似的调用来读取有效负载中的所有数据。 ASN1Helpers.swift包含几个辅助方法,它们将收据中的ASN.1数据类型读取为可以为空的Swift值。在readReceipt(_ :)的末尾添加此代码:

// 1
while ptr! < end {
  // 2
  ASN1_get_object(&ptr, &length, &type, &xclass, ptr!.distance(to: end))
  guard type == V_ASN1_SEQUENCE else {
    receiptStatus = .unexpectedASN1Type
    return
  }
  
  // 3
  guard let attributeType = readASN1Integer(ptr: &ptr, maxLength: length) else {
    receiptStatus = .unexpectedASN1Type
    return
  }
  
  // 4
  guard let _ = readASN1Integer(ptr: &ptr, maxLength: ptr!.distance(to: end)) else {
    receiptStatus = .unexpectedASN1Type
    return
  }
  
  // 5
  ASN1_get_object(&ptr, &length, &type, &xclass, ptr!.distance(to: end))
  guard type == V_ASN1_OCTET_STRING else {
    receiptStatus = .unexpectedASN1Type
    return
  }

  // Insert attribute reading code
}

这段代码的作用:

和以前一样,如果任何值不符合预期,则设置状态代码并且验证失败。

您现在拥有有关当前属性的信息。 您还具有数据类型和指向此属性的数据的指针。 Appledocuments the attributes in a receipt

您将使用switch语句来处理收据中找到的属性类型。 使用以下内容替换// Insert attribute reading code here注释:

switch attributeType {
case 2: // The bundle identifier
  var stringStartPtr = ptr
  bundleIdString = readASN1String(ptr: &stringStartPtr, maxLength: length)
  bundleIdData = readASN1Data(ptr: ptr!, length: length)
  
case 3: // Bundle version
  var stringStartPtr = ptr
  bundleVersionString = readASN1String(ptr: &stringStartPtr, maxLength: length)
  
case 4: // Opaque value
  let dataStartPtr = ptr!
  opaqueData = readASN1Data(ptr: dataStartPtr, length: length)
  
case 5: // Computed GUID (SHA-1 Hash)
  let dataStartPtr = ptr!
  hashData = readASN1Data(ptr: dataStartPtr, length: length)
  
case 12: // Receipt Creation Date
  var dateStartPtr = ptr
  receiptCreationDate = readASN1Date(ptr: &dateStartPtr, maxLength: length)

case 17: // IAP Receipt
  print("IAP Receipt.")
  
case 19: // Original App Version
  var stringStartPtr = ptr
  originalAppVersion = readASN1String(ptr: &stringStartPtr, maxLength: length)
  
case 21: // Expiration Date
  var dateStartPtr = ptr
  expirationDate = readASN1Date(ptr: &dateStartPtr, maxLength: length)
  
default: // Ignore other attributes in receipt
  print("Not processing attribute type: \(attributeType)")
}

// Advance pointer to the next item
ptr = ptr!.advanced(by: length)

此代码使用每个属性的类型来调用适当的辅助函数,该函数将值放入类的属性中。 读取每个值后,最后一行将指针前进到下一个属性的开头,然后继续循环。


Reading In-App Purchases

应用内购买的属性需要更复杂的处理。 应用内购买不是单个整数或字符串,而是此集合中的另一个ASN.1集。 IAPReceipt.swift包含一个用于存储内容的IAPReceipt。 该集的格式与包含它的格式相同,并且读取它的代码非常相似。 将以下初始化程序添加到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)
  }
}

与读取初始集的代码的唯一区别来自应用内购买中发现的不同类型值。 如果在初始化的任何时刻它发现了一个意外的值,它返回nil并停止。

回到Receipt.swift,用以下内容替换case 17: // IAP Receipt in readReceipt(_:)以使用新对象:

case 17: // IAP Receipt
  var iapStartPtr = ptr
  let parsedReceipt = IAPReceipt(with: &iapStartPtr, payloadLength: length)
  if let newReceipt = parsedReceipt {
    inAppReceipts.append(newReceipt)
  }

您将当前指针传递给init()以读取包含IAP的集合。 如果返回有效的收据项,则会将其添加到阵列中。 请注意,对于耗材和非续订订阅(consumable and non-renewing subscriptions),应用内购买仅在购买时出现一次。 它们未包含在将来的收据更新中。 非消费品和自动续订订阅(Non-consumable and auto-renewing subscriptions)将始终显示在收据中。


Validating the Receipt

读取收据有效负载后,您可以完成验证收据。 将此代码添加到Receipt中的init()

validateReceipt()

添加一个新方法到Receipt

private func validateReceipt() {
  guard 
    let idString = bundleIdString,
    let version = bundleVersionString,
    let _ = opaqueData,
    let hash = hashData 
    else {
      receiptStatus = .missingComponent
      return
  }
}

此代码确保收据包含验证所需的元素。 如果缺少任何内容,则验证失败。 在validateReceipt()的末尾添加以下代码:

// Check the bundle identifier
guard let appBundleId = Bundle.main.bundleIdentifier else {
    receiptStatus = .unknownFailure 
    return
}

guard idString == appBundleId else {
  receiptStatus = .invalidBundleIdentifier
  return
}

此代码获取应用程序的包标识符,并将其与收据中的包标识符进行比较。 如果它们不匹配,则收据可能是从另一个应用程序复制而无效。

验证标识符后添加以下代码:

// Check the version
guard let appVersionString = 
  Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String else {
  receiptStatus = .unknownFailure
  return
}
guard version == appVersionString else {
  receiptStatus = .invalidVersionIdentifier
  return
}

您可以将收据中存储的版本与应用的当前版本进行比较。 如果值不匹配,则收据可能是从应用程序的其他版本复制的,因此应使用应用程序更新收据。

最终验证检查验证是否为当前设备创建了收据。 要执行此操作,您需要设备标识符,这是一个字母数字字符串,可为您的应用唯一标识设备。

将以下方法添加到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
}

此方法将设备标识符作为Data对象获取。

您使用hash函数验证设备。 哈希函数很容易在一个方向上计算,但很难逆转。 哈希通常用于允许确认值而无需存储值本身。 例如,密码通常存储为hash值而不是实际密码。 可以将多个值一起散列,如果最终结果相同,您可以确信原始值是相同的。

Receipt类的末尾添加以下方法:

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)
}

您计算SHA-1哈希以验证设备。 OpenSSL库再次可以计算您需要的SHA-1哈希值。 您可以组合收据中的不透明值,收据中的包标识符和设备标识符。 Apple在购买时了解这些值,您的应用在验证时就知道这些值。 通过计算哈希并检查收据中的哈希,您验证是否为当前设备创建了收据。

将以下代码添加到validateReceipt()的末尾:

// Check the GUID hash
let guidHash = computeHash()
guard hash == guidHash else {
  receiptStatus = .invalidHash
  return
}

此代码将计算的哈希值与收据中的值进行比较。 如果它们不匹配,则收据可能是从其他设备复制的,并且无效。

收据的最终检查仅适用于允许批量购买计划(Volume Purchase Program - VPP)购买的应用程序。 这些购买包括收据中的到期日期。 添加以下代码以完成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

如果存在非nil的到期日期,那么您的应用应检查到期日是否在当前日期之后。如果它在当前日期之前,则收据不再有效。如果不存在过期日期,则验证不会失败。

最后,在完成所有这些检查而没有任何失败的情况下,您可以将收据标记为有效。


Running the App

运行该应用程序。您必须在真实设备上运行此项目。存储相关代码在模拟器中不起作用。您还需要一个沙盒帐户设置。在App Store购买的应用程序中,将出现收据。但是在从XCode进行测试时,您需要刷新才能获得收据。教程应用程序已经这样做了。您需要登录。然后,应用程序将使用本教程中的代码验证收据并显示收据。

完成此操作后,添加应用内购买。确保还使用产品标识符(product identifiers)更新ViewController.swift。使用Buy IAP按钮和沙盒帐户。您会看到table view列出了这些应用内购买。还可以尝试消费品购买,并记下刷新收据后它们是如何消失的。


Protecting Receipt Validation Code

攻击者将努力绕过您的收据验证码(receipt validation code)。 使用此或任何其他收据验证码无需更改会产生风险。 如果攻击者可以在一个使用此确切代码的应用程序中绕过检查,则攻击者可以使用相同的代码更轻松地为另一个应用程序重复此过程。 对于高价值或高盈利的应用程序,您需要在保持相同工作的同时修改本教程的代码。

为了防止绕过验证过程,您可以重复执行验证而不是一次。 避免显式错误消息(例如“收据验证失败”)会使攻击者的工作更加困难。 将失败代码放置在远离验证检查的应用程序部分中也会使攻击者的工作更加困难。

最后,您需要平衡未经授权访问您的应用程序的风险与额外的时间和复杂性,代码的额外混淆会增加您的开发过程。

Apple的Receipt Validation Programming Guide提供了有关收据的最佳文档,以及关于 Preventing Unauthorized Purchases with Receipts的WWDC 2014会议。 两者都讨论了本教程中未涉及的服务器验证方法。 来自WWDC 2016的会议, Using Store Kit for In-App Purchases with Swift 3,还讨论了与订阅特别相关的收据。

后记

本篇主要讲述了收据验证,感兴趣的给个赞或者关注~~~

上一篇下一篇

猜你喜欢

热点阅读