Before Android 9 Pie, your options to implement a fingerprint scanning dialog were:
- Build your own from scratch
- Copy Google's sample implementation
- Use some unofficial library
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'
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)
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() {})
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()
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)
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)
}
}
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)
}
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))
}
}
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)
"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)
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)
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
}
}
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 ;-)
...
}
}
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)
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() }
}
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() }
}
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") }
)
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
According to others on Stackoverflow the issue seems to have been introduced with the newest alpha04 version
- https://stackoverflow.com/questions/55683300/android-crashed-after-updating-androidx-biometric-to-1-0-0-alpha04
- https://stackoverflow.com/questions/55934108/fragmentmanager-is-already-executing-transactions-when-executing-biometricprompt
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)
The complete code can be found on Github:
stravag / android-sample-biometric-prompt
Biometric Prompt Sample App
android-sample-biometric-prompt
Biometric Prompt Sample App
Happy coding!
Top comments (8)
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...
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 !!
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.
Thanks Ranil, I got thread where this issue has discussed in detail. issuetracker.google.com/issues/142...
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 courseReally 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.
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.