Trabalhar com vereditos de integridade

A API Play Integrity usa vereditos de integridade para comunicar informações sobre a validade de dispositivos, apps e usuários. O servidor do app pode usar o payload resultante em um veredito descriptografado e verificado para determinar a melhor forma de prosseguir com uma ação ou solicitação específica no app.

Gerar um valor de uso único

Ao proteger uma ação no app com a API Play Integrity, você pode aproveitar o campo do nonce para reduzir certos tipos de ataque, como os de repetição e de person-in-the-middle (PITM). A API Play Integrity retorna o valor definido deste campo na resposta de integridade assinada.

O valor definido no campo do nonce precisa ser formatado corretamente:

  • String
  • Seguro para URL
  • Codificado como Base64 e sem wrapper
  • Mínimo de 16 caracteres
  • Máximo de 500 caracteres

Veja abaixo algumas formas comuns de usar o campo do nonce na API Play Integrity. Para ter a proteção mais forte do valor de uso único, é possível combinar os métodos abaixo.

Proteger ações de alto valor contra adulterações

Você pode usar o campo do nonce da Play Integrity para proteger o conteúdo de uma ação específica de alto valor contra adulterações. Por exemplo, um jogo pode querer informar a pontuação do jogador, e você quer garantir que ela não seja adulterada por um servidor proxy. A implementação segue este modelo:

  1. O usuário inicia a ação de alto valor.
  2. O app prepara uma mensagem que quer proteger, por exemplo, no formato JSON.
  3. O app calcula um hash criptográfico para a mensagem. Por exemplo, usando os algoritmos de hash SHA-256 ou SHA-3-256.
  4. O app chama a API Play Integrity e setNonce() para definir o campo do nonce como o hash criptográfico calculado na etapa anterior.
  5. O app envia a mensagem que quer proteger e o resultado assinado da API Play Integrity ao servidor.
  6. O servidor do app verifica se o hash criptográfico da mensagem recebida corresponde ao valor do campo do nonce no resultado assinado e rejeita quaisquer resultados que não sejam correspondentes.

A Figura 1 contém um diagrama de sequência que ilustra essas etapas:

Figura 1. Diagrama de sequência que mostra como proteger ações de alto valor no app contra adulterações.

Proteger o app contra ataques de repetição

Para evitar que usuários mal-intencionados reutilizem respostas anteriores da API Play Integrity, use o campo do nonce para identificar exclusivamente cada mensagem. A implementação segue este modelo:

  1. Você precisa de um valor universalmente exclusivo de modo que usuários mal-intencionados não possam fazer previsões. Por exemplo, o valor pode ser um número aleatório com segurança criptográfica gerado no lado do servidor. Recomendamos criar valores de 128 bits ou maiores.
  2. O app chama a API Play Integrity e setNonce() para definir o campo do nonce com o valor exclusivo recebido pelo servidor do app.
  3. O app envia o resultado assinado da API Play Integrity ao servidor.
  4. O servidor verifica se o campo do nonce no resultado assinado é igual ao valor exclusivo gerado anteriormente e rejeita qualquer resultado diferente.

A Figura 2 contém um diagrama de sequência que ilustra essas etapas:

Figura 2. Diagrama de sequência que mostra como proteger o app contra ataques de repetição.

Combinar as duas proteções

É possível usar o campo do nonce para proteger o app contra ataques de repetição e adulteração ao mesmo tempo. Para fazer isso, anexe o valor universalmente exclusivo gerado pelo servidor ao hash da mensagem de alto valor e defina esse valor como o campo do nonce na API Play Integrity. Uma implementação que combina as duas abordagens segue este modelo:

  1. O usuário inicia a ação de alto valor.
  2. O app pede ao servidor um valor exclusivo para identificar a solicitação.
  3. O servidor do app gera um valor universalmente exclusivo de modo que usuários mal-intencionados não possam fazer previsões. Por exemplo, é possível usar um gerador de números aleatórios com segurança criptográfica para criar esse valor. Recomendamos a criação de valores de 128 bits ou maiores.
  4. O servidor do app envia o valor universalmente exclusivo ao app.
  5. O app prepara uma mensagem que quer proteger, por exemplo, no formato JSON.
  6. O app calcula um hash criptográfico para a mensagem. Por exemplo, usando os algoritmos de hash SHA-256 ou SHA-3-256.
  7. O app cria uma string anexando o valor exclusivo recebido do servidor do app e o hash da mensagem que ele quer proteger.
  8. O app chama a API Play Integrity e setNonce() para definir o campo do nonce como a string criada na etapa anterior.
  9. O app envia a mensagem que quer proteger e o resultado assinado da API Play Integrity ao servidor.
  10. O servidor do app divide o valor do campo do nonce e verifica se o hash criptográfico da mensagem e o valor exclusivo gerado anteriormente são iguais aos valores esperados e rejeita qualquer resultado diferente.

A Figura 3 contém um diagrama de sequência que ilustra essas etapas:

Figura 3. Diagrama de sequência que mostra como proteger seu app contra ataques de reprodução e proteger ações de alto valor contra adulterações.

Solicitar um veredito de integridade

Após gerar um valor de uso único, é possível solicitar um veredito de integridade do Google Play. Para isso, siga estas etapas:

  1. Crie um IntegrityManager, conforme mostrado nos exemplos abaixo.
  2. Crie um IntegrityTokenRequest, fornecendo o valor de uso único com o método setNonce() no builder associado. Apps que são distribuídos exclusivamente fora do Google Play e dos SDKs também precisam especificar um número de projeto do Google Cloud usando o método setCloudProjectNumber(). No Google Play, os apps são vinculados a um projeto do Google Cloud no Play Console. Por isso, não é necessário definir o número do projeto na solicitação.
  3. Use o gerenciador para chamar requestIntegrityToken(), fornecendo a 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();

Descriptografar e verificar o veredito de integridade

Ao solicitar um veredito de integridade, a API Play Integrity fornece um token de resposta assinado. O valor de uso único incluído na solicitação se torna parte do token de resposta.

Formato do token

O token é um Token JSON da Web (JWT, na sigla em inglês) aninhado, ou seja, Criptografia JSON da Web (JWE, na sigla em inglês) de Assinatura JSON da Web (JWS, na sigla em inglês). Os componentes da JWE e da JWS são representados usando a serialização compacta (links em inglês).

Os algoritmos de criptografia e assinatura têm suporte em várias implementações de JWT:

  • A JWE usa A256KW para alg e A256GCM para enc.
  • A JWS usa ES256.

Descriptografar e verificar nos servidores do Google (recomendado)

A API Play Integrity permite descriptografar e verificar o veredito de integridade nos servidores do Google, o que melhora a segurança do app. Para fazer isso, siga estas etapas:

  1. Crie uma conta de serviço no projeto do Google Cloud que está vinculado ao app. Durante o processo de criação, é preciso conceder à conta os papéis de Usuário da conta de serviço e Consumidor do Service Usage.
  2. No servidor do app, busque o token de acesso nas credenciais da conta de serviço usando o escopo playintegrity e faça a seguinte solicitação:

    playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken -d \
      '{ "integrity_token": "INTEGRITY_TOKEN" }'
  3. Leia a resposta JSON.

Descriptografar e verificar localmente

Se você optar por gerenciar e fazer o download das chaves de criptografia de resposta, vai poder descriptografar e verificar o token retornado no seu próprio ambiente de servidor seguro. Você pode buscar o token retornado usando o método IntegrityTokenResponse#token().

O exemplo abaixo mostra como decodificar a chave AES e a chave pública EC codificada em DER para verificar a assinatura do Play Console no caso de chaves específicas da linguagem (aqui, a linguagem de programação Java) no back-end do app. Observe que as chaves são codificadas em base64 usando sinalizações padrão.

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

Em seguida, use essas chaves para descriptografar o token de integridade (parte JWE) e, em seguida, verificar e extrair a parte JWS aninhada.

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

O payload resultante é um token de texto simples que contém sinais de integridade.

Formato do payload retornado

O payload é JSON em texto simples e contém sinais de integridade e informações fornecidas pelo desenvolvedor.

A estrutura geral do payload é esta:

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

Primeiro, confira se os valores no campo requestDetails correspondem aos da solicitação original antes de verificar cada veredito de integridade.

As seções abaixo descrevem cada campo em mais detalhes.

Campo de detalhes da solicitação

O campo requestDetails contém informações fornecidas na solicitação, incluindo o valor de uso único.

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

Esses valores precisam corresponder aos da solicitação original. Portanto, verifique a parte requestDetails do payload JSON, garantindo que requestPackageName e nonce correspondam ao que foi enviado na solicitação original, conforme mostrado no snippet de código abaixo:

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 integridade do aplicativo

O campo appIntegrity contém informações relacionadas ao pacote.

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 pode ter estes valores:

PLAY_RECOGNIZED
O app e o certificado correspondem às versões distribuídas pelo Google Play.
UNRECOGNIZED_VERSION
O nome do certificado ou do pacote não corresponde aos registros do Google Play.
UNEVALUATED
A integridade do aplicativo não foi avaliada. Um requisito necessário está ausente, por exemplo, o dispositivo não é confiável o suficiente.

Para garantir que o token tenha sido gerado por um app criado por você, confira se a integridade do aplicativo está de acordo com o esperado, conforme mostrado no snippet de código abaixo:

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

Também é possível conferir manualmente o nome do pacote, a versão e os certificados do app.

Campo de integridade do dispositivo

O campo deviceIntegrity contém um único valor, device_recognition_verdict, que representa a forma como um dispositivo pode garantir a integridade do app.

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

Por padrão, device_recognition_verdict pode ter um destes identificadores:

MEETS_DEVICE_INTEGRITY
O app está sendo executado em um dispositivo Android com o Google Play Services. O dispositivo foi aprovado nas verificações de integridade do sistema e atende aos requisitos de compatibilidade do Android.
Nenhum rótulo (um valor em branco)
O app está sendo executado em um dispositivo que tem sinais de ataque, como hooks de API, ou comprometimentos do sistema, como acesso root. Ou, então, o app não está sendo executado em um dispositivo físico, mas sim, por exemplo, em um emulador que falha nas verificações de integridade do Google Play.

Para garantir que o token veio de um dispositivo confiável, verifique se o device_recognition_verdict está de acordo com o esperado, conforme mostrado no snippet de código abaixo:

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

Se você tiver problemas em atender aos requisitos de integridade com o dispositivo de teste, confira se a ROM de fábrica está instalada, por exemplo, redefinindo o dispositivo para as configurações originais, e se o carregador de inicialização está bloqueado. Você também pode criar testes da Play Integrity no Play Console.

Se você aceitar receber outros identificadores no veredito de integridade, o campo device_recognition_verdict vai poder ter os indicadores abaixo:

MEETS_BASIC_INTEGRITY
O app está sendo executado em um dispositivo que é aprovado nas verificações básicas de integridade do sistema. O dispositivo pode não atender aos requisitos de compatibilidade do Android e não ser aprovado para executar o Google Play Services. Por exemplo, o dispositivo pode estar executando uma versão não reconhecida do Android, ter um carregador de inicialização desbloqueado ou não ter sido certificado pelo fabricante.
MEETS_STRONG_INTEGRITY
O app está sendo executado em um dispositivo Android com a tecnologia do Google Play Services e tem uma forte garantia de integridade do sistema, como uma prova de integridade da inicialização protegida por hardware. O dispositivo foi aprovado nas verificações de integridade do sistema e atende aos requisitos de compatibilidade do Android.

Além disso, se o app estiver sendo lançado para emuladores aprovados, o device_recognition_verdict também poderá aceitar este rótulo:

MEETS_VIRTUAL_INTEGRITY
O app está sendo executado em um emulador Android com o Google Play Services. O emulador foi aprovado nas verificações de integridade do sistema e atende aos principais requisitos de compatibilidade do Android.

Campo de detalhes da conta

O campo accountDetails contém um único valor, appLicensingVerdict, que representa o status de licenciamento/titularidade do app.

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

appLicensingVerdict pode ter estes valores:

LICENSED
O usuário tem titularidade do app. Em outras palavras, o usuário instalou ou comprou seu app no Google Play.
UNLICENSED
O usuário não tem a titularidade do app. Isso acontece quando, por exemplo, o usuário transfere o app por sideload ou não o adquire do Google Play.
UNEVALUATED

Os detalhes de licenciamento não foram avaliados porque um requisito necessário está ausente.

Isso pode acontecer por vários motivos, incluindo:

  • O dispositivo não é confiável o suficiente.
  • A versão do app instalada no dispositivo não é reconhecida pelo Google Play.
  • O usuário não está conectado ao Google Play.

Para conferir se o usuário tem titularidade do app, veja se o appLicensingVerdict está de acordo com o esperado, conforme mostrado no snippet de código abaixo.

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