完全性判定の結果を使用する

Play Integrity API は、完全性判定の結果を使用して、デバイス、アプリ、ユーザーの有効性に関する情報を伝えます。アプリのサーバーは、復号と検証がなされた判定結果のペイロードを使用して、アプリ内の特定のアクションまたはリクエストを進めるかどうかや、どう進めるのが最善かを決定できます。

ノンスを生成する

Play Integrity API を使用してアプリのアクションを保護する場合は、nonce フィールドを利用して、中間者(PITM)改ざん攻撃やリプレイ攻撃のような特定のタイプの攻撃を軽減することができます。Play Integrity API は、このフィールドに設定した値を、署名付きの完全性レスポンス内で返します。

nonce フィールドに設定した値が正しい形式であることが必要です。

  • String
  • URL セーフ
  • Base64、ラップなしでエンコード
  • 16 文字以上
  • 500 文字以下

以下では、Play Integrity API で nonce フィールドを使用する一般的な方法をいくつか示します。下記の方法を組み合わせることで、ノンスによる保護を強化できます。

価値の高いアクションを改ざんから保護する

Play Integrity API の nonce フィールドを使用して、価値の高い特定のアクションの内容を改ざんから保護できます。たとえば、プレーヤーのスコアをレポートするゲームで、プロキシ サーバーによってスコアが改ざんされていないことを確認したい場合などです。実装は次のとおりです。

  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. リプレイ攻撃からアプリを保護する方法と、アプリの価値の高いアクションを改ざんから保護する方法を示すシーケンス図。

完全性判定の結果をリクエストする

ノンスを生成したら、Google Play に完全性判定の結果をリクエストできます。そのための手順は次のとおりです。

  1. 以下の例に示すように、IntegrityManager を作成します。
  2. IntegrityTokenRequest を作成し、関連付けられたビルダーの setNonce() メソッドで、生成したノンスを指定します。Google Play 以外で独占配信されているアプリや、SDK についても、setCloudProjectNumber() メソッドで Google Cloud プロジェクト番号を指定する必要があります。Google Play 上のアプリは、Google Play Console で 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 によって署名付きレスポンス トークンが提供されます。リクエストに含めるノンスは、レスポンス トークンの一部になります。

トークン形式

このトークンは、ネストされた JSON Web Token(JWT)、つまり JSON Web Signature(JWS)JSON Web Encryption(JWE)です。JWE と JWS のコンポーネントは、Compact Serialization で表現されます。

暗号化と署名のアルゴリズムは、次のようなさまざまな JWT 実装で適切にサポートされています。

  • JWE は、alg については A256KW、enc については A256GCM を使用します。
  • JWS は ES256 を使用します。

Google のサーバーでの復号と検証(推奨)

Play Integrity API を使用すると、Google のサーバー上で完全性判定の結果を復号して検証することができます。これにより、アプリのセキュリティが強化されます。手順は次のとおりです。

  1. アプリにリンクされている Google Cloud プロジェクト内にサービス アカウントを作成します。このアカウント作成プロセスでは、サービス アカウントにサービス アカウント ユーザーService Usage ユーザーのロールを付与する必要があります。
  2. アプリのサーバーで、playintegrity スコープを使用してサービス アカウントの認証情報からアクセス トークンを取得し、次のリクエストを行います。

    playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken -d \
      '{ "integrity_token": "INTEGRITY_TOKEN" }'
  3. JSON レスポンスを読み取ります。

ローカルでの復号と検証

レスポンスの暗号鍵のご自身で管理、ダウンロードすることを選択した場合は、独自の安全なサーバー環境内で、返されたトークンの復号、検証を行えます。返されたトークンは、IntegrityTokenResponse#token() メソッドを使用して取得できます。

次の例は、Google Play Console から取得した 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 フィールドには、リクエスト内で指定された情報(ノンスなど)が含まれます。

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 フィールドには 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 フックなどの攻撃やルート権限取得などのシステム侵害の兆候があるデバイス、または Google Play の完全性チェックに合格していない仮想デバイス(エミュレータなど)で動作しています。

トークンが信頼できるデバイスから生成されたことを確認するには、次のコード スニペットに示すように、device_recognition_verdict が期待どおりであることを検証します。

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

テストデバイスがデバイスの完全性要件を満たさない問題が発生した場合は、工場出荷時の ROM がインストールされていることと、ブートローダーがロックされていることを確認してください(前者はデバイスをリセットするなどの方法で確認できます)。また、Google Play Console で Play Integrity API テストを作成することもできます。

完全性判定の結果で追加ラベルを受け取ることをオプトインすると、device_recognition_verdict に以下のラベルを追加できます。

MEETS_BASIC_INTEGRITY
アプリは、基本的なシステム完全性チェックに合格したデバイスで動作しています。このデバイスは Android の互換性要件を満たしておらず、Google Play 開発者サービスの実行を承認されていない可能性があります。たとえば、認識されていないバージョンの Android を搭載している、ブートローダーがロック解除されている、メーカーによる認定を受けていない、などの場合があります。
MEETS_STRONG_INTEGRITY
アプリは、Google Play 開発者サービスを搭載した Android デバイスで動作しており、システム完全性の強力な保証(ハードウェア格納型のブート完全性保証など)が存在します。このデバイスはシステム完全性チェックに合格し、Android の互換性要件を満たしています。

さらに、アプリが承認済みのエミュレータにリリースされている場合は、device_recognition_verdict に次のラベルを付けることもできます。

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