DEV Community

Ranil Wijeyratne
Ranil Wijeyratne

Posted on

Securing data with BiometricPrompt

Before Android 9 Pie, your options to implement a fingerprint scanning dialog were:

Unfortunately neither of those options are great. Unless of course you have really specific UI requirements and need to build your own. But for most applications it's enough to have a generic dialog and it'd be great to have an official one.

Luckily, since Android 9 Pie, Google has introduced the BiometricPrompt that addresses this issue. Hopefully this post will help you integrate it.

In all fairness, there are some great existing blog posts on the matter. But kudos aside, the ones I came across lack some things, so I'm hoping this post will fill the gaps.

Mark Ellison's post is great but his example only works on Android 9 Pie and doesn't address encrypting data. Natig Babayev's post also shines, but it doesn't show how it could be encapsulated and also doesn't address encryption.

As always I welcome, and actually seek feedback, so feel free to comment critically.

The basics

Since most Android device manufacturers either struggle, or deliberately prevent, to upgrade their devices to newer Android versions, we're still forced to support older Android versions.

Luckily Google is doing the heavy lifting with their AndroidX libraries (formerly known as Support Library)

So first of all you'll need to add an additional dependency to make use of the Biometric Prompt on older Android versions.

implementation 'androidx.biometric:biometric:1.0.0-alpha04'
Enter fullscreen mode Exit fullscreen mode

The basic components

The main class involved in the biometric prompt is, what a surprise, called androidx.biometric.BiometricPrompt. You can create an instance using its only public constructor:

/**
 * ...
 * 
 * @param fragmentActivity A reference to the client's activity.
 * @param executor An executor to handle callback events.
 * @param callback An object to receive authentication events.
 */
public BiometricPrompt(@NonNull FragmentActivity fragmentActivity,
                       @NonNull Executor executor, 
                       @NonNull AuthenticationCallback callback)
Enter fullscreen mode Exit fullscreen mode

The third parameter: AuthenticationCallback is an abstract static class with three non-abstract methods, so technically we won't have to override any of them.

So for the sake of a simple "hello-world" prompt we can pass an anonymous object without overriding anything.

BiometricPrompt(fragmentActivity, executor, 
    object : BiometricPrompt.AuthenticationCallback() {})
Enter fullscreen mode Exit fullscreen mode

Secondly we need to describe what the prompt looks like. That's where the BiometricPrompt.PromptInfo comes into play. It conveniently provides a builder class:

val cancelString = activity.getString(android.R.string.cancel)
BiometricPrompt.PromptInfo.Builder()
    .setTitle("Prompt Title") // required
    .setSubtitle("Prompt Subtitle")
    .setDescription("Prompt Description: lorem ipsum")
    .setNegativeButtonText(cancelString) // required
    .build()
Enter fullscreen mode Exit fullscreen mode

That's all that can be configured. It should be quite self-explanatory. Only the Title and NegativeButtonText is required.

To launch the prompt, all we have to do is call the authenticate method.

val biometricPrompt = BiometricPrompt(...)
val promptInfo = BiometricPrompt.PromptInfo.Builder()...

biometricPrompt.authenticate(promptInfo)
Enter fullscreen mode Exit fullscreen mode
left: BiometricPrompt on Android 7 Nougat / right: BiometricPrompt on Android 9 Pie

Encryption

In my opinion encrypting data is at the heart of using biometric authentication in the first place. So now that we have a working prompt we should look into it.

⚠️ Important Disclaimer ⚠️ I'm not an in depth security expert! The code and specifically the chosen encryption parameters, are based on Google's previously mentioned sample and merely adapted to the new BiometricPrompt.

 Key & Cipher

To encrypt data we need a SecretKey and a Cipher.

fun createKey(): SecretKey {
  val algorithm = KeyProperties.KEY_ALGORITHM_AES
  val provider = "AndroidKeyStore"
  val keyGenerator = KeyGenerator.getInstance(algorithm, provider)

  val keyName = "MY_KEY"
  val purposes = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
  val keyGenParameterSpec = KeyGenParameterSpec.Builder(keyName, purposes)
          .setBlockModes(BLOCK_MODE)
          .setEncryptionPaddings(PADDING)
          .setUserAuthenticationRequired(true)
          .build()

  keyGenerator.init(keyGenParameterSpec)
  return keyGenerator.generateKey()
}

fun getEncryptCipher(key: Key): Cipher {
  val algorithm = KeyProperties.KEY_ALGORITHM_AES
  val blockMode = KeyProperties.BLOCK_MODE_CBC
  val padding = KeyProperties.ENCRYPTION_PADDING_PKCS7
  return Cipher.getInstance("$algorithm/$blockMode/$padding").apply { 
    init(Cipher.ENCRYPT_MODE, key) 
  }
}
Enter fullscreen mode Exit fullscreen mode

The main take away here is the setUserAuthenticationRequired(true) method call during key creation that:

Sets whether this key is authorized to be used only if the user has been authenticated.

By default, the key is authorized to be used regardless of whether the user has been authenticated.

This basically means: to use the key you have to provide a form of authentication, i.e. your fingerprint. For further explanation refer to the Android documentation 🔍

Since no key length is specified, Android uses a default key length: for AES this is set to 128 bits. For more information on cryptography on Android refer to the documentation 🔍

Using the cipher to encrypt

To encrypt data using the cipher just call its doFinal(byte[] input) method. For decryption later on, we need to keep hold of the cipher's initialization vector.

val clearTextData: ByteArray
val encryptedData: ByteArray = cipher.doFinal(clearTextData)

// save encrypted data & init vector: i.e. in shared preferences
val dat = Base64.encodeToString(encryptedData, Base64.DEFAULT)
val iv  = Base64.encodeToString(cipher.iv, Base64.DEFAULT)
sharedPreferences.edit {
    putString(ENCRYPTED_DATA, dat)
    putString(INITIALIZATION_VECTOR, iv)
}
Enter fullscreen mode Exit fullscreen mode

Decryption

To decrypt the encrypted data you'll need

  • previously created SecretKey
  • previously used initialization vector
  • decryption cipher
fun getKey(): Key? {
  val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { 
    load(null) 
  }
  return keyStore.getKey("MY_KEY", null)
}

fun getDecryptCipher(key: Key, iv: ByteArray): Cipher {
  val algorithm = KeyProperties.KEY_ALGORITHM_AES
  val blockMode = KeyProperties.BLOCK_MODE_CBC
  val padding = KeyProperties.ENCRYPTION_PADDING_PKCS7
  return Cipher.getInstance("$algorithm/$blockMode/$padding").apply { 
    init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) 
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the cipher to decrypt

This is quite straight forward and resembles the encryption call

val encryptedData: ByteArray = getEncryptedData()
val clearTextData: ByteArray = cipher.doFinal(encryptedData)
Enter fullscreen mode Exit fullscreen mode

"Tink" different

Like I mentioned earlier I'm not an in depth security expert. For people like me (and others) Google created Tink with the goal of making encryption simpler and harder to misuse.

With Tink's Android flavor com.google.crypto.tink:tink-android:1.2.2 the whole encryption/decryption breaks down to this simpler and easier to understand code:

Config.register(TinkConfig.LATEST)

val keysetHandle = AndroidKeysetManager.Builder()
        .withSharedPref(activity, TINK_KEYSET_NAME, null)
        .withKeyTemplate(AeadKeyTemplates.AES256_GCM)
        .withMasterKeyUri(MASTER_KEY_URI)
        .build().keysetHandle

val aead = AeadFactory.getPrimitive(keysetHandle)
val encrypted = aead.encrypt("SECRET!".toByteArray(), null)
val cleartext = aead.decrypt(encrypted, null)

Enter fullscreen mode Exit fullscreen mode

A lot of internals we've seen before are now hidden: Cipher, initialization vector, encryption parameters etc.

Unforunately the previously discussed flag setUserAuthenticationRequired is not set, and it's currently not possible to pass this AEAD (Authenticated Encryption with Associated Data) to the BiometricPrompt for "authentication".

I created an issue on Github for this. When there's news on the matter I'll write a follow-up post.


Tying things together

Now that we have a working prompt and understand how to encrypt data, we can tie the two together. Just pass the cipher required to en-/decrypt to the BiometricPrompt:

val promptCrypto = BiometricPrompt.CryptoObject(cipher)
biometricPrompt.authenticate(promptInfo, promptCrypto)
Enter fullscreen mode Exit fullscreen mode

The CryptoObject you pass in, will be "unlocked" (remember the flag we set when creating the key) and passed on to the onAuthenticationSucceeded callback method of the previously seen AuthenticationCallback

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
    super.onAuthenticationSucceeded(result)
    result.cryptoObject?.cipher?.let { cipher ->
        // encrypt or decrypt data with cipher
    }
}
Enter fullscreen mode Exit fullscreen mode

Handling errors

Besides the succeeded callback method, there are two other callback methods for failed attempts:

onAuthenticationFailed "Called when a biometric is valid but not recognized". Probably not something you need to address because there's already a default animation implemented in the prompt.

onAuthenticationError "Called when an unrecoverable error has been encountered and the operation is complete. No further actions will be made on this object"

The error callback method provides int errCode, CharSequence errString that can be used to handle different errors and display feedback. For instance

override fun onAuthenticationError(errorCode: Int, 
                                   errorString: CharSequence) {
    super.onAuthenticationError(errorCode, errString)
    when (errorCode) {
        ...
        BiometricPrompt.ERROR_TIMEOUT -> // user fell asleep ;-)
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Refer to the Android documentation to see all available error codes.

Build for convenience

No even though there are lots of different errors that can be handled using the biometric prompt, I'd argue that in most cases you only want to deal with two cases. Either encryption/decryption works 👍 or it doesn't 👎

So why not implement a supporting class that provides convenience for the two cases and encapsulates everything we previously discussed. API Suggestion:

fun encryptPrompt(data: ByteArray,
                  failedAction: () -> Unit,
                  successAction: () -> Unit)

fun decryptPrompt(failedAction: () -> Unit,
                  successAction: (ByteArray) -> Unit)
Enter fullscreen mode Exit fullscreen mode

So in the succeeded and error callbacks of AuthenticationCallback we can do the following:

 Encrypt case

fun onAuthenticationSucceeded(result: AuthenticationResult) {
    super.onAuthenticationSucceeded(result)
    result.cryptoObject?.cipher?.let { resultCipher ->
        val iv = resultCipher.iv
        val encryptedData = resultCipher.doFinal(data)
        saveEncryptedData(encryptedData, iv)
        activity.runOnUiThread { successAction() }
    }
}

fun onAuthenticationError(errCode: Int, errStr: CharSequence) {
    super.onAuthenticationError(errCode, errStr)
    Log.d(TAG, "Authentication error. $errStr ($errCode)")
    activity.runOnUiThread { failedAction() }
}
Enter fullscreen mode Exit fullscreen mode

Decrypt case

fun onAuthenticationSucceeded(result: AuthenticationResult) {
    super.onAuthenticationSucceeded(result)
    result.cryptoObject?.cipher?.let { cipher ->
        val encrypted = getEncryptedData()
        val decryptedData = cipher.doFinal(encrypted)
        activity.runOnUiThread { successAction(decryptedData) }
    }
}

fun onAuthenticationError(errCode: Int, errStr: CharSequence) {
    super.onAuthenticationError(errCode, errStr)
    Log.d(TAG, "Authentication error. $errStr ($errCode)")
    activity.runOnUiThread { failedAction() }
}
Enter fullscreen mode Exit fullscreen mode

Usage

This should enable you to conveniently use the biometric prompt:

val promptManager = BiometricPromptManager(fragmentActivity)
promptManager.encryptPrompt(
    data = secureText.toByteArray(),
    failedAction = { showToast("encrypt failed") },
    successAction = { showToast("encrypt success") }
)

promptManager.decryptPrompt(
    failedAction = { showToast("decrypt failed") },
    successAction = { showToast("decrypt success: $it") }
)
Enter fullscreen mode Exit fullscreen mode

Alternatives

Of course if you require a more detailed error handling or don't like this style you could also

  • use custom response codes and/or do everything in one callback method
  • add different callback actions (for different errors)
  • pass error code to failedAction

Issues

Unfortunately, I came across the following issue when using the AndroidX biometric prompt in a Fragment on Android 8 and earlier.

java.lang.IllegalStateException: FragmentManager is already executing transactions
Enter fullscreen mode Exit fullscreen mode

According to others on Stackoverflow the issue seems to have been introduced with the newest alpha04 version

To "resolve" the issue: you can artificially delay the call with a Handler , or revert to version alpha03.

Handler().postDelayed({
        // launch biometric prompt for Fragment 
}, 0)
Enter fullscreen mode Exit fullscreen mode

The complete code can be found on Github:

GitHub logo stravag / android-sample-biometric-prompt

Biometric Prompt Sample App

android-sample-biometric-prompt

Biometric Prompt Sample App






Happy coding!


Top comments (8)

Collapse
 
imspatni profile image
Shubham Patni

Hi Ranil, great article this is. While implementing feature I'm facing few issue with iris and face-detection. I have added detail explanation here in SO thread, do you see if this is any implementation issue ?

stackoverflow.com/questions/590903...

Collapse
 
imspatni profile image
Shubham Patni

Further I checked with demo you have provided and in that Face-detection is not working, throwing ERROR_NO_BIOMETRICS (11). Checked with device lock screen and in another sample project as well and it is working. Any suggestion around !!

Collapse
 
ranilch profile image
Ranil Wijeyratne

Hi

Is it the same issue that dev.to/landschaft/comment/fekg is mentioning? I've read of several Samsung related issues when it comes to the new Biometric Prompt.

Thread Thread
 
imspatni profile image
Shubham Patni

Thanks Ranil, I got thread where this issue has discussed in detail. issuetracker.google.com/issues/142...

Collapse
 
ranilch profile image
Ranil Wijeyratne • Edited

Perhaps you can somehow check what kind of security features are available and then create the key accordingly, without the setUserAuthenticationRequired if necessary. As long as your specific security requirements allow that of course

Collapse
 
yaroslavshlapak profile image
Yaroslav Shlapak

Really great article and findings! Especially the part with Tink integration.

But it would be better to split "Managers" to several entities like StringCryptoUseCase, EncryptedDataRepository interface, BiometricPromptUseCase which depends on two abovementioned entities.

Collapse
 
raviyadav4875 profile image
Ravi Yadav

There are two types of factors involved here. STRONG and WEAK. Its merely based on OEM decision that from fingerprint or face scan which will behave as STRONG/WEAK. Even you added face scan to unlock your device and its working well there . But unfortunately when you want to use biometric auth library then based on STRONG/WEAK it will give priority.