Kotlin、Swift、Flutterで実装する安全な生体認証ログイン
本記事では、モバイル生体認証の安全な実装を定義し、主要なモダンモバイル言語3つ、すなわちAndroid向けのKotlin、iOS向けのSwift、Flutterマルチプラットフォームアプリケーション向けのDartによる詳細な実装を紹介します。
はじめに
ユーザー体験とセキュリティを高める手段として、生体認証は大きな人気を集めています。しかし、利便性が大きくなるほど、実装のセキュリティを確保する責任もさらに大きくなります。
本記事では、モバイル生体認証を安全に実装することの重要性を解説し、主要なモダンモバイル言語3つ、すなわちAndroid向けのKotlin、iOS向けのSwift、Flutterマルチプラットフォームアプリケーション向けのDartによる詳細な実装を紹介します。これは、当社がレビューしてきたモバイルアプリケーションの大半に見られたセキュリティ上のギャップに対処する試みです。
では、安全なモバイル生体認証とは何か
何よりもまず、モバイル生体認証の安全な実装では、アプリケーションの機密データにアクセスするためにFace IDまたはTouch IDによる認証が必ず必要になることが保証されます。
モバイルアプリケーションにおける生体認証の安全な実装は、ログイン時に指紋や顔を検証するだけにとどまりません。生体データを使ってアプリケーションの機密データをencryptingすることも含まれます。
この暗号化によって保護の層がもう一つ加わり、権限のない者が機密情報にアクセスしたり利用したりすることが非常に困難になります。 生体データによる暗号化は、権限のない者がマルウェアや物理的なアクセスによって端末にアクセスした場合に極めて重要になります。
暗号化がなければ、攻撃者はメモリを操作して生体認証のチェックをバイパスし、アプリケーションへのログインに成功できてしまいます。しかし、生体データと組み合わせた暗号化が使われていれば、攻撃者はアプリケーションのデータを解読することも利用することもできません。これにより、アプリケーションの機密情報の機密性が保たれ、ユーザーデータのプライバシーとセキュリティが守られます。
安全なモバイル生体認証
アプリケーションのシナリオ:
本記事では、Kotlin、Swift、Flutter(Dart)で安全な生体認証の実装例を作成します。安全な生体認証を有効にするには、ワークフロー全体が次の手順に従う必要があります。
1- ユーザーがモバイルアプリケーションを開くと、ログイン画面が表示されます。
2- ユーザーがユーザー名とパスワードを入力します。
3- アプリケーションは、入力された認証情報をバックエンドサーバーに送信して認証を受け、検証します。
4- 認証情報が有効であれば、バックエンドサーバーはユーザーセッション用の一意なトークンを生成します。
5- アプリケーションは、次回以降のログインのために生体認証(指紋認証や顔認証など)を設定するようユーザーに促します。
6- ユーザーが生体認証を設定すると、アプリケーションはバックエンドのトークンを保存します。
7- ユーザーのログインが完了し、アプリケーションの機能を利用できるようになります。
8- 次回以降のアプリ起動時に、アプリケーションはユーザーが生体認証を有効にしているかどうかを確認します。
9- 生体認証が有効であれば、アプリケーションは生体データを使ってユーザーを認証します。
10- 生体認証に成功すると、アプリケーションは安全なストレージからバックエンドのトークンを取得します。
端末では生体認証が有効になっているものとします(簡潔にするため、そのチェックはコードに含めません)。
また、手順1から5は完了しており、ユーザーがアプリケーションの生体認証を設定している段階であるとします。この段階では、トークンを生体データで暗号化し、以降のログイン成功後にそれを読み出す必要があります。
Kotlin(Android)での安全なモバイル生体認証:
Androidでは、バックエンドのトークンを生体データで暗号化したうえで保存するために、次のように実装します。
1- アプリがAndroidのKeyStoreにSecretKeyを要求します。
2- Android Keystoreが安全な場所(TEE)にシークレットキーを作成します。
3- Keystoreは、secretKeyにアクセスするためのエイリアスをアプリに返します。
4- 暗号化・復号を行うCipherオブジェクトを作成します(暗号化はKeystoreシステム内で行われます)。
5- Keystoreシステムは平文とエイリアスを受け取り、暗号文と呼ばれる暗号化されたデータを返します。
6- アプリが復号を行う際には、Keystoreシステムが暗号文とエイリアスを受け取り、復号されたデータ、つまり平文を返します。
7- 生体認証を有効にし、認証バインディングを使ってシークレットキーを保護するようシステムに要求します。
8- 暗号を運ぶラッパーとしてCryptoObjectを使用します。
9- CryptoObjectにラップされた暗号を使って、バックエンドのトークンを暗号化します。CryptoObjectはonAuthenticationSucceededに引数として渡されます。
10- ログイン成功後、CryptoObjectにラップされた暗号で復号してトークンを読み出します。
この実装では、たとえ端末が侵害され、攻撃者がリクエストを行ったとしても、攻撃者がユーザーに生体認証情報で認証させることができない限り、データは暗号化されたままです。ハードウェアで管理されるKeystoreはユーザーがその場にいなければアクセスできないため、生体認証は侵害された端末上であってもセキュリティの層をさらに一つ追加します。
このフローに対応するコードを見てみましょう。
1- Android KeyStoreからSecretKeyを作成・取得する関数を作成します。
// DECLARE CONSTS
val ANDROID_KEYSTORE = "AndroidKeyStore"
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val KEY_SIZE: Int = 256
private fun getOrCreateSecretKey(keyName: String): SecretKey {
// return Secretkey if it was previously created for that keyName.
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null) // Keystore must be loaded before it can be accessed
keyStore.getKey(keyName, null)?.let { return it as SecretKey }
// Create new SecretKey for the provided keyName
val paramsBuilder = KeyGenParameterSpec.Builder(keyName,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
paramsBuilder.apply {
setBlockModes(ENCRYPTION_BLOCK_MODE)
setEncryptionPaddings(ENCRYPTION_PADDING)
setKeySize(KEY_SIZE)
}
val keyGenParams = paramsBuilder.build()
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE)
keyGenerator.init(keyGenParams)
return keyGenerator.generateKey()
}
2- Cipherオブジェクトを作成し、authenticateメソッドを呼び出す際にCryptoObjectでラップします。
// DECLARE CONSTS
private val ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private lateinit var promptInfo: BiometricPrompt.PromptInfo
private fun authenticateToEncrypt() {
if (BiometricManager.from(applicationContext).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) {
val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
val cipher = Cipher.getInstance(transformation)
val secretKey = getOrCreateSecretKey(KEY_NAME)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val biometricPrompt = createEncryptBiometricPrompt()
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}
private fun authenticateToDecrypt() {
if (BiometricManager.from(applicationContext).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) {
val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
val cipher = Cipher.getInstance(transformation)
val secretKey = getOrCreateSecretKey(KEY_NAME)
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector))
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}
3- データを暗号化するためのものと復号するためのもの、2つの異なるBiometricPromptを実装できます。
private fun createEncryptBiometricPrompt(): BiometricPrompt {
val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
// Handle authentication errors
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
encryptData(result.cryptoObject)
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
// Handle authentication failure
}
}
return BiometricPrompt(this, executor, authenticationCallback)
}
private fun createDecryptBiometricPrompt(): BiometricPrompt {
val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
// Handle authentication errors
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
decryptData(result.cryptoObject)
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
// Handle authentication failure
}
}
return BiometricPrompt(this, executor, authenticationCallback)
4- 最後に、encryptData関数とdecryptData関数を定義します。この例では、機密データはバックエンドの認証トークンです。
private fun encryptData(cipher: Cipher): EncryptedData {
val ciphertext = cipher.doFinal(backendToken.toByteArray(Charset.forName("UTF-8")))
return EncryptedData(ciphertext,cipher.iv)
}
fun decryptData(ciphertext: ByteArray, cipher: Cipher): String {
val plaintext = cipher.doFinal(ciphertext)
return String(plaintext, Charset.forName("UTF-8"))
}

アプリケーションで生体認証を有効にした後に、新しい指紋を追加したり既存の指紋を削除したりするとどうなるか
パスワードによる認証では、パスワード変更後に現在のトークンセッションを無効化する必要があります。それと同様に、生体データの変更後には、Android Keystoreに保存されたSecretKeyへのアクセスを無効化する必要があります。
Androidでは、これはデフォルトの動作ではありません。つまり、新しい指紋を追加すると、ユーザーはログインしてアプリケーションの機密データにアクセスできてしまいます。
変更後にデータを無効化するには、Android Keystore用のsecretKeyを生成する際にsetUserAuthenticationRequiredをtrueに設定する必要があります。
// DECLARE CONSTS
val ANDROID_KEYSTORE = "AndroidKeyStore"
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val KEY_SIZE: Int = 256
private fun getOrCreateSecretKey(keyName: String): SecretKey {
// return Secretkey if it was previously created for that keyName.
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null) // Keystore must be loaded before it can be accessed
keyStore.getKey(keyName, null)?.let { return it as SecretKey }
// Create new SecretKey for the provided keyName
val paramsBuilder = KeyGenParameterSpec.Builder(keyName,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
paramsBuilder.apply {
setBlockModes(ENCRYPTION_BLOCK_MODE)
setEncryptionPaddings(ENCRYPTION_PADDING)
setKeySize(KEY_SIZE)
setUserAuthenticationRequired(true) // WE ADD OUR CALL HERE
}
val keyGenParams = paramsBuilder.build()
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE)
keyGenerator.init(keyGenParams)
return keyGenerator.generateKey()
}
ドキュメントによると、setUserAuthenticationRequiredをTrueに設定すると、既存のデータが無効化されます。
sets whether this key is authorized to be used only if the user has been authenticated.
By default, the key is authorized to be used regardless of whether the user has been authenticated.
When user authentication is required:
- The key can only be generated if secure lock screen is set up (see KeyguardManager.isDeviceSecure()).
Additionally, if the key requires that user authentication takes place for every use of the key (see setUserAuthenticationValidityDurationSeconds(int)), at least one biometric must be enrolled (see BiometricManager.canAuthenticate()).
- The use of the key must be authorized by the user by authenticating to this Android device using a subset of their secure lock screen credentials such as password/PIN/pattern or biometric.
- The key will become irreversibly invalidated once the secure lock screen is disabled (reconfigured to None, Swipe or other mode which does not authenticate the user) or when the secure lock screen is forcibly reset (e.g., by a Device Administrator).
Additionally, if the key requires that user authentication takes place for every use of the key, it is also irreversibly invalidated once a new biometric is enrolled or once\ no more biometrics are enrolled, unless setInvalidatedByBiometricEnrollment(boolean) is used to allow validity after enrollment.
Attempts to initialize cryptographic operations using such keys will throw KeyPermanentlyInvalidatedException.
そのため、setUserAuthenticationRequiredをTrueに設定したうえで新しい指紋を追加すると、アプリは失敗し、新しいsecretKeyを要求する必要が生じます。
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
Caused by: android.security.keystore.KeyPermanentlyInvalidatedException: Key permanently invalidated
Swift(iOS)での安全なモバイル生体認証:
iOSでの実装はAndroidとはかなり異なります。キーチェーンを使ってsecretKeyを保存し、キーチェーンからアイテムにアクセスする際に生体認証の使用を強制します。
1- 生体認証で保護されたキーチェーンアイテムを作成します。
SecAccessControlCreateWithFlagsを使い、次のパラメーターでSecAccessControlを作成します。
kSecAttrAccessibleWhenUnlockedThisDeviceOnly:キーチェーンのエントリは、iOS端末のロックが解除されているときにのみ読み取れます。また、iCloud経由で他の端末にコピーされることはなく、バックアップにも追加されません。.biometryCurrentSet:Touch IDまたはFace IDによる認証を必須にします。エントリは、現在登録されている生体データに厳密に結び付けられます。
static func getBioSecAccessControl() -> SecAccessControl {
var access: SecAccessControl?
var error: Unmanaged<CFError>?
access = SecAccessControlCreateWithFlags(nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&error)
precondition(access != nil, "SecAccessControlCreateWithFlags failed")
return access!
}
static func createBioProtectedEntry(key: String, data: Data) -> OSStatus {
let query = [
kSecClass as String: kSecClassGenericPassword as String,
kSecAttrAccount as String: key,
kSecAttrAccessControl as String: getBioSecAccessControl(),
kSecValueData as String: data ] as CFDictionary
return SecItemAdd(query as CFDictionary, nil)
}
2- 生体認証で保護されたエントリを読み取ります。
static func loadBioProtected(key: String, context: LAContext? = nil,
prompt: String? = nil) -> Data? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: kCFBooleanTrue,
kSecAttrAccessControl as String: getBioSecAccessControl(),
kSecMatchLimit as String: kSecMatchLimitOne ]
if let context = context {
query[kSecUseAuthenticationContext as String] = context
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUISkip
}
if let prompt = prompt {
query[kSecUseOperationPrompt as String] = prompt
}
var dataTypeRef: AnyObject? = nil
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
if status == noErr {
return (dataTypeRef! as! Data)
} else {
return nil
}
}
static func redBioProtectedEntry(entryName: String) {
let authContext = LAContext()
let accessControl = SecAccessControlCreateWithFlags(nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&error)
authContext.evaluateAccessControl(accessControl, operation: .useItem, localizedReason: "Access sample keychain entry") {
(success, error) in
var result = ""
if success, let data = loadBioProtected(key: entryName, context: authContext) {
let result = String(decoding: data, as: UTF8.self)
} else {
result = "Can't read entry, error: \(error?.localizedDescription ?? "-")"
}
}
}
この例では、LAContextインスタンスを使ってユーザーを認証します。authContext.evaluateAccessControlメソッドを呼び出し、ユーザーにTouch IDまたはFace IDによる認証を求めます。
認証に成功すると、そのauthContextインスタンスを使ってキーチェーンエントリの内容を読み取ります。
authContextインスタンスは、kSecUseAuthenticationContextキーの値としてクエリ辞書に格納されます。これにより、直前に行われた認証が、後続のSecItemCopyMatching呼び出しで考慮されるようになります。
アプリケーションで生体認証を有効にした後に、新しい指紋を追加したり既存の指紋を削除したりするとどうなるか
Androidの章で説明したように、生体データの変更後には、キーチェーンのアイテムへのアクセスを無効化する必要があります。
そのため、SecAccessControlCreateFlagsの設定としてbiometryCurrentSetを使用することが推奨されます。この設定では、何らかの変更があると、生体認証で保護されたエントリが自動的に削除されます。
userPresenceフラグやbiometryAnyフラグでは、エントリはキーチェーンに残り、新しい生体データも引き続き有効とみなされるため、キーチェーンからアクセスできてしまいます。
Flutter(AndroidおよびiOS)での安全なモバイル生体認証:
Flutterでの実装には、biometric_storageプラグインを使用します。このプラグインを使うと、生体認証によって端末に暗号化データを書き込んだり、読み取ったりできます。
内部の実装は前述の原則を適用しており、AndroidではCryptoObjectを、iOSでは適切なSecAccessControlCreateFlagsを指定したSecAccessControlを使って、Touch IDまたはFace IDでアクセスを制限しています。
このプラグインには、満たすべき要件の一覧があります。
最初の手順は、生体認証の後にデータを書き込み・読み取りするためのアクセスオブジェクトを作成することです。
/// Retrieves the given biometric storage file. Each store is completely separated and has its own encryption and biometric lock.
Future<BiometricStorageFile> _getStorageFile() async {
final authStorage = await BiometricStorage().getStorage('authenticated_storage',options:StorageFileInitOptions(
///Always call it with `authenticationRequired=true`and`authenticationValidityDurationSeconds = -1` to ensure the secure implementation of bioùetric authentication.
authenticationValidityDurationSeconds: -1,
authenticationRequired: true,
androidBiometricOnly: true,
));
return authStorage;
}
安全なストレージにデータを書き込みます。
Future<void> createBioProtectedEntry(context) async {
if (await _checkAuthenticate() == false) {
showAlertDialog(context,const Text("Can't use biometric auth on this device."));
return ;
}
_storageFile = await _getStorageFile();
await _storageFile?.write(_my_secret_data);
}
データを読み取るには次のようにします。
Future<void> redBioProtectedEntry(context) async {
if (await _checkAuthenticate() == false) {
showAlertDialog(context,const Text("Can't use biometric auth on this device."));
return ;
}
if (_storageFile == null){
showAlertDialog(context,const Text("Enable authentication first."));
return ;
}
final data = await _storageFile?.read();
showAlertDialog(context,Text(data!));
}
これで完成です。この実装はAndroidとiOSの両方で動作します。
_storageFile.writeまたは_storageFile.readを呼び出すたびに、ユーザーにTouch IDまたはFace IDによる認証が求められ、secretkeyへのアクセスは生体バインディングによって保護されます。
Androidの実装を確認すると、null以外のcipher値とauthenticationValidityDurationSeconds == -1を指定してauthenticate関数を呼び出した場合、暗号をラップしたCryptoObjectを伴ってBiometricPromptが呼び出されることがわかります。
if (cipher == null || options.authenticationValidityDurationSeconds >= 0) {
// if authenticationValidityDurationSeconds is not -1 we can't use a CryptoObject
logger.debug { "Authenticating without cipher. ${options.authenticationValidityDurationSeconds}" }
prompt.authenticate(promptBuilder.build())
} else {
prompt.authenticate(promptBuilder.build(), BiometricPrompt.CryptoObject(cipher))
}
null以外のcipherを得るには、同じオプションauthenticationValidityDurationSeconds == -1を渡す必要があります。
ソースコード
val cipher = if (options.authenticationValidityDurationSeconds > -1) {
null
} else try {
cipherForMode()
} catch (e: KeyPermanentlyInvalidatedException) {
// TODO should we communicate this to the caller?
logger.warn(e) { "Key was invalidated. removing previous storage and recreating." }
deleteFile()
// if deleting fails, simply throw the second time around.
cipherForMode()
}
iOSの実装では、セキュリティアクセス制御がkSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyとbiometryCurrentSetで定義されています。
ソースコード
private func accessControl(_ result: @escaping StorageCallback) -> SecAccessControl? {
let accessControlFlags: SecAccessControlCreateFlags
if #available(iOS 11.3, *) {
accessControlFlags = .biometryCurrentSet
} else {
accessControlFlags = .touchIDCurrentSet
}
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil, // Use the default allocator.
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
accessControlFlags,
&error) else {
hpdebug("Error while creating access control flags. \(String(describing: error))")
result(storageError("writing data", "error writing data", "\(String(describing: error))"));
return nil
}
return access
}
この実装でも、保護されたデータの読み取りにLAContextを使用しています。
private func canAuthenticate(result: @escaping StorageCallback) {
var error: NSError?
let context = LAContext()
func read(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) {
guard var query = baseQuery(result) else {
return;
}
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecUseOperationPrompt as String] = promptInfo.accessTitle
query[kSecReturnAttributes as String] = true
query[kSecReturnData as String] = true
query[kSecUseAuthenticationContext as String] = context
まとめ
本記事では、主要な3つのフレームワークにおける生体認証の安全な実装を見てきました。
-
Androidでは、
CryptoObjectを使ってcipherをラップし、生体認証に結び付けることで、Android KeyStoreの鍵にアクセスしました。 -
iOSでは、
SecAccessControlインスタンスを使って、保護されたキーチェーンアイテムを作成しました。 -
Flutterでは、AndroidとiOSで安全な生体認証の実装を用いてファイルへのデータの書き込みと読み取りを行う
biometric_storageプラグインを使用しました。
詳しくは、公式ドキュメントを参照してください。
1- BiometricPrompt#authenticate