iOS 7でアプリ内レシートとバンドルレシートをローカルで検証するための完全なソリューション


160

私は多くのドキュメントとコードを読みましたが、理論的にはアプリ内またはバンドルの領収書を検証します。

SSL、証明書、暗号化などに関する私の知識はほとんどゼロであることを考えると、私が読んだすべての説明は、この有望なもののように、理解するのが難しいと感じました。

彼らはすべての人がそれを行う方法を理解しなければならないので説明は不完全である、またはハッカーはパターンを認識および識別してアプリケーションにパッチを当てることができるクラッカーアプリを作成する簡単な仕事をするでしょう。OK、私はこれにある程度まで同意します。彼らはそれを行う方法を完全に説明し、「このメソッドを変更する」、「この他のメソッドを変更する」、「この変数を難読化する」、「これとその名前を変更する」などの警告を出すことができると思います。

私が5歳のときにiOS 7で領収書とアプリ内購入の領収書をローカルで検証し、バンドルする方法を説明するのに十分な親切な人がいます

ありがとう!!!


アプリで動作しているバージョンがあり、ハッカーがそれをどのように行ったのかがハッカーに知られることが懸念される場合は、ここで公開する前に機密メソッドを変更するだけです。文字列を難読化し、行の順序を変更し、ループを実行する方法(forを使用して列挙をブロックする、またはその逆)を変更します。明らかに、ここに投稿される可能性のあるコードを使用するすべての人は、簡単にハッキングされる危険を冒さずに、同じことを行う必要があります。


1
公平な警告:ローカルで行うと、アプリケーションからこの関数にパッチを適用するのが非常に簡単になります。
NinjaLikesCheez 2013年

2
わかりました、しかし、ここでのポイントは、困難なことを行い、自動化されたクラッキング/パッチを防ぐことです。問題は、ハッカーが実際にアプリをクラックしたい場合、ローカルまたはリモートを問わず、どのような方法でも実行することです。また、リリースする新しいバージョンごとに少しずつ変更して、自動パッチが再度適用されないようにすることもできます。
ダック

4
@NinjaLikesCheez-検証がサーバーで行われても、チェックをNOPできます。
ダック

14
申し訳ありませんが、これは言い訳にはなりません。作者がしなければならない唯一のことは、コードを現状のまま使用しないでくださいと言うことです。例がなければ、ロケット科学者でなければ、これを理解することは不可能です。
ダック

3
DRMの実装に煩わされたくない場合は、ローカル検証を気にしないでください。アプリからレシートを直接AppleにPOSTするだけで、簡単に解析できるJSON形式で再びレシートが返送されます。海賊がこれを解読するのは些細なことですが、フリーミアムに移行するだけで海賊行為を気にしないのであれば、それはほんの数行の非常に簡単なコードです。
Dan Fabulich、2015年

回答:


146

これは、アプリ内購入ライブラリ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を使用してこのコンテナーを開きました。他の人たちは、純粋にシステムフレームワークでそれを行ったようです。

プロジェクトに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形式で表されます。これには、一般的な情報、確認のためのいくつかのフィールド(後で説明します)、および該当する各アプリ内購入の特定の情報が含まれています。

繰り返しますが、OpenSSLは、ASN1を読み取る際に役立ちます。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. 領収書がAppleによって署名されていることの確認。これは、レシートを解析する前に行われました。詳細については、以下で説明します。
  3. 領収書に含まれるバンドルIDがバンドルIDに対応していることを確認します。アプリバンドルを変更して他の領収書を使用することはそれほど難しくないと思われるため、バンドル識別子をハードコードする必要があります。
  4. 領収書に記載されているアプリのバージョンがアプリのバージョンIDに対応していることを確認します。上記と同じ理由で、アプリのバージョンをハードコードする必要があります。
  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 Inc.ルート証明書で署名されています。これはAppleルート認証局からダウンロードできます。次のコードは、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、レシートに含まれるいくつかの不透明な値、およびバンドルIDです。

これは、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 2013年

6
将来的には、OpenSSLを廃止し、システムフレームワークのみを使用してライブラリをコンパクトにするのは素晴らしいことです。
ダック

2
@RubberDuckを参照してくださいgithub.com/robotmedia/RMStore/issues/16。チャイムを入れたり、貢献したりしてください。:)
hpique 2013年

1
@RubberDuck私はこれまでOpenSSLの知識がありませんでした。誰が知っているか、あなたもそれを好きかもしれません。:P
hpique 2013年

2
これは、中間者攻撃の影響を受けやすく、リクエストやレスポンスが傍受されたり変更されたりする可能性があります。たとえば、リクエストがサードパーティのサーバーにリダイレクトされ、誤った応答が返されて、購入されていない製品を購入したとアプリに思わせ、無料で機能を有効にすることができます。
Jasarien 2013年

13

ここでレセイゲンについて誰も触れなかったのには驚きです。これは、難読化されたレシート検証コードを自動的に生成するツールで、毎回異なります。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

1
Receigenに興味がある人のために、これは有料のソリューションで、App Storeで29.99 $で入手できます。2014
。– DevGansta 2017年

確かに、アップデートの欠如は非常に憂慮すべきことです。ただし、それでも機能します。FWIW、私のアプリで使用しています。
Andrey Tarantsov

インストゥルメントでアプリにリークがないか確認してください。
牧師の

レセイゲンは最先端ですが、落とされたように見えるのは残念です。
Fattie '19

1
まだ落としていないようです。3週間前に更新されました。
Oleg Korzhukov

2

注:このタイプの検証をクライアント側で行うことはお勧めしません

これは、アプリ内購入の領収書の検証用の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このリンクを開いてクリック

  • Account tab
  • Do Sign in
  • Open iTune Connect
  • Open My App
  • Open Feature Tab
  • Open In App Purchase
  • Click at the right side on 'View Shared Secret'
  • At the bottom you will get a secrete key

そのキーをコピーして、パスワードフィールドに貼り付けます。

これが迅速なバージョンでそれを望んでいるすべての人のために役立つことを願っています。


19
デバイスからApple検証URLを使用しないでください。サーバーからのみ使用してください。これはWWDCセッションで言及されました。
pechar 2017

ユーザーがアプリを削除したり、長時間開いたりしなかった場合はどうなりますか?有効期限の計算は正常に機能していますか?
karthikeyan

次に、サーバー側で検証を維持する必要があります。
Pushpendra 2018

1
@pecharが言ったように、これを行うべきではありません。回答の上部に追加してください。36:32のWWDCセッションを参照=> developer.apple.com/videos/play/wwdc2016/702
cicerocamargo

領収書データをデバイスから直接送信することが安全ではない理由がわかりません。誰かが説明できますか?
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.