处理完整性判定

Play Integrity API 使用完整性判定来传达与设备、应用和用户的有效性相关的信息。应用服务器可以使用在已解密且经过验证的判定中生成的载荷,来确定如何以最佳方式继续推进应用中的特定操作或请求。

生成 Nonce

“Nonce”是一种一次性的请求识别号,用于验证消息的完整性。理想情况下,该请求 ID 会与生成相应请求的上下文绑定到一起,例如用户 ID 的哈希值或时间戳。

Nonce 应该是唯一的且无法预测。如果某用户获得了针对特定受保护操作的完整性判定,该用户应该无法针对后续保护操作重复使用该判定。这就是所谓的“重放攻击”,Nonce 会阻止这种行为。

如需创建 Nonce,请执行以下任一操作:

您应根据要保护的操作的价值以及感知到的对应用的威胁来选择 Nonce 生成方法。您的客户端应用可能在攻击者的控制之下,因此,在服务器端(而不是客户端)随机生成 Nonce 会更安全。

服务器生成的 Nonce

以下步骤说明了如何在安全的服务器环境中,使用加密安全的随机数字生成器为每个传入的客户端请求生成 Nonce:

  1. 应用将操作告知应用后端,例如注册新用户。
  2. 应用后端生成随即且唯一的 Nonce。
  3. 应用后端向待处理请求表格添加 Nonce/请求对。
  4. 应用后端向应用请求完整性令牌,并将 Nonce 与该请求一起传递。
  5. 与 Play Integrity API 通信后,应用将响应令牌传递到应用后端。然后,系统解密并验证这一代表完整性判定的令牌。通常,应用后端会将该令牌传递到 Play 服务器以解密并验证相应判定,然后再将相应令牌载荷传递回应用后端。应用后端也可以在本地执行解密和验证步骤
  6. 应用后端从已解密的载荷中提取 Nonce。
  7. 应用后端检查 Nonce 是否显示在待处理请求表格的某个条目中,以及对应的请求是否相符。
  8. 应用后端从待处理请求表格中移除该条目。
  9. 应用后端从已解密的载荷中提取时间戳,并验证该时间戳是否属于“前不久”时间范畴(在当时与当前时间的最大允许时间范围内)。
  10. 应用后端从已解密的载荷中提取软件包名称,并验证载荷中的软件包名称是否与应用的实际软件包名称相符。

图 1 所展示的序列图说明了这些步骤。

Nonce 是唯一的且无法预测,因此这种方法提供了有力的保障,保证无法重放请求。

图 1. 序列图,显示了如何生成服务器端 Nonce 以与 Play Integrity API 结合使用。

客户端生成的 Nonce

某些客户端可能无法保留服务器端的所有传入请求或者不能容忍额外往返的延迟。对于此类情况,我们可以采用另一种解决方案,该解决方案用部分重放保证换取了简单性。它基于对请求参数进行哈希处理,并且包含时间戳:

  1. 应用完成一项操作,例如注册新用户。
  2. 应用使用当前时间等参数的哈希值生成 Nonce,以计算该特定请求的唯一值。
  3. 应用向 Play Integrity API 请求完整性令牌,并传入 Nonce。
  4. Play Integrity API 以代表完整性判定的令牌作为响应。
  5. 应用将该令牌以及用于生成 Nonce 的参数传递到应用后端。
  6. 系统解密并验证该令牌。通常,应用后端会将该令牌传递到 Play 服务器以解密并验证相应判定,然后再将相应令牌载荷传递回应用后端。应用后端也可以在本地执行解密和验证步骤
  7. 应用后端从已解密的载荷中提取 Nonce。
  8. 应用后端计算应用提供的客户端参数的哈希值,并检查计算出的这个值是否与加密载荷中的 Nonce 相符。
  9. 应用后端从已解密的载荷中提取时间戳,并验证该时间戳是否属于“前不久”时间范畴(在当时与当前时间的最大允许时间范围内)。
  10. 应用后端从已解密的载荷中提取软件包名称,并验证载荷中的软件包名称是否与应用的实际软件包名称相符。

图 2 所展示的序列图说明了这些步骤。

时间戳需要包含在请求参数中,以确保每个 Nonce 都是唯一的。然而,由于时间戳和请求参数是在客户端生成的,而您不知道客户端是否在攻击者的控制之下,因此您必须假定可能存在对抗行为。

图 2. 序列图,显示了如何生成客户端 Nonce 以与 Play Integrity API 结合使用。

请求完整性判定

生成 Nonce 后,您可以向 Google Play 请求完整性判定。为此,请完成以下步骤:

  1. 创建 IntegrityManager,如以下代码段所示。
  2. 使用该管理器调用 requestIntegrityToken(),并通过关联 IntegrityTokenRequest 构建器中的 setNonce() 方法提供 Nonce。

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

原生代码

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

解密和验证完整性判定

在您请求完整性判定时,Play Integrity API 会提供已签名的响应令牌。您在请求中包含的 Nonce 会成为响应令牌的一部分。

令牌格式

令牌是指嵌套的 JSON 网络令牌 (JWT),即 JSON 网络签名 (JWS)JSON 网络加密 (JWE)。JWE 和 JWS 组件使用紧凑的序列化来表示。

加密/签名算法在各种 JWT 实现中得到了很好的支持:

  • JWE 对 alg 使用 A256KW,对 enc{: .external} 使用 A256GCM。
  • JWS 使用 ES256。

在 Google 服务器上进行解密和验证

借助 Play Integrity API,您可以在 Google 服务器上解密和验证完整性判定,从而提高应用的安全性。为此,请完成以下步骤:

  1. 在与您的应用关联的 Google Cloud 项目中创建一个服务帐号。
  2. 在应用服务器上发出以下请求:

    playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken -d \
      '{ "integrity_token": "INTEGRITY_TOKEN", "nonce": "NONCE"}'
  3. 读取 JSON 响应。

在本地进行解密和验证

如果您选择自行管理 API 密钥,则可以在自己的安全服务器环境中解密和验证返回的令牌。您可以使用 IntegrityTokenResponse#token() 方法来获取返回的令牌。

以下示例展示了如何在应用的后端将通过 Play 管理中心进行签名验证的 AES 密钥和 DER 编码的 EC 公钥解码为特定于语言的密钥。请注意,密钥是使用默认标志以 base64 编码的。

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

接下来,使用这些密钥先解密完整性令牌(JWE 部分),然后再验证和提取嵌套的 JWS 部分。

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

生成的载荷是包含完整性信号的纯文本令牌。

您还必须验证 JSON 载荷的 requestDetails 部分,方法是确保 Nonce 和软件包名称与原始请求中发送的内容相符:

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

返回的载荷格式

载荷是纯文本 JSON,其中包含完整性信号以及开发者提供的信息。

常规载荷结构如下:

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

下面几部分更为详细地介绍了各个字段。

请求详情字段

requestDetails 字段包含在请求中提供的信息,包括 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 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
}

应用完整性字段

appIntegrity 字段包含与软件包相关的信息。

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 可以具有以下值:

PLAY_RECOGNIZED
应用和证书与 Google Play 分发的版本相符。
UNRECOGNIZED_VERSION
证书或软件包名称与 Google Play 记录不符。
UNEVALUATED
未评估应用完整性。缺少必要的要求,例如设备不够可信。

设备完整性字段

deviceIntegrity 字段包含单个值,即 device_recognition_verdict,它表示设备可以在多大程度上强制执行应用完整性。

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

默认情况下,device_recognition_verdict 可以具有以下某个标签:

MEETS_DEVICE_INTEGRITY
应用正在由 Google Play 服务提供支持的 Android 设备上运行。设备通过了系统完整性检查,并且满足 Android 兼容性要求。
无标签(空白值)
应用正在有攻击迹象(如 API 挂接)或系统被侵迹象(如取得 root 权限后入侵)的设备上运行,或者应用未在通过 Google Play 完整性检查的实体设备(如模拟器)上运行。

如果您选择接收完整性判定中的其他标签,则 device_recognition_verdict 可能会具有以下额外标签:

MEETS_BASIC_INTEGRITY
应用正在通过了基本系统完整性检查的设备上运行。设备可能不满足 Android 兼容性要求,也可能未被批准运行 Google Play 服务。例如,设备可能正在运行无法识别的 Android 版本、可能有已解锁的引导加载程序,或者可能没有经过制造商的认证。
MEETS_STRONG_INTEGRITY
应用正在由 Google Play 服务提供支持且具有强有力的系统完整性保证(如由硬件支持的密钥库)的 Android 设备上运行。设备通过了系统完整性检查,并且满足 Android 兼容性要求。

为了有助于在测试判定标签的准确性时获得预期判定,请检查测试设备是否满足以下条件:

  1. USB 调试已关闭。
  2. 引导加载程序已锁定

详细了解如何为应用创建 Play Integrity API 测试

帐号详情字段

accountDetails 字段包含单个值,即 licensingVerdict,它表示应用许可/使用权状态。

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

licensingVerdict 可以具有以下值:

LICENSED
用户拥有应用使用权。换句话说,用户在 Google Play 上安装或购买了您的应用。
UNLICENSED
用户没有应用使用权。例如,当用户旁加载了您的应用,或未从 Google Play 获取您的应用时,就会发生这种情况。
UNEVALUATED

未评估许可详情,因为缺少必要的要求。

导致这种情况的原因可能有多种,其中包括以下原因:

  • 设备不够可信。
  • 设备上安装的应用是 Google Play 未知的版本。