How to Implement Biometric Authentication with Jetpack Compose and AES Encryption

Learn how to implement biometric authentication with Jetpack Compose and AES encryption in Android apps for enhanced security and user experience.
Apr 5 2024 · 10 min read

Background

With the increasing reliance on smartphones for various activities, securing access to sensitive information has become paramount. Traditional methods like passwords or PINs are often cumbersome and prone to security breaches. 

Biometric authentication addresses these concerns by leveraging unique biological traits such as fingerprints, facial features, or iris patterns for identity verification. 

Android devices have built-in support for biometric authentication, making it accessible for developers to integrate into their applications seamlessly.

What we’ll implement in this blog?

The source code is available on github.

Sponsored

We are what we repeatedly do. Excellence, then, is not an act, but a habit. Try out Justly and start building your habits today!

Introduction

Biometric authentication has become a cornerstone of security in modern mobile applications, offering users a convenient and secure way to access sensitive information. 

In this blog post, we will explore how to implement biometric authentication using Jetpack Compose, the modern UI toolkit for Android, coupled with AES encryption for added security.

Benefits

Biometric authentication offers several advantages over traditional methods like passwords or PINs. 

Firstly, it provides a seamless user experience, eliminating the need for users to remember complex passwords. 

Secondly, it enhances security by utilizing unique biological traits such as fingerprints or facial features. 

Lastly, biometric authentication reduces the risk of unauthorized access, as these biological traits are inherently difficult to replicate.

Why Cryptographic Solution is Necessary

While biometric authentication offers enhanced security, it’s crucial to augment it with cryptographic solutions like AES encryption for robust protection of sensitive data. 

Here’s why cryptographic solutions are essential when working with biometric authentication:

  1. Data Protection: Biometric templates (e.g., fingerprints or facial features) are sensitive data that must be securely stored and transmitted. AES encryption ensures that this data is encrypted both at rest and in transit, minimizing the risk of unauthorized access.
  2. Key Management: AES encryption requires encryption keys to encrypt and decrypt data. By implementing proper key management practices, developers can ensure that these keys are securely managed and protected from unauthorized access or misuse.
  3. Compliance Requirements: Many industries, such as finance and healthcare, are subject to strict regulatory requirements regarding data security and privacy. Implementing AES encryption helps organizations comply with these regulations by safeguarding sensitive user information.
  4. Defense against Attacks: Cryptographic solutions like AES encryption provide an additional layer of defense against various security threats, including data breaches, man-in-the-middle attacks, and unauthorized access attempts.

Types of authenticators that your app can support

  1. BIOMETRIC_STRONG: Authentication using a Class 3 biometric, as defined on the Android compatibility definition page.
  2. BIOMETRIC_WEAK: Authentication using a Class 2 biometric, as defined on the Android compatibility definition page.
  3. DEVICE_CREDENTIAL: Authentication using a screen lock credential – the user's PIN, pattern, or password.

Types of Authentication Supported

When implementing biometric authentication in an app, it’s essential to support various biometric modalities to cater to different devices and user preferences. 

Android provides support for the following biometric authentication types:

  1. Fingerprint Authentication: Utilizes the unique patterns of a user’s fingerprints for authentication.
  2. Face Authentication: Verifies the user’s identity by analyzing facial features captured by the device’s camera.
  3. Iris Authentication: Scans the unique patterns in the user’s iris for authentication.

By supporting multiple authentication types, developers can ensure compatibility with a wide range of devices and accommodate users with various preferences and accessibility needs.

In this blog post, we will focus on implementing fingerprint authentication using Jetpack Compose and AES encryption. 

We will walk through the process of integrating biometric authentication into an Android application and securing sensitive data using AES encryption.

So let’s begin with the implementation…

The source code is available on github.

Implementing Biometric Authentication

Add Biometric Authentication Dependencies

To get started with biometric authentication in your Android application, you need to add the following dependencies to your app-level build.gradle file:

dependencies {
    implementation "androidx.biometric:biometric:1.2.0"
}

These dependencies provide the necessary APIs to interact with the biometric hardware on Android devices and authenticate users using their biometric data.

Create CryptoManager for AES Encryption

CryptoManager will manage the encryption and decryption of sensitive data using AES encryption. The CryptoManager class will handle key generation, encryption, and decryption operations.

// Interface defining cryptographic operations
interface CryptoManager {

    // Initialize encryption cipher
    fun initEncryptionCipher(keyName: String): Cipher

    // Initialize decryption cipher
    fun initDecryptionCipher(keyName: String, initializationVector: ByteArray): Cipher

    // Encrypt plaintext
    fun encrypt(plaintext: String, cipher: Cipher): EncryptedData

    // Decrypt ciphertext
    fun decrypt(ciphertext: ByteArray, cipher: Cipher): String

    // Save encrypted data to SharedPreferences
    fun saveToPrefs(
        encryptedData: EncryptedData,
        context: Context,
        filename: String,
        mode: Int,
        prefKey: String
    )

    // Retrieve encrypted data from SharedPreferences
    fun getFromPrefs(
        context: Context,
        filename: String,
        mode: Int,
        prefKey: String
    ): EncryptedData?
}

// Factory function to create CryptoManager instance
fun CryptoManager(): CryptoManager = CryptoManagerImpl()

// Implementation of CryptoManager interface
class CryptoManagerImpl : CryptoManager {

    // Encryption transformation algorithm
    private val ENCRYPTION_TRANSFORMATION = "AES/GCM/NoPadding"
    // Android KeyStore provider
    private val ANDROID_KEYSTORE = "AndroidKeyStore"
    // Key alias for the secret key
    private val KEY_ALIAS = "MyKeyAlias"

    // KeyStore instance
    private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE)

    init {
        // Load the KeyStore
        keyStore.load(null)
        // If key alias doesn't exist, create a new secret key
        if (!keyStore.containsAlias(KEY_ALIAS)) {
            createSecretKey()
        }
    }

    // Initialize encryption cipher
    override fun initEncryptionCipher(keyName: String): Cipher {
        val cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION)
        cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
        return cipher
    }

    // Initialize decryption cipher
    override fun initDecryptionCipher(keyName: String, initializationVector: ByteArray): Cipher {
        val cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION)
        val spec = GCMParameterSpec(128, initializationVector)
        cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
        return cipher
    }

    // Encrypt plaintext
    override fun encrypt(plaintext: String, cipher: Cipher): EncryptedData {
        val encryptedBytes = cipher.doFinal(plaintext.toByteArray(Charset.forName("UTF-8")))
        return EncryptedData(encryptedBytes, cipher.iv)
    }

    // Decrypt ciphertext
    override fun decrypt(ciphertext: ByteArray, cipher: Cipher): String {
        val decryptedBytes = cipher.doFinal(ciphertext)
        return String(decryptedBytes, Charset.forName("UTF-8"))
    }

    // Save encrypted data to SharedPreferences
    override fun saveToPrefs(
        encryptedData: EncryptedData,
        context: Context,
        filename: String,
        mode: Int,
        prefKey: String
    ) {
        val json = Gson().toJson(encryptedData)
        with(context.getSharedPreferences(filename, mode).edit()) {
            putString(prefKey, json)
            apply()
        }
    }

    // Retrieve encrypted data from SharedPreferences
    override fun getFromPrefs(
        context: Context,
        filename: String,
        mode: Int,
        prefKey: String
    ): EncryptedData? {
        val json = context.getSharedPreferences(filename, mode).getString(prefKey, null)
        return Gson().fromJson(json, EncryptedData::class.java)
    }

    // Create a new secret key
    private fun createSecretKey() {
        val keyGenParams = KeyGenParameterSpec.Builder(
            KEY_ALIAS,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
        ).apply {
            setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            WesetEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            setUserAuthenticationRequired(true)
        }.build()

        val keyGenerator =
            KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
        keyGenerator.init(keyGenParams)
        keyGenerator.generateKey()
    }

    // Retrieve the secret key from KeyStore
    private fun getSecretKey(): SecretKey {
        return keyStore.getKey(KEY_ALIAS, null) as SecretKey
    }
}

// Data class to hold encrypted data
data class EncryptedData(val ciphertext: ByteArray, val initializationVector: ByteArray) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as EncryptedData

        if (!ciphertext.contentEquals(other.ciphertext)) return false
        return initializationVector.contentEquals(other.initializationVector)
    }

    override fun hashCode(): Int {
        var result = ciphertext.contentHashCode()
        result = 31 * result + initializationVector.contentHashCode()
        return result
    }
}

Create BiometricHelper that will handle biometric authentication operations

BiometricHelper is a versatile utility object designed to simplify the integration of biometric authentication features into Android applications. This helper class encapsulates complex biometric API interactions, providing developers with a clean and intuitive interface to perform common biometric authentication tasks.

object BiometricHelper {
  ...
}

Now, in BiometricHelper , we will add below functions with specific functionalities:

  • Biometric Availability Check:

The first step in implementing biometric authentication is to check whether the device supports biometric authentication. 

BiometricHelper offers a convenient method, isBiometricAvailable(), which performs this check and returns a boolean value indicating the availability of biometric authentication on the device.

// Check if biometric authentication is available on the device
fun isBiometricAvailable(context: Context): Boolean {
    val biometricManager = BiometricManager.from(context)
    return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK)) {
        BiometricManager.BIOMETRIC_SUCCESS -> true
        else -> {
            Log.e("TAG", "Biometric authentication not available")
            false
        }
    }
}
  • BiometricPrompt Integration:

BiometricHelper seamlessly integrates with the BiometricPrompt API, which serves as the primary interface for biometric authentication on Android devices. 

It provides a method, getBiometricPrompt(), to create a BiometricPrompt instance with a predefined callback, simplifying the setup process and handling of authentication events.

// Retrieve a BiometricPrompt instance with a predefined callback
private fun getBiometricPrompt(
    context: FragmentActivity,
    onAuthSucceed: (BiometricPrompt.AuthenticationResult) -> Unit
): BiometricPrompt {
    val biometricPrompt =
        BiometricPrompt(
            context,
            ContextCompat.getMainExecutor(context),
            object : BiometricPrompt.AuthenticationCallback() {
                // Handle successful authentication
                override fun onAuthenticationSucceeded(
                    result: BiometricPrompt.AuthenticationResult
                ) {
                    Log.e("TAG", "Authentication Succeeded: ${result.cryptoObject}")
                    // Execute custom action on successful authentication
                    onAuthSucceed(result)
                }

                // Handle authentication errors
                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    Log.e("TAG", "onAuthenticationError")
                }

                // Handle authentication failures
                override fun onAuthenticationFailed() {
                    Log.e("TAG", "onAuthenticationFailed")
                }
            }
        )
    return biometricPrompt
}
  • Customizable Prompt Info:

BiometricHelper facilitates the creation of BiometricPrompt.PromptInfo objects with customizable display text, allowing developers to tailor the authentication prompt to match the look and feel of their app. 

The getPromptInfo() method generates a PromptInfo object with customized title, subtitle, description, and negative button text.

// Create BiometricPrompt.PromptInfo with customized display text
private fun getPromptInfo(context: FragmentActivity): BiometricPrompt.PromptInfo {
    return BiometricPrompt.PromptInfo.Builder()
        .setTitle(context.getString(R.string.biometric_prompt_title_text))
        .setSubtitle(context.getString(R.string.biometric_prompt_subtitle_text))
        .setDescription(context.getString(R.string.biometric_prompt_description_text))
        .setConfirmationRequired(false)
        .setNegativeButtonText(
            context.getString(R.string.biometric_prompt_use_password_instead_text)
        )
        .build()
}
  • User Biometrics Registration:

Registering user biometrics involves encrypting sensitive data, such as authentication tokens, using cryptographic techniques. 

BiometricHelper offers a registerUserBiometrics() method, which encrypts a randomly generated token and stores it securely using the CryptoManager, an accompanying cryptographic utility class.

// Register user biometrics by encrypting a randomly generated token
fun registerUserBiometrics(
    context: FragmentActivity,
    onSuccess: (authResult: BiometricPrompt.AuthenticationResult) -> Unit = {}
) {
    val cryptoManager = CryptoManager()
    val cipher = cryptoManager.initEncryptionCipher(SECRET_KEY)
    val biometricPrompt =
        getBiometricPrompt(context) { authResult ->
            authResult.cryptoObject?.cipher?.let { cipher ->
                // Dummy token for now(in production app, generate a unique and genuine token
                // for each user registration or consider using token received from authentication server)
                val token = UUID.randomUUID().toString()
                val encryptedToken = cryptoManager.encrypt(token, cipher)
                cryptoManager.saveToPrefs(
                    encryptedToken,
                    context,
                    ENCRYPTED_FILE_NAME,
                    Context.MODE_PRIVATE,
                    PREF_BIOMETRIC
                )
                // Execute custom action on successful registration
                onSuccess(authResult)
            }
        }
    biometricPrompt.authenticate(getPromptInfo(context), BiometricPrompt.CryptoObject(cipher))
}
  • User Authentication:

authenticateUser() function handles the authentication process using biometrics. It decrypts the stored token using the CryptoManager and initiates the biometric authentication flow. 

Upon successful authentication, the decrypted token is retrieved, enabling the app to grant access to the user.

// Authenticate user using biometrics by decrypting stored token
fun authenticateUser(context: FragmentActivity, onSuccess: (plainText: String) -> Unit) {
    val cryptoManager = CryptoManager()
    val encryptedData =
        cryptoManager.getFromPrefs(
            context,
            ENCRYPTED_FILE_NAME,
            Context.MODE_PRIVATE,
            PREF_BIOMETRIC
        )
    encryptedData?.let { data ->
        val cipher = cryptoManager.initDecryptionCipher(SECRET_KEY, data.initializationVector)
        val biometricPrompt =
            getBiometricPrompt(context) { authResult ->
                authResult.cryptoObject?.cipher?.let { cipher ->
                    val plainText = cryptoManager.decrypt(data.ciphertext, cipher)
                    // Execute custom action on successful authentication
                    onSuccess(plainText)
                }
            }
        val promptInfo = getPromptInfo(context)
        biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
    }
}

So, our BiometricHelper is ready and we now just have to call this functions at required places to manage user authentication.

Sign-Up Screen

Let’s assume you have designed sign-up screen and integrated authorization service e.g., Firebase authentication, then on successful authorization, you will receive a token and we will use that token to register user biometrics.

If you want to checkout how to authenticate user using firebase authentication then you can checkout this blog.

So, upon successful account creation, it will be an opportune moment to prompt users to register their biometrics for future authentication. 

We’ll leverage the BiometricHelper’s registerUserBiometrics() function to seamlessly integrate this process into our SignUp screen.

@Composable
fun SignUpScreen() {
    // Obtain the current FragmentActivity context
    val context = LocalContext.current as FragmentActivity
    
    // Check if biometric authentication is available on the device
    val isBiometricAvailable = remember { BiometricHelper.isBiometricAvailable(context) }
    
    // Retrieve the ViewModel for the SignUpScreen
    val viewModel = hiltViewModel<SignUpScreenViewModel>()
    
    // Collect the current state from the ViewModel
    val state by viewModel.state.collectAsState()

    // Mutable state to control the visibility of the biometric enable dialog
    var showBiometricEnableDialog by remember { mutableStateOf(false) }

    // LaunchedEffect to trigger the biometric enable dialog upon successful sign-up
    LaunchedEffect(key1 = state) {
        if (state is SignUpState.SUCCESS) {
            showBiometricEnableDialog = true
        }
    }

    // Display the biometric enable dialog if necessary
    if (showBiometricEnableDialog) {
        if (isBiometricAvailable) {
            EnableBiometricDialog(
                onEnable = {
                    // Register user biometrics upon enabling
                    BiometricHelper.registerUserBiometrics(context) {
                        showBiometricEnableDialog = false
                        // Update ViewModel to indicate biometric enabled
                        viewModel.setBiometricEnabled(true)
                        // Navigate to the home activity
                        context.startHomeActivity()
                    }
                },
                // Navigate to the home activity if user don't want to register biometric authentication
                {
                    context.startHomeActivity()
                }
            )
        } else {
            // Navigate to the home activity if biometric not available
            context.startHomeActivity()
        }
    }
}

// Extension function to start the HomeActivity and finish the current activity
fun Activity.startHomeActivity() {
    val homeIntent = Intent(this, HomeActivity::class.java)
    this.startActivity(homeIntent)
    this.finish()
}

Explanation:

  • We utilize a boolean flag, showBiometricEnableDialog, to determine whether to display the dialog for enabling biometric authentication.
  • If biometric authentication is available on the device (isBiometricAvailable), we show a dialog prompting the user to enable biometric authentication.
  • Upon enabling biometric authentication, the BiometricHelper’s registerUserBiometrics() function is called, registering the user's biometrics securely.
  • On successful registration, we update the view model to indicate that biometric authentication is enabled and proceed to the home screen.

From here, we have successfully enabled biometric authentication and now we will show biometric prompt on sign-in screen.

Sign-In Screen

Here, we will check if biometric authentication is registered or not. If its registered then we will show biometric-prompt, so that users have delightful experience while signing in.

We’ll leverage the BiometricHelper’s authenticateUser() function to seamlessly integrate this process into our SignIn screen.

@Composable
fun SignInScreen(navController: NavHostController) {
    // Obtain the current FragmentActivity context
    val context = LocalContext.current as FragmentActivity
    // Retrieve the ViewModel for the SignInScreen
    val viewModel = hiltViewModel<SignInScreenViewModel>()

    // Collect the current state from the ViewModel
    val state by viewModel.state.collectAsState()

    // Check if biometric authentication is available on the device
    val isBiometricAvailable = remember { BiometricHelper.isBiometricAvailable(context) }
    
    // Determine whether to show the biometric prompt
    val showBiometricPrompt by viewModel.showBiometricPrompt.collectAsState()
    
    // Mutable state to control the visibility of the biometric icon
    val showBiometricIcon = remember { mutableStateOf(false) }

    // LaunchedEffect to navigate to the home activity upon successful sign-in
    LaunchedEffect(key1 = state) {
        if (state is SignInState.Success) {
            context.navigateToHomeActivity()
        }
    }

    // LaunchedEffect to check if biometric login is enabled
    LaunchedEffect(key1 = isBiometricAvailable) {
        if (isBiometricAvailable) {
            // Check in preference data store if user enabled biometric or not
            viewModel.checkIfBiometricLoginEnabled()
        }
    }

    // LaunchedEffect to handle biometric authentication
    LaunchedEffect(key1 = showBiometricPrompt) {
        if (showBiometricPrompt) {
            BiometricHelper.authenticateUser(context,
                onSuccess = { plainText ->
                    // This will be the same token which we used while registering user
                    viewModel.setToken(plainText)
                    context.navigateToHomeActivity()
                })
        } else {
            // Check if biometric authentication is registered
            val cryptoManager = CryptoManager()
            val encryptedData = cryptoManager.getFromPrefs(
                context,
                ENCRYPTED_FILE_NAME,
                Context.MODE_PRIVATE,
                PREF_BIOMETRIC
            )
            encryptedData?.let {
                showBiometricIcon.value = true
            }
        }
    }
}

Explanation:

  • We start by obtaining the current context and initializing the ViewModel for the SignInScreen.
  • We collect the current state from the ViewModel to handle different UI states.
  • We check if biometric authentication is available on the device using BiometricHelper.
  • We determine whether to show the biometric prompt based on the state managed in the ViewModel.
  • Upon successful sign-in (SignInState.Success), we navigate to the home activity.
  • We check if biometric login is enabled by querying the preference data store through the ViewModel.
  • We handle biometric authentication based on whether the biometric prompt is enabled or not. If enabled, we authenticate the user using biometrics. If not enabled, we check if biometric authentication is registered and show the biometric icon accordingly.

This setup ensures that users are provided with a seamless and secure authentication experience on the Sign-In screen, with support for biometric authentication if available and enabled.

That’s it, we’ve effectively implemented biometric authentication, culminating in a robust security solution. With this setup, users can enjoy a seamless and secure authentication experience.

Conclusion

In conclusion, we’ve successfully implemented biometric authentication using Jetpack Compose and AES encryption, ensuring both security and user convenience in Android applications. 

By adopting biometric authentication, we provide users with a seamless and robust authentication method, reducing reliance on passwords and enhancing overall security.

Integrating AES encryption adds an extra layer of protection to sensitive user data, bolstering security measures. 

As we conclude, it’s clear that embracing biometric authentication aligns with evolving security standards and user expectations, promising a safer and more user-friendly authentication experience.

Related Articles


megh-l image
Megh Lath
Android developer | Sharing knowledge of Jetpack Compose & android development


megh-l image
Megh Lath
Android developer | Sharing knowledge of Jetpack Compose & android development

background-image

Get started today

Let's build the next
big thing!

Let's improve your business's digital strategy and implement robust mobile apps to achieve your business objectives. Schedule Your Free Consultation Now.

Get Free Consultation
footer
Subscribe Here!
Follow us on
2024 Canopas Software LLP. All rights reserved.