무결성 결과 사용

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. Play Integrity API와 함께 사용할 서버 측 nonce를 생성하는 방법을 보여주는 시퀀스 다이어그램

클라이언트 생성 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. Play Integrity API와 함께 사용할 클라이언트 측 nonce를 생성하는 방법을 보여주는 시퀀스 다이어그램

무결성 결과 요청

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

자바

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

자바

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

자바

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

결과 페이로드는 무결성 신호가 포함된 일반 텍스트 토큰입니다.

nonce와 패키지 이름이 원래 요청에서 전송된 것과 일치하는지 확인하여 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!
  ...
}

자바

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 후킹)이나 시스템 손상(예: 루팅됨) 징후가 있는 기기에서 실행되거나 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에서 알 수 없습니다.