I recently tried out Biometric authentication on Android using the new Biometric library. The new API is really good. The OS now provides a dialog for biometric authentication, making things smoother and more consistent for users. Google even has this neat guide to help get you started with it. Unfortunately it is a bit lackluster, it was not clear to me how to get the whole thing running correctly. I wanted to expand on it and write a better guide on how to store a key for encryption/decryption on the Android keystore and retrieve it using the user’s fingerprint.
The use case I have in mind is the user login. My goal is to provide an alternative to the username/password form with a biometric dialog. After the user’s first successful log in, I’ll encrypt the user’s password and keep it my app’s local storage. The key for encryption/decryption will be stored in the Android keystore and will require biometric authentication for access.
Installation
To use the new library we have to include this line in the app’s build.gradle dependencies
block:
// 1.1.0 is the latest stable version at the time of writing
("androidx.biometric:biometric:1.1.0") implementation
Check that biometric authentication is available
Although the new library supports up to API 23, not all devices actually have
biometric features. Before attempting anything, we must check if biometric
authentication is available using BiometricManager.canAuthenticate()
:
val biometricManager = BiometricManager.from(context)
return when (val result =
.canAuthenticate(BIOMETRIC_STRONG)) {
biometricManager.BIOMETRIC_SUCCESS ->
BiometricManager("start biometric authentication")
TODO
.BIOMETRIC_ERROR_NONE_ENROLLED -> {
BiometricManager("take user to biometric enrollment")
TODO}
else -> TODO("biometric authentication not available. Show error message or skip")
}
We pass the BIOMETRIC_STRONG
flag because fingerprint is considered strong
authentication. The keystore does not allow weak authentication for accessing keys.
This method returns different status codes that we have to handle. If the result
is BIOMETRIC_SUCCESS
, we are good to go. If the result is
BIOMETRIC_ERROR_NONE_ENROLLED
, the user needs to enroll a fingerprint on system
settings. You can launch an activity for that, but it’s only available on API 30:
val intent = Intent(Settings.ACTION_BIOMETRIC_ENROLL).apply {
(
putExtra.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED,
Settings
BIOMETRIC_STRONG)
}
(intent) startActivity
Generate a secret key in the Android keystore
Once we have determined the user’s device is ready for biometric
authentication, we need to generate a secret key for encryption/decryption.
To create it on the Android keystore, we need an instance of
KeyGenParameterSpec
:
const val KEY_SIZE = 256
const val SECRET_KEY_NAME = "mySecretKeyName"
.Builder(
KeyGenParameterSpec,
SECRET_KEY_NAME.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
KeyProperties)
.setKeySize(KEY_SIZE)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setUserAuthenticationRequired(true)
.build()
This builder object defines the following things about our key:
- It can encrypt and decrypt.
- Its size is 256 bytes.
- It uses Cipher Block Chaining (CBC) block mode.
- It uses PKCS7 encryption padding scheme.
- Requires user authentication for access. (This is essential for biometric authentication)
Then you can use KeyGenerator
to generate it:
val keyGenerator = KeyGenerator.getInstance(
.KEY_ALGORITHM_AES, "AndroidKeyStore"
KeyProperties)
.init(keyGenParameterSpec)
keyGenerator.generateKey() keyGenerator
Encrypt the user password with the new key
Now that the key is ready, we can use it to create a Cipher
object that can encrypt the user password with AES. Passwords
should never be stored as plain text. We should wait until the user
authenticates with their password, grab it, and then create our cipher:
const val SECRET_KEY_NAME = "mySecretKeyName"
private fun getSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
// Before the keystore can be accessed, it must be loaded.
.load(null)
keyStorereturn keyStore.getKey(SECRET_KEY_NAME, null) as SecretKey
}
private fun getCipher(): Cipher {
return Cipher.getInstance(
.KEY_ALGORITHM_AES + "/"
KeyProperties+ KeyProperties.BLOCK_MODE_CBC + "/"
+ KeyProperties.ENCRYPTION_PADDING_PKCS7
)
}
val cipher = getCipher()
val secretKey = getSecretKey(secretKeyName)
.init(Cipher.ENCRYPT_MODE, secretKey) cipher
Please note that this cipher wont work until the user authenticates. Remember
that .setUserAuthenticationRequired(true)
in the KeyGenParameterSpec
builder? We have to pass the cipher to BiometricPrompt
so that
we can use it after the user authenticates with their fingerprint.
val executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationFailed() {
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
(errorCode, errString)
handleError.makeText(this@MainActivity, errString, Toast.LENGTH_LONG).show()
Toast("exit to next screen")
TODO}
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
// cryptoObject cannot be null because we
// explicitly pass it on authenticate()
val resultCipher = result.cryptoObject!!.cipher
(resultCipher, password)
encryptAndPersistPassword("exit to next screen")
TODO}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric login for my app")
.setSubtitle("Log in using your biometric credential")
.setNegativeButtonText("Use account password")
.setAllowedAuthenticators(BIOMETRIC_STRONG)
.build()
.authenticate(
biometricPrompt,
promptInfo.CryptoObject(cipher)
BiometricPrompt)
Here we create a BiometricPrompt
object,setting the callbacks for events that
occur while the user interacts with the dialog. BiometricPrompt.authenticate()
takes a PromptInfo
object with the text that we want to display and a
CryptoObject
with our cipher to show the biometric authentication to the
user. Now let’s talk about each callback:
In the onAuthenticationFailed()
callback we don’t really need to do anything.
This gets called every time the fingerprint does not match. The dialog shows this
error and lets the user retry multiple times before cancelling the operation.
onAuthenticationError()
is for errors that cancel the operation. Maybe the user
exceeded the maximum number of attempts, or it got cancelled by user action. Most
of the time we can just show the error to the user and exit, but there are a few
errors that should be handled differently. You can see this in handleError()
:
private fun handleError(errorCode: Int, errString: String) {
if (errorCode == ERROR_NEGATIVE_BUTTON) {
// User clicked negative button to close the dialog
("exit to next screen")
TODOreturn
}
if (errorCode == ERROR_USER_CANCELED) {
// User clicked outside the dialog to close it
("exit to next screen")
TODOreturn
}
// Show the error to the user
.makeText(this@MainActivity, errString, Toast.LENGTH_LONG).show()
Toast("exit to next screen")
TODO}
onAuthenticationSucceeded()
is where we get our cipher back with a valid key
and encrypt the password. Here’s the implementation of
encryptAndPersistPassword()
:
private fun encryptAndPersistPassword(cipher: Cipher, password: String) {
private val defaultCharset = Charset.forName("UTF-8")
val encryptedBytes = resultCipher.doFinal(
.toByteArray(defaultCharset)
secret)
val encryptedString = encryptedBytes.toBase64String()
val ivString = resultCipher.iv.toBase64String()
("persist encryptedString and ivString")
TODO}
After encrypting there are two byte arrays that we need to persist: the password
and the initialization vector (IV). The cipher won’t be able to
decrypt the password unless you provide both. For persisting you could use SQLite
or just SharedPreferences
. It’s far easier to persist strings than byte arrays, so I convert them to
Base64 strings. These are the extension
methods that I use:
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
fun String.toBase64ByteArray() = Base64.decode(this, Base64.DEFAULT)
Decrypt the user password
The next time the user wants to sign in we can just decrypt the password and
instantly authenticate with the server. Once again, we have to create a
Cipher
object, but this time in DECRYPT_MODE
and pass the IV we persisted at
the end of the previous section:
val cipher = getCipher()
val secretKey = getSecretKey()
val ivArray = ivString.toBase64ByteArray()
.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(ivArray)) cipher
Then, similar to the previous section, we have to create a BiometricPrompt
instance and call authenticate()
:
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric login for my app")
.setSubtitle("Log in using your biometric credential")
.setNegativeButtonText("Use account password")
.build()
.authenticate(
biometricPrompt,
promptInfo.CryptoObject(cipher)
BiometricPrompt)
The BiometricPrompt
callback implementations are pretty similar to last time.
Error handling on onAuthenticationError()
is bit different because
ERROR_NEGATIVE_BUTTON
now has to revert the UI to the old username/password form.
if (errorCode == ERROR_NEGATIVE_BUTTON) {
// User clicked negative button to close the dialog
TODO("revert to username/password form")
return
}
if (errorCode == ERROR_USER_CANCELED) {
// User clicked outside the dialog to close it
return
}
// Show the error to the user
Toast.makeText(this@MainActivity, errString, Toast.LENGTH_LONG).show()
TODO("exit to next screen")
Finally, the code to decrypt the password on onAuthenticationSucceeded()
is very straightforward:
val decryptedPassword = resultCipher.doFinal(
.toBase64ByteArray()
encryptedString)
("log in with the decrypted password") TODO
And that’s it. We now have biometric authentication in our app. The new biometric prompt has great UX. If something goes wrong, the user can always fallback to the username/password form.