Android 보안: 생체 인증과 Keystore 안전한 키 관리

모바일 개발

Android 보안BiometricPromptAndroid Keystore생체 인증암호화

이 글은 누구를 위한 것인가

  • 금융/의료 앱에서 생체 인증과 안전한 데이터 저장이 필요한 팀
  • Android Keystore로 키를 안전하게 관리하고 싶은 개발자
  • 루팅된 기기나 MITM 공격을 방어해야 하는 보안 개발자

들어가며

앱에 민감한 데이터(토큰, 카드 번호)를 저장할 때 SharedPreferences에 평문으로 넣으면 루팅된 기기에서 읽힌다. Android Keystore와 생체 인증으로 하드웨어 수준의 보안을 구현할 수 있다.

이 글은 bluefoxdev.kr의 Android 보안 가이드 를 참고하여 작성했습니다.


1. Android 보안 아키텍처

[Android 보안 계층]

하드웨어 보안:
  TEE (Trusted Execution Environment)
  StrongBox (물리적 보안 칩)
  → Android Keystore가 이 위에서 동작

Android Keystore:
  키가 앱 프로세스 밖에 저장
  루팅해도 키 추출 불가 (TEE 보호)
  생체 인증과 연결 가능
  키 사용 시 인증 요구 설정

EncryptedSharedPreferences:
  Keystore 키로 SharedPreferences 암호화
  AndroidX Security 라이브러리
  투명하게 암호화/복호화

[생체 인증 강도]
  BIOMETRIC_STRONG (Class 3):
    지문, 얼굴 (3D), 홍채
    금융/결제 앱 권장
  
  BIOMETRIC_WEAK (Class 2):
    얼굴 (2D)
    일반 앱용

[Certificate Pinning]
  서버 인증서를 앱에 고정
  MITM 공격 방지
  Wildcard 인증서 갱신 고려
  OkHttp: CertificatePinner 사용

2. 생체 인증 및 Keystore 구현

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey

class BiometricKeyManager(private val activity: FragmentActivity) {
    
    private val KEY_ALIAS = "biometric_key"
    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    
    // 생체 인증 사용 가능 여부 확인
    fun isBiometricAvailable(): Boolean {
        val biometricManager = BiometricManager.from(activity)
        return biometricManager.canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        ) == BiometricManager.BIOMETRIC_SUCCESS
    }
    
    // 생체 인증과 연결된 Keystore 키 생성
    fun generateBiometricKey() {
        if (keyStore.containsAlias(KEY_ALIAS)) return
        
        val keyGenerator = KeyGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_AES,
            "AndroidKeyStore",
        )
        
        keyGenerator.init(
            KeyGenParameterSpec.Builder(
                KEY_ALIAS,
                KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
            )
                .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
                .setUserAuthenticationRequired(true)  // 생체 인증 필요
                .setUserAuthenticationParameters(
                    0,  // 0 = 매번 인증 필요
                    KeyProperties.AUTH_BIOMETRIC_STRONG,
                )
                .setInvalidatedByBiometricEnrollment(true)  // 새 지문 등록 시 키 무효화
                .build()
        )
        
        keyGenerator.generateKey()
    }
    
    // 생체 인증 후 암호화
    fun encryptWithBiometric(
        data: String,
        onSuccess: (ByteArray, ByteArray) -> Unit,
        onError: (String) -> Unit,
    ) {
        val key = keyStore.getKey(KEY_ALIAS, null) as SecretKey
        val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding").apply {
            init(Cipher.ENCRYPT_MODE, key)
        }
        
        val biometricPrompt = BiometricPrompt(
            activity,
            ContextCompat.getMainExecutor(activity),
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    val authenticatedCipher = result.cryptoObject?.cipher ?: return
                    val encrypted = authenticatedCipher.doFinal(data.toByteArray())
                    val iv = authenticatedCipher.iv
                    onSuccess(encrypted, iv)
                }
                
                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    onError(errString.toString())
                }
                
                override fun onAuthenticationFailed() {
                    onError("인증 실패")
                }
            }
        )
        
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("생체 인증")
            .setSubtitle("데이터를 암호화하려면 인증이 필요합니다")
            .setNegativeButtonText("취소")
            .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
            .build()
        
        biometricPrompt.authenticate(
            promptInfo,
            BiometricPrompt.CryptoObject(cipher),
        )
    }
    
    // 생체 인증 후 복호화
    fun decryptWithBiometric(
        encrypted: ByteArray,
        iv: ByteArray,
        onSuccess: (String) -> Unit,
        onError: (String) -> Unit,
    ) {
        val key = keyStore.getKey(KEY_ALIAS, null) as SecretKey
        val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding").apply {
            init(Cipher.DECRYPT_MODE, key, javax.crypto.spec.IvParameterSpec(iv))
        }
        
        val biometricPrompt = BiometricPrompt(
            activity,
            ContextCompat.getMainExecutor(activity),
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    val decrypted = result.cryptoObject?.cipher?.doFinal(encrypted) ?: return
                    onSuccess(String(decrypted))
                }
                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    onError(errString.toString())
                }
                override fun onAuthenticationFailed() { onError("인증 실패") }
            }
        )
        
        biometricPrompt.authenticate(
            BiometricPrompt.PromptInfo.Builder()
                .setTitle("생체 인증").setSubtitle("데이터에 접근하려면 인증이 필요합니다")
                .setNegativeButtonText("취소")
                .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
                .build(),
            BiometricPrompt.CryptoObject(cipher),
        )
    }
}

// EncryptedSharedPreferences 설정
class SecurePreferences(context: android.content.Context) {
    private val prefs = androidx.security.crypto.EncryptedSharedPreferences.create(
        context,
        "secure_prefs",
        androidx.security.crypto.MasterKey.Builder(context)
            .setKeyScheme(androidx.security.crypto.MasterKey.KeyScheme.AES256_GCM)
            .build(),
        androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
    )
    
    fun saveToken(token: String) = prefs.edit().putString("auth_token", token).apply()
    fun getToken() = prefs.getString("auth_token", null)
    fun clearToken() = prefs.edit().remove("auth_token").apply()
}

// Certificate Pinning (OkHttp)
fun createSecureHttpClient(): okhttp3.OkHttpClient {
    val certificatePinner = okhttp3.CertificatePinner.Builder()
        .add("api.myapp.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
        .add("api.myapp.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")  // 백업
        .build()
    
    return okhttp3.OkHttpClient.Builder()
        .certificatePinner(certificatePinner)
        .build()
}

마무리

Android 보안의 3원칙: 민감 데이터는 EncryptedSharedPreferences에, 키는 Keystore에, 네트워크는 Certificate Pinning으로. 금융 앱은 생체 인증을 Keystore와 연결해 "인증 없이 키 사용 불가"를 하드웨어 수준에서 보장하라. 루팅 감지(SafetyNet Attestation)는 우회 가능하지만 기본 방어선으로 유효하다.