Una solución completa para validar LOCALMENTE los recibos integrados y los recibos de paquetes en iOS 7

160

He leído muchos documentos y códigos que, en teoría, validarán un recibo en la aplicación y / o paquete.

Dado que mi conocimiento de SSL, certificados, cifrado, etc., es casi nulo, todas las explicaciones que he leído, como esta prometedora , me han resultado difíciles de entender.

Dicen que las explicaciones son incompletas porque cada persona tiene que descubrir cómo hacerlo, o los hackers tendrán un trabajo fácil creando una aplicación de cracker que pueda reconocer e identificar patrones y parchear la aplicación. OK, estoy de acuerdo con esto hasta cierto punto. Creo que podrían explicar completamente cómo hacerlo y poner una advertencia que diga "modificar este método", "modificar este otro método", "ofuscar esta variable", "cambiar el nombre de esto y aquello", etc.

¿Puede una buena alma tener la amabilidad de explicar cómo validar LOCALMENTE, agrupar recibos y recibos de compra en la aplicación en iOS 7 ya que tengo cinco años (ok, que sea 3), de arriba a abajo, claramente?

¡¡¡Gracias!!!


Si tiene una versión trabajando en sus aplicaciones y le preocupa que los hackers vean cómo lo hizo, simplemente cambie sus métodos sensibles antes de publicar aquí. Ofusque cadenas, cambie el orden de las líneas, cambie la forma en que realiza los bucles (de usar para bloquear la enumeración y viceversa) y cosas así. Obviamente, cada persona que usa el código que puede publicarse aquí, tiene que hacer lo mismo, para no arriesgarse a ser hackeada fácilmente.

Pato
fuente
1
Advertencia justa: hacerlo localmente hace que sea mucho más fácil parchear esta función fuera de su aplicación.
NinjaLikesCheez
2
Bien, lo sé, pero el punto aquí es hacer las cosas difíciles y evitar el craqueo / parcheo automático. La pregunta es si un hacker realmente quiere descifrar su aplicación, lo hará, sea cual sea el método que utilice, local o remoto. La idea también es cambiarlo ligeramente cada vez que se lance una nueva versión, para evitar parches automáticos nuevamente.
Pato
44
@NinjaLikesCheez: se puede NOP la verificación incluso si la verificación se realiza en un servidor.
Pato
14
lo siento, pero esto no es excusa. Lo único que debe hacer el autor es decir NO USE EL CÓDIGO COMO ES. Sin ningún ejemplo, es imposible entender esto sin ser un científico de cohetes.
Pato
3
Si no quiere molestarse en implementar DRM, no se moleste con la verificación local. Simplemente PUBLICA el recibo directamente a Apple desde tu aplicación, y ellos te lo enviarán nuevamente en un formato JSON fácil de analizar. Para los piratas es trivial descifrar esto, pero si solo estás haciendo la transición a freemium y no te importa la piratería, son solo unas pocas líneas de código muy fácil.
Dan Fabulich

Respuestas:

146

Aquí hay un tutorial sobre cómo resolví esto en mi biblioteca de compras en la aplicación RMStore . Explicaré cómo verificar una transacción, lo que incluye verificar el recibo completo.

De un vistazo

Obtenga el recibo y verifique la transacción. Si falla, actualice el recibo e intente nuevamente. Esto hace que el proceso de verificación sea asíncrono, ya que la actualización del recibo es asíncrona.

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

Obteniendo los datos del recibo

El recibo está dentro [[NSBundle mainBundle] appStoreReceiptURL]y es en realidad un contenedor PCKS7. Apesto con la criptografía, así que usé OpenSSL para abrir este contenedor. Otros aparentemente lo han hecho puramente con marcos de sistema .

Agregar OpenSSL a su proyecto no es trivial. El wiki de RMStore debería ayudar.

Si opta por utilizar OpenSSL para abrir el contenedor PKCS7, su código podría verse así. 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 en los detalles de la verificación más adelante.

Obteniendo los campos de recibo

El recibo se expresa en formato ASN1. Contiene información general, algunos campos para fines de verificación (lo veremos más adelante) e información específica de cada compra en la aplicación aplicable.

Nuevamente, OpenSSL viene al rescate cuando se trata de leer ASN1. Desde RMAppReceipt , utilizando algunos 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;

Obtener las compras en la aplicación

Cada compra en la aplicación también está en ASN1. Analizarlo es muy similar a analizar la información general del recibo.

Desde RMAppReceipt , utilizando los mismos métodos de ayuda:

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

Cabe señalar que ciertas compras en la aplicación, como consumibles y suscripciones no renovables, aparecerán solo una vez en el recibo. Debe verificarlos justo después de la compra (nuevamente, RMStore lo ayuda con esto).

Verificación de un vistazo

Ahora tenemos todos los campos del recibo y todas sus compras en la aplicación. Primero verificamos el recibo en sí, y luego simplemente verificamos si el recibo contiene el producto de la transacción.

A continuación se muestra el método que llamamos al principio. Desde 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 el recibo

La verificación del recibo en sí se reduce a:

  1. Comprobando que el recibo es válido PKCS7 y ASN1. Ya lo hemos hecho implícitamente.
  2. Verificando que el recibo esté firmado por Apple. Esto se realizó antes de analizar el recibo y se detallará a continuación.
  3. Comprobando que el identificador de paquete incluido en el recibo corresponde a su identificador de paquete. Debe codificar su identificador de paquete, ya que no parece ser muy difícil modificar el paquete de su aplicación y usar algún otro recibo.
  4. Comprobar que la versión de la aplicación incluida en el recibo corresponde a su identificador de versión de la aplicación. Debe codificar la versión de la aplicación, por los mismos motivos indicados anteriormente.
  5. Verifique el hash del recibo para asegurarse de que el recibo corresponde al dispositivo actual.

Los 5 pasos en el código de alto nivel, desde 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;
}

Analicemos los pasos 2 y 5.

Verificación de la firma del recibo

Cuando extrajimos los datos, revisamos la verificación de la firma del recibo. El recibo se firma con el Certificado raíz de Apple Inc., que se puede descargar de la Autoridad certificadora raíz de Apple . El siguiente código toma el contenedor PKCS7 y el certificado raíz como datos y comprueba si coinciden:

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

Esto se hizo al principio, antes de analizar el recibo.

Verificación del hash del recibo

El hash incluido en el recibo es un SHA1 de la identificación del dispositivo, algún valor opaco incluido en el recibo y la identificación del paquete.

Así es como verificarías el hash de recibo en 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];
}

Y esa es la esencia de esto. Puede que me falte algo aquí o allá, así que podría volver a esta publicación más tarde. En cualquier caso, recomiendo navegar por el código completo para más detalles.

hpique
fuente
2
Descargo de responsabilidad de seguridad: el uso de código fuente abierto hace que su aplicación sea más vulnerable. Si la seguridad es una preocupación, es posible que desee utilizar RMStore y el código anterior solo como guía.
hpique
66
Sería fantástico si en el futuro se deshace de OpenSSL y hace que su biblioteca sea compacta utilizando solo los marcos del sistema.
Pato
2
@RubberDuck Consulte github.com/robotmedia/RMStore/issues/16 . Siéntase libre de intervenir o contribuir. :)
hpique
1
@RubberDuck Tenía cero conocimiento de OpenSSL hasta esto. Quién sabe, incluso podría gustarte. : P
hpique
2
Es susceptible a un ataque Man In The Middle, donde la solicitud y / o respuesta puede ser interceptada y modificada. Por ejemplo, la solicitud se puede redirigir a un servidor de terceros y se puede devolver una respuesta falsa, engañando a la aplicación para que piense que se compró un producto, cuando no fue así, y permitiendo la funcionalidad de forma gratuita.
Jasarien
13

Me sorprende que nadie haya mencionado Receigen aquí. Es una herramienta que genera automáticamente un código de validación de recibo ofuscado, uno diferente cada vez; Es compatible con la GUI y la operación de línea de comandos. Muy recomendable.

(No afiliado a Receigen, solo un usuario feliz).

Utilizo un Rakefile como este para volver a ejecutar automáticamente Receigen (porque debe hacerse en cada cambio de versión) cuando escribo 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
fuente
1
Para aquellos que estén interesados ​​en Receigen, esta es una solución paga, que está disponible en la App Store por 29.99 $. Aunque no se ha actualizado desde septiembre de 2014.
DevGansta
Es cierto que la falta de actualizaciones es muy alarmante. Sin embargo, todavía funciona; FWIW, lo estoy usando en mis aplicaciones.
Andrey Tarantsov
Verifique su aplicación en instrumentos para detectar fugas, con Receigen las obtengo mucho.
El reverendo
Receigen es la vanguardia, pero sí, es una pena que parezca que se ha caído.
Fattie
1
Parece que aún no se ha caído. Actualizado hace tres semanas!
Oleg Korzhukov
2

Nota: no se recomienda hacer este tipo de verificación en el lado del cliente

Esta es una versión de Swift 4 para la validación del recibo de compra en la aplicación ...

Vamos a crear una enumeración para representar los posibles errores de la validación del recibo

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

Luego, creemos la función que valida el recibo, arrojará un error si no puede validarlo.

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

Usemos esta función auxiliar para obtener la fecha de vencimiento de un producto específico. La función recibe una respuesta JSON y una identificación del producto. La respuesta JSON puede contener información de recibos múltiples para diferentes productos, por lo que obtiene la última información para el 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
}

Ahora puede llamar a esta función y manejar los posibles casos de error

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

Puede obtener una contraseña de App Store Connect. https://developer.apple.comabra este enlace haga clic en

  • 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 esa clave y péguela en el campo de contraseña.

Espero que esto ayude a todos los que quieran eso en la versión rápida.

Pushpendra
fuente
19
Nunca debe usar la URL de validación de Apple desde su dispositivo. Solo debe usarse desde su servidor. Esto fue mencionado en las sesiones de WWDC.
pechar
¿Qué pasaría si el usuario elimina las aplicaciones o no abre mucho tiempo? ¿Funciona bien el cálculo de la fecha de vencimiento?
karthikeyan
Entonces debe mantener la validación en el lado del servidor.
Pushpendra
1
Como dijo @pechar, nunca debes hacer esto. Agréguelo al principio de su respuesta. Vea la sesión de WWDC a las 36:32 => developer.apple.com/videos/play/wwdc2016/702
cicerocamargo el
No entiendo por qué no es seguro enviar los datos del recibo directamente desde el dispositivo. ¿Alguien podría explicar?
Koh