Sparround

Biometric authentication

BiometricPrompt (androidx.biometric) is the unified API for fingerprint/face auth: it shows the system dialog and hides sensor differences.

Key concepts:

  • Authenticator classes: BIOMETRIC_STRONG (Class 3 — allowed for cryptographic operations), BIOMETRIC_WEAK (Class 2), DEVICE_CREDENTIAL (PIN/pattern fallback).
  • canAuthenticate() — checks biometrics exist/are enrolled/available; route to enrollment accordingly.
  • CryptoObjectbinding biometrics to cryptography: a Keystore key created with setUserAuthenticationRequired(true); the cipher works only after successful biometrics.

The critical distinction — two levels:

  • Weak integration: onAuthenticationSucceeded → just if (success) unlock() — hookable with Frida and bypassable.
  • Strong integration: the success callback hands you the CryptoObject's cipher → real data (the refresh token) is decrypted with it — without biometrics the data stays sealed, making hooks useless.

In banking: biometrics for login + re-confirmation for critical operations (large transfers, limit changes) — step-up auth.

kotlin
// Keystore açarı: yalnız auth-dan sonra istifadə oluna bilər
private fun buildKey() {
    val spec = KeyGenParameterSpec.Builder(
        "biometric_key",
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setUserAuthenticationRequired(true)               // biometrika şərti
        .setInvalidatedByBiometricEnrollment(true)          // yeni barmaq → açar ləğv
        .build()
    KeyGenerator.getInstance("AES", "AndroidKeyStore")
        .apply { init(spec) }.generateKey()
}

val prompt = BiometricPrompt(this, executor,
    object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            val cipher = result.cryptoObject?.cipher ?: return
            val token = String(cipher.doFinal(encryptedRefreshToken))  // real decrypt
            proceedWithLogin(token)
        }
    })

prompt.authenticate(
    BiometricPrompt.PromptInfo.Builder()
        .setTitle("Bank hesabınıza giriş")
        .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
        .setNegativeButtonText("PIN ilə daxil ol")
        .build(),
    BiometricPrompt.CryptoObject(cipher)
)

Strong integration via CryptoObject

Interview question: "What should happen when a new fingerprint is enrolled?" The banking answer: with setInvalidatedByBiometricEnrollment(true) the key auto-invalidates → the user signs in again with full credentials → biometrics get re-enabled. The reason: an attacker who adds their own finger to the device must not get in. It is a UX-security trade-off — non-critical apps skip it, a bank must not.

🛠 Practice task

Compare the two levels of BiometricPrompt:

  • Start simple: onAuthenticationSucceededunlock(). Test it with an enrolled fingerprint on the emulator.
  • Then go strong: create a Keystore key with setUserAuthenticationRequired(true), encrypt a string with it, and make decryption possible only through the CryptoObject.
  • Enroll a new fingerprint on the emulator and watch the key invalidate thanks to setInvalidatedByBiometricEnrollment.

Done when: you can state in one sentence what changes for an attacker between the two.

📚 Sources and documentation