Повне рішення для МІСЦЕВНОГО підтвердження надходжень у програмі та пакетних квитанцій на iOS 7


160

Я прочитав багато документів і кодів, які теоретично підтвердять отримання квитанції через додаток та / або пакет.

Зважаючи на те, що мої знання про SSL, сертифікати, шифрування тощо майже дорівнюють нулю, усі пояснення, які я прочитав, як це багатообіцяюче , мені було важко зрозуміти.

Вони кажуть, що пояснення неповні, тому що кожна людина повинна розібратися, як це зробити, або хакерам буде легка робота, створюючи додаток для злому, який може розпізнати та ідентифікувати шаблони та виправити додаток. Гаразд, я згоден з цим до певного моменту. Я думаю, що вони могли б повністю пояснити, як це зробити, і поставили попередження: «змінити цей метод», «змінити цей інший метод», «притупити цю змінну», «змінити назву цього і того» тощо.

Чи може якась добра душа там бути доброю, щоб пояснити, як МІСЦЕ ВАЛІТИЧНО підтверджувати, купувати квитанції та квитанції про придбання через додаток на iOS 7, як мені вже п’ять років (добре, зробіть це 3), зверху вниз, чітко?

Дякую!!!


Якщо у вас є версія, яка працює над вашими програмами, і ви побоюєтеся, що хакери побачать, як ви це зробили, просто змініть свої чутливі методи перед публікацією тут. Обплутайте рядки, змініть порядок рядків, змініть спосіб виконання циклів (від використання для блокування перерахування і навпаки) тощо. Очевидно, що кожна людина, яка використовує код, який може бути розміщений тут, повинна робити те саме, щоб не ризикувати, щоб його легко зламали.


1
Справедливе попередження: якщо це зробити локально, це набагато простіше виправити цю функцію зі своєї програми.
NinjaLikesCheez

2
Гаразд, я знаю, але справа тут у тому, щоб робити важкі речі та запобігати автоматичному розтріскуванню / виправленню. Питання полягає в тому, що якщо хакер дійсно хоче зламати вашу програму, він / вона зробить це, незалежно від методу, локального чи віддаленого. Ідея полягає також у тому, щоб трохи змінити її у кожній новій версії, яку ви випускаєте, щоб не допустити автоматичного виправлення знову.
Качка

4
@NinjaLikesCheez - можна NOP перевірити, навіть якщо перевірка проводиться на сервері.
Качка

14
вибачте, але це не привід. Єдине, що повинен зробити автор - це сказати, НЕ ВИКОРИСТОВУЙТЕ КОД, ЯКЩО Є. Без жодного прикладу неможливо зрозуміти це, не будучи вченим-ракетом.
Качка

3
Якщо ви не хочете займатись впровадженням DRM, не торкайтеся локальної перевірки. Просто надішліть квитанцію безпосередньо Apple із вашого додатка, і вони знову надішлють вам її у легко розібраному форматі JSON. Пірати це зламають, але якщо ви просто переходите на freemium і не піклуєтесь про піратство, це лише кілька рядків дуже простого коду.
Дан Фабуліч

Відповіді:


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. Він містить загальну інформацію, деякі поля для цілей перевірки (про це ми підемо пізніше) та конкретну інформацію про кожну застосовну покупку через додаток.

Знову 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. Перевірка, що ідентифікатор пакета, що входить у квитанцію, відповідає вашому ідентифікатору пакету. Вам слід ввести жорсткий код свого ідентифікатора пакета, оскільки, здається, не дуже складно змінити пакет додатка та використовувати іншу квитанцію.
  4. Перевірка, що версія додатка, включена в квитанцію, відповідає ідентифікатору вашої програми. Вам слід жорстко кодувати версію програми з тих самих причин, що вказані вище.
  5. Перевірте хеш квитанції, щоб переконатися, що квитанція відповідає поточному пристрою.

5 кроків коду на високому рівні від RMStoreAppReceiptVerificator :

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

Це було зроблено ще на початку, до розбору квитанції.

Перевірка хеша квитанції

Хеш, включений у квитанцію, є 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 і зробите свою бібліотеку компактною, використовуючи лише системні рамки.
Качка

2
@RubberDuck Див. Github.com/robotmedia/RMStore/isissue/16 . Не соромтеся звучати чи робити свій внесок. :)
hpique

1
@RubberDuck До цього я не знав нульових знань OpenSSL. Хто знає, може вам це навіть сподобається. : P
hpique

2
Це сприйнятливо до людини, що перебуває в атаці, коли запит та / або відповідь можуть бути перехоплені та змінені. Наприклад, запит можна буде перенаправити на сторонній сервер, і помилкову відповідь можна повернути, що наштовхує додаток на думку, що продукт був придбаний, коли його не було, і включення функціональності безкоштовно.
Jasarien

13

Я здивований, що тут ніхто не згадав про Receigen . Це інструмент, який автоматично генерує неясний код перевірки квитанції, кожен раз інший; він підтримує і графічний інтерфейс, і командний рядок. Настійно рекомендується.

(Не пов'язаний з Receigen, просто щасливий користувач.)

Я використовую подібний Rakefile для автоматичного повторного повторного отримання (тому що це потрібно робити при кожній зміні версії) при введенні 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

Правда, відсутність оновлень дуже насторожує. Однак це все ще працює; FWIW, я використовую це у своїх додатках.
Андрій Таранцов

Перевірте свою програму в інструментах на протікання, з Receigen я їх отримую дуже багато.
преподобний

Receigen - це передовий край, але так, це шкода, здається, його відкинули.
Fattie

1
Схоже, його ще не скинули. Оновлено три тижні тому!
Олег Коржуков

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 та ідентифікатор продукту. Відповідь 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
Ніколи не слід використовувати URL-адресу перевірки Apple зі свого пристрою. Її слід використовувати лише з вашого сервера. Про це йшлося на сесіях WWDC.
печер

Що буде, якщо користувач видалить програми або не відкриє тривалий час? Чи працює ваш розрахунок дати закінчення терміну дії?
karthikeyan

Тоді вам потрібно тримати перевірку на стороні сервера.
Пушпендра

1
Як сказав @pechar, ви ніколи цього не повинні робити. Будь ласка, додайте його до вершини своєї відповіді. Дивіться сесію WWDC о 36:32 => developer.apple.com/videos/play/wwdc2016/702
cicerocamargo

Я не розумію, чому не безпечно надсилати дані про отримання безпосередньо з пристрою. Хтось міг би пояснити?
Ко
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.