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:
- El usuario inicia la acción de alto valor.
- Tu app prepara un mensaje que quiere proteger, por ejemplo, en formato JSON.
- 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.
- La app llama a la API de Play Integrity y a
setNonce()
para establecer el campononce
en el hash criptográfico calculado en el paso anterior. - La app envía el mensaje que quiere proteger y el resultado firmado de la API de Play Integrity a tu servidor.
- 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
Para 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:
- 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.
- Tu app llama a la API de Play Integrity y a
setNonce()
para establecer el campononce
en el valor único que recibió el servidor de la app. - La app envía el resultado firmado de la API de Play Integrity a tu servidor.
- 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:
- El usuario inicia la acción de alto valor.
- Tu app le solicita al servidor un valor único para identificar la solicitud.
- 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 aleatorios con seguridad criptográfica para crear ese valor. Te recomendamos que crees valores de 128 bits como mínimo.
- El servidor de la app envía a esta el valor único a nivel global.
- Tu app prepara un mensaje que quiere proteger, por ejemplo, en formato JSON.
- 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.
- La app crea una string cuando agrega el valor único recibido del servidor de la app y el hash del mensaje que desea proteger.
- La app llama a la API de Play Integrity y a
setNonce()
para establecer el campononce
en la string que creaste en el paso anterior. - La app envía el mensaje que quiere proteger y el resultado firmado de la API de Play Integrity a tu servidor.
- 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:
- Crea un
IntegrityManager
, como se muestra en los ejemplos que aparecen a continuación. - Crea un
IntegrityTokenRequest
y proporciona el nonce a través del métodosetNonce()
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étodosetCloudProjectNumber()
. 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. Usa el administrador para llamar a
requestIntegrityToken()
, y proporciona elIntegrityTokenRequest
.
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:
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:
- 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.
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" }'
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
puede contener un solo valor, deviceRecognitionVerdict
, que tenga una o más etiquetas que representen la capacidad de un dispositivo para aplicar de manera forzosa la integridad de la app. Si un dispositivo no cumple con los criterios de ninguna etiqueta, el campo deviceIntegrity
estará vacío.
deviceIntegrity: { // "MEETS_DEVICE_INTEGRITY" is one of several possible values. deviceRecognitionVerdict: ["MEETS_DEVICE_INTEGRITY"] }
De forma predeterminada, deviceRecognitionVerdict
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 deviceRecognitionVerdict
sea el esperado, como se muestra en el siguiente fragmento de código:
Kotlin
val deviceIntegrity = JSONObject(payload).getJSONObject("deviceIntegrity") val deviceRecognitionVerdict = if (deviceIntegrity.has("deviceRecognitionVerdict")) { deviceIntegrity.getJSONArray("deviceRecognitionVerdict").toString() } else { "" } if (deviceRecognitionVerdict.contains("MEETS_DEVICE_INTEGRITY")) { // Looks good! }
Java
JSONObject deviceIntegrity = new JSONObject(payload).getJSONObject("deviceIntegrity"); String deviceRecognitionVerdict = deviceIntegrity.has("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, deviceRecognitionVerdict
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 deviceRecognitionVerdict
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.
Para 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! }