完整性判定結果的運用

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」欄位,防止特定的重要操作內容遭到竄改。例如,遊戲可能想報告玩家的得分,而您想要確保得分不會遭到某個 Proxy 伺服器竄改。實作方式如下:

  1. 使用者啟動該項重要操作。
  2. 應用程式準備一則想要保護的訊息,例如 JSON 格式的訊息。
  3. 應用程式計算要保護訊息的加密編譯雜湊值。例如,使用 SHA-256SHA-3-256 雜湊演算法。
  4. 應用程式會呼叫 Play Integrity API,並呼叫 setNonce() 將「nonce」欄位設為上一個步驟中算出的加密編譯雜湊值。
  5. 應用程式會將要保護的訊息和 Play Integrity API 的已簽署結果傳送至您的伺服器。
  6. 應用程式伺服器會驗證收到訊息的加密編譯雜湊值是否與已簽署結果中的「nonce」欄位值相符,並拒絕任何不相符的結果。

圖 1 中的序列圖說明了上述步驟:

圖 1. 此序列圖展示如何防範應用程式中的重要操作遭到竄改。

防止應用程式受到重送攻擊

為了避免惡意使用者重複使用 Play Integrity API 之前的回應,可以使用 nonce 欄位來明確識別每則訊息。實作方式如下:

  1. 您需要一個全域唯一且惡意使用者無法預測的值。例如,在伺服器端產生的密碼編譯安全隨機號碼就屬於這類的值。建議您建立 128 位元以上的值。
  2. 應用程式會呼叫 Play Integrity API,並呼叫 setNonce() 以將「nonce」欄位設為應用程式伺服器接收到的唯一值。
  3. 應用程式會將 Play Integrity API 的已簽署結果傳送至您的伺服器。
  4. 伺服器會驗證已簽署結果中的「nonce」欄位是否與之前產生的唯一值相符,並拒絕任何不相符的結果。

圖 2 中的序列圖說明了上述步驟:

圖 2. 此序列圖展示如何防止應用程式受到重送攻擊。

搭配運用兩種保護機制

可以使用 nonce 欄位來同時防範重送攻擊和竄改攻擊,方法是將伺服器產生的全域唯一值附加到重要訊息的雜湊值中,並在 Play Integrity API 中將該唯一值設為「nonce」欄位。結合運用這兩種方法的實作方式如下:

  1. 使用者啟動該項重要操作。
  2. 應用程式要求伺服器提供一個唯一的值來識別該要求
  3. 應用程式伺服器以惡意使用者無法預測的方式,產生一個全域的唯一值。舉例來說,可以使用經加密編譯的隨機號碼產生器來建立這個值。建議您建立 128 位元以上的值。
  4. 應用程式伺服器會將全域唯一值傳送至該應用程式。
  5. 應用程式準備一則想要保護的訊息,例如 JSON 格式的訊息。
  6. 應用程式計算要保護訊息的加密編譯雜湊值。例如,使用 SHA-256SHA-3-256 雜湊演算法。
  7. 應用程式會將應用程式伺服器傳送的唯一值,以及要保護的訊息雜湊值附加上去,產生一個字串。
  8. 應用程式會呼叫 Play Integrity API,並呼叫 setNonce() 以將「nonce」欄位設為上一個步驟中建立的字串。
  9. 應用程式會將要保護的訊息和 Play Integrity API 的已簽署結果傳送至您的伺服器。
  10. 應用程式伺服器會分割「nonce」欄位的值,並驗證訊息的加密編譯雜湊值及其先前產生的唯一值是否與預期值相符,並拒絕任何不相符的結果。

圖 3 中的序列圖說明了以下步驟:

圖 3.序列圖展示如何保護應用程式免受重送攻擊,並防範應用程式中的重要操作遭到竄改。

要求完整性判定結果

產生 Nonce 後,可以要求 Google Play 提供完整性判定結果。若要這樣做,請完成下列步驟:

  1. 依照以下範例建立 IntegrityManager
  2. 建構 IntegrityTokenRequest,透過相關聯建構工具中的 setNonce() 方法提供 Nonce。只在 Google Play 和 SDK 以外管道發行的應用程式也必須透過 setCloudProjectNumber() 方法指定 Google Cloud 專案編號。Google Play 上的應用程式會連結至 Play 管理中心的 Cloud 專案,您不必在要求中設定 Cloud 專案編號。
  3. 使用管理員呼叫 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 Web Token (JWT),即為 JSON Web Signature (JWS)JSON Web Encryption (JWE)。JWE 和 JWS 元件是以精簡序列化表示。

各種 JWT 實作皆完整支援加密/簽署演算法:

  • JWE 將 A256KW 用於 alg,將 A256GCM 用於 enc
  • JWS 使用的是 ES256。

在 Google 伺服器上解密並驗證 (建議)

Play Integrity API 可讓您在 Google 伺服器上解密並驗證完整性判定結果,可加强應用程式的安全性。若要這樣做,請完成下列步驟:

  1. 在連結至應用程式的 Google Cloud 專案中建立服務帳戶。在建立帳戶的流程中,您必須將您的服務帳戶授予服務帳戶使用者服務使用情形消費者角色。
  2. 在應用程式伺服器上,使用 playintegrity 範圍從服務帳戶的憑證中擷取存取權杖,然後提出下列要求:

    playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken -d \
      '{ "integrity_token": "INTEGRITY_TOKEN" }'
  3. 讀取 JSON 回應。

在本機解密並驗證

如果您選擇管理及下載回應加密金鑰,可以在自己的安全伺服器環境中解密及驗證傳回的權杖。您可以使用 IntegrityTokenResponse#token() 方法取得傳回的權杖。

以下範例說明如何解碼 AES 金鑰和 DER 編碼的公開 EC 金鑰,以便在應用程式後端為 Play 管理中心乃至特定語言 (在這裡是 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"
}

這些值應與原始要求的值相符。因此,請確認 requestPackageNamenonce 與原始要求中傳送的值相符,以驗證 JSON 酬載的 requestDetails 部分,如下列程式碼片段所示:

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 Emulator 上執行。該模擬器已通過系統完整性檢查,符合主要的 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!
}