서론

    졸업 프로젝트를 하다 React Native 환경의 클라이언트에서 앱 무결성 검증을 해야 하는 상황이 되었다.
    이 기록은 앱 무결성 검증을 하며 헤맨 부분의 기록이다.

    본론

    구글의 Integrity API Decode 과정에는 서비스 계정이 필요하다.

    https://playintegrity.googleapis.com/v1/$packageName:decodeIntegrityToken에 Header로 Authorization: Bearer를 넘겨줄 때, 본인의 계정을 사용할 필요가 없다. 오히려 사용해서는 안된다.

    반드시 서비스 계정을 생성, 그 서비스 계정의 json을 이용해 AccessToken을 받아오도록 하자.

    // implementation("com.google.auth:google-auth-library-oauth2-http:1.34.0")
    @OptIn(DelicateCoroutinesApi::class)
    @Scheduled(fixedRate = 3300000) // Refresh every 55 minutes
    private fun refreshGoogleAccessToken() {
        GlobalScope.launch {
            try {
                val credentials = GoogleCredentials.fromStream(ByteArrayInputStream(googleAccountJson.toByteArray()))
                    .createScoped("https://www.googleapis.com/auth/playintegrity")
                credentials.refresh()
                googleAccessToken = credentials.accessToken
            } catch (e: Exception) {
                throw RuntimeException("Failed to refresh Google access token: ${e.message}")
            }
        }
    }

    위의 코드로 AccessToken을 얻어올 수 있다. (googleAccountJson은 방금 받아온 서비스계정의 json 키 String이다.)

    fun verifyAndroidIntegrity(challenge: String, nonce: String): IntegrityVerificationResponse {
        return try {
            val decodedTokenBytes = webClient.post()
                .uri(decodeIntegrityTokenUrl)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(mapOf("integrity_token" to challenge))
                .header("Authorization", "Bearer ${googleAccessToken?.tokenValue}")
                .retrieve()
                .bodyToMono(ByteArray::class.java)
                .block()
    
            if (decodedTokenBytes == null) {
                return IntegrityVerificationResponse(
                    isValid = false,
                    message = "Decoded token is null"
                )
            }
    
            val decodedTokenJson = String(decodedTokenBytes)
    
            val decodeResponse: DecodeIntegrityTokenResponse = gson.fromJson(decodedTokenJson, DecodeIntegrityTokenResponse::class.java)
            val tokenPayload = decodeResponse.tokenPayloadExternal
    
            // Package Name Verification
            if (tokenPayload.appIntegrity.packageName != packageName) {
                return IntegrityVerificationResponse(
                    isValid = false,
                    message = "Package name mismatch",
                    details = mapOf(
                        "expected" to packageName,
                        "actual" to tokenPayload.requestDetails.requestPackageName
                    )
                )
            }
    
            if (tokenPayload.requestDetails.nonce.replace("=", "").trim() !=
                nonce.replace("=", "").trim()
            ) {
                return IntegrityVerificationResponse(
                    isValid = false,
                    message = "Challenge mismatch",
                    details = mapOf(
                        "expected" to nonce,
                        "actual" to tokenPayload.requestDetails.nonce
                    )
                )
            }
    
            // Timestamp Verification
            if (isTokenExpired(tokenPayload.requestDetails.timestampMillis)) {
                return IntegrityVerificationResponse(
                    isValid = false,
                    message = "Token has expired",
                    details = mapOf("timestamp" to tokenPayload.requestDetails.timestampMillis)
                )
            }
    
            // App Integrity Verification
            if (tokenPayload.appIntegrity.appRecognitionVerdict != "PLAY_RECOGNIZED") {
                return IntegrityVerificationResponse(
                    isValid = false,
                    message = "App not recognized by Google Play",
                    details = mapOf("verdict" to tokenPayload.appIntegrity.appRecognitionVerdict)
                )
            }
    
            // Device Integrity Verification
            if (!tokenPayload.deviceIntegrity.deviceRecognitionVerdict.contains("MEETS_DEVICE_INTEGRITY")) {
                return IntegrityVerificationResponse(
                    isValid = false,
                    message = "Device integrity check failed",
                    details = mapOf("verdicts" to tokenPayload.deviceIntegrity.deviceRecognitionVerdict)
                )
            }
    
            IntegrityVerificationResponse(
                isValid = true,
                message = "Android app integrity verification successful"
            )
    
        } catch (e: Exception) {
            IntegrityVerificationResponse(
                isValid = false,
                message = "Error verifying integrity: ${e.message}",
                details = mapOf("errorType" to e.javaClass.simpleName)
            )
        }
    }

    위의 코드로 전체 검증을 실시한다. 앱이 설치된 환경(디바이스), 앱의 변조여부 등, 구글에서 범용적으로 검증 가능하다고 판단하는 기준을 따라 검증된 결과를 필터하는 코드이다.

    결론

    다음은 한국에서도 잘 알려지지 않은 iOS AppAttest의 검증코드를 올릴 예정이다.

    Posted by dalbodeule