本文へスキップ
すべての記事

2026年のAndroid生体認証:BiometricPrompt、Keystore、ローカルファーストアプリのロック

androidx.biometricの実践ガイド — BiometricPrompt、CryptoObjectに裏付けられた鍵、デバイス認証情報へのフォールバック、そして指紋チェックが何も守らなくなる典型的な間違い。

MFKAPPS 1 分で読めます

画面をロックするだけの指紋認証ダイアログは、単なる飾りにすぎない。裏側のデータがその間ずっと平文のRoomデータベースに入っているなら、adbとroot化された端末——あるいはバックアップ抽出ツール——さえあれば、誰でもセンサーに一切触れずにそのデータを読み取れる。Androidにおける本当の生体認証保護とは、ダイアログが暗号鍵をアンロックすることを意味し、攻撃者とデータの間に実際に立ちはだかるのはその鍵だ。ここがほとんどのBiometricPromptチュートリアルが飛ばす部分であり、唯一重要な部分でもある。

私はこれをGranynに追加し、端末上の予算データをロックした。同じパターンは、どんなローカルファーストアプリでも服薬記録やサブスクリプションリストを保護できる。以下が実際に機能する実装だ。

「生体認証」と呼ばれる2つの異なるもの

androidx.biometricは2種類の認証モードをサポートしており、それぞれ全く異なるものを保護する。

  • 認証のみ。 CryptoObjectなしのBiometricPrompt.authenticate(PromptInfo)は「登録された生体情報が一致したか」を尋ねるだけで、はいかいいえを返す。これはUIのゲートにすぎない。何も暗号化されず、何も復号されない——root化された端末やcontent providerのバグは、これを完全にバイパスする。データがダイアログの成功に一度も依存していないからだ。
  • 暗号に裏付けられた認証。 BiometricPrompt.authenticate(PromptInfo, CryptoObject)は、setUserAuthenticationRequired(true)が設定されたAndroid Keystoreの鍵から構築されたCipherにダイアログを紐付ける。対応する生体認証またはデバイス認証情報の認証が直前に成功するまで、OS自体がその鍵に何もさせない——暗号化も復号も署名もだ。鍵は慣例的にゲートされているだけでなく、ダイアログなしでは物理的に使用不能になる。

「予算の数字をロック解除なしでは読めないようにする」ことが目的なら、それを実現するのは後者だけだ。前者は、保存時の機密データを扱わない画面での破壊的操作前の再確認など、リスクの低い用途には十分だ。

認証を要求するKeystoreの鍵を構築する

private const val KEY_ALIAS = "granyn_db_key"

fun getOrCreateKey(): SecretKey {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }

    val keyGenerator = KeyGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
    )
    val spec = KeyGenParameterSpec.Builder(
        KEY_ALIAS,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setUserAuthenticationRequired(true)
        .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
        .build()
    keyGenerator.init(spec)
    return keyGenerator.generateKey()
}

setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG)が重要な行だ。期間0は、鍵が一定の時間ウィンドウではなく、認証ごとにちょうど1回の操作にのみ使用できることを意味する。これは、成功した認証の後N秒間鍵をアンロック状態に保つ古いsetUserAuthenticationValidityDurationSecondsよりも意図的に厳格だ——便利ではあったが、バックグラウンドサービスが2回目に鍵に触れた瞬間、多くの「生体認証で保護された」アプリが静かに何も保護しなくなっていた原因でもある。BIOMETRIC_STRONGを具体的に要求すること。BIOMETRIC_WEAKは、写真で時々騙せるような角度からの顔認証などをカバーしており、金融データを守るために求めるべき基準ではない。

ダイアログをcipherに接続する

suspend fun authenticateAndDecrypt(
    activity: FragmentActivity,
    ciphertext: ByteArray,
    iv: ByteArray,
): ByteArray = suspendCancellableCoroutine { cont ->
    val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
        init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
    }

    val callback = object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            val decrypted = result.cryptoObject?.cipher?.doFinal(ciphertext)
            if (decrypted != null) cont.resume(decrypted) else cont.cancel()
        }
        override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
            cont.cancel()
        }
    }

    val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
    val promptInfo = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Unlock Granyn")
        .setSubtitle("Confirm it's you to view your budget")
        .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
        .build()

    prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}

ここで2つのつまずきポイントがある。まず、強力な生体認証をデバイスのPIN/パターン/パスワードのフォールバックと組み合わせるsetAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)は、setNegativeButtonText()と互いに排他的だ。両方を呼び出すと、ダイアログは構築時に例外をスローする。デバイス認証情報オプションは、センサーが失敗したり何も登録されていない場合にすでにユーザーに逃げ道を与えているので、手動のネガティブボタンはそれと併用するものではなく、それと重複するものだ。

次に、onAuthenticationSucceededで受け取るCryptoObjectは複製ではない——今アンロックされた同じCipherインスタンスであり、すぐにdoFinal()を呼び出す必要がある。コールバックが発火した後に新しいCipherを再構築すると、目的全体が台無しになる。その新しいcipherは認証チェックによって一度も承認されていないため、使おうとするとUserNotAuthenticatedExceptionをスローする——さらに悪いことに、認証要件なしで構築した場合は静かに動作し、閉じたはずの穴を静かに再び開けてしまう。

ダイアログを表示する前に利用可能性を確認する

val biometricManager = BiometricManager.from(context)
when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
    BiometricManager.BIOMETRIC_SUCCESS -> { /* 続行 */ }
    BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
        // 指紋/顔もPIN/パターン/パスワードも設定されていない。ただ
        // authenticate() を呼んで黙って失敗させるのではなく、
        // Settings.ACTION_BIOMETRIC_ENROLL に誘導する。
    }
    BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
    BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
        // 自前で管理するアプリレベルのPINにフォールバックするか、
        // アクセスをブロックする代わりにこの端末ではロック機能を省略する。
    }
    else -> { /* 残りのエラーコードを処理する */ }
}

canAuthenticate()は省略可能な定型コードではない——「これを有効にするには画面ロックを設定してください」という明確な案内と、登録済み認証情報を持たないユーザーが初めてトグルをタップしたときの分かりにくい失敗との違いを生む。このパスは意図的にテストすること。エミュレータの登録済み指紋を消去し(設定 → セキュリティ → 指紋、またはエミュレータの拡張コントロールの指紋パネル)、アプリがクラッシュせずに正しく縮退することを確認する。

実際にロックが必要なもの

すべてがこれの恩恵を受けるわけではない。暗号に裏付けられた鍵は実際の負荷を加える——復号ごとに1回の認証、読み取りごとに1回のKeystoreの往復——そのため、データベースの読み取りパス全体をこれでゲートすると、「アプリを開く」が明らかに遅い操作になってしまう。Granynで最終的に決めたのは、電話を紛失した場合に重要になるフィールドだけを暗号化すること——口座残高と取引メモ——であり、カテゴリ名やUI設定は平文のRoomカラムのままにしている。ロックは実際に機密性の高いものを守り、それ以外は高速なままだ。

これらすべてから持ち帰るべき習慣はこうだ。「指紋で保護」という機能を出荷する前に、ダイアログが完全にスキップされたらどうなるかを問うこと——root、デバッガ、content providerのバイパス。答えが「何も起きない、データは依然として読める」であれば、そのダイアログは見た目通りの仕事を一度も果たしていなかったということだ。