Secure Biometric Authentication in Android

2 hour read This article provides an in-depth exploration of Secure Biometric Authentication in Android, covering its architecture, functionality, security mechanisms, and implementation best practices. Developers will gain valuable insights into key features such as Dual-Mode Authentication, Android KeyStore Integration, Cryptographic Verification, Manual Crypto Fallback, and Anti-Frida Detection, ensuring robust security for their Android applications. February 26, 2025 07:34 Secure Biometric Authentication in Android SecureBiometricUtils: Mastering Secure Biometric Authentication in Android

SecureBiometricUtils: Mastering Secure Biometric Authentication in Android

Biometric authentication is a pivotal element of modern mobile security, blending convenience with robust identity verification. Yet, securing it across Android’s diverse ecosystem—spanning varied hardware, biometric modalities like fingerprint and face recognition, and threats such as tampering or reverse-engineering—demands a sophisticated approach. Enter SecureBiometricUtils, a Kotlin-based utility from the mainapp.utils package. This class excels in delivering both strong (crypto-backed) and weak (manually verified) biometric authentication flows. This article explores its architecture, capabilities, security fortifications, and practical application, serving as an essential guide for Android developers aiming to build secure, user-centric authentication systems.

Overview

SecureBiometricUtils harnesses Android’s BiometricPrompt API to enable biometric authentication, adeptly supporting fingerprint and facial recognition. Tailored for Secure biometric apps, it prioritizes extensibility, security, and testability, embedding features like tamper resistance, cryptographic validation, and adaptive modality selection to meet diverse device requirements.

Key Features

  • Dual-Mode Authentication: Seamlessly toggles between strong biometric authentication (using cryptographic objects) and weak biometric authentication (with a manual crypto fallback).
  • Dynamic Modality Selection: Intelligently opts for BIOMETRIC_STRONG, BIOMETRIC_WEAK, or DEVICE_CREDENTIAL based on device biometric capabilities.
  • Advanced Security: Implements anti-Frida measures, runtime integrity checks, and tamper detection (e.g., debugger presence, memory hooks).
  • Cryptographic Robustness: Leverages Android KeyStore for key management, employs AES/CBC/PKCS7 encryption, and uses HMAC-SHA256 for verification.
  • Developer-Friendly Logging: Offers ASCII box-style logs with obfuscation, active only in debug builds for enhanced diagnostics.
  • Test-Ready Design: Features injectable providers (SystemProvider, SecurityProvider, CryptoProvider) and a test mode for streamlined unit testing.

Architecture and Design

Built on dependency injection and modular principles, SecureBiometricUtils ensures flexibility and ease of testing. Its core components are meticulously designed to separate concerns while maintaining a cohesive security framework.

Core Dependencies

  • Context: Facilitates access to Android services like BiometricManager for capability checks.
  • Providers:
    • SystemProvider: Abstracts system-level operations (e.g., process enumeration, emulator detection).
    • SecurityProvider: Oversees security validations (e.g., Frida detection, HMAC computations).
    • CryptoProvider: Manages cryptographic tasks (e.g., key generation, cipher operations).
  • BiometricPrompt Factory: A customizable lambda for instantiating BiometricPrompt, ideal for mocking in tests.
  • Test Mode: Includes a constructor flag (isTestMode) and a static toggle (GLOBAL_TEST_MODE) to disable security checks during testing.

Static Companion Object

The companion object centralizes constants, shared state, and utility functions:

  • Constants: Defines KEY_NAME, SECURITY_CHECK_INTERVAL_MS (2 seconds), and MANUAL_VERIFICATION_ATTEMPTS (3).
  • Shared State: Maintains sessionToken (UUID), securitySalt (32-byte random), and lastCipherIV.
  • Logging Utilities: Implements ASCII box-style logs (logSecurityAlert, logDebug, etc.) with SHA-256-based obfuscation for sensitive data.

Provider Interfaces

  • SystemProvider: Encapsulates low-level system queries (e.g., reading /proc files).
  • SecurityProvider: Handles anti-tampering and integrity checks.
  • CryptoProvider: Provides cryptographic primitives via Android KeyStore.

Default implementations (DefaultSystemProvider, DefaultSecurityProvider, DefaultCryptoProvider) offer production-ready functionality, easily swapped for mocks in testing scenarios.

Biometric Authentication Flow

The authenticate function serves as the primary entry point, orchestrating a secure and adaptive biometric authentication process:

fun authenticate(
    activity: FragmentActivity,
    title: String = "Biometric Authentication",
    subtitle: String = "Verify your identity",
    negativeButtonText: String = "Cancel",
    onSuccess: () -> Unit,
    onError: (Int, String) -> Unit,
    onFailed: () -> Unit
)

Steps in the Authentication Flow

  1. Security Validation:
    • Invokes isSecurityCompromised() to detect threats like Frida or debuggers.
    • Fails with code 1001 ("Security verification failed") if compromised.
  2. Capability Detection:
    • Uses canAuthenticateWithBiometrics() to assess BIOMETRIC_STRONG and BIOMETRIC_WEAK support.
    • Prioritizes strong biometrics unless only weak modalities (e.g., face) are available.
  3. Authenticator Assignment:
    • getAllowedAuthenticatorsAutomatically() opts for BIOMETRIC_WEAK on face-dominant devices, else BIOMETRIC_STRONG.
  4. Key Preparation:
    • Strong flow: Generates a KeyStore key via generateSecretKey(true) and builds a CryptoObject.
    • Weak flow: Creates a temporary key (fallbackWeakKey) with generateSecretKey(false).
  5. Prompt Execution:
    • Constructs a PromptInfo and launches BiometricPrompt using triggerBiometricAuthentication.
  6. Result Processing:
    • Success:
      • Strong: Validates cipher output (handleStrongAuthentication).
      • Weak: Runs manual crypto checks (handleWeakAuthentication).
    • Error: Returns error code and message.
    • Failure: Triggers onFailed.

Security Mechanisms

Strong Biometric Authentication

  • Crypto Integration: Employs a BiometricPrompt.CryptoObject with AES cipher from a KeyStore key.
  • Verification: Encrypts a UUID and validates the output for authenticity.
  • Key Specs: Enforces user authentication, 30-second validity, and AES/CBC/PKCS7 encryption.

Weak Biometric Authentication

  • Fallback Strategy: Generates a short-lived key when no CryptoObject is provided (e.g., face recognition).
  • Manual Verification:
    • Encrypts/decrypts a 16-byte challenge via verifyChallenge.
    • Retries up to 3 times (MANUAL_VERIFICATION_ATTEMPTS) with 50ms intervals.
  • Security Layer: Pairs with tamper detection to bolster integrity.

Anti-Tampering Defenses

  • Frida Detection: Scans for Frida signatures in processes, memory, and libraries.
  • Debugger Monitoring: Detects via Debug.isDebuggerConnected() and TracerPid.
  • Emulator Checks: Inspects Build properties for emulator indicators (e.g., goldfish).
  • Runtime Integrity: Hashes critical class methods to spot hooks.
  • Memory Protection: Searches /proc/self/maps for anomalies.

Cryptographic Fortifications

  • KeyStore Security: Stores keys with mandatory authentication.
  • HMAC-SHA256: Enhances verification with salted keys.
  • Entropy Boost: Uses securitySalt and sessionToken to thwart replays.

Implementation Details

Core Functions

  • canAuthenticateWithBiometrics():
    • Outputs a BiometricCapability detailing modality support.
    • Avoids Build-based heuristics for cleaner detection.
  • generateSecretKey():
    • Uses KeyGenParameterSpec for AES/CBC/PKCS7 with 30-second validity.
    • Refreshes keys by deleting prior entries.
  • performManualCryptoVerification():
    • Challenges with a 16-byte random array, verifying encryption/decryption.
    • Logs attempt timing for diagnostics.
  • isSecurityCompromised():
    • Checks every 2 seconds, caching results for efficiency.
    • Skips non-critical tests in test mode.

Logging System

  • Style: ASCII boxes (e.g., │ 🔐 SECURITY) enhance readability.
  • Obfuscation: Adds SHA-256 prefixes to sensitive logs.
  • Control: Enabled solely in debug builds (BuildConfig.DEBUG).

Usage Example

Here’s how to integrate SecureBiometricUtils into a FragmentActivity:

val biometricUtils = SecureBiometricUtils(context)
biometricUtils.authenticate(
    activity = this,
    title = "Login with Biometrics",
    subtitle = "Use your fingerprint or face",
    negativeButtonText = "Use PIN instead",
    onSuccess = {
        Toast.makeText(this, "Authentication successful!", Toast.LENGTH_SHORT).show()
    },
    onError = { code, message ->
        Toast.makeText(this, "Error $code: $message", Toast.LENGTH_SHORT).show()
    },
    onFailed = {
        Toast.makeText(this, "Authentication failed", Toast.LENGTH_SHORT).show()
    }
)

Full Source Code

Below is the complete source code for SecureBiometricUtils as implemented in the mainapp.utils package. Use the "Copy" button to easily copy the code to your clipboard for reference or use in your projects.

package mainapp.utils

import mainapp.BuildConfig
import android.content.Context
import android.os.Build
import android.os.Debug
import android.os.Handler
import android.os.Looper
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import timber.log.Timber
import java.io.File
import java.lang.reflect.Method
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.UUID
import java.util.concurrent.Executor
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.Mac
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec

/**
 * SecureBiometricUtils provides secure biometric authentication (face and fingerprint)
 * using both strong (crypto‑backed) and weak (manual crypto verification) methods.
 *
 * Features:
 * - Supports both strong and weak biometrics (with a fallback manual crypto verification)
 * - Leverages Android’s OS‑based modality selection by automatically choosing between:
 *      • BIOMETRIC_STRONG or BIOMETRIC_WEAK (when a weak modality like face is detected)
 *      • BIOMETRIC_STRONG or DEVICE_CREDENTIAL (otherwise)
 * - If a crypto object is returned (strong biometric), additional cipher verification is performed.
 * - If no crypto object is returned (as with weak biometrics), a weak key is generated with a short
 *   validity (10 seconds) and used for manual crypto verification.
 * - Includes anti‑Frida and tamper detection (process, memory, hooks).
 * - Uses ASCII box–style logs with obfuscation.
 * - Designed to be debug‑friendly and unit test–friendly via injectable providers.
 */
class SecureBiometricUtils(
    private val context: Context,
    private val systemProvider: SystemProvider = DefaultSystemProvider(),
    val securityProvider: SecurityProvider = DefaultSecurityProvider(),
    val cryptoProvider: CryptoProvider = DefaultCryptoProvider(),
    val isTestMode: Boolean = false, // For unit tests only.
            val biometricPromptFactory: (FragmentActivity, Executor, BiometricPrompt.AuthenticationCallback) -> BiometricPrompt =
        { activity, executor, callback -> BiometricPrompt(activity, executor, callback) }
) {

    companion object {
        const val KEY_NAME = "BiometricKey"
        const val RO_DEBUG = "ro.debuggable"
        const val SVF = "Security verification failed"
        const val AVE = "Authentication validation exception"
        const val KEYSTORE_PROVIDER = "AndroidKeyStore"
        const val SECURITY_CHECK_INTERVAL_MS = 2000L // 2 seconds
        const val MANUAL_VERIFICATION_ATTEMPTS = 3
        var securityCheckCooldown: Long = 0
        var lastSecurityState: Boolean = false
        private var lastCipherIV: ByteArray? = null

        // Device-specific values (initialized at installation time)
        private val sessionToken: String by lazy { UUID.randomUUID().toString() }
        private val securitySalt: ByteArray by lazy { generateSecuritySalt() }

        // Global test mode (for unit tests only – not used in production)
        @JvmStatic
        var GLOBAL_TEST_MODE = false

        @JvmStatic
        fun setGlobalTestMode(enabled: Boolean) {
            if (BuildConfig.DEBUG && !enabled) {
                // Only allow disabling test mode in debug builds
                GLOBAL_TEST_MODE = false
                logDebug("🧪 Global test mode disabled")
            }
        }

        fun generateSecuritySalt(): ByteArray {
            val salt = ByteArray(32)
            SecureRandom().nextBytes(salt)
            return salt
        }

        // ────── Logging Helpers ──────

        fun logSecurityAlert(message: String) {
            logIfEnabled {
                val obfuscatedMessage = obfuscateLogMessage("SECURITY_ALERT", message)
                Timber.w(
                    """
            ┌─────────────────────────────────────────
            │ 🔐 SECURITY
            │ $obfuscatedMessage
            └─────────────────────────────────────────
            """.trimIndent()
                )
            }
        }

        inline fun logIfEnabled(action: () -> Unit) {
            if (BuildConfig.DEBUG) action()
        }

        fun logDebug(message: String) {
            logIfEnabled {
                val obfuscatedMessage = obfuscateLogMessage("DEBUG_LOG", message)
                Timber.d(
                    """
            ┌─────────────────────────────────────────
            │ 🔍 DEBUG
            │ $obfuscatedMessage
            └─────────────────────────────────────────
            """.trimIndent()
                )
            }
        }

        fun logCryptoOperation(message: String) {
            logIfEnabled {
                val obfuscatedMessage = obfuscateLogMessage("CRYPTO_OP", message)
                Timber.i(
                    """
            ┌─────────────────────────────────────────
            │ 🔑 OPERATION
            │ $obfuscatedMessage
            └─────────────────────────────────────────
            """.trimIndent()
                )
            }
        }

        fun logError(error: String, exception: Exception? = null) {
            logIfEnabled {
                if (exception != null) {
                    Timber.e(
                        exception,
                        """
                ┌─────────────────────────────────────────
                │ ❌ ERROR
                │ $error
                └─────────────────────────────────────────
                """.trimIndent()
                    )
                } else {
                    Timber.e(
                        """
                ┌─────────────────────────────────────────
                │ ❌ ERROR
                │ $error
                └─────────────────────────────────────────
                """.trimIndent()
                    )
                }
            }
        }

        fun obfuscateLogMessage(type: String, message: String): String {
            val text = "$type:$message"
            return try {
                val digest = MessageDigest.getInstance("SHA-256")
                val hash = digest.digest(text.toByteArray())
                val obfuscated = hash.copyOfRange(0, 8).joinToString("") { "%02x".format(it) }
                "$obfuscated:$message"
            } catch (e: Exception) {
                message
            }
        }
    }

    // ────── Provider Interfaces ──────

    interface SystemProvider {
        fun getProcessList(): List
        fun readMemoryMaps(): String
        fun isDebuggerConnected(): Boolean
        fun isRunningInEmulator(): Boolean
        fun getLibraries(): List
        fun getSystemProperties(): Map
    }

    interface SecurityProvider {
        fun detectFrida(
            processList: List,
            memoryMaps: String,
            libraries: List
        ): Boolean

        fun calculateHmac(data: ByteArray, key: ByteArray): ByteArray
        fun verifyHmac(data: ByteArray, key: ByteArray, expectedHmac: ByteArray): Boolean
        fun getRuntimeIntegrity(): String
    }

    interface CryptoProvider {
        fun getKeyStore(): KeyStore
        fun generateSecretKey(useStrongBiometrics: Boolean): SecretKey?
        fun getCipher(): Cipher
        fun createCryptoObject(
            secretKey: SecretKey,
            useStrongBiometrics: Boolean
        ): BiometricPrompt.CryptoObject?

        fun encrypt(data: ByteArray, secretKey: SecretKey): Pair
        fun decrypt(data: ByteArray, secretKey: SecretKey, iv: ByteArray): ByteArray
    }

    // ────── Default Provider Implementations ──────

    open class DefaultSystemProvider : SystemProvider {
        override fun getProcessList(): List {
            return try {
                File("/proc").listFiles()
                    ?.filter { it.name.all { char -> char.isDigit() } }
                    ?.mapNotNull { proc ->
                        try {
                            File(proc, "cmdline").readText()
                        } catch (e: Exception) {
                            null
                        }
                    } ?: emptyList()
            } catch (e: Exception) {
                emptyList()
            }
        }

        override fun readMemoryMaps(): String {
            return try {
                File("/proc/self/maps").readText()
            } catch (e: Exception) {
                ""
            }
        }

        override fun isDebuggerConnected(): Boolean {
            return Debug.isDebuggerConnected() || isPtraceAttached()
        }

        private fun isPtraceAttached(): Boolean {
            return try {
                val status = File("/proc/self/status").readText()
                val tracerPid =
                    Regex("TracerPid:\\s*(\\d+)").find(status)?.groupValues?.get(1)?.toInt() ?: 0
                tracerPid > 0
            } catch (e: Exception) {
                false
            }
        }

        override fun isRunningInEmulator(): Boolean {
            return Build.FINGERPRINT.startsWith("generic") ||
                    Build.FINGERPRINT.startsWith("unknown") ||
                    Build.MODEL.contains("google_sdk") ||
                    Build.MODEL.contains("Emulator") ||
                    Build.MODEL.contains("Android SDK") ||
                    Build.MANUFACTURER.contains("Genymotion") ||
                    Build.HARDWARE.contains("goldfish") ||
                    Build.HARDWARE.contains("ranchu") ||
                    Build.PRODUCT.contains("sdk") ||
                    Build.PRODUCT.contains("google_sdk") ||
                    Build.PRODUCT.contains("sdk_x86") ||
                    Build.PRODUCT.contains("vbox86p") ||
                    Build.BOARD.lowercase().contains("nox") ||
                    Build.BOOTLOADER.lowercase().contains("nox") ||
                    Build.HARDWARE.lowercase().contains("nox") ||
                    Build.PRODUCT.lowercase().contains("nox")
        }

        override fun getLibraries(): List {
            return try {
                val libs = mutableListOf()
                System.getProperty("java.library.path")?.split(":")?.forEach { path ->
                    File(path).listFiles()?.filter { it.isFile && it.name.endsWith(".so") }
                        ?.forEach { libs.add(it.absolutePath) }
                }
                libs
            } catch (e: Exception) {
                emptyList()
            }
        }

        override fun getSystemProperties(): Map {
            val props = mutableMapOf()
            try {
                props[RO_DEBUG] = getSystemProperty(RO_DEBUG, "0")
                props["ro.secure"] = getSystemProperty("ro.secure", "1")
                props["service.adb.root"] = getSystemProperty("service.adb.root", "0")
            } catch (e: Exception) {
                logDebug(e.message.toString())
            }
            return props
        }

        open fun getSystemProperty(property: String, defaultValue: String): String {
            return try {
                val process = Runtime.getRuntime().exec("getprop $property")
                process.inputStream.bufferedReader().use { it.readLine() } ?: defaultValue
            } catch (e: Exception) {
                defaultValue
            }
        }
    }

    class DefaultSecurityProvider : SecurityProvider {
        override fun detectFrida(
            processList: List,
            memoryMaps: String,
            libraries: List
        ): Boolean {
            val keywords = listOf(
                "frida", "frida-server", "frida-agent", "frida-gadget", "frida-inject",
                "frida-helper", "libfrida-gadget", "frida-core", "frida-trace", "objection",
                "fridump", "epicfrida", "r2frida", "redfang", "hyperion", "fridascript"
            )
            val found = keywords.any { keyword ->
                processList.any { it.contains(keyword, ignoreCase = true) } ||
                        memoryMaps.contains(keyword, ignoreCase = true) ||
                        libraries.any { it.contains(keyword, ignoreCase = true) }
            }
            if (found) logSecurityAlert("Detected possible Frida-based tampering.")
            return found || checkFridaSockets()
        }

        fun checkFridaSockets(fileReader: (String) -> List = { path ->
            File(path).useLines { it.toList() }
        }): Boolean {
            val fridaPorts = setOf("69B2", "69B3") // Hex for Frida ports 27042 and 27043
            return try {
                listOf("/proc/net/tcp", "/proc/net/tcp6").any { path ->
                    fileReader(path).any { line -> fridaPorts.any { port -> port in line } }
                }
            } catch (e: Exception) {
                false
            }
        }

        override fun calculateHmac(data: ByteArray, key: ByteArray): ByteArray {
            // Combine data with securitySalt and sessionToken for extra entropy.
            val additionalData = securitySalt + sessionToken.toByteArray()
            val combinedData = data + additionalData

            val hmacKey = SecretKeySpec(key, "HmacSHA256")
            val mac = Mac.getInstance("HmacSHA256")
            mac.init(hmacKey)
            return mac.doFinal(combinedData)
        }

        override fun verifyHmac(data: ByteArray, key: ByteArray, expectedHmac: ByteArray): Boolean {
            val calculated = calculateHmac(data, key)
            return calculated.contentEquals(expectedHmac)
        }

        override fun getRuntimeIntegrity(): String {
            return try {
                val classes = listOf(
                    BiometricPrompt::class.java.name,
                    KeyStore::class.java.name,
                    Cipher::class.java.name,
                    SecureBiometricUtils::class.java.name
                )
                val digest = MessageDigest.getInstance("SHA-256")
                classes.forEach { className ->
                    try {
                        Class.forName(className).declaredMethods.forEach { method ->
                            digest.update(method.name.toByteArray())
                        }
                    } catch (e: Exception) {
                        logDebug(e.message.toString())
                    }
                }
                digest.digest().joinToString("") { "%02x".format(it) }
            } catch (e: Exception) {
                ""
            }
        }
    }

    open class DefaultCryptoProvider : CryptoProvider {
        override fun getKeyStore(): KeyStore {
            val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER)
            keyStore.load(null)
            return keyStore
        }

        override fun generateSecretKey(useStrongBiometrics: Boolean): SecretKey? {
            return try {
                val ks = getKeyStore()
                val alias = if (useStrongBiometrics) "${KEY_NAME}_STRONG" else "${KEY_NAME}_WEAK"
                if (ks.containsAlias(alias)) ks.deleteEntry(alias)

                val keyGenerator =
                    KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)

                val keyGenSpecBuilder = KeyGenParameterSpec.Builder(
                    alias,
                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
                )
                    .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
                    .setUserAuthenticationRequired(true)
                    .setInvalidatedByBiometricEnrollment(false)

                @Suppress("kotlin:S1874", "DEPRECATION")
                keyGenSpecBuilder.setUserAuthenticationValidityDurationSeconds(30)

                val spec = keyGenSpecBuilder.build()
                keyGenerator.init(spec)
                keyGenerator.generateKey().let {
                    ks.getKey(alias, null) as SecretKey
                }
            } catch (e: Exception) {
                logError("Failed to generate secure key", e)
                null
            }
        }

        override fun getCipher(): Cipher {
            return Cipher.getInstance(
                "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}"
            )
        }

        override fun createCryptoObject(
            secretKey: SecretKey,
            useStrongBiometrics: Boolean
        ): BiometricPrompt.CryptoObject? {
            // If the biometric modality is weak (e.g. face recognition), bypass crypto object creation.
            if (!useStrongBiometrics) {
                logDebug("Skipping crypto object creation for weak biometrics.")
                return null
            }
            // Otherwise, try using the cipher for strong biometrics.
            return try {
                val cipher = getCipher()
                cipher.init(Cipher.ENCRYPT_MODE, secretKey)
                logDebug("🔑 Using Cipher for strong biometric authentication")
                BiometricPrompt.CryptoObject(cipher)
            } catch (e: Exception) {
                logError("Failed to initialize crypto object", e)
                null
            }
        }

        override fun encrypt(data: ByteArray, secretKey: SecretKey): Pair {
            val cipher = getCipher()
            cipher.init(Cipher.ENCRYPT_MODE, secretKey)
            val encrypted = cipher.doFinal(data)
            return Pair(encrypted, cipher.iv)
        }

        override fun decrypt(data: ByteArray, secretKey: SecretKey, iv: ByteArray): ByteArray {
            val cipher = getCipher()
            cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv))
            return cipher.doFinal(data)
        }
    }

    // ────── Biometric Capability Data ──────

    data class BiometricCapability(
        val isStrongAvailable: Boolean,
        val isWeakAvailable: Boolean,
        val isFaceAvailable: Boolean,
        val isFingerprintAvailable: Boolean,
        val strongStatusCode: Int,
        val weakStatusCode: Int
    ) {
        val isAnyBiometricAvailable: Boolean get() = isStrongAvailable || isWeakAvailable
        fun getPreferredType(): BiometricType {
            return when {
                isStrongAvailable -> BiometricType.STRONG
                isWeakAvailable -> BiometricType.WEAK
                else -> BiometricType.NONE
            }
        }
    }

    enum class BiometricType { STRONG, WEAK, NONE }

    // The function that checks biometric capability.
    fun canAuthenticateWithBiometrics(): BiometricCapability {
        val biometricManager = BiometricManager.from(context)
        val strongResult =
            biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
        val weakResult =
            biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
        val strongAvailable = strongResult == BiometricManager.BIOMETRIC_SUCCESS
        val weakAvailable = weakResult == BiometricManager.BIOMETRIC_SUCCESS
        // If weak biometrics are available, assume that both face and fingerprint options should be offered.
        val faceAvailable = weakAvailable  // instead of using Build.FINGERPRINT heuristic
        val fingerprintAvailable = strongAvailable || weakAvailable
        return BiometricCapability(
            isStrongAvailable = strongAvailable,
            isWeakAvailable = weakAvailable,
            isFaceAvailable = faceAvailable,
            isFingerprintAvailable = fingerprintAvailable,
            strongStatusCode = strongResult,
            weakStatusCode = weakResult
        )
    }

    // Fallback weak key storage for manual crypto verification.
    var fallbackWeakKey: SecretKey? = null

    /**
     * Helper that automatically selects allowed authenticators based on biometric capability.
     * If a weak modality (typically face) is detected, returns:
     *   BIOMETRIC_STRONG or BIOMETRIC_WEAK
     */
    fun getAllowedAuthenticatorsAutomatically(): Int {
        val capability = canAuthenticateWithBiometrics()
        // If face biometrics are available (i.e. using a weak modality), force the weak path.
        return if (capability.isFaceAvailable && capability.isWeakAvailable) {
            BiometricManager.Authenticators.BIOMETRIC_WEAK
        } else {
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        }
    }

    // ────── Public Authentication Entry Point ──────
    /**
     * Initiates secure biometric authentication.
     *
     * This public API leverages Android’s OS‑based selection by automatically choosing allowed authenticators.
     * It uses (BIOMETRIC_STRONG or BIOMETRIC_WEAK) when a weak modality (e.g. face) is available,
     * and (BIOMETRIC_STRONG or DEVICE_CREDENTIAL) otherwise.
     *
     * If a crypto object is returned (strong biometric), additional cipher verification is performed.
     * If no crypto object is returned (weak biometric), we fall back on manual crypto verification if possible.
     *
     * fun authenticate(
     *      activity: FragmentActivity,
     *      title: String = "Biometric Authentication",
     *      subtitle: String = "Verify your identity",
     *      negativeButtonText: String = "Cancel",
     *      onSuccess: () -> Unit,
     *      onError: (Int, String) -> Unit,
     *      onFailed: () -> Unit
     * )
     */

    fun authenticate(
        activity: FragmentActivity,
        title: String = "Biometric Authentication",
        subtitle: String = "Verify your identity",
        negativeButtonText: String = "Cancel",
        onSuccess: () -> Unit,
        onError: (Int, String) -> Unit,
        onFailed: () -> Unit
    ) {
        if (isSecurityCompromised() || performSecondaryVerification()) {
            logSecurityAlert("Authentication blocked due to security compromise")
            onError(1001, SVF)
            return
        }

        val allowedAuthenticators = getAllowedAuthenticatorsAutomatically()
        val promptInfo = buildPromptInfo(title, subtitle, negativeButtonText, allowedAuthenticators)
        val authCallback = createAuthCallback(onSuccess, onError, onFailed)
        val executor = MainThreadExecutor()
        // Use the injected factory to create the BiometricPrompt.
        val biometricPrompt = biometricPromptFactory(activity, executor, authCallback)

        try {
            val capability = canAuthenticateWithBiometrics()
            val useStrong = capability.getPreferredType() == BiometricType.STRONG && !capability.isFaceAvailable

            if (useStrong) {
                val strongKey = cryptoProvider.generateSecretKey(true)
                val cryptoObject = strongKey?.let { cryptoProvider.createCryptoObject(it, true) }
                if (cryptoObject != null) {
                    triggerBiometricAuthentication(biometricPrompt, promptInfo, cryptoObject)
                } else {
                    fallbackWeakKey = cryptoProvider.generateSecretKey(false)
                    triggerBiometricAuthentication(biometricPrompt, promptInfo)
                }
            } else if (capability.isWeakAvailable) {
                fallbackWeakKey = cryptoProvider.generateSecretKey(false)
                triggerBiometricAuthentication(biometricPrompt, promptInfo)
            } else {
                triggerBiometricAuthentication(biometricPrompt, promptInfo)
            }
        } catch (e: Exception) {
            logError("Failed to start biometric authentication", e)
            onError(1008, "Failed to start authentication: ${e.localizedMessage}")
        }
    }

    @Suppress("kotlin:S6293") // Suppress Sonar security hotspot for biometric authentication without CryptoObject
// Justification: Weak biometric authentication (e.g., face unlock) is used for non-sensitive operations.
// Manual crypto verification is implemented as a fallback to ensure security.
    fun triggerBiometricAuthentication(
        biometricPrompt: BiometricPrompt,
        promptInfo: BiometricPrompt.PromptInfo,
        cryptoObject: BiometricPrompt.CryptoObject? = null
    ) {
        try {
            cryptoObject?.let {
                biometricPrompt.authenticate(promptInfo, it)
            } ?: biometricPrompt.authenticate(promptInfo)
        } catch (e: SecurityException) {
            logError("🚨 Security Exception in biometric authentication: ${e.localizedMessage}")
        } catch (e: Exception) {
            logError("🔥 Unexpected biometric authentication failure: ${e.localizedMessage}")
        }
    }

    fun buildPromptInfo(
        title: String,
        subtitle: String,
        negativeButtonText: String,
        allowedAuthenticators: Int
    ): BiometricPrompt.PromptInfo {
        val builder = BiometricPrompt.PromptInfo.Builder()
            .setTitle(title)
            .setSubtitle(subtitle)
            .setAllowedAuthenticators(allowedAuthenticators)
        if (allowedAuthenticators and BiometricManager.Authenticators.DEVICE_CREDENTIAL == 0) {
            builder.setNegativeButtonText(negativeButtonText)
        }
        return builder.build()
    }

    fun createAuthCallback(
        onSuccess: () -> Unit,
        onError: (Int, String) -> Unit,
        onFailed: () -> Unit
    ): BiometricPrompt.AuthenticationCallback {
        return object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                logDebug("✅ Biometric authentication succeeded")

                // Immediately check for signs of tampering.
                if (isSecurityCompromised()) {
                    logSecurityAlert("Security compromised after biometric authentication")
                    onError(1013, SVF)
                    return
                }

                // If a crypto object is returned, perform strong flow verification.
                result.cryptoObject?.cipher?.let { cipher ->
                    handleStrongAuthentication(cipher, onSuccess, onError)
                    return
                }
                // Otherwise, process as weak biometric authentication using local verification.
                handleWeakAuthentication(onSuccess, onError)
            }

            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                logError("Authentication error: $errString ($errorCode)")
                onError(errorCode, errString.toString())
            }

            override fun onAuthenticationFailed() {
                logDebug("Authentication attempt failed")
                onFailed()
            }
        }
    }

    fun handleStrongAuthentication(
        cipher: Cipher,
        onSuccess: () -> Unit,
        onError: (Int, String) -> Unit
    ) {
        try {
            val verificationData = "VERIFY:${UUID.randomUUID()}".toByteArray()
            val encrypted = cipher.doFinal(verificationData)
            if (encrypted.isEmpty()) {
                logSecurityAlert("🔴 Authentication tampered: empty encryption result")
                onError(1011, "Authentication validation failed")
                return
            }
            logCryptoOperation("🔐 Strong biometric authentication verified with crypto")
            onSuccess()
        } catch (e: Exception) {
            logSecurityAlert("🔴 Exception during cipher verification: ${e.message}")
            onError(1012, AVE)
        }
    }

    fun handleWeakAuthentication(
        onSuccess: () -> Unit,
        onError: (Int, String) -> Unit
    ) {
        try {
            val freshKey = cryptoProvider.generateSecretKey(false)
            if (freshKey != null) {
                fallbackWeakKey = freshKey
                logDebug("Generated fresh weak key after authentication")
                // Call your local verification routine.
                performManualCryptoVerification(
                    freshKey,
                    {
                        // Success callback: no parameters.
                        onSuccess()
                    },
                    { _, errorMsg ->
                        // Error callback: always report error code 1012 and fixed message.
                        logSecurityAlert("🔴 Exception during local verification: $errorMsg")
                        onError(1012, AVE)
                    }
                )
            } else {
                logSecurityAlert("Weak biometric auth - no verification available")
                if (isSecurityCompromised()) {
                    logSecurityAlert("Security compromised during fallback path")
                    onError(1012, AVE)
                    return
                }
                onSuccess()
            }
        } catch (e: Exception) {
            logError("Exception during weak biometric verification", e)
            if (isSecurityCompromised()) {
                logSecurityAlert("Security compromised during exception handler")
                onError(1012, AVE)
            } else {
                logSecurityAlert("Accepting weak biometric auth after key error")
                onSuccess()
            }
        }
    }

    // ────── Manual Crypto Verification for Weak Biometrics ──────

    fun verifyChallenge(secretKey: SecretKey, challenge: ByteArray): Boolean {
        return try {
            val (encrypted, iv) = cryptoProvider.encrypt(challenge, secretKey)
            val decrypted = cryptoProvider.decrypt(encrypted, secretKey, iv)
            if (challenge.contentEquals(decrypted)) {
                lastCipherIV = iv
                true
            } else {
                logSecurityAlert("🔴 Challenge verification failed – content mismatch")
                false
            }
        } catch (e: Exception) {
            logDebug("Challenge verification exception: ${e.message}")
            false
        }
    }

    fun performManualCryptoVerification(
        secretKey: SecretKey,
        onSuccess: () -> Unit,
        onError: (Int, String) -> Unit
    ) {
        val startTime = System.currentTimeMillis()
        var lastException: Exception? = null

        repeat(MANUAL_VERIFICATION_ATTEMPTS) { attempt ->
            try {
                // Generate a random challenge using a defined challenge size constant.
                val challenge = ByteArray(16).apply { SecureRandom().nextBytes(this) }
                if (verifyChallenge(secretKey, challenge)) {
                    val elapsed = System.currentTimeMillis() - startTime
                    logCryptoOperation("🔐 Weak biometric auth verified with manual crypto (attempt ${attempt + 1}, ${elapsed}ms)")

                    // Additional security check after successful verification.
                    if (isSecurityCompromised()) {
                        logSecurityAlert("Security compromised during verification")
                        onError(1016, SVF)
                        return
                    }
                    onSuccess()
                    return
                }
            } catch (e: Exception) {
                lastException = e
                val elapsedSoFar = System.currentTimeMillis() - startTime
                logDebug("Manual verification attempt ${attempt + 1} failed after ${elapsedSoFar}ms: ${e.message}")
            }
            // Delay between attempts for device timing variations.
            Thread.sleep(50)
        }

        val totalElapsed = System.currentTimeMillis() - startTime
        logSecurityAlert("🔴 Manual crypto verification failed after $MANUAL_VERIFICATION_ATTEMPTS attempts (${totalElapsed}ms)")
        lastException?.let { logError("Last exception during verification", it) }
        onError(1010, "Post-authentication verification failed")
    }

    // ────── Security Checks ──────
    // Add this function for a separate verification path
    fun performSecondaryVerification(): Boolean {
        try {
            // Secondary verification that runs even in debug mode
            val memoryMaps = systemProvider.readMemoryMaps()
            // Check for specific Frida signatures that debug mode shouldn't bypass
            val criticalSignatures = listOf(
                "frida-gadget",
                "frida-agent",
                "libfrida"
            )
            return criticalSignatures.any { memoryMaps.contains(it, ignoreCase = true) }
        } catch (e: Exception) {
            return false
        }
    }

    fun isSecurityCompromised(): Boolean {
        if (isTestMode || GLOBAL_TEST_MODE) {
            // Only perform critical checks in test mode
            val runtimeIntegrity = securityProvider.getRuntimeIntegrity()
            if (runtimeIntegrity.isEmpty()) {
                logSecurityAlert("Runtime integrity verification failed even in test mode")
                return true
            }
            return false
        }

        val currentTime = System.currentTimeMillis()
        if (currentTime - securityCheckCooldown < SECURITY_CHECK_INTERVAL_MS) return lastSecurityState

        try {
            val processList = systemProvider.getProcessList()
            val memoryMaps = systemProvider.readMemoryMaps()
            val libraries = systemProvider.getLibraries()
            val sysProps = systemProvider.getSystemProperties()

            if (securityProvider.getRuntimeIntegrity().isEmpty()) {
                logSecurityAlert("Runtime integrity verification failed")
                updateSecurityState(true)
                return true
            }
            if (securityProvider.detectFrida(processList, memoryMaps, libraries)) {
                logSecurityAlert("Security violation detected (Frida)")
                updateSecurityState(true)
                return true
            }

            if (systemProvider.isDebuggerConnected()) {
                logSecurityAlert("Debug session detected")
                updateSecurityState(true)
                return true
            }
            if (sysProps[RO_DEBUG] == "1") {
                logSecurityAlert("Device is debuggable")
                updateSecurityState(true)
                return true
            }
            if (detectFunctionHooks() || detectMemoryTampering()) {
                logSecurityAlert("Function hooks or memory tampering detected")
                updateSecurityState(true)
                return true
            }
            updateSecurityState(false)
            return false
        } catch (e: Exception) {
            logError("Error during security verification", e)
            updateSecurityState(true)
            return true
        }
    }

    fun updateSecurityState(isCompromised: Boolean) {
        lastSecurityState = isCompromised
        securityCheckCooldown = System.currentTimeMillis()
    }

    fun detectFunctionHooks(
        classLoader: (String) -> Array? = { className ->
            try {
                Class.forName(className).declaredMethods
            } catch (e: Exception) {
                null
            }
        }
    ): Boolean {
        val classesToCheck = listOf(
            "android.hardware.biometrics.BiometricPrompt",
            "android.security.keystore.KeyStore"
        )
        return classesToCheck.any { className ->
            classLoader(className)?.any { it.isAccessible || it.isSynthetic } ?: false
        }
    }

    fun detectMemoryTampering(fileReader: () -> String = { File("/proc/self/maps").readText() }): Boolean {
        return try {
            fileReader().contains("frida", ignoreCase = true)
        } catch (e: Exception) {
            false
        }
    }

    fun isRunningInEmulator(): Boolean {
        return systemProvider.isRunningInEmulator() // Delegate to the system provider
    }
    // ────── Executor for BiometricPrompt Callbacks ──────

    class MainThreadExecutor : Executor {
        private val handler = Handler(Looper.getMainLooper())
        override fun execute(command: Runnable) {
            handler.post(command)
        }
    }
}

Testing Considerations

Unit Testing

  • Provider Injection: Mock SystemProvider, SecurityProvider, and CryptoProvider for controlled behavior.
  • Test Mode: Enable isTestMode = true or GLOBAL_TEST_MODE = true to skip security checks.
  • Prompt Mocking: Use a custom biometricPromptFactory to bypass UI dependencies.

Sample Test

@Test
fun `test strong biometric authentication`() {
    val mockCrypto = mock {
        on { generateSecretKey(true) } doReturn SecretKeySpec(ByteArray(16), "AES")
        on { createCryptoObject(any(), eq(true)) } doReturn BiometricPrompt.CryptoObject(Cipher.getInstance("AES/CBC/PKCS7Padding"))
    }
    val utils = SecureBiometricUtils(
        context = mock(),
        cryptoProvider = mockCrypto,
        isTestMode = true
    )
    utils.authenticate(
        activity = mock(),
        onSuccess = { assertTrue(true) },
        onError = { _, _ -> fail() },
        onFailed = { fail() }
    )
}

Strengths and Limitations

Strengths

  • Adaptability: Handles strong and weak biometrics with ease.
  • Security Depth: Combines tamper resistance with cryptographic rigor.
  • Modularity: Provider-based design supports customization.
  • Diagnostics: Rich, obfuscated logs streamline debugging.

Limitations

  • Weak Flow Complexity: Manual verification introduces latency risks.
  • Platform Reliance: Assumes Android biometric integrity, vulnerable on rooted devices.
  • Resource Use: Frequent security checks may strain low-end hardware.

Conclusion

SecureBiometricUtils stands out as a versatile, security-focused solution for Android biometric authentication. Its dual-mode design ensures broad device compatibility, while its anti-tampering and cryptographic safeguards counter advanced threats. It provides a reliable, extensible base. Mastering its mechanics empowers you to deliver seamless, fortified authentication experiences that uphold the highest security standards.

User Comments (0)

Add Comment
We'll never share your email with anyone else.