[ios] iOS 7에서 인앱 영수증 및 번들 영수증을 LOCALLY로 검증하는 완벽한 솔루션

이론적으로 인앱 및 / 또는 번들 영수증의 유효성을 검사하는 많은 문서와 코드를 읽었습니다.

SSL, 인증서, 암호화 등에 대한 나의 지식이 거의 제로라고 가정 할 때, 이 유망한 설명 과 같이 읽은 모든 설명은 이해하기 어렵다는 것을 알게되었습니다.

그들은 모든 사람이 어떻게해야하는지 설명해야하기 때문에 설명이 불완전하다고 말합니다. 그렇지 않으면 해커가 패턴을 인식하고 식별하고 응용 프로그램을 패치 할 수있는 크래커 앱을 쉽게 만들 수 있습니다. 좋아, 나는 특정 시점까지 이것에 동의합니다. 나는 그들이 어떻게 해야하는지 완전히 설명하고 “이 방법 수정”, “이 다른 방법 수정”, “이 변수를 난독 화하다”, “이 이름과 그 이름 변경”등의 경고를 넣을 수 있다고 생각합니다.

iOS 7 에서 5 세 (OK, 3)로 위아래에서 명확하게 LOCALLY 유효성 검사, 번들 영수증 및 인앱 구매 영수증을 확인 하는 방법을 충분히 설명 할 수 있을까요?

감사!!!


앱에서 작동하는 버전이 있고 해커가 자신의 방식을 알게 될 우려가있는 경우 여기에 게시하기 전에 민감한 방법을 변경하면됩니다. 문자열을 난독 화하고, 줄 순서를 변경하고, 루프 사용 방식을 변경합니다 (for 사용에서 열거 및 그 반대로). 분명히, 여기에 게시 된 코드를 사용하는 모든 사람은 쉽게 해킹 당할 위험이 없도록 동일한 작업을 수행해야합니다.



답변

다음은 인앱 구매 라이브러리 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 위키 도움이 될 것입니다.

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. Apple이 영수증에 서명했는지 확인 이는 영수증을 파싱하기 전에 수행되었으며 아래에 자세히 설명되어 있습니다.
  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 Inc. Root Certificate로 서명되며 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이며, 영수증에 포함 된 일부 불투명 한 값과 번들 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];
}

그리고 그것은 그것의 요지입니다. 나는 여기 또는 저기에 뭔가 빠져있을 수 있으므로 나중에이 게시물로 돌아올 수 있습니다. 어쨌든 자세한 내용은 전체 코드를 찾아 보는 것이 좋습니다.


답변

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


답변

참고 : 클라이언트 측에서는 이러한 유형의 확인을 수행하지 않는 것이 좋습니다.

이것은 인앱 구매 영수증의 유효성 검사를위한 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

해당 키를 복사하여 비밀번호 필드에 붙여 넣습니다.

이것이 빠른 버전으로 원하는 모든 사람들에게 도움이되기를 바랍니다.


답변