web-dev-qa-db-fra.com

Une solution complète pour valider LOCALEMENT un reçu intégré à une application et un ensemble de reçus sur iOS 7

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

Étant donné que ma connaissance de SSL, des certificats, du cryptage, etc. est presque nulle, toutes les explications que j'ai lues, comme celle-ci est prometteuse , j'ai trouvé difficile à comprendre.

Ils expliquent que les explications sont incomplètes, car chaque personne doit trouver le moyen de le faire. Sinon, les pirates auront la tâche facile de créer une application de piratage capable de reconnaître et d’identifier les schémas et de corriger l’application. OK, je suis d'accord avec cela jusqu'à un certain point. Je pense qu'ils pourraient expliquer complètement comment faire cela et mettre un avertissement disant "modifiez cette méthode", "modifiez cette autre méthode", "masquer cette variable", "changez le nom de ceci et cela", etc.

Une bonne âme peut-elle avoir la gentillesse d’expliquer comment valider LOCALEMENT, regrouper les reçus et les reçus d’achats intégrés sur iOS 7 alors que je suis dans cinq ans vieux (ok, fais-en 3), de haut en bas, clairement?

Merci!!!


Si vous avez une version qui fonctionne sur vos applications et que vous craignez que les pirates informatiques ne voient comment vous y êtes parvenu, il vous suffit de modifier vos méthodes sensibles avant de publier ici. Brouiller les chaînes, changer l'ordre des lignes, changer votre façon de faire des boucles (d'utiliser pour pour bloquer l'énumération et vice-versa) et ainsi de suite. De toute évidence, chaque personne qui utilise le code qui peut être affiché ici doit faire la même chose, sans risquer d’être facilement piratée.

153
SpaceDog

Voici un exemple de la façon dont j'ai résolu ce problème dans ma bibliothèque d'achats intégrés RMStore . Je vais expliquer comment vérifier une transaction, ce qui inclut la vérification de tout le reçu.

En un coup d'oeil

Obtenez le reçu et vérifiez la transaction. Si cela échoue, actualisez le reçu et réessayez. Cela rend le processus de vérification asynchrone car l'actualisation de l'accusé de réception est asynchrone.

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 réception

Le reçu est en [[NSBundle mainBundle] appStoreReceiptURL] et est en fait un conteneur PCKS7. J'adore la cryptographie et j'ai donc 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 une mince affaire. 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, des champs à des fins de vérification (nous y reviendrons plus tard) et des informations spécifiques sur chaque achat intégré appliqué.

Encore une fois, OpenSSL vient à la rescousse quand il s’agit de lire ASN1. De RMAppReceipt , à l'aide de 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. Son analyse est très similaire à celle des informations de réception générales.

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 convient de 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 devriez les vérifier juste après l'achat (encore une fois, RMStore vous aide à cela).

Vérification en un coup d'oeil

Nous avons maintenant tous les champs du reçu et de 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.

Ci-dessous, la méthode que nous avons rappelée au début. De 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 revient à:

  1. Vérifier que le reçu est valide PKCS7 et ASN1. Nous l'avons déjà fait implicitement.
  2. Vérification que le reçu est signé par Apple. Cela a été fait avant d'analyser le reçu et sera détaillé ci-dessous.
  3. Vérifier que l'identifiant de paquet inclus dans le reçu correspond à l'identifiant de votre paquet. 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'identificateur de version de votre application. Vous devez coder en dur la version de l'application pour les mêmes raisons que celles indiquées ci-dessus.
  5. Vérifiez le hachage du reçu pour vous assurer que le reçu correspond au périphérique actuel.

Les 5 étapes du code à un niveau élevé, à 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

Lorsque nous avons extrait les données, nous avons survolé 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 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 de réception

Le hachage inclus dans le reçu est un SHA1 de l'ID de périphérique, une valeur opaque incluse dans le reçu et l'ID de l'ensemble.

Voici comment vérifier le hachage de réception 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 se peut que je manque quelque chose ici ou là, alors je pourrais revenir à ce post plus tard. Dans tous les cas, je vous recommande de parcourir le code complet pour plus de détails.

141
hpique

Je suis surpris que personne ne mentionne Receigen ici. C'est un outil qui génère automatiquement un code de validation des reçus masqués, différent à chaque fois; il supporte à la fois l'interface graphique et les opérations en ligne de commande. Hautement recommandé.

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

J'utilise un fichier Rakefile comme celui-ci pour réexécuter 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
13
Andrey Tarantsov

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

Ceci est une version Swift 4 pour la validation du reçu d'achat intégré à l'application ...

Permet de créer une énumération pour représenter les erreurs possibles de la validation de la réception

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

Ensuite, créons la fonction qui valide le reçu, une erreur sera renvoyée 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'assistance 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 réception pour différents produits. Elle obtient donc les dernières informations du 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 erreurs 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 depuis l'App Store Connect. https://developer.Apple.com ouvrez ce lien cliquez 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 la version Swift version.

5
Pushpendra