대다수 개발자에게 적합한 표준 API 요청만 실행할 계획이라면 무결성 확인 결과로 건너뛰면 됩니다. 이 페이지에서는 Android 4.4(API 수준 19) 이상에서 지원되는 무결성 확인 결과에 관한 기존 API 요청을 실행하는 방법을 설명합니다.
고려사항
표준 요청과 기존 요청 비교
앱의 보안 및 악용 방지 요구사항에 따라 표준 요청, 기존 요청 또는 두 요청을 조합하여 실행할 수 있습니다. 표준 요청은 모든 앱과 게임에 적합하며 모든 작업 또는 서버 호출이 진짜인지 확인하는 데 사용할 수 있고 재전송 및 무단 반출 방지를 Google Play에 위임합니다. 기존 요청은 비용이 더 많이 들고 무단 반출 및 특정 유형의 공격으로부터 보호하기 위해 요청을 올바르게 구현하는 것은 개발자의 책임입니다. 기존 요청은 표준 요청보다 자주 실행하면 안 됩니다. 예를 들어 매우 중요하거나 민감한 작업이 진짜인지 확인하기 위해 일회성으로 가끔 실행해야 합니다.
다음 표에서는 두 가지 요청 유형 간 주요 차이점을 보여줍니다.
표준 API 요청 | 기존 API 요청 | |
---|---|---|
기본 요건 | ||
필요한 최소 Android SDK 버전 | Android 5.0(API 수준 21) 이상 | Android 4.4(API 수준 19) 이상 |
Google Play 요구사항 | Google Play 스토어 및 Google Play 서비스 | Google Play 스토어 및 Google Play 서비스 |
통합 세부정보 | ||
API 준비 필요 | ✔️(몇 초) | ❌ |
일반적인 요청 지연 시간 | 수백 밀리초 | 몇 초 |
잠재적 요청 빈도 | 잦음(모든 작업 또는 요청에 관한 주문형 확인) | 드묾(가장 중요한 작업 또는 가장 민감한 요청에 관한 일회성 확인) |
제한 시간 | 대부분의 준비 작업은 10초 미만이지만 서버 호출을 수반하므로 제한 시간을 길게 설정하는 것이 좋습니다(예: 1분). 확인 결과 요청은 클라이언트 측에서 발생합니다. | 대부분의 요청은 10초 미만이지만 서버 호출을 수반하므로 제한 시간을 길게 설정하는 것이 좋습니다(예: 1분). |
무결성 확인 결과 토큰 | ||
기기, 앱, 계정 세부정보 포함 | ✔️ | ✔️ |
토큰 캐싱 | Google Play의 보호된 기기 내 캐싱 | 권장하지 않음 |
Google Play 서버를 통해 토큰 복호화 및 확인 | ✔️ | ✔️ |
일반적인 복호화 서버 간 요청 지연 시간 | 99.9% 가용성의 수십 밀리초 | 99.9% 가용성의 수십 밀리초 |
보안 서버 환경에서 로컬로 토큰 복호화 및 확인 | ❌ | ✔️ |
클라이언트 측에서 토큰 복호화 및 확인 | ❌ | ❌ |
무결성 확인 결과 업데이트 | Google Play에서 일부 자동 캐싱 및 새로고침 | 각 요청에 관해 다시 계산된 모든 확인 결과 |
제한 | ||
앱당 일일 요청 수 | 기본적으로 10,000회(증가를 요청할 수 있음) | 기본적으로 10,000회(증가를 요청할 수 있음) |
분당 앱 인스턴스별 요청 수 | 준비: 분당 5회 무결성 토큰: 공개 제한 없음* |
무결성 토큰: 분당 5회 |
보호 | ||
조작 및 유사한 공격 완화 | requestHash 필드 사용 |
요청 데이터에 기반한 콘텐츠 결합과 함께 nonce 필드 사용 |
재전송 및 유사한 공격 완화 | Google Play에서 자동 완화 | 서버 측 로직과 함께 nonce 필드 사용 |
* 공개 제한이 없는 요청을 포함한 모든 요청에는 높은 값에서 비공개 방어 제한이 적용됩니다.
기존 요청 드물게 실행
무결성 토큰을 생성하면 시간, 데이터, 배터리가 사용되며 각 앱에는 일일 최대 기존 요청 가능 횟수가 있습니다. 따라서 표준 요청에 추가 보증을 원하는 경우 가장 중요하거나 가장 민감한 작업이 진짜인지 확인하는 기존 요청만 해야 합니다. 자주 발생하거나 중요하지 않은 작업에는 기존 요청을 해서는 안 됩니다. 앱이 포그라운드로 전환될 때마다 또는 백그라운드에서 몇 분 간격으로 기존 요청을 하지 마세요. 또한 다수의 기기에서 동시에 호출하면 안 됩니다. 기존 요청을 너무 많이 호출하는 앱은 사용자를 잘못된 구현으로부터 보호하기 위해 제한될 수 있습니다.
확인 결과 캐싱 방지
확인 결과를 캐시하면 신뢰할 수 없는 환경에서 좋은 확인 결과를 재사용하는 무단 반출 및 재전송과 같은 공격의 위험이 증가합니다. 기존 요청을 한 후 나중에 사용할 수 있도록 캐시하려는 경우 주문형으로 표준 요청을 대신 실행하는 것이 좋습니다. 표준 요청에는 기기의 캐싱이 포함되지만 Google Play에서 추가 보호 기법을 사용하여 재전송 공격 및 무단 반출의 위험을 완화합니다.
nonce 필드를 사용하여 기존 요청 보호
Play Integrity API는 재전송 및 조작 공격과 같은 특정 공격으로부터 앱을 더욱 안전하게 보호하는 데 사용할 수 있는 nonce
필드를 제공합니다. Play Integrity API는 서명된 무결성 응답 내에서 이 필드에 설정한 값을 반환합니다. nonce를 생성하는 방법에 관한 안내를 주의 깊게 따라 앱을 공격으로부터 보호하세요.
지수 백오프로 기존 요청 재시도
불안정한 인터넷 연결이나 과부하된 기기와 같은 환경 조건으로 인해 기기 무결성 검사에 실패할 수 있습니다. 따라서 일반적으로는 신뢰할 수 있는 기기라도 라벨이 생성되지 않을 수 있습니다. 이러한 시나리오를 방지하려면 지수 백오프로 재시도 옵션을 포함하세요.
개요
무결성 검사로 보호하려는 앱에서 사용자가 중요 작업을 실행할 때 다음 단계를 완료하세요.
- 앱의 서버 측 백엔드는 고유한 값을 생성하여 클라이언트 측 로직에 전송합니다. 나머지 단계에서는 이 로직을 '앱'이라고 합니다.
- 앱은 고유한 값과 중요한 작업의 콘텐츠에서
nonce
를 생성합니다. 그런 다음 앱에서 Play Integrity API를 호출하여nonce
를 전달합니다. - 앱은 Play Integrity API에서 서명되고 암호화된 결과를 수신합니다.
- 앱은 서명되고 암호화된 결과를 앱의 백엔드에 전달합니다.
- 앱의 백엔드는 Google Play 서버로 결과를 전송합니다. Google Play 서버는 결과를 복호화하고 확인하여 결과를 앱의 백엔드로 반환합니다.
- 앱의 백엔드는 토큰 페이로드에 포함된 신호에 따라 진행 방법을 결정합니다.
- 앱의 백엔드는 결과를 앱에 전송합니다.
nonce 생성
Play Integrity API로 앱의 작업을 보호하면 nonce
필드를 활용하여 중간자(PITM) 조작 공격, 재전송 공격 등 특정 유형의 공격을 완화할 수 있습니다. Play Integrity API는 서명된 무결성 응답 내에서 이 필드에 설정한 값을 반환합니다.
nonce
필드에 설정된 값은 다음 조건에 맞는 올바른 형식이어야 합니다.
String
- URL 안전
- Base64로 인코딩되고 래핑되지 않음
- 최소 16자(영문 기준)
- 최대 500자(영문 기준)
다음은 Play Integrity API에서 nonce
필드를 사용하는 일반적인 방법입니다. nonce
를 사용해 가장 강력하게 보호하려면 아래 메서드를 결합하면 됩니다.
조작 방지를 위해 요청 해시 포함
표준 API 요청의 requestHash
매개변수와 마찬가지로 기존 API 요청에서 nonce
매개변수를 사용하여 요청의 내용을 조작으로부터 보호할 수 있습니다.
무결성 확인 결과를 요청하는 경우:
- 발생하는 사용자 작업 또는 서버 요청에서 모든 중요한 요청 매개변수(예: 안정적인 요청 직렬화의 SHA256)의 다이제스트를 계산합니다.
setNonce
를 사용하여nonce
필드를 계산된 다이제스트 값으로 설정합니다.
무결성 확인 결과를 수신하는 경우:
- 무결성 토큰을 디코딩하고 확인한 후
nonce
필드에서 다이제스트를 가져옵니다. - 앱과 동일한 방식으로 요청 다이제스트를 계산합니다(예: 안정적인 요청 직렬화의 SHA256).
- 앱 측 다이제스트와 서버 측 다이제스트를 비교합니다. 일치하지 않으면 요청을 신뢰할 수 없습니다.
재전송 공격 방지를 위해 고유한 값 포함
악의적인 사용자가 Play Integrity API의 이전 응답을 재사용하지 못하게 하려면 nonce
필드를 사용하여 각 메시지를 고유하게 식별하면 됩니다.
무결성 확인 결과를 요청하는 경우:
- 악의적인 사용자가 예측할 수 없는 방식으로 전역적으로 고유한 값을 가져옵니다. 예를 들어 서버 측에서 생성된, 암호화 방식으로 안전한 랜덤 숫자가 이러한 값일 수 있고 세션 또는 거래 ID와 같은 기존 ID가 이러한 값일 수 있습니다. 더 간단하지만 보안 수준이 낮은 변형은 기기에서 랜덤 숫자를 생성하는 것입니다. 값은 128비트 이상으로 만드는 것이 좋습니다.
setNonce()
를 호출하여nonce
필드를 1단계의 고유한 값으로 설정합니다.
무결성 확인 결과를 수신하는 경우:
- 무결성 토큰을 디코딩하고 확인한 후
nonce
필드에서 고유한 값을 가져옵니다. - 1단계의 값이 서버에서 생성되었다면 수신된 고유한 값이 생성된 값 중 하나이고 해당 값이 처음 사용되는지 확인합니다(서버는 생성된 값의 레코드를 적절한 기간 동안 유지해야 함). 수신된 고유한 값이 이미 사용되었거나 레코드에 표시되지 않으면 요청을 거부합니다.
- 그렇지 않고 고유한 값이 기기에서 생성된 경우에는 수신된 값이 처음으로 사용되고 있는지 확인합니다(서버는 이미 표시된 값의 레코드를 적절한 기간 동안 유지해야 함). 수신된 고유한 값이 이미 사용된 경우 요청을 거부합니다.
조작 방지와 재전송 공격 방지 결합
nonce
필드를 사용하여 조작 및 재전송 공격을 동시에 방지할 수 있습니다. 이렇게 하려면 위에서 설명한 대로 고유한 값을 생성하고 이를 요청의 일부로 포함합니다. 그런 다음 요청 해시를 계산하여 고유한 값을 해시의 일부로 포함하도록 합니다. 두 접근 방식을 결합한 구현은 다음과 같습니다.
무결성 확인 결과를 요청하는 경우:
- 사용자가 중요 작업을 시작합니다.
- 재전송 공격 방지를 위해 고유한 값 포함 섹션에 설명된 대로 이 작업의 고유한 값을 가져옵니다.
- 보호하려는 메시지를 준비합니다. 메시지에 2단계의 고유한 값을 포함합니다.
- 앱은 조작 방지를 위해 요청 해시 포함 섹션에 설명된 대로 보호하려는 메시지의 다이제스트를 계산합니다. 메시지에는 고유한 값이 포함되므로 이 고유한 값은 해시의 일부입니다.
setNonce()
를 사용하여nonce
필드를 이전 단계에서 계산된 다이제스트로 설정합니다.
무결성 확인 결과를 수신하는 경우:
- 요청에서 고유한 값을 가져옵니다.
- 무결성 토큰을 디코딩하고 확인한 후
nonce
필드에서 다이제스트를 가져옵니다. - 조작 방지를 위해 요청 해시 포함 섹션에 설명된 대로 서버 측에서 다이제스트를 다시 계산하여 무결성 토큰에서 가져온 다이제스트와 일치하는지 확인합니다.
- 재전송 공격 방지를 위해 고유한 값 포함 섹션에 설명된 대로 고유한 값의 유효성을 확인합니다.
다음 시퀀스 다이어그램은 서버 측 nonce
를 사용하는 이러한 단계를 보여줍니다.
무결성 확인 결과 요청
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 서버에서 무결성 확인 결과를 복호화하고 확인할 수 있어 앱의 보안이 강화됩니다. 이렇게 하려면 다음 단계를 완료하세요.
- 서비스 계정 만들기 프로젝트 내에서만 사용할 수 있습니다
앱 서버에서
playintegrity
범위를 사용하여 서비스 계정 사용자 인증 정보로부터 액세스 토큰을 가져오고 다음과 같이 요청합니다. 드림playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken -d \ '{ "integrity_token": "INTEGRITY_TOKEN" }'
JSON 응답을 읽습니다.
로컬에서 복호화 및 확인
응답 암호화 키를 관리하고 다운로드하도록 선택하면 반환된 토큰을 자체 보안 서버 환경 내에서 복호화하고 확인하는 것이 가능합니다.
IntegrityTokenResponse#token()
메서드를 사용하여 반환된 토큰을 가져올 수 있습니다.
다음 예는 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))
자바
// 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();
결과 페이로드는 무결성 확인 결과가 포함된 일반 텍스트 토큰입니다.