서론

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

    본론

    우선 Play Integrity API는 앱의 무결성 검증. 즉 변조(해킹)된 앱으로 접속해 해킹하는 것을 방지하는 역할을 한다.
    무결성 검증은 배포 앱에서 가장 중요한 부분 중 하나이다.

    1. 우선 Google Cloud Console의 설정을 요구한다.
      • 이 파트는 다른 블로그를 참고하길 바란다. 너무 잘 설명되어 있다.
    2. Firebase AppCheck와 직접 구현. 두 가지의 방법이 있다.
      • 우리는 직접 구현을 선택하였다.
    3. Springboot backend를 구현한다.
      • 여기가 가장 힘든 파트 중 하나이다.

    우선 자신의 환경에 맞추어 @RestController 를 선언한다.
    나의 환경에 맞는 컨트롤러를 게재하겠다.

    @RestController
    @RequestMapping("/api/integrity")
    class IntegrityController(
        @Autowired private val integrityService: IntegrityService,
        @Autowired private val challengeService: IntegrityChallengeService,
        private val integrityChallengeService: IntegrityChallengeService
    ) {
        private val logger = LoggerFactory.getLogger(this::class.java)
    
        @PostMapping("/challenge")
        fun getChallenge(
            @Valid @RequestBody request: ChallengeRequest
        ): ResponseEntity<ChallengeResponse> {
            try {
            val challenge = challengeService.generateChallenge(request.deviceId)
                return ResponseEntity.ok(
                    ChallengeResponse(
                        challenge = challenge.first ?: "",
                        expiresInMinutes = challenge.second,
                        message = ""
                    )
                )
            } catch(e: Exception) {
                logger.error("Error on getting challenge: ${e.message}")
                logger.debug(e.stackTraceToString())
                return ResponseEntity.ok(
                    ChallengeResponse(
                        challenge = "",
                        expiresInMinutes = 0,
                        message = "An error occurred while generating challenge. Please try again later."
                    )
                )
            }
        }
    
        @Transactional(timeout = 20)
        @PostMapping("/verify")
        fun verifyIntegrity(
            @RequestBody request: IntegrityVerificationRequest
        ): ResponseEntity<IntegrityVerificationResponse> {
            val challengeErrors = challengeService.verifyChallenge(request.challenge, request.deviceId)
            if(challengeErrors != null) return ResponseEntity.ok(challengeErrors)
    
            try {
                val result = when (request.platform.lowercase()) {
                    "android" -> integrityService.verifyAndroidIntegrity(request.attestation, request.challenge)
                    "ios" -> integrityService.verifyIosAppAttest(
                        request.attestation,
                        request.keyId ?: "",
                        request.challenge
                    )
    
                    else -> IntegrityVerificationResponse(
                        isValid = false,
                        message = "Unsupported platform: ${request.platform}"
                    )
                }
    
                integrityChallengeService.completeChallenge(request.challenge)
                return ResponseEntity.ok(result)
            } catch(e: Exception) {
                logger.error("Error on verifying integrity: ${e.message}")
                logger.debug(e.stackTraceToString())
                return ResponseEntity.ok(
                    IntegrityVerificationResponse(
                        isValid = false,
                        message = "An error occurred while verifying integrity. Please try again later."
                    )
                )
            }
        }
    }

    각 Challenge Code (Nonce) 생성 파트와, 실제로 들어온 요청을 처리하는 엔드포인트 2개로 나뉘어져 있다.

    다음은 Challenge Code (Nonce) 관련 Service이다.

    @Service
    class IntegrityChallengeService(
        @Autowired private val challengeRepository: IntegrityChallengeRepository,
        @Value("\${app.integrity-challenge-exp:15}") private val challengeExp: Int
    ) {
        private val logger = LoggerFactory.getLogger(this::class.java)
        private val secureRandom = SecureRandom()
    
        fun generateChallenge(deviceId: String): Pair<String?, Long> {
           val pendingChallenges = challengeRepository.findByDeviceIdAndStatus(
               deviceId,
               IntegrityChallengeStatus.PENDING
           )
    
            if(pendingChallenges.isNotEmpty()) {
                val latestChallenge = pendingChallenges.maxByOrNull { it.createdAt }
    
                if (latestChallenge != null && latestChallenge.expiresAt?.isAfter(LocalDateTime.now()) == true) {
                    logger.info("Challenge already exists for device: $deviceId")
                    return Pair(latestChallenge.challenge, ChronoUnit.MINUTES.between(LocalDateTime.now(), latestChallenge.expiresAt))
                }
            }
    
            val bytes = ByteArray(32)
            secureRandom.nextBytes(bytes)
    
            val challenge = Base64.getEncoder()
                .encodeToString(bytes)
            val expiresAt = LocalDateTime.now().plusMinutes(challengeExp.toLong())
    
            val integrityChallenge = IntegrityChallenge(
                challenge = challenge,
                deviceId = deviceId,
                expiresAt = expiresAt,
            )
    
            challengeRepository.save(integrityChallenge)
            logger.info("Generated new integrity challenge for device: $deviceId, expiresAT: $expiresAt")
            return Pair(challenge, ChronoUnit.MINUTES.between(LocalDateTime.now(), expiresAt))
        }
    
        fun verifyChallenge(challenge: String, deviceId: String): IntegrityVerificationResponse? {
            val challengeOpt = challengeRepository.findByChallenge(challenge).getOrNull()
    
            if (challengeOpt == null) {
                logger.info("Challenge not found for device: $deviceId")
                return IntegrityVerificationResponse(false, "Challenge not found")
            }
    
            if (challengeOpt.deviceId != deviceId) {
                logger.info("Device ID mismatch for challenge: $challenge, expected: $deviceId, actual: ${challengeOpt.deviceId}")
                return IntegrityVerificationResponse(false,"Device ID mismatch")
            }
    
            val currentTime = LocalDateTime.now()
            challengeOpt.expiresAt?.let {
                if (it.isBefore(currentTime)) {
                    challengeOpt.status = IntegrityChallengeStatus.EXPIRED
                    challengeRepository.save(challengeOpt)
                    logger.info("Challenge expired: $challenge")
                    return IntegrityVerificationResponse(false,"Challenge expired")
                }
            }
    
            return when (challengeOpt.status) {
                IntegrityChallengeStatus.COMPLETED -> IntegrityVerificationResponse(false, "Challenge already used")
                IntegrityChallengeStatus.EXPIRED -> IntegrityVerificationResponse(false, "Challenge expired")
                else -> null
            }
        }
    
        fun completeChallenge(challenge: String): Boolean {
            val challengeOpt = challengeRepository.findByChallenge(challenge).getOrNull()
    
            if(challengeOpt == null) {
                return false
            }
    
            challengeOpt.status = IntegrityChallengeStatus.COMPLETED
            challengeRepository.save(challengeOpt)
            logger.info("Challenge completed: $challenge")
    
            return true
        }
    }

    Nonce를 기록, 저장, 삭제하는 부분이다. 각 Nonce는 일정 시간(기본값: 15분)동안 Valid하고, 이후 Expire된다.
    이 Nonce는 Repository에 저장되고, Play Integrity Service, iOS AppAttest 모두 Nonce에 대한 검증을 같이 하게 된다.

    결론

    우선 기본적인 설정을 먼저 진행하였다.
    다음 글에서 Play Integrity Service 로직을 먼저 소개한 뒤, iOS AppAttest 로직을 소개할 예정이다.

    Posted by dalbodeule