Una soluzione completa per validare LOCALMENTE le ricevute in-app e raggruppare le ricevute su iOS 7


160

Ho letto molti documenti e codici che in teoria convalideranno una ricevuta in-app e / o in bundle.

Dato che la mia conoscenza di SSL, certificati, crittografia, ecc. È quasi zero, tutte le spiegazioni che ho letto, come questa promettente , le ho trovate difficili da capire.

Dicono che le spiegazioni sono incomplete perché ogni persona deve capire come farlo, o gli hacker avranno un lavoro facile creando un'app cracker in grado di riconoscere e identificare schemi e patchare l'applicazione. OK, sono d'accordo con questo fino a un certo punto. Penso che potrebbero spiegare completamente come farlo e mettere un avvertimento dicendo "modifica questo metodo", "modifica questo altro metodo", "offusca questa variabile", "cambia il nome di questo e quello", ecc.

Può una buona anima là fuori essere abbastanza gentile da spiegare come convalidare LOCALMENTE, raggruppare le ricevute e le ricevute di acquisto in-app su iOS 7 quando ho cinque anni (ok, fallo 3), dall'alto verso il basso, chiaramente?

Grazie!!!


Se disponi di una versione funzionante sulle tue app e temi che gli hacker possano vedere come hai fatto, modifica semplicemente i tuoi metodi sensibili prima di pubblicarli qui. Offusca le stringhe, cambia l'ordine delle linee, cambia il modo in cui esegui i loop (dall'uso per bloccare l'enumerazione e viceversa) e cose del genere. Ovviamente, ogni persona che utilizza il codice che può essere pubblicato qui, deve fare la stessa cosa, senza rischiare di essere facilmente violata.


1
Avviso equo: farlo localmente rende molto più facile eliminare questa funzione dall'applicazione.
NinjaLikesCheez

2
OK, lo so, ma il punto qui è fare cose difficili e prevenire crack / patch automatizzati. La domanda è che se un hacker vuole davvero crackare la tua app, lo farà, qualunque sia il metodo che usi, locale o remoto. L'idea è anche quella di cambiarla leggermente ogni nuova versione rilasciata, per evitare di nuovo il patching automatico.
Anatra,

4
@NinjaLikesCheez: è possibile NON eseguire il check anche se la verifica viene eseguita su un server.
Anatra,

14
scusa, ma questa non è una scusa. L'unica cosa che l'autore deve fare è dire NON UTILIZZARE IL CODICE COSÌ COM'È. Senza alcun esempio, è impossibile capirlo senza essere uno scienziato missilistico.
Anatra

3
Se non vuoi disturbare l'implementazione del DRM, non preoccuparti della verifica locale. Basta inviare la ricevuta direttamente ad Apple dalla tua app e te la rispediranno nuovamente in un formato JSON facilmente analizzabile. È banale per i pirati decifrare questo, ma se stai solo passando al freemium e non ti importa della pirateria, sono solo poche righe di codice molto semplice.
Dan Fabulich,

Risposte:


146

Ecco una panoramica di come ho risolto questo problema nella mia libreria di acquisti in-app RMStore . Spiegherò come verificare una transazione, che include la verifica dell'intera ricevuta.

A prima vista

Ottieni la ricevuta e verifica la transazione. In caso contrario, aggiorna la ricevuta e riprova. Ciò rende il processo di verifica asincrono poiché l'aggiornamento della ricevuta è asincrono.

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

Ottenere i dati della ricevuta

La ricevuta è in [[NSBundle mainBundle] appStoreReceiptURL]ed è in realtà un contenitore PCKS7. Faccio schifo alla crittografia, quindi ho usato OpenSSL per aprire questo contenitore. Altri apparentemente l'hanno fatto semplicemente con framework di sistema .

Aggiungere OpenSSL al tuo progetto non è banale. Il wiki di RMStore dovrebbe aiutare.

Se si sceglie di utilizzare OpenSSL per aprire il contenitore PKCS7, il codice potrebbe essere simile al seguente. Da 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;
}

Entreremo nei dettagli della verifica in seguito.

Ottenere i campi della ricevuta

La ricevuta è espressa in formato ASN1. Contiene informazioni generali, alcuni campi a scopo di verifica (ne vedremo più avanti) e informazioni specifiche per ogni acquisto in-app applicabile.

Ancora una volta, OpenSSL viene in soccorso quando si tratta di leggere ASN1. Da RMAppReceipt , utilizzando alcuni metodi di supporto:

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;

Ottenere gli acquisti in-app

Ogni acquisto in-app è anche in ASN1. L'analisi è molto simile all'analisi delle informazioni generali sulla ricevuta.

Da RMAppReceipt , utilizzando gli stessi metodi di supporto:

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

Va notato che alcuni acquisti in-app, come materiali di consumo e abbonamenti non rinnovabili, verranno visualizzati una sola volta nella ricevuta. Dovresti verificarli subito dopo l'acquisto (di nuovo, RMStore ti aiuta in questo).

Verifica a colpo d'occhio

Ora abbiamo ottenuto tutti i campi dalla ricevuta e tutti i suoi acquisti in-app. Prima verifichiamo la ricevuta stessa, quindi controlliamo semplicemente se la ricevuta contiene il prodotto della transazione.

Di seguito è riportato il metodo che abbiamo richiamato all'inizio. Da 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;
}

Verifica della ricevuta

La verifica della ricevuta stessa si riduce a:

  1. Verifica che la ricevuta sia valida PKCS7 e ASN1. Lo abbiamo già fatto implicitamente.
  2. Verifica che la ricevuta sia firmata da Apple. Ciò è stato fatto prima dell'analisi della ricevuta e verrà dettagliato di seguito.
  3. Verifica che l'identificatore del bundle incluso nella ricevuta corrisponda all'identificatore del bundle. È necessario codificare l'identificatore del bundle, in quanto non sembra essere molto difficile modificare il bundle dell'app e utilizzare qualche altra ricevuta.
  4. Verifica che la versione dell'app inclusa nella ricevuta corrisponda all'identificatore della versione dell'app. È necessario codificare la versione dell'app, per gli stessi motivi indicati sopra.
  5. Controlla l'hash della ricevuta per assicurarti che la ricevuta corrisponda al dispositivo corrente.

I 5 passaggi nel codice ad alto livello, da 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;
}

Eseguiamo il drill-down nei passaggi 2 e 5.

Verifica della firma della ricevuta

Quando abbiamo estratto i dati che abbiamo dato un'occhiata alla verifica della firma della ricevuta. La ricevuta è firmata con il certificato radice di Apple Inc., che può essere scaricato dall'autorità di certificazione radice Apple . Il codice seguente prende il contenitore PKCS7 e il certificato radice come dati e controlla se corrispondono:

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

Ciò è stato fatto all'inizio, prima che la ricevuta fosse analizzata.

Verifica dell'hash della ricevuta

L'hash incluso nella ricevuta è un SHA1 dell'ID del dispositivo, un valore opaco incluso nella ricevuta e l'id del bundle.

Ecco come verifichi l'hash della ricevuta su iOS. Da 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];
}

E questo è l'essenza. Potrei mancare qualcosa qui o là, quindi potrei tornare a questo post più tardi. In ogni caso, ti consiglio di sfogliare il codice completo per maggiori dettagli.


2
Dichiarazione di non responsabilità sulla sicurezza: l'utilizzo di codice open source rende la tua app più vulnerabile. Se la sicurezza è un problema, potresti voler utilizzare RMStore e il codice sopra solo come guida.
hpique

6
Sarebbe fantastico se in futuro ti sbarazzassi di OpenSSL e rendessi compatta la tua libreria usando solo framework di sistema.
Duck,

2
@RubberDuck Vedi github.com/robotmedia/RMStore/issues/16 . Sentiti libero di intervenire o di contribuire. :)
hpique

1
@RubberDuck Fino a questo momento non avevo conoscenza di OpenSSL. Chissà, potrebbe anche piacerti. : P
hpique,

2
È suscettibile a un Man In The Middle Attack, in cui la richiesta e / o la risposta possono essere intercettate e modificate. Ad esempio, la richiesta potrebbe essere reindirizzata a un server di terze parti e potrebbe essere restituita una risposta falsa, inducendo l'app a pensare che un prodotto è stato acquistato, quando non lo era, e abilitando la funzionalità gratuitamente.
Jasarien,

13

Sono sorpreso che nessuno abbia menzionato Receigen qui. È uno strumento che genera automaticamente un codice di convalida della ricevuta offuscato, uno ogni volta diverso; supporta sia la GUI che il funzionamento da riga di comando. Altamente raccomandato.

(Non affiliato a Receigen, solo un utente felice.)

Uso un Rakefile come questo per rieseguire automaticamente Receigen (perché deve essere fatto ad ogni cambio di versione) quando scrivo 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
Per coloro che sono interessati a Receigen, questa è una soluzione a pagamento, disponibile su App Store per 29,99 $. Anche se non è stato aggiornato da settembre 2014.
DevGansta

È vero, la mancanza di aggiornamenti è molto allarmante. Tuttavia funziona ancora; FWIW, lo sto usando nelle mie app.
Andrey Tarantsov,

Controlla la tua app negli strumenti per perdite, con Receigen le ottengo molto.
il reverendo

Receigen è all'avanguardia, ma sì è un peccato che sembra essere stato abbandonato.
Fattie,

1
Sembra che non sia ancora caduto. Aggiornato tre settimane fa!
Oleg Korzhukov

2

Nota: non è consigliabile eseguire questo tipo di verifica sul lato client

Questa è una versione di Swift 4 per la convalida della ricevuta di acquisto in-app ...

Consente di creare un enum per rappresentare i possibili errori della convalida della ricevuta

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

Quindi creiamo la funzione che convalida la ricevuta, genererà un errore se non è in grado di convalidarla.

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

Usiamo questa funzione di supporto per ottenere la data di scadenza di un prodotto specifico. La funzione riceve una risposta JSON e un ID prodotto. La risposta JSON può contenere più informazioni sulle ricevute per prodotti diversi, quindi ottiene le ultime informazioni per il parametro specificato.

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
}

Ora puoi chiamare questa funzione e gestire i possibili casi di errore

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

Puoi ottenere una password dall'App Store Connect. https://developer.apple.comapri questo link clicca su

  • 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

Copia quella chiave e incollala nel campo della password.

Spero che questo possa essere d'aiuto per tutti coloro che lo desiderano nella versione rapida.


19
Non dovresti mai usare l'URL di convalida Apple dal tuo dispositivo. Dovrebbe essere usato solo dal tuo server. Questo è stato menzionato nelle sessioni del WWDC.
Pechar,

Cosa succederebbe se l'utente eliminasse le app o non aprisse a lungo? Il calcolo della data di scadenza funziona correttamente?
karthikeyan

Quindi è necessario mantenere la convalida sul lato server.
Pushpendra,

1
Come ha detto @pechar, non dovresti mai farlo. Per favore aggiungilo all'inizio della tua risposta. Vedi sessione WWDC a 36:32 => developer.apple.com/videos/play/wwdc2016/702
cicerocamargo

Non capisco perché non sia sicuro inviare i dati di ricevuta direttamente dal dispositivo. Qualcuno sarebbe in grado di spiegare?
Koh
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.