Play Integrity API 使用完整性判定来传达与设备、应用和用户的有效性相关的信息。应用服务器可以使用在已解密且经过验证的判定中生成的载荷,来确定如何以最佳方式继续推进应用中的特定操作或请求。
生成 Nonce
使用 Play Integrity API 保护应用中的某项操作时,您可以利用 nonce
字段来缓解特定类型的攻击,例如中间人 (PITM) 篡改攻击和重放攻击。Play Integrity API 会在带签名的完整性响应中返回您在此字段中设置的值。
在 nonce
字段中设置的值必须采用正确的格式:
String
- 具有网址安全性
- 编码为 Base64 且没有换行
- 最少 16 个字符
- 最多 500 个字符
以下是在 Play Integrity API 中使用 nonce
字段的一些常用方法。为了获得最强大的 Nonce 保护,您可以组合使用以下方法。
保护重要操作不被篡改
您可以使用 Play Integrity 的 nonce
字段来保护特定重要操作的内容,使其免遭篡改。例如,游戏可能想要报告玩家得分,并且您希望确保此得分没有被代理服务器篡改。实现方法如下所示:
- 用户发起重要操作。
- 应用准备好要保护的消息,例如 JSON 格式的消息。
- 应用计算要保护的消息的加密哈希值。 例如,使用 SHA-256 或 SHA-3-256 哈希算法。
- 应用调用 Play Integrity API,并调用
setNonce()
以将nonce
字段设置为在上一步计算的加密哈希值。 - 应用将要保护的消息以及 Play Integrity API 的签名结果发送给服务器。
- 应用服务器验证其收到的消息的加密哈希值是否与签名结果中的
nonce
字段值匹配,并拒绝任何不匹配的结果。
图 1 中的序列图说明了这些步骤:
图 1. 显示了如何保护应用中的重要操作不被篡改的序列图。
保护您的应用免遭重放攻击
为防止恶意用户重复使用之前来自 Play Integrity API 的响应,您可以使用 nonce
字段对每条消息进行唯一标识。实现方法如下所示:
- 您需要一个全局唯一值,防止恶意用户做出预测。例如,在服务器端生成的加密安全随机数就可以。我们建议创建不小于 128 位的值。
- 应用调用 Play Integrity API,并调用
setNonce()
以将nonce
字段设置为您的应用服务器收到的唯一值。 - 应用将 Play Integrity API 的签名结果发送给服务器。
- 服务器验证签名结果中的
nonce
字段与之前生成的唯一值是否匹配,并拒绝任何不匹配的结果。
图 2 中的序列图说明了这些步骤:
图 2. 显示了如何保护应用免遭重放攻击的序列图。
结合使用两种保护措施
使用 nonce
字段可以同时防范重放攻击和篡改。为此,您可以将服务器生成的全局唯一值附加到重要消息的哈希值上,并将此值设置为 Play Integrity API 中的 nonce
字段的值。将这两种方法结合到一起的实现方式如下:
- 用户发起重要操作。
- 应用要求服务器提供一个标识请求的唯一值
- 应用服务器生成全局唯一值,防止恶意用户做出预测。例如,您可以使用加密安全的随机数生成器创建此类值。我们建议创建不小于 128 位的值。
- 应用服务器向应用发送全局唯一值。
- 应用准备好要保护的消息,例如 JSON 格式的消息。
- 应用计算要保护的消息的加密哈希值。 例如,使用 SHA-256 或 SHA-3-256 哈希算法。
- 应用通过附加从应用服务器收到的唯一值以及要保护的消息的哈希值来创建一个字符串。
- 应用调用 Play Integrity API,并调用
setNonce()
以将nonce
字段设置为在上一步中创建的字符串。 - 应用将要保护的消息以及 Play Integrity API 的签名结果发送给服务器。
- 应用服务器拆分
nonce
字段的值,然后验证消息的加密哈希值以及之前生成的唯一值是否与预期值相匹配,并拒绝任何不匹配的结果。
图 3 所包含的序列图说明了这些步骤。
图 3. 显示了如何保护应用免遭重放攻击以及如何保护应用中的重要操作不受篡改的序列图。
请求完整性判定
生成 Nonce 后,您可以向 Google Play 请求完整性判定。为此,请完成以下步骤:
- 创建一个
IntegrityManager
,如以下示例所示。 - 构造一个
IntegrityTokenRequest
,通过关联构建器中的setNonce()
方法提供 Nonce。在 Google Play 之外专门分发的应用和 SDK 还必须通过setCloudProjectNumber()
方法指定其 Google Cloud 项目编号。Google Play 中的应用已在 Play 管理中心关联到 Cloud 项目,因此不需要在请求中设置 Cloud 项目编号。 使用该管理器调用
requestIntegrityToken()
,并提供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(); }
原生代码
/// 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();
解密和验证完整性判定
在您请求完整性判定时,Play Integrity API 会提供已签名的响应令牌。您在请求中包含的 Nonce 会成为响应令牌的一部分。
令牌格式
令牌是指嵌套的 JSON 网络令牌 (JWT),即 JSON 网络签名 (JWS) 的 JSON 网络加密 (JWE)。JWE 和 JWS 组件使用紧凑的序列化来表示。
加密/签名算法在各种 JWT 实现中得到了很好的支持:
在 Google 服务器上进行解密和验证(推荐做法)
借助 Play Integrity API,您可以在 Google 服务器上解密和验证完整性判定结果,从而提高应用的安全性。为此,请完成以下步骤:
- 在与您的应用关联的 Google Cloud 项目中创建一个服务帐号。在帐号创建过程中,您需要向服务帐号授予 Service Account User 和 Service Usage Consumer 角色。
在应用服务器上,使用
playintegrity
范围从服务帐号凭据中提取访问令牌,然后发出以下请求:playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken -d \ '{ "integrity_token": "INTEGRITY_TOKEN" }'
读取 JSON 响应。
在本地进行解密和验证
如果您选择管理和下载响应加密密钥,可以在您自己的安全服务器环境中解密和验证返回的令牌。您可以使用 IntegrityTokenResponse#token()
方法获取返回的令牌。
以下示例展示了如何在应用的后端将用于在 Play 管理中心进行签名验证的 AES 密钥和 DER 编码的 EC 公钥,解码为特定于语言(在本例中为 Java 编程语言)的密钥。请注意,这些密钥是使用默认标记以 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: { ... } appIntegrity: { ... } deviceIntegrity: { ... } accountDetails: { ... } }
您首先必须检查 requestDetails
字段中的值是否与原始请求的值匹配,然后再检查每个完整性判定结果。
下面几部分更为详细地介绍了各个字段。
请求详情字段
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 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" }
这些值应与原始请求中的值相符。因此,请验证 JSON 载荷的 requestDetails
部分,确保 requestPackageName
和 nonce
与原始请求中发送的内容一致,如以下代码段所示:
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. ... }
应用完整性字段
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. // 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
- 未评估应用完整性。未满足必要条件,例如设备不够可信。
如需确保令牌是由您创建的应用生成的,请验证应用完整性是否符合预期,如以下代码段所示:
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! }
您也可以手动检查应用软件包名称、应用版本和应用证书。
设备完整性字段
deviceIntegrity
字段可以包含单个值 deviceRecognitionVerdict
,后者可有一个或多个标签,这些标签代表设备可以在多大程度上强制执行应用完整性检查。如果设备不满足使用任何标签的条件,deviceIntegrity
字段将为空。
deviceIntegrity: { // "MEETS_DEVICE_INTEGRITY" is one of several possible values. deviceRecognitionVerdict: ["MEETS_DEVICE_INTEGRITY"] }
默认情况下,deviceRecognitionVerdict
可以具有以下某个标签:
MEETS_DEVICE_INTEGRITY
- 应用正在由 Google Play 服务提供支持的 Android 设备上运行。设备通过了系统完整性检查,并且满足 Android 兼容性要求。
- 无标签(空白值)
- 应用正在存在攻击迹象(如 API 挂接)或系统被侵入迹象(如取得 root 权限后入侵)的设备上运行,或者应用未在实体设备(如未通过 Google Play 完整性检查的模拟器)上运行。
为确保令牌来自可信设备,请验证 deviceRecognitionVerdict
是否符合预期,如以下代码段所示:
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! }
如果您在测试设备时发现设备不能满足完整性要求,请确保已安装出厂 ROM(例如,通过重置设备),并且引导加载程序处于锁定状态。您还可以在 Play 管理中心内创建 Play Integrity 测试。
如果您选择在完整性判定结果中接收其他标签,则 deviceRecognitionVerdict
可能会具有以下额外标签:
MEETS_BASIC_INTEGRITY
- 应用正在通过了基本系统完整性检查的设备上运行。设备可能不满足 Android 兼容性要求,也可能未被批准运行 Google Play 服务。例如,设备可能正在运行无法识别的 Android 版本、可能有已解锁的引导加载程序,或者可能没有经过制造商的认证。
MEETS_STRONG_INTEGRITY
- 应用正在由 Google Play 服务提供支持且具有强有力的系统完整性保证(如由硬件提供支持的启动完整性保证)的 Android 设备上运行。设备通过了系统完整性检查,并且满足 Android 兼容性要求。
此外,如果您的应用将向获得批准的模拟器发布,deviceRecognitionVerdict
可能还会具有以下标签:
MEETS_VIRTUAL_INTEGRITY
- 应用正在由 Google Play 服务提供支持的 Android 模拟器上运行。模拟器通过了系统完整性检查,并且满足核心 Android 兼容性要求。
帐号详情字段
accountDetails
字段包含单个值,即 appLicensingVerdict
,它表示应用许可/使用权状态。
accountDetails: { // This field can be LICENSED, UNLICENSED, or UNEVALUATED. appLicensingVerdict: "LICENSED" }
appLicensingVerdict
可以具有以下值:
LICENSED
- 用户拥有应用使用权。换句话说,用户在 Google Play 上安装或购买了您的应用。
UNLICENSED
- 用户没有应用使用权。例如,当用户旁加载了您的应用,或未从 Google Play 获取您的应用时,就会发生这种情况。
UNEVALUATED
未评估许可详情,因为缺少必要的要求。
导致这种情况的原因可能有多种,其中包括以下原因:
- 设备不够可信。
- 设备上安装的应用是 Google Play 未知的版本。
- 用户未登录 Google Play。
如需检查用户是否拥有应用的使用权,请验证 appLicensingVerdict
是否符合预期,如以下代码段所示:
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! }