이 글은 누구를 위한 것인가
- 금융/의료 앱에서 생체 인증과 안전한 데이터 저장이 필요한 팀
- 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)는 우회 가능하지만 기본 방어선으로 유효하다.