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

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

ノンスを生成する

ノンスは、メッセージの完全性を検証するために使用する 1 回限りのリクエスト識別番号です。理想的には、このリクエスト ID は、生成されたコンテキスト(ユーザー ID やタイムスタンプのハッシュなど)にバインドされます。

ノンスは一意かつ予測不可能である必要があります。ユーザーが特定の保護対象アクションについて完全性判定の結果を取得した場合、それをその後の保護対象アクションに再利用できないようにする必要があります。このような再利用は「リプレイ攻撃」と呼ばれ、ノンスで防止できます。

ノンスを作成するには、次のいずれかを行います。

ノンス生成の手法は、保護対象アクションの価値と、アプリに対して予想される脅威に応じて選択する必要があります。クライアント アプリが攻撃者の支配下に置かれている可能性があるため、ノンスをクライアントサイドではなくサーバーサイドでランダムに生成する方がはるかに安全です。

サーバー生成のノンス

受け取ったクライアント リクエストごとに、安全なサーバー環境で、暗号論的に安全な乱数ジェネレータを使用してノンスを生成する方法は次のとおりです。

  1. アプリが、新規ユーザーの登録などのアクションをアプリのバックエンドに通知します。
  2. アプリのバックエンドが、ランダムかつ一意のノンスを生成します。
  3. アプリのバックエンドが、ノンスとリクエストのペアを保留中のリクエストのテーブルに追加します。
  4. アプリのバックエンドが、アプリに完全性トークンをリクエストし、リクエストとともにノンスを渡します。
  5. Play Integrity API と通信した後、アプリがレスポンス トークンをアプリのバックエンドに渡します。その後、完全性判定の結果を表すトークンの復号と検証が行われます。通常、アプリのバックエンドは Play のサーバーにトークンを渡して判定結果の復号と検証を行います。その後、トークンのペイロードがアプリのバックエンドに返されます。アプリのバックエンドは、復号と検証の手順をローカルで行うこともできます。
  6. アプリのバックエンドが、復号されたペイロードからノンスを抽出します。
  7. 保留中のリクエスト テーブルのエントリにそのノンスが存在し、対応するリクエストが一致していることをアプリのバックエンドが確認します。
  8. アプリのバックエンドが、保留中のリクエスト テーブルからエントリを削除します。
  9. アプリのバックエンドが、復号されたペイロードからタイムスタンプを抽出し、そのタイムスタンプが最近のものであって、その時点から現在までの最大許容時間枠内のものであることを確認します。
  10. アプリのバックエンドが、復号されたペイロードからパッケージ名を抽出し、ペイロード内のパッケージ名がアプリの実際のパッケージ名と一致することを確認します。

図 1 に、この手順を表すシーケンス図を示します。

ノンスは一意かつ予測不可能であるため、この方法からはリクエストがリプレイできないという確実な保証が得られます。

図 1. Play Integrity API で使用するサーバーサイドのノンスの生成方法を示すシーケンス図。

クライアント生成のノンス

クライアントによっては、サーバー側にすべての受信リクエストを保持できなかったり、サーバーとのやり取りによる遅延に耐えられなかったりする場合があります。そのような場合、ある程度のリプレイ保証と引き換えに単純化を図る別の方法があります。これはリクエスト パラメータのハッシュ化に基づいており、タイムスタンプを使用します。

  1. アプリが、新規ユーザーの登録などのアクションを完了します。
  2. アプリが、現在時刻などのパラメータのハッシュを使用してノンスを作成し、この特定のリクエストに対して一意の値を計算します。
  3. アプリがノンスを渡して Play Integrity API に完全性トークンをリクエストします。
  4. Play Integrity API が、完全性判定の結果を表すトークンを返します。
  5. アプリが、ノンスの生成に使用したパラメータとともに、トークンをアプリのバックエンドに渡します。
  6. トークンの復号と検証が行われます。通常、アプリのバックエンドは Play のサーバーにトークンを渡して判定結果の復号と検証を行います。その後、トークンのペイロードがアプリのバックエンドに返されます。アプリのバックエンドは、復号と検証の手順をローカルで行うこともできます。
  7. アプリのバックエンドが、復号されたペイロードからノンスを抽出します。
  8. アプリのバックエンドが、アプリから与えられたクライアントサイド パラメータのハッシュを計算し、この計算値が復号されたペイロードのノンスと一致することを確認します。
  9. アプリのバックエンドが、復号されたペイロードからタイムスタンプを抽出し、そのタイムスタンプが最近のものであって、その時点から現在までの最大許容時間枠内のものであることを確認します。
  10. アプリのバックエンドが、復号されたペイロードからパッケージ名を抽出し、ペイロード内のパッケージ名がアプリの実際のパッケージ名と一致することを確認します。

図 2 に、この手順を表すシーケンス図を示します。

各ノンスが一意になるよう、リクエスト パラメータにタイムスタンプを含める必要があります。それでも、タイムスタンプとリクエスト パラメータはクライアント上で生成され、そのクライアントは攻撃者のものである場合もあるため、悪意のある行動の可能性は否定できません。

図 2. Play Integrity API で使用するクライアントサイドのノンスの生成方法を示すシーケンス図。

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

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

  1. 次のコード スニペットに示すように、IntegrityManager を作成します。
  2. 作成したマネージャーを使用して requestIntegrityToken() を呼び出し、関連付けられた IntegrityTokenRequest ビルダーの setNonce() メソッドでノンスを指定します。

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 によって署名付きレスポンス トークンが提供されます。リクエストに含めるノンスは、レスポンス トークンの一部になります。

トークン形式

このトークンは、ネストされた 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 プロジェクト内にサービス アカウントを作成します。
  2. アプリのサーバーで、次のリクエストを行います。

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

ローカルでの復号と検証

API キーを自己管理する場合は、返されたトークンを独自の安全なサーバー環境で復号して検証できます。返されるトークンは、IntegrityTokenResponse#token() メソッドを使用して取得できます。

次の例は、Google Play Console で取得した 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 部分も検証する必要があります。それには、ノンスとパッケージ名が元のリクエストで送信したものと一致することを確認します。

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 フィールドには、ノンスなど、リクエストで指定された情報が含まれます。これらの値は、元のリクエストの値と一致する必要があります。

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 フックなどの攻撃やルート権限取得などのシステム侵害の兆候があるデバイス、または 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 に認識されていない。