생체인증 구현: iOS Face ID/Touch ID와 Android BiometricPrompt

모바일

생체인증Face IDTouch IDBiometricPrompt모바일 보안

이 글은 누구를 위한 것인가

  • 앱 잠금 해제 또는 결제 확인에 생체인증을 적용하려는 팀
  • Keystore와 Keychain으로 생체정보 키를 안전하게 관리하려는 개발자
  • 생체인증 실패 시 폴백 처리를 설계하려는 팀

들어가며

"매번 비밀번호 입력이 불편하다" — 생체인증은 편의성과 보안을 동시에 제공한다. 생체정보 자체는 기기에 저장되고 앱에 전달되지 않는다. 앱이 받는 것은 "인증 성공/실패" 결과뿐이다.

이 글은 bluefoxdev.kr의 모바일 생체인증 Face ID BiometricPrompt 가이드 를 참고하여 작성했습니다.


1. 생체인증 보안 아키텍처

[iOS 생체인증]
  LocalAuthentication: Face ID / Touch ID
  Keychain: 생체인증으로 보호된 키 저장
  
  보안 수준:
  - .biometryCurrentSet: 현재 등록된 생체정보만
  - .biometryAny: 어떤 생체정보든 (추가/변경 허용)
  - .deviceOwnerAuthentication: 생체 + PIN 폴백

[Android 생체인증]
  BiometricPrompt: 통합 생체인증 UI
  Keystore: 하드웨어 보안 모듈 키 관리
  
  인증 레벨:
  - BIOMETRIC_STRONG: 강력 (하드웨어 보안 모듈)
  - BIOMETRIC_WEAK: 약한 (얼굴 인식 등)
  - DEVICE_CREDENTIAL: 기기 PIN/패턴

[Keychain + 생체인증 결합]
  1. 생체인증으로 보호된 Keychain 항목 생성
  2. 항목 접근 시 자동으로 생체인증 요청
  3. 인증 성공 → 키 반환 → 데이터 복호화

[폴백 전략]
  1회 실패: 재시도 허용
  3회 실패: PIN 폴백 제시
  생체 불가 기기: 항상 PIN

2. 생체인증 구현

import LocalAuthentication
import Security
import SwiftUI

// iOS 생체인증
class BiometricAuthManager {
    private let context = LAContext()
    
    var biometricType: String {
        context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
        switch context.biometryType {
        case .faceID: return "Face ID"
        case .touchID: return "Touch ID"
        default: return "생체인증"
        }
    }
    
    func authenticate(reason: String) async -> Result<Bool, Error> {
        return await withCheckedContinuation { continuation in
            context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, error in
                if success {
                    continuation.resume(returning: .success(true))
                } else if let error = error {
                    continuation.resume(returning: .failure(error))
                }
            }
        }
    }
    
    // Keychain + 생체인증으로 토큰 저장
    func saveTokenWithBiometric(_ token: String, for key: String) throws {
        let tokenData = token.data(using: .utf8)!
        
        // 생체인증 접근 제어 생성
        var error: Unmanaged<CFError>?
        guard let access = SecAccessControlCreateWithFlags(
            nil,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            .biometryCurrentSet, // 현재 등록된 생체만 허용
            &error
        ) else { throw error!.takeRetainedValue() as Error }
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: tokenData,
            kSecAttrAccessControl as String: access,
        ]
        
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.saveFailure }
    }
    
    // 생체인증으로 토큰 읽기
    func getTokenWithBiometric(for key: String, reason: String) throws -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecUseOperationPrompt as String: reason,
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess, let data = result as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }
}

enum KeychainError: Error { case saveFailure }

// SwiftUI 생체인증 뷰
struct BiometricLockView: View {
    @State private var isAuthenticated = false
    @State private var error: String?
    private let authManager = BiometricAuthManager()
    
    var body: some View {
        if isAuthenticated {
            Text("잠금 해제됨")
        } else {
            VStack(spacing: 20) {
                Image(systemName: "faceid").font(.system(size: 60)).foregroundColor(.blue)
                Text("\(authManager.biometricType)로 잠금 해제")
                if let error = error { Text(error).foregroundColor(.red).font(.caption) }
                Button("인증하기") { authenticate() }
                    .buttonStyle(.borderedProminent)
            }
        }
    }
    
    private func authenticate() {
        Task {
            let result = await authManager.authenticate(reason: "앱 잠금을 해제합니다")
            switch result {
            case .success: await MainActor.run { isAuthenticated = true }
            case .failure(let err): await MainActor.run { error = err.localizedDescription }
            }
        }
    }
}
// Android BiometricPrompt
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

class BiometricManager(private val activity: FragmentActivity) {
    private val executor = ContextCompat.getMainExecutor(activity)

    fun authenticate(
        title: String,
        subtitle: String,
        onSuccess: (BiometricPrompt.AuthenticationResult) -> Unit,
        onError: (String) -> Unit
    ) {
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle(title)
            .setSubtitle(subtitle)
            .setNegativeButtonText("취소")
            .setAllowedAuthenticators(
                androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
            )
            .build()

        val biometricPrompt = BiometricPrompt(
            activity,
            executor,
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    onSuccess(result)
                }
                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    onError(errString.toString())
                }
                override fun onAuthenticationFailed() {
                    // 개별 실패 (3회 후 잠금)
                }
            }
        )

        // Keystore 연동: 암호화 객체와 함께 인증
        val cipher = getCipher()
        val cryptoObject = BiometricPrompt.CryptoObject(cipher)
        biometricPrompt.authenticate(promptInfo, cryptoObject)
    }

    private fun getCipher(): Cipher {
        val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
        val keyGenerator = KeyGenerator.getInstance("AES", "AndroidKeyStore")
        keyGenerator.init(
            android.security.keystore.KeyGenParameterSpec.Builder("biometric_key",
                android.security.keystore.KeyProperties.PURPOSE_ENCRYPT or
                android.security.keystore.KeyProperties.PURPOSE_DECRYPT)
                .setBlockModes(android.security.keystore.KeyProperties.BLOCK_MODE_CBC)
                .setEncryptionPaddings(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_PKCS7)
                .setUserAuthenticationRequired(true)
                .build()
        )
        keyGenerator.generateKey()
        val secretKey = keyStore.getKey("biometric_key", null) as javax.crypto.SecretKey
        return Cipher.getInstance("AES/CBC/PKCS7Padding").apply { init(Cipher.ENCRYPT_MODE, secretKey) }
    }
}

마무리

생체인증의 핵심은 생체정보가 앱에 전달되지 않는다는 것이다. iOS는 Keychain + kSecAttrAccessControl으로, Android는 Keystore + BiometricPrompt CryptoObject로 생체인증과 키를 연결한다. 인증 성공 시에만 키에 접근할 수 있어 루팅/탈옥된 기기에서도 키를 직접 추출할 수 없다.