inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

Jetpack Securityで生体認証による期限付きのデータアクセスを実装する

こんにちは。スマートバンクで iOS / Android エンジニアをしている nakamuuu です。

B/43 の iOS / Android アプリではカード情報の表示や入金・出金など、一部の操作を行う前に6桁のパスコードの入力をお願いすることがあります。セキュリティ要件上、致し方ない仕様ではあるのですが、ユーザーさんには不便をおかけしている面もありました。

最近のアップデートでは、この6桁のパスコードの入力を生体認証で省略できるようになりました 🎉

パスコードの入力画面 / 生体認証によるパスコードの省略

この機能は、パスコードを代替する認証情報をデバイス上に保存する形で実装しています。その際、認証情報の悪用を防ぐ保護する観点から以下の要件を満たす必要がありました。

  • デバイスに暗号化して認証情報を保存する
  • 保存した認証情報へのアクセスは生体認証後のごく短時間のみ許可する

そこで、Android版では Jetpack Security の機能を利用することにしました。このエントリーではその “生体認証での期限付きのデータアクセス” の実装について紹介していきたいと思います。

Jetpack Security

developer.android.com

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.googlesource.com

デバイスの認証方式の分類

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 の例外が投げられることは注意すべきポイントでしょう。

android.googlesource.com

また、顔認証と虹彩認証のような “受動的な生体認証” の振る舞いにも注意が必要です。これらの認証後にはデフォルトで完了表示が挟まります。ユーザーの認証後から完了表示を閉じるまでに 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 をサポートしていません。

https://developer.android.com/topic/security/data

Jetpack Security を用いた “生体認証による期限付きのデータアクセス” の実装においては、 保存された情報がメモリあるいはネットワーク通信上に露出するリスク を正しく認識する必要があります。メモリ領域の脆弱性、あるいは通信の傍受などへの対策を仕様面で考慮する必要性も生じてきます。(保存する情報の有効範囲や寿命の適切な設定、使い捨てのランダム値を用いた リプレイ攻撃 の防止など)

暗号処理レベルでの BiometricPrompt の利用を最初に検討してから、端末上に保存する情報の性質や要求されるセキュリティレベルも加味した上で実装方針を定めていくのが望ましいと考えています。

参考資料


スマートバンクでは一緒に B/43 を作り上げていくメンバーを募集しています!カジュアル面談も受け付けていますので、お気軽にご応募ください 🙌 smartbank.co.jp

*1:このオプションの指定はあくまでも “システムへのヒント” であり、デバイスは指定を無視した振る舞いをすることもあります。 https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.Builder#setConfirmationRequired(boolean)

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.