Uma solução completa para validar LOCALMENTE os recibos no aplicativo e agrupar recibos no iOS 7

160

Eu li muitos documentos e códigos que, em teoria, validam um recebimento no aplicativo e / ou pacote.

Dado que meu conhecimento de SSL, certificados, criptografia etc. é quase zero, todas as explicações que li, como essa promissora , foram difíceis de entender.

Eles dizem que as explicações estão incompletas porque todas as pessoas precisam descobrir como fazê-lo, ou os hackers terão um trabalho fácil criando um aplicativo de cracker que pode reconhecer e identificar padrões e corrigir o aplicativo. OK, eu concordo com isso até um certo ponto. Eu acho que eles poderiam explicar completamente como fazê-lo e colocar um aviso dizendo "modificar este método", "modificar este outro método", "ofuscar essa variável", "mudar o nome disso e daquilo", etc.

Alguma alma boa por aí pode ter a gentileza de explicar como validar, agrupar recibos e recibos de compra no aplicativo no iOS 7 quando eu tiver cinco anos (ok, faça 3), de cima para baixo, claramente?

Obrigado!!!


Se você tem uma versão trabalhando em seus aplicativos e tem a preocupação de que os hackers verão como você fez isso, basta alterar seus métodos confidenciais antes de publicar aqui. Ofusque as strings, mude a ordem das linhas, mude a maneira como você faz loops (de usar para para bloquear a enumeração e vice-versa) e coisas assim. Obviamente, toda pessoa que usa o código que pode ser postado aqui deve fazer o mesmo, para não correr o risco de ser facilmente invadida.

Pato
fonte
1
Aviso justo: fazê-lo localmente torna muito mais fácil corrigir esta função do seu aplicativo.
NinjaLikesCheez
2
OK, eu sei, mas o objetivo aqui é fazer as coisas difíceis e evitar a quebra / correção automatizada. A questão é que, se um hacker realmente deseja invadir seu aplicativo, ele o fará, qualquer que seja o método que você use, local ou remoto. A idéia também é alterá-lo levemente a cada nova versão lançada, para evitar o patch automatizado novamente.
Duck
4
@NinjaLikesCheez - é possível NOP a verificação, mesmo que a verificação seja feita em um servidor.
Duck
14
desculpe, mas isso não é desculpa. A única coisa que o autor precisa fazer é dizer NÃO USE O CÓDIGO COMO ESTÁ. Sem nenhum exemplo, é impossível entender isso sem ser um cientista de foguetes.
Duck
3
Se você não quer se preocupar em implementar o DRM, não se preocupe com a verificação local. Basta enviar o recibo diretamente para a Apple a partir do seu aplicativo, e eles o enviarão novamente para você em um formato JSON facilmente analisado. É fácil para os piratas decifrar isso, mas se você está apenas migrando para o freemium e não se importa com pirataria, são apenas algumas linhas de código muito fácil.
Dan Fabulich

Respostas:

146

Aqui está uma explicação de como eu resolvi isso na minha biblioteca de compras no aplicativo RMStore . Vou explicar como verificar uma transação, que inclui a verificação de todo o recebimento.

Num relance

Obtenha o recibo e verifique a transação. Se falhar, atualize o recibo e tente novamente. Isso torna o processo de verificação assíncrono, pois a atualização do recebimento é assíncrona.

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

Obtendo os dados do recebimento

O recibo está dentro [[NSBundle mainBundle] appStoreReceiptURL]e é realmente um contêiner PCKS7. Eu sou péssima em criptografia, então usei o OpenSSL para abrir este contêiner. Outros aparentemente fizeram isso puramente com estruturas de sistema .

Adicionar o OpenSSL ao seu projeto não é trivial. O wiki do RMStore deve ajudar.

Se você optar por usar o OpenSSL para abrir o contêiner PKCS7, seu código poderá ser assim. 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;
}

Entraremos em detalhes da verificação posteriormente.

Obtendo os campos de recebimento

O recibo é expresso no formato ASN1. Ele contém informações gerais, alguns campos para fins de verificação (veremos mais adiante) e informações específicas de cada compra no aplicativo aplicável.

Novamente, o OpenSSL vem em socorro quando se trata de ler o ASN1. No RMAppReceipt , usando alguns métodos auxiliares:

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;

Obtendo as compras no aplicativo

Cada compra no aplicativo também está no ASN1. Analisá-lo é muito semelhante ao analisar as informações gerais de recebimento.

No RMAppReceipt , usando os mesmos métodos auxiliares:

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

Observe que determinadas compras no aplicativo, como consumíveis e assinaturas não renováveis, aparecerão apenas uma vez no recibo. Você deve verificá-las logo após a compra (novamente, o RMStore ajuda você com isso).

Verificação de relance

Agora temos todos os campos do recibo e todas as suas compras no aplicativo. Primeiro, verificamos o recibo em si e, em seguida, simplesmente verificamos se o recibo contém o produto da transação.

Abaixo está o método que chamamos de volta no começo. No 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;
}

Verificando o recibo

A verificação do recibo em si se resume a:

  1. Verificando se o recebimento é válido PKCS7 e ASN1. Já fizemos isso implicitamente.
  2. Verificando se o recibo é assinado pela Apple. Isso foi feito antes de analisar o recebimento e será detalhado abaixo.
  3. Verificando se o identificador de pacote incluído no recibo corresponde ao seu identificador de pacote. Você deve codificar o identificador do pacote configurável, pois não parece muito difícil modificar o pacote do aplicativo e usar outro recibo.
  4. Verificando se a versão do aplicativo incluída no recibo corresponde ao identificador da versão do aplicativo. Você deve codificar a versão do aplicativo pelos mesmos motivos indicados acima.
  5. Verifique o hash do recibo para garantir que o recibo corresponda ao dispositivo atual.

As 5 etapas do código em alto nível, no 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;
}

Vamos detalhar as etapas 2 e 5.

Verificando a assinatura do recibo

Quando extraímos os dados, examinamos a verificação da assinatura do recibo. O recibo é assinado com o certificado raiz da Apple Inc., que pode ser baixado na autoridade de certificação raiz da Apple . O código a seguir usa o contêiner PKCS7 e o certificado raiz como dados e verifica se eles correspondem:

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

Isso foi feito no início, antes da análise do recebimento.

Verificando o hash de recebimento

O hash incluído no recebimento é um SHA1 do ID do dispositivo, algum valor opaco incluído no recebimento e o ID do pacote.

É assim que você verificaria o hash de recebimento no 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];
}

E essa é a essência disso. Talvez esteja faltando alguma coisa aqui ou ali, para poder voltar a este post mais tarde. De qualquer forma, recomendo procurar o código completo para obter mais detalhes.

hpique
fonte
2
Isenção de responsabilidade de segurança: o uso de código-fonte aberto torna seu aplicativo mais vulnerável. Se a segurança for uma preocupação, convém usar o RMStore e o código acima apenas como um guia.
Hpique
6
Seria fantástico se, no futuro, você se livrar do OpenSSL e tornar sua biblioteca compacta usando apenas estruturas de sistema.
Duck
2
@RubberDuck Consulte github.com/robotmedia/RMStore/issues/16 . Sinta-se à vontade para comentar ou contribuir. :)
hpique
1
@RubberDuck Eu tinha zero conhecimento de OpenSSL até isso. Quem sabe, você pode até gostar. : P
hpique
2
É suscetível a um ataque do homem no meio, onde a solicitação e / ou resposta podem ser interceptadas e modificadas. Por exemplo, a solicitação pode ser redirecionada para um servidor de terceiros e uma resposta falsa pode ser retornada, fazendo com que o aplicativo pense que um produto foi comprado, quando não era, e habilite a funcionalidade gratuitamente.
Jasarien
13

Estou surpreso que ninguém tenha mencionado Receigen aqui. É uma ferramenta que gera automaticamente código de validação de recibo ofuscado, um diferente a cada vez; suporta operação de GUI e de linha de comando. Altamente recomendado.

(Não afiliado ao Receigen, apenas um usuário satisfeito.)

Eu uso um Rakefile como este para executar novamente o Receigen automaticamente (porque ele precisa ser feito em todas as alterações de versão) quando digito 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
Andrey Tarantsov
fonte
1
Para quem está interessado no Receigen, essa é uma solução paga, disponível na App Store por 29,99 $. Embora não seja atualizado desde setembro de 2014.
DevGansta 14/05
É verdade que a falta de atualizações é muito alarmante. No entanto, ainda funciona; FWIW, estou usando nos meus aplicativos.
Andrey Tarantsov
Verifique se há vazamentos no seu aplicativo, com o Receigen eu os recebo bastante.
o reverendo
Receigen é a vanguarda, mas sim, é uma pena que parece ter sido descartada.
Fattie 19/09/17
1
Parece que ainda não foi descartado. Atualizado há três semanas!
Oleg Korzhukov
2

Nota: não é recomendável fazer esse tipo de verificação no lado do cliente

Esta é uma versão do Swift 4 para validação de recibo de compra no aplicativo ...

Vamos criar uma enumeração para representar os possíveis erros da validação de recebimento

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

Em seguida, vamos criar a função que valida o recebimento; ele gera um erro se não puder validá-lo.

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

Vamos usar esta função auxiliar para obter a data de validade de um produto específico. A função recebe uma resposta JSON e um ID do produto. A resposta JSON pode conter várias informações de recibos para diferentes produtos, para obter as últimas informações para o parâmetro especificado.

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
}

Agora você pode chamar essa função e manipular os possíveis casos de erro

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

Você pode obter uma senha na App Store Connect. https://developer.apple.comabrir este link clique em

  • 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

Copie essa chave e cole no campo de senha.

Espero que isso ajude a todos que desejam isso na versão rápida.

Pushpendra
fonte
19
Você nunca deve usar o URL de validação da Apple no seu dispositivo. Só deve ser usado no seu servidor. Isso foi mencionado nas sessões da WWDC.
Pechar
O que aconteceria se o usuário excluir os aplicativos ou não abrir por um longo tempo? Seu cálculo da data de validade está funcionando bem?
precisa saber é o seguinte
Então você precisa manter a validação no lado do servidor.
precisa saber é o seguinte
1
Como o @pechar disse, você nunca deve fazer isso. Por favor, adicione-o ao topo da sua resposta. Veja a sessão da WWDC em 36:32 => developer.apple.com/videos/play/wwdc2016/702
cicerocamargo
Não entendo por que não é seguro enviar os dados do recibo diretamente do dispositivo. Alguém seria capaz de explicar?
Koh