iOS 7上本地验证应用内购买收据和捆绑收据的完整解决方案

167

我已经阅读了很多文档和代码,理论上可以验证应用内购买和/或捆绑收据。

考虑到我对SSL、证书、加密等知识几乎一无所知,我读过的所有解释,像这个有希望的解释,都让我难以理解。

他们说这些解释不完整,因为每个人都必须找出如何做,否则黑客将轻松地创建一个破解应用程序,可以识别和标识模式并修补应用程序。好吧,我同意在某种程度上这点。我认为他们可以完全解释如何做,并放置警告说“修改这种方法”,“修改那种方法”,“混淆这个变量”,“更改这个和那个的名称”等等。

是否有一些善良的灵魂能够友好地解释如何在iOS 7上本地验证、捆绑收据和应用内购买收据,就像我才三岁一样,从头到尾,清晰明了?

谢谢!!!


如果你有一个在应用中运行的版本,并且你担心黑客会看到你是如何做的,只需在发布之前更改敏感方法。混淆字符串、更改行的顺序、改变循环方式(从使用for到块枚举或反之)等等。显然,每个使用可能在这里发布的代码的人都必须做同样的事情,以避免被轻易破解。


2
好的,我知道,但这里的重点是要做一些困难的事情并防止自动破解/修补。问题在于,如果黑客真的想破解您的应用程序,无论您使用本地还是远程方法,他/她都会这样做。另一个想法是每次发布新版本时稍微更改它,以再次防止自动修补。 - Duck
4
即使在服务器上进行验证,仍然可以使用NOP来绕过检查。 - Duck
1
你知道为什么在处理应用内本地收据的问题上,网上没有一个单一的解决方案吗?那是因为如果有这样的代码,每个人都会使用它 -- 因为自己编写非常困难 -- 然后就很容易被破解。 - Jason
14
抱歉,但这并不是借口。作者所要做的唯一事情就是说“不要直接使用此代码”。没有任何示例,非常难以理解,除非你是个火箭科学家。 - Duck
4
如果你不想实现数字版权管理技术(DRM),就不要费心进行本地验证。直接从你的应用程序将收据POST到苹果公司,并且他们会将其以易于解析的JSON格式返回给你。这对盗版者来说很容易破解,但如果你只是转向免费增值模式并且不关心盗版问题,这只需要几行非常简单的代码。 - Dan Fabulich
显示剩余4条评论
3个回答

150
以下是我在我的应用内购买库RMStore中解决此问题的步骤说明。我将解释如何验证交易,包括整个收据的验证。
一览:
获取收据并验证交易。如果失败,请刷新收据并重试。这使得验证过程是异步的,因为刷新收据是异步的。
RMStoreAppReceiptVerifier中:
RMAppReceipt *receipt = [RMAppReceipt bundleReceipt];
const BOOL verified = [self verifyTransaction:transaction inReceipt:receipt success:successBlock failure:nil]; // failureBlock is nil intentionally. See below.
if (verified) return;

// Apple recommends to refresh the receipt if validation fails on iOS
[[RMStore defaultStore] refreshReceiptOnSuccess:^{
    RMAppReceipt *receipt = [RMAppReceipt bundleReceipt];
    [self verifyTransaction:transaction inReceipt:receipt success:successBlock failure:failureBlock];
} failure:^(NSError *error) {
    [self failWithBlock:failureBlock error:error];
}];

获取收据数据

收据位于[[NSBundle mainBundle] appStoreReceiptURL],实际上是一个PCKS7容器。我不擅长密码学,所以我使用了OpenSSL来打开这个容器。其他人似乎纯粹使用system frameworks就做到了这一点。

将OpenSSL添加到您的项目中并不容易。RMStore wiki应该会有帮助。

如果您选择使用OpenSSL来打开PKCS7容器,则您的代码可能如下所示。来自RMAppReceipt:

+ (NSData*)dataFromPKCS7Path:(NSString*)path
{
    const char *cpath = [[path stringByStandardizingPath] fileSystemRepresentation];
    FILE *fp = fopen(cpath, "rb");
    if (!fp) return nil;

    PKCS7 *p7 = d2i_PKCS7_fp(fp, NULL);
    fclose(fp);

    if (!p7) return nil;

    NSData *data;
    NSURL *certificateURL = [[NSBundle mainBundle] URLForResource:@"AppleIncRootCertificate" withExtension:@"cer"];
    NSData *certificateData = [NSData dataWithContentsOfURL:certificateURL];
    if ([self verifyPKCS7:p7 withCertificateData:certificateData])
    {
        struct pkcs7_st *contents = p7->d.sign->contents;
        if (PKCS7_type_is_data(contents))
        {
            ASN1_OCTET_STRING *octets = contents->d.data;
            data = [NSData dataWithBytes:octets->data length:octets->length];
        }
    }
    PKCS7_free(p7);
    return data;
}

我们稍后会详细介绍验证的细节。

获取收据字段

收据以ASN1格式表示。它包含一般信息,一些用于验证目的的字段(稍后我们会讲到),以及每个适用的应用内购买的特定信息。

同样地,当涉及到读取ASN1时,OpenSSL可以提供帮助。使用RMAppReceipt中的一些辅助方法:

NSMutableArray *purchases = [NSMutableArray array];
[RMAppReceipt enumerateASN1Attributes:asn1Data.bytes length:asn1Data.length usingBlock:^(NSData *data, int type) {
    const uint8_t *s = data.bytes;
    const NSUInteger length = data.length;
    switch (type)
    {
        case RMAppReceiptASN1TypeBundleIdentifier:
            _bundleIdentifierData = data;
            _bundleIdentifier = RMASN1ReadUTF8String(&s, length);
            break;
        case RMAppReceiptASN1TypeAppVersion:
            _appVersion = RMASN1ReadUTF8String(&s, length);
            break;
        case RMAppReceiptASN1TypeOpaqueValue:
            _opaqueValue = data;
            break;
        case RMAppReceiptASN1TypeHash:
            _hash = data;
            break;
        case RMAppReceiptASN1TypeInAppPurchaseReceipt:
        {
            RMAppReceiptIAP *purchase = [[RMAppReceiptIAP alloc] initWithASN1Data:data];
            [purchases addObject:purchase];
            break;
        }
        case RMAppReceiptASN1TypeOriginalAppVersion:
            _originalAppVersion = RMASN1ReadUTF8String(&s, length);
            break;
        case RMAppReceiptASN1TypeExpirationDate:
        {
            NSString *string = RMASN1ReadIA5SString(&s, length);
            _expirationDate = [RMAppReceipt formatRFC3339String:string];
            break;
        }
    }
}];
_inAppPurchases = purchases;

获取应用内购买

每个应用内购买也都是ASN1格式的。解析它与解析一般收据信息非常相似。

RMAppReceipt中使用相同的辅助方法:

[RMAppReceipt enumerateASN1Attributes:asn1Data.bytes length:asn1Data.length usingBlock:^(NSData *data, int type) {
    const uint8_t *p = data.bytes;
    const NSUInteger length = data.length;
    switch (type)
    {
        case RMAppReceiptASN1TypeQuantity:
            _quantity = RMASN1ReadInteger(&p, length);
            break;
        case RMAppReceiptASN1TypeProductIdentifier:
            _productIdentifier = RMASN1ReadUTF8String(&p, length);
            break;
        case RMAppReceiptASN1TypeTransactionIdentifier:
            _transactionIdentifier = RMASN1ReadUTF8String(&p, length);
            break;
        case RMAppReceiptASN1TypePurchaseDate:
        {
            NSString *string = RMASN1ReadIA5SString(&p, length);
            _purchaseDate = [RMAppReceipt formatRFC3339String:string];
            break;
        }
        case RMAppReceiptASN1TypeOriginalTransactionIdentifier:
            _originalTransactionIdentifier = RMASN1ReadUTF8String(&p, length);
            break;
        case RMAppReceiptASN1TypeOriginalPurchaseDate:
        {
            NSString *string = RMASN1ReadIA5SString(&p, length);
            _originalPurchaseDate = [RMAppReceipt formatRFC3339String:string];
            break;
        }
        case RMAppReceiptASN1TypeSubscriptionExpirationDate:
        {
            NSString *string = RMASN1ReadIA5SString(&p, length);
            _subscriptionExpirationDate = [RMAppReceipt formatRFC3339String:string];
            break;
        }
        case RMAppReceiptASN1TypeWebOrderLineItemID:
            _webOrderLineItemID = RMASN1ReadInteger(&p, length);
            break;
        case RMAppReceiptASN1TypeCancellationDate:
        {
            NSString *string = RMASN1ReadIA5SString(&p, length);
            _cancellationDate = [RMAppReceipt formatRFC3339String:string];
            break;
        }
    }
}]; 

请注意,某些应用内购买(例如可消耗品和非续订订阅)将仅在收据中出现一次。您应该在购买后立即验证这些内容(再次使用RMStore帮助您完成此操作)。
一目了然的验证
现在我们已经获得了收据和所有应用内购买的所有字段。首先,我们验证收据本身,然后简单地检查收据是否包含交易产品。
下面是我们在开始时调用的方法。来自RMStoreAppReceiptVerificator
- (BOOL)verifyTransaction:(SKPaymentTransaction*)transaction
                inReceipt:(RMAppReceipt*)receipt
                           success:(void (^)())successBlock
                           failure:(void (^)(NSError *error))failureBlock
{
    const BOOL receiptVerified = [self verifyAppReceipt:receipt];
    if (!receiptVerified)
    {
        [self failWithBlock:failureBlock message:NSLocalizedString(@"The app receipt failed verification", @"")];
        return NO;
    }
    SKPayment *payment = transaction.payment;
    const BOOL transactionVerified = [receipt containsInAppPurchaseOfProductIdentifier:payment.productIdentifier];
    if (!transactionVerified)
    {
        [self failWithBlock:failureBlock message:NSLocalizedString(@"The app receipt doest not contain the given product", @"")];
        return NO;
    }
    if (successBlock)
    {
        successBlock();
    }
    return YES;
}

验证收据

验证收据本身包括以下内容:

  1. 检查收据是否是有效的PKCS7和ASN1格式。我们已经隐式地完成了这一步。
  2. 验证收据是否由苹果签名。在解析收据之前已经完成了此操作,并将在下面详细说明。
  3. 检查收据中包含的捆绑标识符是否与您的捆绑标识符相对应。您应该硬编码您的捆绑标识符,因为很容易修改您的应用程序捆绑并使用其他收据。
  4. 检查收据中包含的应用程序版本是否与您的应用程序版本标识符相对应。出于上述原因,您应该硬编码应用程序版本。
  5. 检查收据哈希值,以确保收据与当前设备相对应。

RMStoreAppReceiptVerificator的代码高层次上看,有5个步骤:

- (BOOL)verifyAppReceipt:(RMAppReceipt*)receipt
{
    // Steps 1 & 2 were done while parsing the receipt
    if (!receipt) return NO;   

    // Step 3
    if (![receipt.bundleIdentifier isEqualToString:self.bundleIdentifier]) return NO;

    // Step 4        
    if (![receipt.appVersion isEqualToString:self.bundleVersion]) return NO;

    // Step 5        
    if (![receipt verifyReceiptHash]) return NO;

    return YES;
}

让我们深入了解步骤2和5。

验证收据签名

在提取数据时,我们忽略了收据签名验证。该收据使用苹果公司根证书进行签名,可从Apple Root Certificate Authority下载。以下代码将PKCS7容器和根证书作为数据,并检查它们是否匹配:

+ (BOOL)verifyPKCS7:(PKCS7*)container withCertificateData:(NSData*)certificateData
{ // Based on: https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateLocally.html#//apple_ref/doc/uid/TP40010573-CH1-SW17
    static int verified = 1;
    int result = 0;
    OpenSSL_add_all_digests(); // Required for PKCS7_verify to work
    X509_STORE *store = X509_STORE_new();
    if (store)
    {
        const uint8_t *certificateBytes = (uint8_t *)(certificateData.bytes);
        X509 *certificate = d2i_X509(NULL, &certificateBytes, (long)certificateData.length);
        if (certificate)
        {
            X509_STORE_add_cert(store, certificate);

            BIO *payload = BIO_new(BIO_s_mem());
            result = PKCS7_verify(container, NULL, store, NULL, payload, 0);
            BIO_free(payload);

            X509_free(certificate);
        }
    }
    X509_STORE_free(store);
    EVP_cleanup(); // Balances OpenSSL_add_all_digests (), per http://www.openssl.org/docs/crypto/OpenSSL_add_all_algorithms.html

    return result == verified;
}

在解析收据之前,这是在开始时完成的。

验证收据哈希

收据中包含的哈希是设备ID、收据中包含的一些不透明值和捆绑标识符的SHA1。

以下是在iOS上验证收据哈希的方法。从RMAppReceipt

- (BOOL)verifyReceiptHash
{
    // TODO: Getting the uuid in Mac is different. See: https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateLocally.html#//apple_ref/doc/uid/TP40010573-CH1-SW5
    NSUUID *uuid = [[UIDevice currentDevice] identifierForVendor];
    unsigned char uuidBytes[16];
    [uuid getUUIDBytes:uuidBytes];

    // Order taken from: https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateLocally.html#//apple_ref/doc/uid/TP40010573-CH1-SW5
    NSMutableData *data = [NSMutableData data];
    [data appendBytes:uuidBytes length:sizeof(uuidBytes)];
    [data appendData:self.opaqueValue];
    [data appendData:self.bundleIdentifierData];

    NSMutableData *expectedHash = [NSMutableData dataWithLength:SHA_DIGEST_LENGTH];
    SHA1(data.bytes, data.length, expectedHash.mutableBytes);

    return [expectedHash isEqualToData:self.hash];
}

这就是要点。可能会有一些细节我没有涉及到,所以我之后可能会回来修改这篇文章。无论如何,我建议浏览完整的代码以获取更多细节。


2
安全声明:使用开源代码会使您的应用程序更易受攻击。如果安全是一个问题,您可能希望使用RMStore,并将上述代码仅作为指南。 - hpique
6
如果未来你能摆脱OpenSSL并仅使用系统框架来使你的库更加精简,那将是非常棒的。 - Duck
2
@RubberDuck 请查看 https://github.com/robotmedia/RMStore/issues/16。欢迎参与讨论或贡献代码。 :) - hpique
1
@RubberDuck 在这之前我对 OpenSSL 一无所知。谁知道呢,你甚至可能会喜欢它。:P - hpique
3
它容易受到中间人攻击,攻击者可以拦截和修改请求和/或响应。例如,请求可能被重定向到第三方服务器,并返回虚假的响应,欺骗应用程序认为已购买产品,而实际上并没有购买,从而免费启用功能。 - Jasarien
显示剩余22条评论

13

我很惊讶没有人在这里提到Receigen。它是一个工具,可以自动生成混淆的收据验证代码,每次生成的代码都不同;它支持GUI和命令行操作。强烈推荐。

(与Receigen无关,只是一位快乐的用户。)

我使用像这样的Rakefile自动重新运行Receigen(因为它需要在每个版本更改时完成),当我输入rake receigen时:

desc "Regenerate App Store Receipt validation code using Receigen app (which must be already installed)"
task :receigen do
  # TODO: modify these to match your app
  bundle_id = 'com.example.YourBundleIdentifierHere'
  output_file = File.join(__dir__, 'SomeDir/ReceiptValidation.h')

  version = PList.get(File.join(__dir__, 'YourProjectFolder/Info.plist'), 'CFBundleVersion')
  command = %Q</Applications/Receigen.app/Contents/MacOS/Receigen --identifier #{bundle_id} --version #{version} --os ios --prefix ReceiptValidation --success callblock --failure callblock>
  puts "#{command} > #{output_file}"
  data = `#{command}`
  File.open(output_file, 'w') { |f| f.write(data) }
end

module PList
  def self.get file_name, key
    if File.read(file_name) =~ %r!<key>#{Regexp.escape(key)}</key>\s*<string>(.*?)</string>!
      $1.strip
    else
      nil
    end
  end
end

2
对于那些对Receigen感兴趣的人,这是一个付费解决方案,可以在App Store上以29.99美元的价格购买。尽管它自2014年9月以来没有更新过。 - DevGansta
确实,缺乏更新非常令人担忧。但是它仍然可以使用;顺便说一句,我正在我的应用程序中使用它。 - Andrey Tarantsov
使用Instruments检查您的应用程序是否存在泄漏,我经常使用Receigen来获取它们。 - the Reverend
Receigen是尖端技术,但很遗憾它似乎已经被放弃了。 - Fattie
1
看起来它还没有被删除。三周前更新! - Oleg Korzhukov
耶!是的,Receigen已经重获新生了,我确认我已经在一个新项目中成功使用它。现在它甚至可以生成Swift代码。 - Andrey Tarantsov

5
注意:不建议在客户端执行此类型验证。
这是一个用于验证应用内购买收据的Swift 4版本...
让我们创建一个枚举来表示收据验证可能出现的错误。
enum ReceiptValidationError: Error {
    case receiptNotFound
    case jsonResponseIsNotValid(description: String)
    case notBought
    case expired
}

接下来我们创建一个验证收据的函数,如果无法验证,则会抛出错误。

func validateReceipt() throws {
    guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) else {
        throw ReceiptValidationError.receiptNotFound
    }
    
    let receiptData = try! Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
    let receiptString = receiptData.base64EncodedString()
    let jsonObjectBody = ["receipt-data" : receiptString, "password" : <#String#>]
    
    #if DEBUG
    let url = URL(string: "https://sandbox.itunes.apple.com/verifyReceipt")!
    #else
    let url = URL(string: "https://buy.itunes.apple.com/verifyReceipt")!
    #endif
    
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.httpBody = try! JSONSerialization.data(withJSONObject: jsonObjectBody, options: .prettyPrinted)
    
    let semaphore = DispatchSemaphore(value: 0)
    
    var validationError : ReceiptValidationError?
    
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        guard let data = data, let httpResponse = response as? HTTPURLResponse, error == nil, httpResponse.statusCode == 200 else {
            validationError = ReceiptValidationError.jsonResponseIsNotValid(description: error?.localizedDescription ?? "")
            semaphore.signal()
            return
        }
        guard let jsonResponse = (try? JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.mutableContainers)) as? [AnyHashable: Any] else {
            validationError = ReceiptValidationError.jsonResponseIsNotValid(description: "Unable to parse json")
            semaphore.signal()
            return
        }
        guard let expirationDate = self.expirationDate(jsonResponse: jsonResponse, forProductId: <#String#>) else {
            validationError = ReceiptValidationError.notBought
            semaphore.signal()
            return
        }
        
        let currentDate = Date()
        if currentDate > expirationDate {
            validationError = ReceiptValidationError.expired
        }
        
        semaphore.signal()
    }
    task.resume()
    
    semaphore.wait()
    
    if let validationError = validationError {
        throw validationError
    }
}

让我们使用这个辅助函数来获取特定产品的到期日期。该函数接收JSON响应和产品ID。JSON响应可能包含多个不同产品的收据信息,因此它会获取指定参数的最后一个信息。

func expirationDate(jsonResponse: [AnyHashable: Any], forProductId productId :String) -> Date? {
    guard let receiptInfo = (jsonResponse["latest_receipt_info"] as? [[AnyHashable: Any]]) else {
        return nil
    }
    
    let filteredReceipts = receiptInfo.filter{ return ($0["product_id"] as? String) == productId }
    
    guard let lastReceipt = filteredReceipts.last else {
        return nil
    }
    
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd HH:mm:ss VV"
    
    if let expiresString = lastReceipt["expires_date"] as? String {
        return formatter.date(from: expiresString)
    }
    
    return nil
}

现在你可以调用这个函数并处理可能出现的错误情况。
do {
    try validateReceipt()
    // The receipt is valid 
    print("Receipt is valid")
} catch ReceiptValidationError.receiptNotFound {
    // There is no receipt on the device 
} catch ReceiptValidationError.jsonResponseIsNotValid(let description) {
    // unable to parse the json 
    print(description)
} catch ReceiptValidationError.notBought {
    // the subscription hasn't being purchased 
} catch ReceiptValidationError.expired {
    // the subscription is expired 
} catch {
    print("Unexpected error: \(error).")
}

您可以从App Store Connect获取密码。打开链接https://developer.apple.com,点击以下步骤:
  • 账户标签
  • 登录
  • 打开iTune Connect
  • 打开我的应用程序
  • 打开功能标签
  • 打开应用内购买
  • 在右侧点击“查看共享密钥”
  • 底部将显示一个秘密密钥
将该密钥复制并粘贴到密码字段中。

22
不应该从设备上使用苹果验证 URL,只能从服务器上使用。这在 WWDC 会议中有提到过。 - Lukas
如果用户删除了应用程序或长时间不打开,会发生什么?您的到期日期计算是否正常工作? - karthikeyan
然后您需要在服务器端进行验证。 - Pushpendra
2
正如@pechar所说,你永远不应该这样做。请将其添加到您的答案顶部。请参阅WWDC会议36:32 => https://developer.apple.com/videos/play/wwdc2016/702/ - cicerocamargo
我不理解为什么直接从设备发送收据数据不安全。有人能够解释一下吗? - Koh
@Koh 由于可能存在中间人攻击,您无法控制连接的任何一方,如此处所述:https://developer.apple.com/documentation/storekit/in-app_purchase/validating_receipts_with_the_app_store - laka

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接