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

Un nonce es un número de identificación de solicitud de un solo uso que se utiliza para verificar la integridad del mensaje. Lo ideal es que este ID de solicitud esté vinculado al contexto en el que se genera, por ejemplo, el hash del ID del usuario o una marca de tiempo.

Los nonces deben ser únicos e impredecibles. Si un usuario obtiene un veredicto de integridad para una acción protegida específica, no debería poder usarlo para acciones de protección posteriores. Se conoce como ataque de repetición y se evita mediante el nonce.

Para crear un nonce, realiza una de las siguientes acciones:

Debes elegir la técnica de generación de nonce en función del valor de la acción que proteges y de la amenaza aparente a la app. Tu app cliente podría estar bajo el control de un atacante, por lo que es mucho más seguro que el nonce sea generado, de forma aleatoria, por el servidor, en lugar de por el cliente.

Nonces generados por el servidor

En los siguientes pasos, se explica cómo generar nonces con un generador de números aleatorios criptográficamente seguros en un entorno de servidor seguro para cada solicitud entrante de cliente.

  1. Tu app informa al backend de tu app una acción, por ejemplo, el registro de un usuario nuevo.
  2. El backend de tu app genera un nonce aleatorio y único.
  3. El backend de tu app agrega un par de nonces o solicitudes a la tabla de solicitudes pendientes.
  4. El backend de tu app solicita un token de integridad desde tu app y pasa el nonce junto con esta solicitud.
  5. Después de comunicarse con la API de Play Integrity, tu app pasa un token de respuesta al backend de tu app. Luego, se desencripta y se verifica el token, que representa el veredicto de integridad. Por lo general, el backend de tu app pasa el token a los servidores de Play para desencriptar y verificar el veredicto y, luego, devuelve la carga útil del token al backend de tu app. El backend de tu app también puede realizar los pasos de desencriptación y verificación de forma local.
  6. El backend de tu app extrae el nonce de la carga útil desencriptada.
  7. El backend de tu app verifica que el nonce aparezca en una entrada de la tabla de solicitudes pendientes y que coincida la solicitud correspondiente.
  8. El backend de tu app quita la entrada de la tabla de solicitudes pendientes.
  9. El backend de tu app extrae la marca de tiempo de la carga útil desencriptada y verifica que esta marca sea del pasado reciente, dentro del período máximo permitido entre ese momento y el presente.
  10. El backend de tu app extrae el nombre del paquete de la carga útil desencriptada y verifica que este nombre coincida con el nombre real del paquete de tu app.

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

El nonce es único e imposible de predecir, por lo que este enfoque ofrece una garantía sólida de que no se podrá repetir la solicitud.

Figura 1: Diagrama de secuencia que muestra cómo generar un nonce del servidor para usar con la API de Play Integrity.

Nonces generados por el cliente

Es posible que algunos clientes no puedan retener todas las solicitudes entrantes del servidor o que no puedan tolerar el retraso de un recorrido de ida y vuelta adicional. Para esos casos, existe otra solución que cambia algunas de las garantías de reproducción por simplicidad. Se basa en la generación de hash de los parámetros de la solicitud e incluye una marca de tiempo:

  1. Tu app completa una acción, por ejemplo, el registro de un usuario nuevo.
  2. Tu app genera un nonce mediante un hash de parámetros, como la hora actual, a fin de calcular un valor único para esta solicitud específica.
  3. Tu app le solicita un token de integridad a la API de Play Integrity y pasa el nonce.
  4. La API de Play Integrity responde con un token que representa el veredicto de integridad.
  5. Tu app pasa el token, junto con los parámetros que se usan para generar el nonce, al backend de tu app.
  6. Se desencripta y se verifica el token. Por lo general, el backend de tu app pasa el token a los servidores de Play para desencriptar y verificar el veredicto y, luego, devuelve la carga útil del token al backend de tu app. El backend de tu app también puede realizar los pasos de desencriptación y verificación de forma local.
  7. El backend de tu app extrae el nonce de la carga útil desencriptada.
  8. El backend de tu app calcula el hash de los parámetros del cliente que se brindan desde tu app y verifica que este valor calculado coincida con el nonce de la carga útil desencriptada.
  9. El backend de tu app extrae la marca de tiempo de la carga útil desencriptada y verifica que esta marca sea del pasado reciente, dentro del período máximo permitido entre ese momento y el presente.
  10. El backend de tu app extrae el nombre del paquete de la carga útil desencriptada y verifica que este nombre coincida con el nombre real del paquete de tu app.

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

La marca de tiempo debe incluirse en los parámetros de la solicitud, de modo que cada nonce sea único. Sin embargo, como la marca de tiempo y los parámetros de la solicitud se generan en el cliente, y no se sabe si este está bajo el control de un atacante, debes suponer que es posible que se produzca un comportamiento adverso.

Figura 2: Diagrama de secuencia que muestra cómo generar un nonce del cliente para usar con la API de Play Integrity.

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 objeto IntegrityManager, como se muestra en el siguiente fragmento de código:
  2. Usa el administrador a fin de llamar a requestIntegrityToken(), y proporciona el nonce a través del método setNonce() en el compilador de IntegrityTokenRequest asociado.

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())

// Allows your app to interpret error codes from the API.
integrityTokenResponse.addOnFailureListener(error -> ...)

Java

// 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());

// Allows your app to interpret error codes from the API.
integrityTokenResponse.addOnFailureListener(error -> ...);

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 before calling
/// 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.
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:

  • La JWE usa A256KW para alg y A256GCM para enc {: .external}
  • JWS usa ES256.

Cómo desencriptar y verificar en los servidores de Google

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.
  2. En el servidor de tu app, realiza la siguiente solicitud:

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

Cómo desencriptar y verificar de manera local

Si decides autoadministrar tus claves de API, puedes desencriptar y verificar el token mostrado dentro de tu propio entorno de servidor seguro. Puedes obtener ese token usando el método IntegrityTokenResponse#token().

En el siguiente ejemplo, se muestra el modo en que se decodifica la clave AES y la clave EC pública con codificación DER para la verificación de firma desde Play Console a claves específicas del lenguaje en el backend de la app. Ten en cuenta que las claves están codificadas en Base64 mediante 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.

También debes verificar la parte de requestDetails de la carga útil de JSON; para ello, asegúrate de que el nonce y el nombre del paquete coincidan con lo que se envió en la solicitud original:

Kotlin

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

if (!requestPackageName.equals(expectedPackageName)
    // See "Generate nonce" section of the doc on how to store/compute
    // the expected nonce.
    || !nonce.equals(expectedNonce)
    || currentTimestampMillis - timestampMillis > ALLOWED_WINDOW_MILLIS) {
  // The token is invalid!
  ...
}

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 = ...;

if (!requestPackageName.equals(expectedPackageName)
    // See "Generate nonce" section of the doc on how to store/compute
    // the expected nonce.
    || !nonce.equals(expectedNonce)
    || currentTimestampMillis - timestampMillis > ALLOWED_WINDOW_MILLIS) {
  // The token is invalid!
  ...
}

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: { ... }
}

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. Estos valores deben coincidir con los de la solicitud original.

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

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 on device.
  // 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.

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 integridad de Google Play).

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 un almacén de claves de la copia de seguridad en hardware. El dispositivo pasa las verificaciones de integridad del sistema y cumple con los requisitos de compatibilidad de Android.

Para ayudar a recibir los veredictos previstos cuando pruebes la exactitud de las etiquetas de los veredictos, verifica que se cumplan las siguientes condiciones en tus dispositivos de prueba:

  1. La depuración por USB está desactivada.
  2. El bootloader está bloqueado.

Obtén más información sobre cómo crear pruebas de la API de Play Integrity para tu app.

Campo de detalles de la cuenta

El campo accountDetails contiene un solo valor, licensingVerdict, 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.
  licensingVerdict: "LICENSED"
}

licensingVerdict 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.