Cómo trabajar con veredictos de integridad

La API de Play Integrity usa veredictos de integridad para comunicar información sobre la validez de los dispositivos, las apps y los usuarios. El servidor de tu app puede usar la carga útil resultante en un veredicto verificado y desencriptado para determinar la mejor manera de proceder con una acción o una solicitud particulares en tu app.

Cómo generar un nonce

Cuando proteges una acción en tu app con la API de Play Integrity, puedes aprovechar el campo nonce para mitigar ciertos tipos de ataques, como los de manipulación relacionados a ataques de intermediario (PITM) y los de repetición. La API de Play Integrity muestra el valor que estableces en este campo, dentro de la respuesta de integridad firmada.

El valor establecido en el campo nonce debe tener el formato correcto con las siguientes características:

  • String
  • Seguro para URL
  • Codificado en Base64 y sin unión
  • Con 16 caracteres como mínimo
  • Con 500 caracteres como máximo

Las siguientes son algunas formas comunes de usar el campo nonce en la API de Play Integrity. Para obtener la protección más sólida del nonce, puedes combinar los métodos que se muestran a continuación.

Cómo proteger las acciones de alto valor de la manipulación

Puedes usar el campo nonce de Play Integrity para proteger el contenido de una acción específica de alto valor contra la manipulación. Por ejemplo, en un juego, es posible que se desee informar la puntuación de un jugador. En ese caso, asegúrate de que un servidor proxy no haya manipulado esa puntuación. La implementación es la siguiente:

  1. El usuario inicia la acción de alto valor.
  2. Tu app prepara un mensaje que quiere proteger, por ejemplo, en formato JSON.
  3. La app calcula un hash criptográfico del mensaje que desea proteger. Lo hace, por ejemplo, con los algoritmos de hash SHA-256 o SHA-3-256.
  4. La app llama a la API de Play Integrity y a setNonce() a fin de establecer el campo nonce en el hash criptográfico calculado en el paso anterior.
  5. La app envía el mensaje que quiere proteger y el resultado firmado de la API de Play Integrity a tu servidor.
  6. El servidor de la app verifica que el hash criptográfico del mensaje que recibió coincida con el valor del campo nonce en el resultado firmado y rechaza los resultados que no cumplan esta condición.

En la Figura 1, se muestra un diagrama de secuencia que ilustra estos pasos:

Figura 1: Diagrama de secuencias que muestra cómo proteger las acciones de alto valor en tu app contra la manipulación.

Cómo proteger tu app de ataques de repetición

A fin de evitar que usuarios maliciosos reutilicen respuestas anteriores de la API de Play Integrity, puedes usar el campo nonce para identificar cada mensaje de forma única. La implementación es la siguiente:

  1. Necesitas un valor único a nivel global de modo que los usuarios maliciosos no puedan predecirlo. Por ejemplo, un número al azar, seguro en términos criptográficos y generado en el servidor puede ser un valor de ese tipo. Te recomendamos que crees valores de 128 bits como mínimo.
  2. Tu app llama a la API de Play Integrity y a setNonce() a fin de establecer el campo nonce en el valor único que recibió el servidor de la app.
  3. La app envía el resultado firmado de la API de Play Integrity a tu servidor.
  4. El servidor verifica que el campo nonce del resultado firmado coincida con el valor único que generó antes y rechaza los resultados que no cumplan esta condición.

En la Figura 2, se muestra un diagrama de secuencia que ilustra estos pasos:

Figura 2: Diagrama de secuencias que muestra cómo proteger tu app contra ataques de repetición.

Cómo combinar ambas protecciones

Es posible usar el campo nonce a los efectos de proteger contra los ataques de repetición y las manipulaciones al mismo tiempo. Para hacerlo, puedes agregar el valor único a nivel global generado por el servidor al hash del mensaje de alto valor y establecer ese valor como el campo nonce en la API de Play Integrity. Una implementación que combina ambos enfoques es la siguiente:

  1. El usuario inicia la acción de alto valor.
  2. Tu app le solicita al servidor un valor único para identificar la solicitud.
  3. El servidor de la app genera un valor único a nivel global de modo que los usuarios maliciosos no puedan predecirlo. Por ejemplo, puedes usar un generador de números al azar con seguridad criptográfica para crear ese valor. Te recomendamos que crees valores de 128 bits como mínimo.
  4. El servidor de la app envía a esta el valor único a nivel global.
  5. Tu app prepara un mensaje que quiere proteger, por ejemplo, en formato JSON.
  6. La app calcula un hash criptográfico del mensaje que desea proteger. Lo hace, por ejemplo, con los algoritmos de hash SHA-256 o SHA-3-256.
  7. La app crea una string cuando agrega el valor único recibido del servidor de la app y el hash del mensaje que desea proteger.
  8. La app llama a la API de Play Integrity y a setNonce() para establecer el campo nonce en la string que creaste en el paso anterior.
  9. La app envía el mensaje que quiere proteger y el resultado firmado de la API de Play Integrity a tu servidor.
  10. El servidor de la app divide el valor del campo nonce y verifica que tanto el hash criptográfico del mensaje como el valor único que generó con anterioridad coincidan con los valores esperados, y rechaza los resultados que no cumplan esta condición.

En la Figura 3, se muestra un diagrama de secuencia que ilustra estos pasos:

Figura 3: Diagrama de secuencias que muestra cómo proteger tu app contra ataques de repetición y cómo proteger acciones de alto valor en la app contra manipulaciones.

Cómo solicitar un veredicto de integridad

Después de generar un nonce, puedes solicitar un veredicto de integridad desde Google Play. Para ello, completa los siguientes pasos:

  1. Crea un IntegrityManager, como se muestra en los ejemplos que aparecen a continuación.
  2. Crea un IntegrityTokenRequest y proporciona el nonce a través del método setNonce() en el compilador asociado. Las apps que se distribuyen de forma exclusiva fuera de Google Play y los SDK también deben especificar el número de proyecto de Google Cloud a través del método setCloudProjectNumber(). Las apps de Google Play están vinculadas a un proyecto de Cloud en Play Console y no es necesario que configures el número del proyecto de Cloud en la solicitud.
  3. Usa el administrador a fin de llamar a requestIntegrityToken(), y proporciona el IntegrityTokenRequest.

Kotlin

// Receive the nonce from the secure server.
val nonce: String = ...

// Create an instance of a manager.
val integrityManager =
    IntegrityManagerFactory.create(applicationContext)

// Request the integrity token by providing a nonce.
val integrityTokenResponse: Task<IntegrityTokenResponse> =
    integrityManager.requestIntegrityToken(
        IntegrityTokenRequest.builder()
             .setNonce(nonce)
             .build())

Java

import com.google.android.gms.tasks.Task; ...

// Receive the nonce from the secure server.
String nonce = ...

// Create an instance of a manager.
IntegrityManager integrityManager =
    IntegrityManagerFactory.create(getApplicationContext());

// Request the integrity token by providing a nonce.
Task<IntegrityTokenResponse> integrityTokenResponse =
    integrityManager
        .requestIntegrityToken(
            IntegrityTokenRequest.builder().setNonce(nonce).build());

Unity

IEnumerator RequestIntegrityTokenCoroutine() {
    // Receive the nonce from the secure server.
    var nonce = ...

    // Create an instance of a manager.
    var integrityManager = new IntegrityManager();

    // Request the integrity token by providing a nonce.
    var tokenRequest = new IntegrityTokenRequest(nonce);
    var requestIntegrityTokenOperation =
        integrityManager.RequestIntegrityToken(tokenRequest);

    // Wait for PlayAsyncOperation to complete.
    yield return requestIntegrityTokenOperation;

    // Check the resulting error code.
    if (requestIntegrityTokenOperation.Error != IntegrityErrorCode.NoError)
    {
        AppendStatusLog("IntegrityAsyncOperation failed with error: " +
                requestIntegrityTokenOperation.Error);
        yield break;
    }

    // Get the response.
    var tokenResponse = requestIntegrityTokenOperation.GetResult();
}

Nativo

/// Create an IntegrityTokenRequest opaque object.
const char* nonce = RequestNonceFromServer();
IntegrityTokenRequest* request;
IntegrityTokenRequest_create(&request);
IntegrityTokenRequest_setNonce(request, nonce);

/// Prepare an IntegrityTokenResponse opaque type pointer and call
/// IntegerityManager_requestIntegrityToken().
IntegrityTokenResponse* response;
IntegrityErrorCode error_code =
        IntegrityManager_requestIntegrityToken(request, &response);

/// ...
/// Proceed to polling iff error_code == INTEGRITY_NO_ERROR
if (error_code != INTEGRITY_NO_ERROR)
{
    /// Remember to call the *_destroy() functions.
    return;
}
/// ...
/// Use polling to wait for the async operation to complete.
/// Note, the polling shouldn't block the thread where the IntegrityManager
/// is running.

IntegrityResponseStatus response_status;

/// Check for error codes.
IntegrityErrorCode error_code =
        IntegrityTokenResponse_getStatus(response, &response_status);
if (error_code == INTEGRITY_NO_ERROR
    && response_status == INTEGRITY_RESPONSE_COMPLETED)
{
    const char* integrity_token = IntegrityTokenResponse_getToken(response);
    SendTokenToServer(integrity_token);
}
/// ...
/// Remember to free up resources.
IntegrityTokenRequest_destroy(request);
IntegrityTokenResponse_destroy(response);
IntegrityManager_destroy();

Cómo desencriptar y verificar el veredicto de integridad

Cuando solicitas un veredicto de integridad, la API de Play Integrity proporciona un token de respuesta firmado. El nonce que incluyes en tu solicitud se vuelve parte del token de respuesta.

Formato del token

El token es un Token web JSON (JWT) anidado, que es la Encriptación web JSON (JWE) de la Firma web JSON (JWS). Los componentes de JWE y JWS se representan mediante la serialización compacta.

Los algoritmos de encriptación y firma son compatibles con varias implementaciones de JWT:

  • JWE usa A256KW para alg y A256GCM para enc.
  • JWS usa ES256.

Cómo desencriptar y verificar en los servidores de Google (recomendado)

La API de Play Integrity te permite desencriptar y verificar el veredicto de integridad en los servidores de Google, lo que mejora la seguridad de tu app. Para hacerlo, completa estos pasos:

  1. Crea una cuenta de servicio dentro del proyecto de Google Cloud que esté vinculado a tu app. Durante este proceso de creación de la cuenta, debes otorgar a tu cuenta de servicio los roles de usuario de cuenta de servicio y consumidor de Service Usage.
  2. En el servidor de la app, recupera el token de acceso de las credenciales de la cuenta de servicio con el permiso playintegrity y realiza la siguiente solicitud:

    playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken -d \
      '{ "integrity_token": "INTEGRITY_TOKEN" }'
  3. Lee la respuesta JSON.

Cómo desencriptar y verificar de manera local

Si optas por administrar y descargar tus claves de encriptación de respuestas, puedes desencriptar y verificar el token que se muestra dentro de tu propio entorno de servidor seguro. Puedes obtener ese token con el método IntegrityTokenResponse#token().

En el siguiente ejemplo, se muestra el modo en que se decodifican la clave AES y la EC pública con codificación DER para la verificación de firma desde Play Console a claves específicas del lenguaje (el lenguaje de programación Java, en nuestro caso) en el backend de la app. Ten en cuenta que las claves están codificadas en Base64 con marcas predeterminadas.

Kotlin

// base64OfEncodedDecryptionKey is provided through Play Console.
var decryptionKeyBytes: ByteArray =
    Base64.decode(base64OfEncodedDecryptionKey, Base64.DEFAULT)

// Deserialized encryption (symmetric) key.
var decryptionKey: SecretKey = SecretKeySpec(
    decryptionKeyBytes,
    /* offset= */ 0,
    AES_KEY_SIZE_BYTES,
    AES_KEY_TYPE
)

// base64OfEncodedVerificationKey is provided through Play Console.
var encodedVerificationKey: ByteArray =
    Base64.decode(base64OfEncodedVerificationKey, Base64.DEFAULT)

// Deserialized verification (public) key.
var verificationKey: PublicKey = KeyFactory.getInstance(EC_KEY_TYPE)
    .generatePublic(X509EncodedKeySpec(encodedVerificationKey))

Java


// base64OfEncodedDecryptionKey is provided through Play Console.
byte[] decryptionKeyBytes =
    Base64.decode(base64OfEncodedDecryptionKey, Base64.DEFAULT);

// Deserialized encryption (symmetric) key.
SecretKey decryptionKey =
    new SecretKeySpec(
        decryptionKeyBytes,
        /* offset= */ 0,
        AES_KEY_SIZE_BYTES,
        AES_KEY_TYPE);

// base64OfEncodedVerificationKey is provided through Play Console.
byte[] encodedVerificationKey =
    Base64.decode(base64OfEncodedVerificationKey, Base64.DEFAULT);
// Deserialized verification (public) key.
PublicKey verificationKey =
    KeyFactory.getInstance(EC_KEY_TYPE)
        .generatePublic(new X509EncodedKeySpec(encodedVerificationKey));

A continuación, usa estas claves para desencriptar el token de integridad (la parte de la JWE) y, luego, verificar y extraer la parte JWS anidada.

Kotlin

val jwe: JsonWebEncryption =
    JsonWebStructure.fromCompactSerialization(integrityToken) as JsonWebEncryption
jwe.setKey(decryptionKey)

// This also decrypts the JWE token.
val compactJws: String = jwe.getPayload()

val jws: JsonWebSignature =
    JsonWebStructure.fromCompactSerialization(compactJws) as JsonWebSignature
jws.setKey(verificationKey)

// This also verifies the signature.
val payload: String = jws.getPayload()

Java

JsonWebEncryption jwe =
    (JsonWebEncryption)JsonWebStructure
        .fromCompactSerialization(integrityToken);
jwe.setKey(decryptionKey);

// This also decrypts the JWE token.
String compactJws = jwe.getPayload();

JsonWebSignature jws =
    (JsonWebSignature) JsonWebStructure.fromCompactSerialization(compactJws);
jws.setKey(verificationKey);

// This also verifies the signature.
String payload = jws.getPayload();

La carga útil resultante es un token de texto sin formato que contiene indicadores de integridad.

Formato de la carga útil que se muestra

La carga útil es un archivo JSON de texto sin formato y contiene indicadores de integridad junto con información que proporciona el desarrollador.

La estructura general de la carga útil es la siguiente:

{
  requestDetails: { ... }
  appIntegrity: { ... }
  deviceIntegrity: { ... }
  accountDetails: { ... }
}

Antes de verificar cada veredicto de integridad, debes comprobar que los valores del campo requestDetails coincidan con los de la solicitud original.

En las siguientes secciones, se describe cada campo con mayor detalle.

Campo de detalles de la solicitud

El campo requestDetails contiene información que se proporcionó en la solicitud, incluido el nonce.

requestDetails: {
  // Application package name this attestation was requested for.
  // Note that this field might be spoofed in the middle of the
  // request.
  requestPackageName: "com.package.name"
  // base64-encoded URL-safe no-wrap nonce provided by the developer.
  nonce: "aGVsbG8gd29scmQgdGhlcmU"
  // The timestamp in milliseconds when the request was made
  // (computed on the server).
  timestampMillis: "1617893780"
}

Estos valores deben coincidir con los de la solicitud original. Por lo tanto, verifica la parte requestDetails de la carga útil de JSON asegurándote de que requestPackageName y nonce coincidan con lo que se envió en la solicitud original, como se muestra en el siguiente fragmento de código:

Kotlin

val requestDetails = JSONObject(payload).getJSONObject("requestDetails")
val requestPackageName = requestDetails.getString("requestPackageName")
val nonce = requestDetails.getString("nonce")
val timestampMillis = requestDetails.getLong("timestampMillis")
val currentTimestampMillis = ...

// Ensure the token is from your app.
if (!requestPackageName.equals(expectedPackageName)
        // Ensure the token is for this specific request. See “Generate nonce”
        // section of the doc on how to store/compute the expected nonce.
    || !nonce.equals(expectedNonce)
        // Ensure the freshness of the token.
    || currentTimestampMillis - timestampMillis > ALLOWED_WINDOW_MILLIS) {
        // The token is invalid! See below for further checks.
        ...
}

Java

JSONObject requestDetails =
    new JSONObject(payload).getJSONObject("requestDetails");
String requestPackageName = requestDetails.getString("requestPackageName");
String nonce = requestDetails.getString("nonce");
long timestampMillis = requestDetails.getLong("timestampMillis");
long currentTimestampMillis = ...;

// Ensure the token is from your app.
if (!requestPackageName.equals(expectedPackageName)
        // Ensure the token is for this specific request. See “Generate nonce”
        // section of the doc on how to store/compute the expected nonce.
    || !nonce.equals(expectedNonce)
        // Ensure the freshness of the token.
    || currentTimestampMillis - timestampMillis > ALLOWED_WINDOW_MILLIS) {
        // The token is invalid! See below for further checks.
        ...
}

Campo de integridad de la aplicación

El campo appIntegrity contiene información relacionada con el paquete.

appIntegrity: {
  // PLAY_RECOGNIZED, UNRECOGNIZED_VERSION, or UNEVALUATED.
  appRecognitionVerdict: "PLAY_RECOGNIZED"
  // The package name of the app.
  // This field is populated iff appRecognitionVerdict != UNEVALUATED.
  packageName: "com.package.name"
  // The sha256 digest of app certificates.
  // This field is populated iff appRecognitionVerdict != UNEVALUATED.
  certificateSha256Digest: ["6a6a1474b5cbbb2b1aa57e0bc3"]
  // The version of the app.
  // This field is populated iff appRecognitionVerdict != UNEVALUATED.
  versionCode: "42"
}

appRecognitionVerdict puede tener los siguientes valores:

PLAY_RECOGNIZED
La app y el certificado coinciden con las versiones que distribuye Google Play.
UNRECOGNIZED_VERSION
El certificado o el nombre del paquete no coinciden con los registros de Google Play.
UNEVALUATED
No se evaluó la integridad de la aplicación. Se omitió un requisito necesario, por ejemplo, el dispositivo no es lo suficientemente confiable.

Para asegurarte de que una app creada por ti generó el token, verifica que la integridad de la aplicación sea la esperada, como se muestra en el siguiente fragmento de código:

Kotlin

val requestDetails = JSONObject(payload).getJSONObject("appIntegrity")
val appRecognitionVerdict = requestDetails.getString("appRecognitionVerdict")

if (appRecognitionVerdict == "PLAY_RECOGNIZED") {
    // Looks good!
}

Java

JSONObject requestDetails =
    new JSONObject(payload).getJSONObject("appIntegrity");
String appRecognitionVerdict =
    requestDetails.getString("appRecognitionVerdict");

if (appRecognitionVerdict.equals("PLAY_RECOGNIZED")) {
    // Looks good!
}

También puedes verificar el nombre del paquete de la app, su versión y los certificados de forma manual.

Campo de integridad del dispositivo

El campo deviceIntegrity contiene un solo valor, device_recognition_verdict, que representa la eficacia con la que un dispositivo puede aplicar de manera forzosa la integridad de la app.

deviceIntegrity: {
  // "MEETS_DEVICE_INTEGRITY" is one of several possible values.
  deviceRecognitionVerdict: ["MEETS_DEVICE_INTEGRITY"]
}

De forma predeterminada, device_recognition_verdict puede tener una de las siguientes etiquetas:

MEETS_DEVICE_INTEGRITY
La app se está ejecutando en un dispositivo Android con la tecnología de los Servicios de Google Play. El dispositivo pasa las verificaciones de integridad del sistema y cumple con los requisitos de compatibilidad de Android.
Sin etiquetas (un valor en blanco)
La app se está ejecutando en un dispositivo que muestra indicios de ataque (como trampas de API) o de vulneración del sistema (como un dispositivo con permisos de administrador), o bien no se está ejecutando en un dispositivo físico (como un emulador que no pasa las verificaciones de Google Play Integrity).

Para asegurarte de que el token provenga de un dispositivo confiable, verifica que el device_recognition_verdict sea el esperado, como se muestra en el siguiente fragmento de código:

Kotlin

val deviceIntegrity =
                JSONObject(payload).getJSONObject("deviceIntegrity")
val deviceRecognitionVerdict = deviceIntegrity
                .getJSONArray("deviceRecognitionVerdict")
                .toString()

if (deviceRecognitionVerdict.contains("MEETS_DEVICE_INTEGRITY")) {
    // Looks good!
}

Java

JSONObject deviceIntegrity =
                new JSONObject(payload).getJSONObject("deviceIntegrity");
String deviceRecognitionVerdict = deviceIntegrity
                .getJSONArray("deviceRecognitionVerdict")
                .toString();

if (deviceRecognitionVerdict.contains("MEETS_DEVICE_INTEGRITY")) {
    // Looks good!
}

Si tienes problemas con la integridad del dispositivo de prueba, asegúrate de que la ROM de fábrica esté instalada (por ejemplo, puedes restablecer el dispositivo) y de que el bootloader esté bloqueado. También puedes crear pruebas de Play Integrity en tu Play Console.

Si aceptas recibir etiquetas adicionales en el veredicto de integridad, device_recognition_verdict puede tener las siguientes etiquetas adicionales:

MEETS_BASIC_INTEGRITY
La app se está ejecutando en un dispositivo que pasa las verificaciones básicas de integridad del sistema. Es posible que el dispositivo no cumpla con los requisitos de compatibilidad de Android y que no esté aprobado para ejecutar los Servicios de Google Play. Por ejemplo, puede que el dispositivo esté ejecutando una versión no reconocida de Android, que tenga un bootloader desbloqueado o que el fabricante no lo haya certificado.
MEETS_STRONG_INTEGRITY
La app se está ejecutando en un dispositivo Android con la tecnología de los Servicios de Google Play y tiene una garantía sólida de integridad del sistema, como una prueba de integridad de inicio con copia de seguridad en hardware. El dispositivo pasa las verificaciones de integridad del sistema y cumple con los requisitos de compatibilidad de Android.

Además, si tu app se lanza en emuladores aprobados, el device_recognition_verdict también puede adoptar la siguiente etiqueta:

MEETS_VIRTUAL_INTEGRITY
La app se está ejecutando en un Android Emulator con la tecnología de los Servicios de Google Play. El emulador pasa las verificaciones de integridad del sistema y cumple con los requisitos principales de compatibilidad de Android.

Campo de detalles de la cuenta

El campo accountDetails contiene un solo valor, appLicensingVerdict, que representa el estado de la licencia de la app o el del derecho de acceso a esta última.

accountDetails: {
  // This field can be LICENSED, UNLICENSED, or UNEVALUATED.
  appLicensingVerdict: "LICENSED"
}

appLicensingVerdict puede tener los siguientes valores:

LICENSED
El usuario tiene derechos de acceso a la app. En otras palabras, el usuario instaló o compró tu app en Google Play.
UNLICENSED
El usuario no tiene derechos de acceso a la app. Esto sucede, por ejemplo, cuando el usuario transfiere tu app o no la adquiere en Google Play.
UNEVALUATED

No se evaluó la información de las licencias porque se omitió un requisito necesario.

Estos son algunos de los diversos motivos por los que podría suceder:

  • El dispositivo no es lo suficientemente confiable.
  • La versión de tu app instalada en el dispositivo es desconocida para Google Play.
  • El usuario no accedió a Google Play.

A fin de verificar que el usuario tenga derechos de acceso a tu app, comprueba que el objeto appLicensingVerdict sea el esperado, como se muestra en el siguiente fragmento de código:

Kotlin

val requestDetails = JSONObject(payload).getJSONObject("accountDetails")
val appLicensingVerdict = requestDetails.getString("appLicensingVerdict")

if (appLicensingVerdict == "LICENSED") {
    // Looks good!
}

Java

JSONObject requestDetails =
    new JSONObject(payload).getJSONObject("accountDetails");
String appLicensingVerdict = requestDetails.getString("appLicensingVerdict");

if (appLicensingVerdict.equals("LICENSED")) {
    // Looks good!
}