こんにちは。スマートバンクで iOS / Android エンジニアをしている nakamuuu です。
B/43 の iOS / Android アプリではカード情報の表示や入金・出金など、一部の操作を行う前に6桁のパスコードの入力をお願いすることがあります。セキュリティ要件上、致し方ない仕様ではあるのですが、ユーザーさんには不便をおかけしている面もありました。
最近のアップデートでは、この6桁のパスコードの入力を生体認証で省略できるようになりました 🎉
この機能は、パスコードを代替する認証情報をデバイス上に保存する形で実装しています。その際、認証情報の悪用を防ぐ保護する観点から以下の要件を満たす必要がありました。
- デバイスに暗号化して認証情報を保存する
- 保存した認証情報へのアクセスは生体認証後のごく短時間のみ許可する
そこで、Android版では Jetpack Security の機能を利用することにしました。このエントリーではその “生体認証での期限付きのデータアクセス” の実装について紹介していきたいと思います。
Jetpack Security
2021年に正式版が公開された Jetpack Security は暗号化処理などのセキュリティ関連の実装をシンプルなインターフェースで提供しています。その内、保存データの暗号化関連の実装を持つ crypto アーティファクトでは以下の2つの機能が提供されています。
EncryptedSharedPreferences
SharedPreferences
クラスをラップし、2 つの方式を使用してキーと値を自動的に暗号化します。EncryptedFile
FileInputStream
とFileOutputStream
のカスタム実装を使って、アプリによるストリーミング読み取り操作とストリーミング書き込み操作のセキュリティを強化できます。
また、それぞれのデータの暗号化に用いるマスターキーの生成も 従来のAndroid Keystoreシステムを直接用いる実装 よりも簡単に行えるようになっています。保存するデータの暗号化のみを行うのであれば、それぞれ通常の SharedPreferences
やファイルを扱うのとほぼ同じ感覚で利用できることでしょう。
// EncryptedSharedPreferencesの使用例 val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() val sharedPreferences = EncryptedSharedPreferences.create( context, "file_name", masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) // Read val value = sharedPreferences.getString("pref_key", null) // Write sharedPreferences.edit { putString("pref_key", value) }
生体認証での期限付きのデータアクセスの実装
保存するデータの暗号化のみを行う実装に以下の2点を加えることで、 “生体認証での期限付きのデータアクセス” を実現することができます。
- MasterKey の生成時のオプションの指定
- BiometricPrompt による生体認証
まず、MasterKey の生成時に setUserAuthenticationRequired(Boolean, Int)
のオプションを指定します。以下は認証後15秒間の期限内にのみ鍵(≒保存されたデータ)へのアクセスを許可する例です。
val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setUserAuthenticationRequired(true, 15) // ✅ 認証後15秒間の期限内にのみアクセスを許可する .build() val sharedPreferences = EncryptedSharedPreferences.create(...)
次に BiometricPrompt
による生体認証を実装していきます。
val prompt = BiometricPrompt(activity, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { // 必要に応じて Read or Write val value = sharedPreferences.getString("pref_key", null) sharedPreferences.edit { putString("pref_key", value) } } override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { // TODO: 生体認証の失敗時のエラーハンドリング } }) val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle("Title") // ※ setAllowedAuthenticators / setConfirmationRequired の指定については後述 .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .setConfirmationRequired(false) .build() prompt.authenticate(promptInfo)
実際にはエラーハンドリングやUI周りなども作りこむ必要がありますが、共通して必要な実装は以上です。非常にシンプルに生体認証での期限付きのデータアクセスを実現できることがわかります。
ここからは実際に実装を進めた際に意識することとなった注意点に触れていこうと思います。
復号化時に鍵が読み込まれるタイミング
冒頭で触れた “生体認証でのパスコード入力の省略機能” を実装し始めた当初、以下の意図しない挙動に遭遇することがありました。
- データアクセスを試行する前に
UserNotAuthenticatedException
でアプリがクラッシュする BiometricPrompt
による生体認証後、十分な時間が経過してもデータアクセスが行える
これは復号化に用られる鍵が読み込まれるタイミング(=生体認証が必要になるタイミング)を以下のように誤解したまま実装を進めたことが原因でした。
- ❌ 誤 : 「
SharedPreferences#getString
EncryptedFile#openFileInput
などの呼び出しの際に鍵が読み込まれる」 - ⭕️ 正 : 「
EncryptedSharedPreferences
/EncryptedFile
のインスタンスの生成時に鍵が読み込まれる」
この誤解のもと、当初は EncryptedSharedPreferences
のインスタンスを長期間にわたり保持し続けるような実装をしていました。
EncryptedSharedPreferences
/ EncryptedFile
のインスタンスは BiometricPrompt
による生体認証が完了してから生成する必要があります。また、意図しないデータアクセスを防ぐ観点からも、必要なデータの読み取りが完了したらインスタンスをすぐに解放することが望ましいでしょう。
// ❌ シングルトンの実装に `EncryptedSharedPreferences` のインスタンスを持たせている @Singleton class BiometricAuthenticator @Inject constructor( @ApplicationContext private val context: Context, private val sharedPreferences: EncryptedSharedPreferences ) { ... } // ⭕️ 必要な時だけ `EncryptedSharedPreferences` のインスタンスを扱うようにスコープを狭める @Singleton class BiometricAuthenticator @Inject constructor( @ApplicationContext private val context: Context ) { private val sharedPreferences: SharedPreferences get() { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setUserAuthenticationRequired(true, 15) .build() val sharedPreferences = EncryptedSharedPreferences.create(...) } ... }
デバイスの認証方式の分類
Androidデバイスにおけるユーザー認証にはいくつかの方式があり、認証強度などによって分類することができます。Jetpack Security を用いたキーの生成、あるいは BiometricPrompt
による認証を正しく実装する上で、それらの分類への理解が必要です。
認証方式 | 認証強度(※) | 受動的な生体認証 |
---|---|---|
顔認証 虹彩認証 |
BIOMETRIC_STRONG または BIOMETRIC_WEAK (デバイスごとに規定) |
○ |
指紋認証 | BIOMETRIC_STRONG または BIOMETRIC_WEAK (デバイスごとに規定) |
- |
Smart Lock 生体認証(一部) |
※ ロック解除のみに利用可能 | - |
パターン パスワード PIN |
DEVICE_CREDENTIAL |
- |
※ 「認証強度」には BiometricManager.Authenticators の値を記載
各デバイスに搭載されている生体認証は「Android互換性定義ドキュメント(CDD)」の規定に基づいて、 BIOMETRIC_STRONG
/ BIOMETRIC_WEAK
/ “ロック解除のみに利用可能” のいずれかに属しています。
📝 各デバイスにおける生体認証のサポート例
- Pixel 7 Pro : 指紋認証( `BIOMETRIC_STRONG` ) / ロック解除のみに利用可能な顔認証
- Galaxy S23 : 指紋認証( `BIOMETRIC_STRONG` ) / 顔認証( `BIOMETRIC_WEAK` )
- Xiaomi Pad 5 : ロック解除のみに利用可能な顔認証
さて、生体認証での期限付きのデータアクセスの実装例において、暗号化に用いるキーを以下のように生成しました。このキーはどの認証方法を用いた場合にアクセス可能になるでしょうか?
val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setUserAuthenticationRequired(true, 15) .build()
答えは「BIOMETRIC_STRONG
DEVICE_CREDENTIAL
に属する認証方法」で、これは androidx.security.crypto.MasterKey
の実装からも確認することができます。BIOMETRIC_WEAK
に属する生体認証の後に EncryptedSharedPreferences
EncryptedFile
のインスタンスを生成しようとしても、 UserNotAuthenticatedException の例外が投げられることは注意すべきポイントでしょう。
また、顔認証と虹彩認証のような “受動的な生体認証” の振る舞いにも注意が必要です。これらの認証後にはデフォルトで完了表示が挟まります。ユーザーの認証後から完了表示を閉じるまでに MasterKey.Builder#setUserAuthenticationRequired
で指定した秒数が経過してしまった場合には、 UserNotAuthenticatedException
の例外が投げられます。
生体認証での期限付きのデータアクセスの実装において BiometricPrompt
を表示する際には、以下の2つのオプションの指定は忘れずに行いましょう。
setAllowedAuthenticators(BiometricManager.Authenticators)
: 許可する認証方法の指定setConfirmationRequired(Boolean)
: “受動的な生体認証” で完了表示を挟むかの指定 *1
val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle("Title") .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .setConfirmationRequired(false) .build()
おわりに
このエントリーでは “生体認証による期限付きのデータアクセス” の実装について紹介しました。デバイス上におけるデータの暗号化には難しいイメージを抱きがちですが、 Jetpack Security の機能を用いることで比較的シンプルな実装に収められたことと思います。
《追記》 Jetpack Security を用いた実装方法のトレードオフを認識する
本記事で紹介した実装方法についてフィードバックをいただいたため、言及が不足していた部分を追記します。
BiometricPrompt では標準でAndroid KeyStoreシステムを用いた暗号化処理の組み込みをサポートしています。しかし、Jetpack Security では同等のセキュリティレベルを持つ生体認証と統合された機能は提供されていません。(2023年9月時点)
注: セキュリティ ライブラリは、暗号処理レベルでの
BiometricPrompt
をサポートしていません。
Jetpack Security を用いた “生体認証による期限付きのデータアクセス” の実装においては、 保存された情報がメモリあるいはネットワーク通信上に露出するリスク を正しく認識する必要があります。メモリ領域の脆弱性、あるいは通信の傍受などへの対策を仕様面で考慮する必要性も生じてきます。(保存する情報の有効範囲や寿命の適切な設定、使い捨てのランダム値を用いた リプレイ攻撃 の防止など)
暗号処理レベルでの BiometricPrompt
の利用を最初に検討してから、端末上に保存する情報の性質や要求されるセキュリティレベルも加味した上で実装方針を定めていくのが望ましいと考えています。
参考資料
- Work with data more securely | Android Developers
- Google Online Security Blog: Data Encryption on Android with Jetpack Security
スマートバンクでは一緒に B/43 を作り上げていくメンバーを募集しています!カジュアル面談も受け付けていますので、お気軽にご応募ください 🙌 smartbank.co.jp
*1:このオプションの指定はあくまでも “システムへのヒント” であり、デバイスは指定を無視した振る舞いをすることもあります。 https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.Builder#setConfirmationRequired(boolean)