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


160

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

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

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

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

감사!!!


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


1
공정한 경고 : 로컬로 수행하면 응용 프로그램 에서이 기능을 패치하기가 훨씬 쉽습니다.
NinjaLikesCheez

2
알았지 만 여기서 요점은 어려운 작업을 수행하고 자동 크래킹 / 패칭을 방지하는 것입니다. 문제는 해커가 실제로 앱을 크래킹하려는 경우 로컬 또는 원격으로 사용하는 방법에 관계없이 해킹한다는 것입니다. 또한 자동 패치 적용을 방지하기 위해 릴리스 할 때마다 약간 씩 변경하는 것이 좋습니다.
Duck

4
@NinjaLikesCheez-서버에서 확인을 수행하더라도 확인을 NOP 할 수 있습니다.
Duck

14
죄송하지만이 변명은 아닙니다. 저자가해야 할 일은 코드를 그대로 사용하지 마십시오. 예를 들어 로켓 과학자가 아니라면 이것을 이해하는 것은 불가능합니다.
Duck

3
DRM 구현을 방해하지 않으려면 로컬 확인을 방해하지 마십시오. 앱에서 영수증을 Apple에 직접 게시하면 쉽게 파싱 된 JSON 형식으로 다시 보내드립니다. 해적들이이 문제를 해결하는 것은 사소한 일이지만, 단지 프리미엄으로 전환하고 불법 복제에 신경 쓰지 않는다면 아주 쉬운 코드 몇 줄에 불과합니다.
Dan Fabulich

답변:


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 위키 도움이 될 것입니다.

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];
}

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


2
보안 면책 조항 : 오픈 소스 코드를 사용하면 앱이 더 취약 해집니다. 보안이 중요한 경우 RMStore 및 위의 코드를 지침으로 만 사용할 수 있습니다.
hpique

6
미래에 OpenSSL을 제거하고 시스템 프레임 워크 만 사용하여 라이브러리를 컴팩트하게 만들면 환상적 일 것입니다.
Duck

2
@RubberDuck 참조 github.com/robotmedia/RMStore/issues/16 . 자유롭게 참여하거나 참여하십시오. :)
hpique

1
@RubberDuck이 시점까지 OpenSSL에 대한 지식이 없었습니다. 누가 알더라도 좋아할 것입니다. : P
hpique

2
요청 및 / 또는 응답을 가로 채서 수정할 수있는 중간 공격에 취약한 사람입니다. 예를 들어, 요청이 타사 서버로 리디렉션 될 수 있고, 잘못된 응답이 반환되어 앱이 제품을 구매 한 시점, 그렇지 않은 시점에 앱을 속여서 무료로 기능을 활성화 할 수 있습니다.
Jasarien

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

1
Receigen에 관심이있는 분들을 위해이 솔루션은 App Store에서 29.99 $에 구입할 수 있습니다. 2014 년 9 월 이후 업데이트되지는 않았습니다.
DevGansta

사실, 업데이트가 부족하다는 것은 매우 놀라운 일입니다. 그러나 여전히 작동합니다. FWIW, 내 앱에서 사용하고 있습니다.
Andrey Tarantsov 17 년

Receigen으로 누출이 있는지 계측기에서 앱을 확인하십시오.
목사

Receigen은 최첨단이지만 그렇습니다.
Fattie

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

사용자가 앱을 삭제하거나 장시간 열지 않으면 어떻게됩니까? 만료일 계산이 제대로 작동합니까?
karthikeyan

그런 다음 서버 측에서 유효성 검사를 유지해야합니다.
Pushpendra

1
@pechar가 말했듯이 절대로 이것을해서는 안됩니다. 답변 상단에 추가하십시오. 36:32 => developer.apple.com/videos/play/wwdc2016/702
cicerocamargo

기기에서 직접 영수증 데이터를 보내는 것이 안전하지 않은 이유를 이해하지 못합니다. 누구나 설명 할 수 있습니까?
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.