Nik ~ Home

Android Biometric APIs - Using Crypto Objects in Kotlin

☕️☕️ 11 min read

Introduction

Earlier this year Google released two new APIs to handle Biometric authentication with Android 10. The BiometricManager and BiometricPrompt classes. The purpose of these APIs is to provide a centralised mechanism for interfacing with biometric authentication mechanisms on Android, regardless of the hardware of the device i.e fingerprint / face.

Face and Fingerprint authentication prompt side by side

The main reason I’m writing this is because I found it very difficult to find any information on integrating CryptoObjects with the new API, especially anything in Kotlin. With that in mind, the aim of this post is to talk about the new biometric architecture, to showcase a basic example of the new APIs using Crypto Objects, and highlight some of the inherent risks introduced with these new APIs.

Background

The new Biometric APIs have been in alpha / beta since last September, however, they have only just been fully released, as can be seen here. Originally it was rumoured that these APIs were to handle Facial Recognition exclusively because of this commit, however this is not true. These APIs are built with the intention of unifying biometric authentication mechanisms. They currently support fingerprint and face authentication with the view of integrating other modalities in the future, such as iris.

New Biometric Architecture

The two new APIs superseed the old FingerprintManager that was used for handling fingerprint biometrics on Android devices. Notably, the FingerprintManager class was deprecated in API level 28. The flow diagram across android versions can be seen below, the original image can be found in the Android Developer documentation here.

Image Source: https://source.android.com/security/biometric#implementation

According to the developer documentation here: "All biometric implementations must meet security specifications and have a strong rating in order to participate in the BiometricPrompt class". For more information on the guidelines, see here and here.

Project setup

In order to leverage the new APIs, the project must include the androidx biometric dependency and have the required permission present in the Android Manifest.

Add the androidx biometric library to the application by referencing it in the project’s dependency list found in app/build.gradle, as seen below:

dependencies {
    implementation 'androidx.biometric:biometric:1.0.0'
}

Add the USE_BIOMETRIC permission to the project’s Android manifest file found in app/src/main/AndroidManifest.xml, as seen below:

<uses-permission android:name="android.permission.USE_BIOMETRIC"
    android:requiredFeature="false"/>

BiometricManager Usage

The BiometricManager class provides a centralised mechanism for querying the availability of biometric authentication on the device. Notably, under the hood this will invoke the FingerprintManagerCompat class on pre Android P devices. A simple example of how to invoke this method can be seen below:

when (biometricManager.canAuthenticate()) {
    BiometricManager.BIOMETRIC_SUCCESS ->
        println("Biometrics available on the device")
    BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
        println("No biometric features available on this device.")
    BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
        println("Biometric features are currently unavailable.")
    BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
        println("User has not setup biometrics with their account.")
}

The canAuthenticate method returns one of 4 constant values that can be used to identify the biometric status of the device. Handle and act appropriately, next let’s look at leveraging biometrics into this flow.

BiometricPrompt Usage - Showing a Dialogue

The BiometricPrompt class provides a consistent and centralised mechanism for managing the system-provided biometric dialog. First, let’s look at building the BiometricPrompt PromptInfo object. This can be done by leveraging the builder method as seen below:

var biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Nik.re Login")
            .setSubtitle("Biometric Auth")
            .setDescription("Performing x function to do y")
            .setNegativeButtonText("Abort biometric login")
            .setConfirmationRequired(true)
            .setDeviceCredentialAllowed(false)
            .build()

Many of the APIs referenced in the snippet above are for setting cosmetic attributes such as setTitle and setDescription, used to set dialogue information. However the setConfirmationRequired and setDeviceCredentialAllowed are interesting from a security perspective.

The setConfirmationRequired method is used to define whether or not the dialogue should require explicit user action when performing authentication. This is important when considering implicit biometric modalities like Face and Iris authentication which are passive by design. Meaning they don’t require an explicit user action to complete. It is recommended to set this flag to true when performing high risk actions such as making a purchase, or sending a payment. Low-risk actions such as re-authenticating a recently authenticated session may want to consider setting this flag to false. Notably, this setting acts as a hint to the system, meaning the system may choose to ignore the flag. For example, if the user disables implicit authentication in Settings, or if it does not apply to a modality such as fingerprint. More information on this API can be found here. See the image below for a side by side comparison.

BiometricPrompt setConfirmationRequired side by side

The setDeviceCredentialAllowed method is used to define whether or not the dialogue should give the user the option to authenticate with their device PIN, pattern, or password. This allows the user to choose between authenticating using their biometric or their device authentication method. There are a number of additional considerations when enabling this API, such as checking if the device is secured with a PIN, pattern or password. This can be done by using the KeyguardManager.isDeviceSecure and KeyguardManager.isKeyguardSecure APIs. More information on this API can be found here. For the purposes of this example this flag is disabled.

The next step is to initialise the BiometricPrompt itself and create handlers for the many authentication callbacks BiometricPrompt can return, a full list of these callbacks can be found here. See below a basic example of how to configure this.

val executor = ContextCompat.getMainExecutor(this)

val biometricPrompt = BiometricPrompt(this, executor,
  object : BiometricPrompt.AuthenticationCallback() {
      override fun onAuthenticationError(errorCode: Int,
                                         errString: CharSequence) {
          super.onAuthenticationError(errorCode, errString)
          Toast.makeText(applicationContext,
              "Authentication error: $errString", Toast.LENGTH_SHORT)
              .show()
      }

      override fun onAuthenticationSucceeded(
          result: BiometricPrompt.AuthenticationResult) {
            super.onAuthenticationSucceeded(result)
            val authenticatedCryptoObject: BiometricPrompt.CryptoObject?
              = result.getCryptoObject()
            // User has verified the signature, cipher, or message
            // authentication code (MAC) associated with the crypto
            //object, use this in your app's crypto-driven workflows.


            Toast.makeText(applicationContext, "Authentication Success!",
                Toast.LENGTH_SHORT)
                .show()
      }

      override fun onAuthenticationFailed() {
          super.onAuthenticationFailed()
          Toast.makeText(applicationContext, "Authentication failed",
              Toast.LENGTH_SHORT)
              .show()
      }
  })

The authentication process can then begin by invoking the authenticate method on the BiomtricPromt object and passing in the BiometricPrompt.PromptInfo object we created earlier as a parameter. An example of this can be seen below:

biometricPrompt.authenticate(promptInfo)

This will then present the biometric dialogue seen earlier. Depending on the result of the biometric authentication process, this will then invoke the callback logic we registered earlier. In this example it will simply display a toast message.

BiometricPrompt Usage - Crypto Objects

In the developer documentation there are two available versions of the BiometricPrompt.authenticate API as can be seen here. In the previous example we used the straightforward version where you just pass in a BiometriPrompt.PromptInfo object and that’s it. Now let’s look at the version that allows you to handle BiometricPrompt.CryptoObjects.

This API provides a mechanism for incorporating cryptographic operations into biometric authentication workflows. This presents an opportunity to further protect sensitive information within an application without horribly impacting the user experience. Currently, the framework supports the following cryptographic objects: Signature, Cipher, and Mac. For the purposes of this example, the cipher object is used. First lets start by generating an AES key and storing it in the keystore. The example leverages the following useful wrapper functions taken from here.


private fun generateSecretKey(keyGenParameterSpec: KeyGenParameterSpec) {
    val keyGenerator = KeyGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
    keyGenerator.init(keyGenParameterSpec)
    keyGenerator.generateKey()
}

This can then be used with the KeyGenParameterSpec.Builder class to store the newly generated key in the keystore as can be seen below. Detailed documentation on this API can be found here.

val KEY_NAME = "insert_obfuscated_keyname"

if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N){
  generateSecretKey(KeyGenParameterSpec.Builder(
      KEY_NAME,
      KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
      .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
      .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
      .setUserAuthenticationRequired(true)
      // Invalidate the keys if the user has registered a new biometric
      // credential, such as a new fingerprint. Can call this method only
      // on Android 7.0 (API level 24) or higher. The variable
      // "invalidatedByBiometricEnrollment" is true by default.
      .setInvalidatedByBiometricEnrollment(true)
      .build())
}
else{
  generateSecretKey(KeyGenParameterSpec.Builder(
      KEY_NAME,
      KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
      .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
      .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
      .setUserAuthenticationRequired(true)
      .build())
}

In this example the key is restricted to require user authentication before it can be used, as identified by the setUserAuthenticationRequired flag. In addition, the setInvalidatedByBiometricEnrollment flag is enabled, meaning the key will be invalidated if the user registers a new biometric credential, such as a new fingerprint. Both of these settings are recommended, however, the latter is only usable on Android 7.0 (API level 24) devices or higher. If the application attempts to access this key without the user performing authentication then the action will fail and an exception will be raised, as can be seen below.

at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930) 
Caused by: android.security.KeyStoreException: Key user not authenticated
at android.security.KeyStore.getKeyStoreException(KeyStore.java:1292)

Access to the key can be granted by the BiometricPrompt API on successful authentication. This is done by passing in the Cipher object as a parameter to the biometricPrompt.authenticate function along with the biometricPromptInfo as was done in the previous example. An example of this can be seen in the code snippet below with a cipher of mode Cipher.ENCRYPT_MODE. Notably, the getSecretKey and getCipher wrapper functions are taken from here.

lateinit var iv: ByteArray

fun performBiometricAuthenticationEncrypt(){
    val cipher = getCipher()
    val secretKey = getSecretKey()

    cipher.init(Cipher.ENCRYPT_MODE, secretKey, SecureRandom())
    iv = cipher.iv

    biometricPrompt.authenticate(biometricPromptInfo,
        BiometricPrompt.CryptoObject(cipher)
    )
}

private fun getSecretKey(): SecretKey {
    val keyStore = KeyStore.getInstance("AndroidKeyStore")

    // Before the keystore can be accessed, it must be loaded.
    keyStore.load(null)
    return keyStore.getKey(KEY_NAME, null) as SecretKey
}

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

Upon successful authentication the corresponding CryptoObject will be available in the onAuthenticationSucceeded callback from BiometricPrompt. This Cipher object can then be freely used to perform cryptographic operations. Notably, in this example the Cipher object is restricted to only performing encryption operations, as defined by the Cipher.ENCRYPT_MODE initialisation vector set in the previous example. To encrypt a piece of data use the cipher.doFinal method on the Cipher object with the value to encrypt as a parameter. An example of this can be seen in the snippet of code below.

val superSecretValue = "Sup3rSecr3tValueShh"
lateinit var encryptedSuperSecretValue: ByteArray

...

override fun onAuthenticationSucceeded(
    result: BiometricPrompt.AuthenticationResult) {
    val authResultTextView = findViewById<TextView>(
        R.id.authResultTextView)

    encryptedSuperSecretValue = result.cryptoObject?.cipher?.doFinal(
        superSecretValue.toByteArray(Charset.defaultCharset()))!!

    var uiResultData = "Encrypt: " +
    encryptedSuperSecretValue.toString(Charset.defaultCharset())

    authResultTextView.setText(uiResultData)

    Toast.makeText(applicationContext,
        "Authentication Success - Encrypting!",
        Toast.LENGTH_SHORT)
        .show()
}

This code can also be adjusted to decrypt data. This can be done by adjusting the Cipher initialisation parameters to set the mode to Cipher.DECRYPT_MODE, along with passing in the IV used during the encryption process. An example of this can be seen in the code snippet below.

lateinit var iv: ByteArray

...

fun performBiometricAuthenticationDecrypt(){
    val decryptionCipher = getCipher()
    val decryptionSecretKey = getSecretKey()

    if (::iv.isInitialized) {
        decryptionCipher.init(Cipher.DECRYPT_MODE, decryptionSecretKey,
            IvParameterSpec(iv))

        decryptBiometricPrompt.authenticate(
            decryptBiometricPromptInfo,
            BiometricPrompt.CryptoObject(decryptionCipher)
        )
    }

}

Just like before, upon successful authentication the corresponding CryptoObject will be available in the onAuthenticationSucceeded callback from BiometricPrompt. This Cipher object can then be used to decrypt a piece of data. This can be done by using the same cipher.doFinal method on the Cipher object with the value to decrypt as a parameter. An example of this can be seen in the snippet of code below.

lateinit var encryptedSuperSecretValue: ByteArray

...

override fun onAuthenticationSucceeded(
        result: BiometricPrompt.AuthenticationResult) {
        val authResultTextView = findViewById<TextView>(
            R.id.authResultTextView)

        val decryptedData = result.cryptoObject?.cipher?.doFinal(
            encryptedSuperSecretValue)!!
        var uiResultData = "Decrypt: " +
            decryptedData?.toString(Charset.defaultCharset())

        Toast.makeText(applicationContext,
            "Authentication Success - Decrypting!",
            Toast.LENGTH_SHORT)
            .show()
    }

As can be seen below, the code presented can be used to securely access keys stored in the keystore to encrypt / decrypt data. The full source code can be found on my Github here.

Face and Fingerprint authentication prompt side by side

Conclusion

The new Biometric APIs provide an easy to use solution for handling biometrics on Android. The abstracted logic makes it easy for a developer to integrate biometric authentication without having to worry about the modality supported by the device, i.e face / finger / iris. Additionally, from a development perspective the lifecycle aware and consistent UI components are really nice to work with.

With that being said, from a security perspective, these new biometric APIs introduce a risk by not allowing developers to control / restrict biometric modalities. Organisations may not feel comfortable with biometric x but may be comfortable with biometric y. Especially when handling sensitive information such as the key example in this post. Ideally, these APIs should provide some mechanism to control this, such as via an initialisation flag to BiometricPrompt or a Manifest permission. If this is an issue sticking with the deprecated FingerprintManager class is probably the best option.

The full source code can be found on my Github here. Many of the code snippets used in this post were inspired by Google’s tutorial on integrating the new Biometric APIs, I recommend checking this out here.

References and Further Reading