output
Using Biometrics for Encryption in Android
Introduction
One of the things that make unique mobile platforms is the security, especially when handling sensitive data. We want to ensure that it is the actual owner of the phone who is carrying out the operations, and for this, we can rely on the KeyStore API to leverage Android’s biometrics capabilities to unlock the Cryptographic key only when the user uses a valid biometric method.
In this blog, we will explore how to use Biometrics to allow encryption/decryption of data using the KeyStore and BiometricPrompt API. We will also see how to set a validity time so that you can perform multiple cryptographic operations in a window of time to enhance user experience.
What are we going to build
We will build a TokenRepository that allows us to read and write two tokens; an accessToken and a refreshToken. The access token should be accessible always by our HTTP client in order to make authenticated requests while getting stored in a secure manner using non-biometric encryption, since we don’t want user interaction when accessing the accessToken. On the other hand, we will store our refreshToken encrypted with biometrics and we will only be able to decrypt it with the user’s biometrics. This way it is always clear when the accessToken is expired and we need to get a new one with the refreshToken.
Considerations
As simple as this might sound, there are a few considerations when implementing this feature:
- Showing the Biometric Prompt: The most known implementation of Biometrics+Cryptography requires us to show a biometric prompt every time we want to perform a cryptographic operation (encrypt/decrypt) meaning that if you exchange tokens and receive a new refreshToken, you will need to** show two biometric prompts** (one for decrypting the stored refreshToken and other for encrypting and storing the incoming one) harming UX.
- Toggle biometrics on/off: Many apps have a biometric toggle that allows the user to enable or disable biometrics as a way to authenticate or perform operations on the device.
- Biometric prompt failures: Since we need user interaction to complete a process, we need to account for failures or cancellation of the encryption process.
- Devices without Biometrics enabled or sensors: Some android devices might not even have a biometric sensor or might have it disabled, so we need to be able to handle those scenarions.
Building Blocks
KeyStore: The keystore (although imported trough Java’s cryptographic API) allows us to access the cryptographic key (SecretKey) stored securely on the device.SecretKey: This key gets created/loaded inside theKeyStorewhen enabling biometrics, we will use the methodsetUserAuthenticationRequiredto tell the KeyStore to only load a key when a biometric sensor successfully detects the user’s biometrics.
Cipher: The Cipher is used to perform the actual encryption/decryption it requires the following information:- The** cryptographic** key (
SecretKey) - The Transformation which species the encryption algorithm, block mode and padding scheme.
- A operation mode wether the cipher will encrypt or decrypt
- The** cryptographic** key (
BiometricPrompt: This class comes as an external dependency, but it allows us to show a system biometric prompt, which will allow our keys that havesetUserAuthenticationRequiredenabled to be available for cryptographic operations.
Hands-on
1. Define our Repository Interface & Actions
We first need to define a high-level definition of our repository to visualise what we want it to do, we need to:
- Get the access token, without any need for biometric propmpt
- Get Refresh token, which could be protected by biometrics.
- Set the tokens, if biometrics are enabled we need to store the refreshToken with biometric-encrypted key.
- Know if the biometrics are available in the device
- Know if the user wants to use biometrics in the app
- Toggle biometrics on/off, if we want to disable, we first need to get the encrypted value and encrypted with a non-biometric secretKey.
- Clear the tokens (i.e. log out)
Note: I’m aware that by using the FragmentActivity we are tying the domain layer to the Android framework, this could be solved by returning aResult where the ViewModel can send an event to the UI to show the biometric prompt and retry the action, but to keep it self-contain I will show with this method.
interface TokenRepository {
suspend fun getAccessToken(): String?
suspend fun getRefreshToken(fragmentActivity: FragmentActivity?): String?
suspend fun setTokens(accessToken: String, refreshToken: String, fragmentActivity: FragmentActivity?)
suspend fun isBiometricsEnabled(): Boolean
suspend fun isBiometricAvailable(): Boolean
suspend fun setBiometricsEnabled(enabled: Boolean, fragmentActivity: FragmentActivity?): Boolean
suspend fun clearTokens()
}
2. Define our Encryption helper
This encryption helper will help us perform a couple of actions, like encrypt and decrypt using different cryptographic keys, specifically a MASTER\_KEY\_ALIAS that is not protected by biometrics and a BIOMETRICS\_KEY\_ALIAS that will be protected by biometrics. It also contains a BiometricsHelper which just helps us show a biometric prompt and just listen for a result.
Additionally, note that when creating our Biometric-protected key, it uses the setUserAuthenticationParameters , which takes a BIOMETRIC\_PROMPT\_TIMEOUT\_SECONDS , this is what will allow us to perform multiple cryptographic operations in that window of time (BIOMETRIC\_PROMPT\_TIMEOUT\_SECONDS) without having to show the biometric prompt multiple times. Note that this **forces you to provide fallback to non-biometric credentials and it won’t allow you to pass a CryptoObject to the **authenticate() method.
For super sensitive data it is better to pass the CryptoObject to the authenticate() method to bind the Cipher to the actual success of the BiometricPrompt and to ensure actual user presence.
class EncryptionHelper(val context: Context) {
private fun generateSecretKey(keyGenParameterSpec: KeyGenParameterSpec) {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKey()
}
private fun keyExists(alias: String): Boolean {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null)
return keyStore.containsAlias(alias)
}
init {
// Create master key only if missing
if (!keyExists(MASTER_KEY_ALIAS)) {
generateSecretKey(
KeyGenParameterSpec.Builder(
MASTER_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
// .setRandomizedEncryptionRequired(true) // default is true
.build()
)
}
// Create biometrics key only if missing
if (!keyExists(BIOMETRICS_KEY_ALIAS) && isBiometricAvailable()) {
generateSecretKey(
KeyGenParameterSpec.Builder(
BIOMETRICS_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setUserAuthenticationRequired(true)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setUserAuthenticationParameters(
BIOMETRIC_PROMPT_TIMEOUT_SECONDS,
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
)
}
}
.build()
)
}
}
private fun getSecretKey(method: Method): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
// Before the keystore can be accessed, it must be loaded.
keyStore.load(null)
return keyStore.getKey(if(method == Method.BIOMETRICS) BIOMETRICS_KEY_ALIAS else MASTER_KEY_ALIAS, null) as SecretKey
}
private fun getCipher(): Cipher {
return Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/"
+ KeyProperties.BLOCK_MODE_CBC + "/"
+ KeyProperties.ENCRYPTION_PADDING_PKCS7)
}
fun encrypt(data: String, method: Method): String {
if (data.isEmpty()) return ""
val cipher = getCipher()
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey(method))
val iv = cipher.iv
val ciphertext = cipher.doFinal(data.toByteArray(StandardCharsets.UTF_8))
val combined = ByteArray(iv.size + ciphertext.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
return Base64.encodeToString(combined, Base64.DEFAULT)
}
fun decrypt(encryptedData: String, method: Method): String? {
if (encryptedData.isEmpty()) return null
return try {
val combined = Base64.decode(encryptedData, Base64.DEFAULT)
val cipher = getCipher()
if (combined.size < cipher.blockSize + 1) {
return null
}
val iv = Arrays.copyOfRange(combined, 0, cipher.blockSize)
val ciphertext = Arrays.copyOfRange(combined, cipher.blockSize, combined.size)
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(method), ivSpec)
val decryptedBytes = cipher.doFinal(ciphertext)
String(decryptedBytes, StandardCharsets.UTF_8)
} catch(e: UserNotAuthenticatedException) {
throw e
}
catch (e: Exception) {
null
}
}
fun isBiometricAvailable(): Boolean {
val status = BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
return status == BiometricManager.BIOMETRIC_SUCCESS
}
inner class BiometricsHelper {
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Enter biometric to access this data")
.setSubtitle("To have access to refresh token we need access to your fingerprint")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.build()
suspend fun showBiometricPrompt(
fragmentActivity: FragmentActivity,
): Result<Boolean> {
return suspendCancellableCoroutine { continuation ->
val biometricPrompt = BiometricPrompt(fragmentActivity, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
continuation.resumeWith(Result.failure(EncryptionError.BiometricCancelled(errString.toString())))
}
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
continuation.resumeWith(Result.success(Result.success((true))))
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
Toast.makeText(
context, "Authentication failed",
Toast.LENGTH_SHORT
)
.show()
}
})
biometricPrompt.authenticate(promptInfo)
}
}
}
companion object {
private const val MASTER_KEY_ALIAS = "auth_master_key"
private const val BIOMETRICS_KEY_ALIAS = "auth_biometrics_key"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val BIOMETRIC_PROMPT_TIMEOUT_SECONDS = 5
enum class Method {
BIOMETRICS, GENERIC
}
sealed class EncryptionError: Exception() {
data class BiometricCancelled(override val message: String?) : EncryptionError()
}
}
}
3. Defining our TokenRepository Implementation
With that defined, we can now implement our actual TokenRepository, which will
- Handle the persistency of our encrypted tokens using dataStore
- Handle persistency and update the biometrics preference
- Allow to get / set both tokens
- Most important, allow to encrypt/decrypt the refresh token based on the current biometric preference and handle the
UserNotAuthenticatedException, show the biometric prompt and try again. Handled byprocessDataWithCurrentMethod
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_tokens")
class TokenRepositoryImpl(
val context: Context,
val encryptionHelper: EncryptionHelper = EncryptionHelper(context)
): TokenRepository {
private val dataStore = context.dataStore
override suspend fun getAccessToken(): String? {
return dataStore.data.map { preferences ->
preferences[ACCESS_TOKEN_KEY]?.let {
encryptionHelper.decrypt(it, GENERIC)
}
}.first()
}
override suspend fun getRefreshToken(fragmentActivity: FragmentActivity?): String? {
return dataStore.data.map { preferences ->
preferences[REFRESH_TOKEN_KEY]?.let {
processDataWithCurrentMethod(
it,
fragmentActivity,
Action.Decrypt
).getOrNull()
}
}.first()
}
override suspend fun setTokens(accessToken: String, refreshToken: String, fragmentActivity: FragmentActivity?) {
dataStore.edit { preferences ->
preferences[ACCESS_TOKEN_KEY] = encryptionHelper.encrypt(accessToken, GENERIC)
processDataWithCurrentMethod(refreshToken, fragmentActivity, Action.Encrypt).getOrNull()?.let {
preferences[REFRESH_TOKEN_KEY] = it
}
}
}
override suspend fun isBiometricsEnabled(): Boolean {
return dataStore.data.map { preferences ->
preferences[BIOMETRICS_ENABLED]
}.first() ?: false
}
override suspend fun isBiometricAvailable(): Boolean = encryptionHelper.isBiometricAvailable()
override suspend fun setBiometricsEnabled(enabled: Boolean, fragmentActivity: FragmentActivity?): Boolean {
val refreshToken = getRefreshToken(fragmentActivity) ?: return isBiometricsEnabled()
dataStore.edit { preferences ->
preferences[BIOMETRICS_ENABLED] = enabled
}
val refreshTokenResult = processDataWithCurrentMethod(refreshToken, fragmentActivity, Action.Encrypt).getOrNull() ?: run {
dataStore.edit { preferences ->
preferences[BIOMETRICS_ENABLED] = !enabled
}
return isBiometricsEnabled()
}
dataStore.edit { preferences ->
preferences[REFRESH_TOKEN_KEY] = refreshTokenResult
}
return isBiometricsEnabled()
}
override suspend fun clearTokens() {
dataStore.edit { preferences ->
preferences.remove(ACCESS_TOKEN_KEY)
preferences.remove(REFRESH_TOKEN_KEY)
}
}
// Helper functions
private suspend fun processDataWithCurrentMethod(
data: String,
fragmentActivity: FragmentActivity?,
action: Action
): Result<String> {
val method = if (isBiometricsEnabled()) BIOMETRICS else GENERIC
return try {
val result = if (action == Action.Encrypt) {
encryptionHelper.encrypt(data,method)
} else {
encryptionHelper.decrypt(data,method)
}
Result.success(result ?: return Result.failure(Exception("Operation failed")))
} catch (e: UserNotAuthenticatedException) {
try {
encryptionHelper.BiometricsHelper().showBiometricPrompt(fragmentActivity!!).fold(
onSuccess = {
val result = if (action == Action.Encrypt) {
encryptionHelper.encrypt(data,method)
} else {
encryptionHelper.decrypt(data,method)
}
Result.success(result ?: return Result.failure(Exception("Operation failed")))
},
onFailure = {
Result.failure(it)
}
)
} catch (e: Exception) {
Result.failure(e)
}
} catch (e: Exception) {
Result.failure(e)
}
}
companion object Companion {
private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
private val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")
private val BIOMETRICS_ENABLED = booleanPreferencesKey("biometrics_enabled")
private enum class Action {
Encrypt, Decrypt;
}
}
}
Conclusion
Using biometric-backed encryption in Android is quite similar to using regular encryption APIs, but when having to consider real-life scenarios, like toggles, multiple cryptographic operations in the same session, etc. It could get a bit more complex. Hopefully after this blog you have a clear base on how to implement biometrics. While the structure is not production-ready and could use some improvements, the basics are shown here.
References
Show a biometric authentication dialog | Identity | Android Developers