Полное решение для локальной проверки квитанций в приложении и пакетных квитанций на iOS 7

160

Я прочитал много документов и кодов, которые теоретически подтвердят получение и / или получение в приложении.

Учитывая, что мои знания о SSL, сертификатах, шифровании и т. Д. Почти равны нулю, все объяснения, которые я прочитал, как и это многообещающее , я нашел трудным для понимания.

Они говорят, что объяснения неполные, потому что каждый человек должен выяснить, как это сделать, или хакерам будет легко создать приложение-взломщик, которое сможет распознавать и идентифицировать шаблоны и исправлять приложение. Хорошо, я согласен с этим до определенного момента. Я думаю, что они могли бы полностью объяснить, как это сделать, и поставить предупреждение «изменить этот метод», «изменить этот другой метод», «скрыть эту переменную», «изменить имя того и того» и т. Д.

Может ли какая-нибудь добрая душа быть достаточно любезной, чтобы объяснить, как ЛОКАЛЬНО проверять, связывать квитанции и квитанции о покупках в приложении на iOS 7, поскольку мне пять лет (хорошо, сделайте это 3), сверху вниз, ясно?

Спасибо!!!


Если у вас есть версия, работающая с вашими приложениями, и вы опасаетесь, что хакеры увидят, как вы это сделали, просто измените свои чувствительные методы перед публикацией здесь. Запутывайте строки, меняйте порядок строк, изменяйте то, как вы делаете циклы (от использования for до блокировки перечисления и наоборот) и тому подобное. Очевидно, что каждый, кто использует код, который может быть размещен здесь, должен делать то же самое, чтобы не рискнуть быть легко взломанным.

Утка
источник
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 . Следующий код принимает контейнер 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];
}

И это суть этого. Я мог что-то упустить здесь или там, поэтому я могу вернуться к этому посту позже. В любом случае, я рекомендую просмотреть полный код для более подробной информации.

hpique
источник
2
Отказ от ответственности: использование открытого исходного кода делает ваше приложение более уязвимым. Если вас беспокоит безопасность, вы можете использовать RMStore и приведенный выше код только в качестве руководства.
hpique
6
Было бы здорово, если бы в будущем вы избавились от OpenSSL и сделали свою библиотеку компактной, используя только системные фреймворки.
Утка
2
@RubberDuck См github.com/robotmedia/RMStore/issues/16 . Не стесняйтесь присоединиться или внести свой вклад. :)
hpique
1
@RubberDuck У меня было нулевое знание OpenSSL до этого. Кто знает, вам это может даже понравиться. : P
hpique
2
Он подвержен атаке «Человек посередине», где запрос и / или ответ могут быть перехвачены и изменены. Например, запрос может быть перенаправлен на сторонний сервер, и может быть возвращен ложный ответ, заставляя приложение думать, что продукт был куплен, а когда нет, и включая функциональность бесплатно.
Jasarien
13

Я удивлен, что никто не упомянул здесь Receigen . Это инструмент, который автоматически генерирует запутанный код подтверждения получения, каждый раз другой; он поддерживает как графический интерфейс, так и работу в командной строке. Настоятельно рекомендуется.

(Не связан с 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
Правда, отсутствие обновлений очень настораживает. Однако это все еще работает; FWIW, я использую это в своих приложениях.
Андрей Таранцов
Проверяйте ваше приложение в приборах на предмет утечек, с Receigen я их много получаю.
Преподобный
Receigen - это передовой край, но да, это позор, который, кажется, упал.
Толстяк
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

Скопируйте этот ключ и вставьте в поле пароля.

Надеюсь, что это поможет каждому, кто хочет это в быстрой версии.

Pushpendra
источник
19
Вы никогда не должны использовать проверочный URL Apple с вашего устройства. Он должен использоваться только с вашего сервера. Это упоминалось на сессиях WWDC.
Pechar
Что произойдет, если пользователь удалит приложения или долго не будет их открывать? Ваш расчет срока годности работает нормально?
Картикеян
Затем вам нужно сохранить проверку на стороне сервера.
Пушпендра
1
Как сказал @pechar, вы никогда не должны этого делать. Пожалуйста, добавьте его в начало вашего ответа. Смотрите сессию WWDC в 36:32 => developer.apple.com/videos/play/wwdc2016/702
cicerocamargo
Я не понимаю, почему небезопасно отправлять данные квитанции напрямую с устройства. Кто-нибудь сможет объяснить?
Ко