Play Integrity API는 무결성 확인 결과를 사용하여 기기와 앱, 사용자의 유효성에 관한 정보를 전달합니다. 앱 서버는 복호화되어 확인된 결과에서 결과 페이로드를 사용하여 앱에서 특정 작업이나 요청을 처리하는 최선의 방법을 결정할 수 있습니다.
nonce 생성
Play Integrity API로 앱의 작업을 보호하면 nonce
필드를 활용하여 중간자(PITM) 조작 공격, 재전송 공격 등 특정 유형의 공격을 완화할 수 있습니다. Play Integrity API는 서명된 무결성 응답 내에서 이 필드에 설정한 값을 반환합니다.
nonce
필드에 설정된 값은 다음 조건에 맞는 올바른 형식이어야 합니다.
String
- URL 안전
- 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
를 만듭니다. - 연결된 빌더의
setNonce()
메서드를 통해 nonce를 제공하는IntegrityTokenRequest
를 구성합니다. Google Play와 SDK 외부에 독점적으로 배포된 앱은setCloudProjectNumber()
메서드를 통해 Google Cloud 프로젝트 번호도 지정해야 합니다. Google Play의 앱은 Play Console의 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 프로젝트 내에서 서비스 계정을 만듭니다. 계정을 만드는 과정에서 서비스 계정 사용자 및 서비스 사용량 소비자 역할을 서비스 계정에 부여해야 합니다.
앱 서버에서
playintegrity
범위를 사용하여 서비스 계정 사용자 인증 정보로부터 액세스 토큰을 가져오고 다음과 같이 요청합니다.playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken -d \ '{ "integrity_token": "INTEGRITY_TOKEN" }'
JSON 응답을 읽습니다.
로컬에서 복호화 및 확인
응답 암호화 키를 관리하고 다운로드하도록 선택하면 반환된 토큰을 자체 보안 서버 환경 내에서 복호화하고 확인하는 것이 가능합니다. 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();
결과 페이로드는 무결성 신호가 포함된 일반 텍스트 토큰입니다.
반환된 페이로드 형식
페이로드는 일반 텍스트 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" }
이러한 값은 원래 요청의 값과 일치해야 합니다. 따라서 requestPackageName
과 nonce
가 다음 코드 스니펫과 같이 원래 요청에서 전송된 것과 일치하는지 확인하여 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! }
자바
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 후킹)이나 시스템 손상(예: 루팅됨) 징후가 있는 기기에서 실행되거나 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! }
자바
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 Console에서 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! }