Une solution complète pour valider LOCALEMENT les reçus intégrés à l'application et les reçus groupés sur iOS 7


160

J'ai lu beaucoup de documents et de code qui valideront en théorie un reçu intégré à l'application et / ou au bundle.

Étant donné que ma connaissance du SSL, des certificats, du cryptage, etc. est quasiment nulle, toutes les explications que j'ai lues, comme celle-ci prometteuse , m'ont été difficiles à comprendre.

Ils disent que les explications sont incomplètes parce que chaque personne doit comprendre comment le faire, ou les pirates auront un travail facile pour créer une application de piratage qui peut reconnaître et identifier les modèles et patcher l'application. OK, je suis d'accord avec cela jusqu'à un certain point. Je pense qu'ils pourraient expliquer complètement comment faire et mettre un avertissement disant "modifier cette méthode", "modifier cette autre méthode", "obscurcir cette variable", "changer le nom de ceci et cela", etc.

Une bonne âme peut-elle être assez gentille pour expliquer comment valider LOCALEMENT, regrouper les reçus et les reçus d'achat intégrés sur iOS 7, car j'ai cinq ans (d'accord, faites-en 3), de haut en bas, clairement?

Merci!!!


Si vous avez une version fonctionnant sur vos applications et que vous craignez que les pirates voient comment vous l'avez fait, changez simplement vos méthodes sensibles avant de publier ici. Obfusquez les chaînes, changez l'ordre des lignes, changez la façon dont vous faites des boucles (de l'utilisation de for à l'énumération de bloc et vice-versa) et des choses comme ça. Évidemment, chaque personne qui utilise le code qui peut être affiché ici, doit faire la même chose, pour ne pas risquer d'être facilement piratée.


1
Avertissement juste: le faire localement rend beaucoup plus facile de patcher cette fonction hors de votre application.
NinjaLikesCheez

2
OK, je sais, mais le but ici est de faire les choses difficiles et d'éviter le cracking / patching automatisé. La question est que si un pirate veut vraiment cracker votre application, il le fera, quelle que soit la méthode que vous utilisez, locale ou distante. L'idée est également de la modifier légèrement à chaque nouvelle version que vous publiez, pour éviter à nouveau les correctifs automatisés.
Canard du

4
@NinjaLikesCheez - on peut NOP la vérification même si la vérification est faite sur un serveur.
Canard du

14
désolé, mais ce n'est pas une excuse. La seule chose que l'auteur doit faire est de dire: N'UTILISEZ PAS LE CODE TEL QUEL. Sans aucun exemple, il est impossible de comprendre cela sans être un spécialiste des fusées.
Canard du

3
Si vous ne voulez pas vous soucier de l'implémentation de DRM, ne vous souciez pas de la vérification locale. POSTUREZ simplement le reçu directement à Apple depuis votre application, et ils vous le renverront à nouveau dans un format JSON facilement analysé. C'est trivial pour les pirates de déchiffrer cela, mais si vous êtes en train de passer au freemium et que vous ne vous souciez pas du piratage, ce ne sont que quelques lignes de code très faciles.
Dan Fabulich

Réponses:


146

Voici une procédure pas à pas de la façon dont j'ai résolu ce problème dans ma bibliothèque d'achat intégré RMStore . Je vais vous expliquer comment vérifier une transaction, ce qui comprend la vérification de l'ensemble du reçu.

En un coup d'oeil

Obtenez le reçu et vérifiez la transaction. En cas d'échec, actualisez le reçu et réessayez. Cela rend le processus de vérification asynchrone car l'actualisation de la réception est asynchrone.

À partir de 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];
}];

Obtenir les données de reçu

Le reçu est dans [[NSBundle mainBundle] appStoreReceiptURL]et est en fait un conteneur PCKS7. Je suis nul en cryptographie, alors j'ai utilisé OpenSSL pour ouvrir ce conteneur. D'autres l'ont apparemment fait uniquement avec des cadres système .

Ajouter OpenSSL à votre projet n'est pas anodin. Le wiki RMStore devrait vous aider.

Si vous choisissez d'utiliser OpenSSL pour ouvrir le conteneur PKCS7, votre code pourrait ressembler à ceci. De 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;
}

Nous entrerons dans les détails de la vérification plus tard.

Obtenir les champs de réception

Le reçu est exprimé au format ASN1. Il contient des informations générales, certains champs à des fins de vérification (nous y reviendrons plus tard) et des informations spécifiques sur chaque achat via l'application applicable.

Encore une fois, OpenSSL vient à la rescousse lorsqu'il s'agit de lire ASN1. À partir de RMAppReceipt , en utilisant quelques méthodes d'assistance:

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;

Obtenir les achats intégrés

Chaque achat intégré est également en ASN1. L'analyse est très similaire à l'analyse des informations générales de réception.

À partir de RMAppReceipt , en utilisant les mêmes méthodes d'assistance:

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

Il est à noter que certains achats intégrés, tels que les consommables et les abonnements non renouvelables, n'apparaîtront qu'une seule fois sur le reçu. Vous devez les vérifier juste après l'achat (encore une fois, RMStore vous aide avec cela).

Vérification en un coup d'œil

Maintenant, nous avons tous les champs du reçu et tous ses achats intégrés. Nous vérifions d'abord le reçu lui-même, puis nous vérifions simplement si le reçu contient le produit de la transaction.

Voici la méthode que nous avons rappelée au début. Depuis 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;
}

Vérification du reçu

La vérification du reçu lui-même se résume à:

  1. Vérification de la validité du reçu PKCS7 et ASN1. Nous l'avons déjà fait implicitement.
  2. Vérifier que le reçu est signé par Apple. Cela a été fait avant l'analyse du reçu et sera détaillé ci-dessous.
  3. Vérifier que l'identifiant du bundle inclus dans le reçu correspond à votre identifiant du bundle. Vous devez coder en dur l'identifiant de votre bundle, car il ne semble pas très difficile de modifier votre bundle d'applications et d'utiliser un autre reçu.
  4. Vérifier que la version de l'application incluse dans le reçu correspond à l'identifiant de la version de votre application. Vous devez coder en dur la version de l'application, pour les mêmes raisons indiquées ci-dessus.
  5. Vérifiez le hachage du reçu pour vous assurer que le reçu correspond à l'appareil actuel.

Les 5 étapes du code de haut niveau, à partir de 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;
}

Explorons les étapes 2 et 5.

Vérification de la signature du reçu

À l'époque où nous avons extrait les données, nous avons jeté un coup d'œil sur la vérification de la signature du reçu. Le reçu est signé avec le certificat racine Apple Inc., qui peut être téléchargé à partir de l' autorité de certification racine Apple . Le code suivant prend le conteneur PKCS7 et le certificat racine en tant que données et vérifie s'ils correspondent:

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

Cela a été fait au début, avant que le reçu ne soit analysé.

Vérification du hachage du reçu

Le hachage inclus dans le reçu est un SHA1 de l'identifiant de l'appareil, une valeur opaque incluse dans le reçu et l'identifiant du bundle.

C'est ainsi que vous vérifieriez le hachage du reçu sur iOS. De 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];
}

Et c'est l'essentiel. Il me manque peut-être quelque chose ici ou là, alors je reviendrai peut-être sur ce post plus tard. Dans tous les cas, je recommande de parcourir le code complet pour plus de détails.


2
Clause de non-responsabilité de sécurité: l'utilisation de code open source rend votre application plus vulnérable. Si la sécurité est un problème, vous pouvez utiliser RMStore et le code ci-dessus uniquement comme guide.
hpique

6
Ce serait fantastique si à l'avenir vous vous débarrassiez d'OpenSSL et que vous rendiez votre bibliothèque compacte en utilisant uniquement des frameworks système.
Canard

2
@RubberDuck Voir github.com/robotmedia/RMStore/issues/16 . N'hésitez pas à intervenir ou à contribuer. :)
hpique

1
@RubberDuck Je n'avais aucune connaissance d'OpenSSL jusqu'à présent. Qui sait, vous pourriez même l'aimer. : P
hpique

2
Il est sensible à une attaque de l'homme au milieu, où la demande et / ou la réponse peuvent être interceptées et modifiées. Par exemple, la demande pourrait être redirigée vers un serveur tiers et une fausse réponse pourrait être renvoyée, faisant croire à l'application qu'un produit a été acheté, alors qu'il ne l'était pas, et activant la fonctionnalité gratuitement.
Jasarien

13

Je suis surpris que personne n'ait mentionné Receigen ici. C'est un outil qui génère automatiquement un code de validation de reçu obscurci, différent à chaque fois; il prend en charge les opérations GUI et en ligne de commande. Hautement recommandé.

(Non affilié à Receigen, juste un utilisateur heureux.)

J'utilise un Rakefile comme celui-ci pour relancer automatiquement Receigen (car cela doit être fait à chaque changement de version) lorsque je tape 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
Pour ceux qui s'intéressent à Receigen, il s'agit d'une solution payante, disponible sur App Store au prix de 29,99 $. Bien qu'il n'ait pas été mis à jour depuis septembre 2014.
DevGansta

Certes, le manque de mises à jour est très alarmant. Cependant, cela fonctionne toujours; FWIW, je l'utilise dans mes applications.
Andrey Tarantsov

Vérifiez votre application dans les instruments pour des fuites, avec Receigen je les reçois beaucoup.
le révérend

Receigen est à la pointe, mais oui, c'est dommage qu'il semble avoir été abandonné.
Fattie

1
On dirait qu'il n'a pas encore été abandonné. Mis à jour il y a trois semaines!
Oleg Korzhukov

2

Remarque: il n'est pas recommandé de faire ce type de vérification côté client

Ceci est une version Swift 4 pour la validation du reçu d'achat in-app ...

Permet de créer une énumération pour représenter les erreurs possibles de la validation du reçu

enum ReceiptValidationError: Error {
    case receiptNotFound
    case jsonResponseIsNotValid(description: String)
    case notBought
    case expired
}

Créons ensuite la fonction qui valide le reçu, elle lèvera une erreur s'il ne parvient pas à le valider.

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

Utilisons cette fonction d'aide pour obtenir la date d'expiration d'un produit spécifique. La fonction reçoit une réponse JSON et un identifiant de produit. La réponse JSON peut contenir plusieurs informations de reçu pour différents produits, afin d'obtenir les dernières informations pour le paramètre spécifié.

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
}

Vous pouvez maintenant appeler cette fonction et gérer les cas d'erreur possibles

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).")
}

Vous pouvez obtenir un mot de passe sur l'App Store Connect. https://developer.apple.comouvrir ce lien cliquer sur

  • 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

Copiez cette clé et collez-la dans le champ du mot de passe.

J'espère que cela aidera pour tous ceux qui le souhaitent dans une version rapide.


19
Vous ne devez jamais utiliser l'URL de validation Apple de votre appareil. Il ne doit être utilisé qu'à partir de votre serveur. Cela a été mentionné dans les sessions de la WWDC.
pechar

Que se passerait-il si l'utilisateur supprimait les applications ou ne les ouvrait pas longtemps? Votre calcul de la date d'expiration fonctionne-t-il correctement?
karthikeyan

Ensuite, vous devez conserver la validation côté serveur.
Pushpendra

1
Comme @pechar l'a dit, vous ne devriez jamais faire cela. Veuillez l'ajouter en haut de votre réponse. Voir la session WWDC à 36:32 => developer.apple.com/videos/play/wwdc2016/702
cicerocamargo

Je ne comprends pas pourquoi il n'est pas sûr d'envoyer les données de reçu directement à partir de l'appareil. Quelqu'un pourrait-il expliquer?
Koh
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.