How to secure data on Android?

Storing encrypted data with biometric authentication

Mobile@Exxeta
Mobile App Circular
6 min readMay 3, 2024

--

Photo by Wiredsmart from Pexels

A common problem is that with increasing security, usability becomes worse. When protecting your app with a password or a login, you get forced to log in every time you open the app, which might become very annoying. For authentication, three patterns can be considered.

  1. knowing something, like a password
  2. having something, like a second factor
  3. being something that refers to biometric factors, such as a fingerprint or face

This is an approach already used in a lot of apps. We want to show you how to incorporate this into your app to protect some resources and even to require a fingerprint for protecting keys for cryptographic operations.
We will start with adding a fingerprint to your application.

Biometric authentication

In order to request a fingerprint (or any other biometric input) you must start a BiometricPrompt. To be able to create a BiometricPrompt you have to construct an instance that accepts the hosting activity, an optional executor and the callback to be called with the result. The code looks like this:

val executor = ContextCompat.getMainExecutor(activity) 

val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errCode: Int, errString: CharSequence) {
super.onAuthenticationError(errCode, errString)
// This method is called when an error occurs where authentication
// cannot be continued. For example if no biometric is enrolled
}

override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
// This method is called when a biometric is presented but not
// valid. For example it was not recognized
}

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
// This method is called when the authentication succeeded

}
}
val biometricPrompt = BiometricPrompt(activity, executor, callback)

To present this BiometricPrompt you need to call the authenticate method and pass a PromptInfo object.

biometricPrompt.authenticate(createPromptInfo(context)) 

You can pass some configuration parameters to the PromptInfo object. For example, you can set a title, subtitle, and description. But also, a negative text, where the user can request to authenticate with the device credentials. Moreover, you can also configure if the user is automatically forwarded after authentication (for example after just looking into the camera) or if it is necessary to manually confirm the entered biometric input.

fun createPromptInfo(activity: FragmentActivity): BiometricPrompt.PromptInfo { 

return BiometricPrompt.PromptInfo.Builder().apply {
setNegativeButtonText(activity.getString(R.string.prompt_info_use_app_password))
setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG)
setTitle(activity.getString(R.string.prompt_info_title))
setSubtitle(activity.getString(R.string.prompt_info_subtitle))
setDescription(activity.getString(R.string.prompt_info_description))
setConfirmationRequired(false)
}.build()

}

And that’s it. Now you can create a Biometric Prompt from inside your Composablesor Fragments.

In the next step, we will combine this with encrypting and storing data using the Java KeyStoreand DataStore.

Secure Storage

To secure the data we want to store, we encrypt it using symmetric encryption with the AES algorithm. Since we don’t want to share the key, there is no need for using an asymmetric encryption and we can use a smaller key. To store the key, we’re using the Java KeyStore. In the following code snippets, you can see how to create and configure the key.

The first snippet loads the KeyStore instance and tries to load an existing key with a password. This password is optional (so null is also a valid value) but increases security since it protects the key stored in the KeyStore. To make the key protected by the users, they would need to pass through even more prompts in order to access your app, so it’s another tradeoff between security and usability. You might also consider using a hard-coded password, so other apps cannot access this key. If a key already exists, it will be returned.

val keyStore = KeyStore.getInstance(“AndroidKeyStore“) 
keyStore.load(null)

keyStore.getKey(SECRET_KEY_NAME, password)?.let { return it as SecretKey }

If there is no key, it will be created using the following code:

val paramsBuilder = KeyGenParameterSpec.Builder( 
SECRET_KEY_NAME,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)

paramsBuilder.apply {
setBlockModes(KeyProperties.BLOCK_MODE_GCM)
setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
setKeySize(256)
setRandomizedEncryptionRequired(true)
setUserAuthenticationRequired(false)
}

val keyGenParams = paramsBuilder.build()
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)

keyGenerator.init(keyGenParams)
return keyGenerator.generateKey()

First, we pass some properties like the name of the key and the purpose (encrypting and decrypting). Next, we specify some more properties. The first is for the encrypting algorithm itself. We use Blockmode CGM, no padding and a key size of 256 bits. After that, we ensure that our encryption must use a sufficient randomized key. The last parameter can be used to enforce user authentication. For this, a BiometricPrompt with a cipher object must be used. More on this later when we combine the BiometricPrompt with encryption. We want to start by showing you how to use the SecretKey to encrypt and decrypt data.

Encrypting data — the simple way

To just encrypt data without any biometric prompt, the steps that need to be done are:

1. Initialize the cipher:

val cipher = Cipher.getInstance("AES/GCM/NoPadding") 

val secretKey = getOrCreateSecretKey()
cipher.init(Cipher.ENCRYPT_MODE, secretKey) val ciphertext = cipher.doFinal(plaintext.toByteArray(Charset.forName("UTF-8")))

2. Encrypt the data:

val ciphertext = cipher.doFinal(plaintext.toByteArray(Charset.forName("UTF-8"))) 

3. Store the data:

To store the data, wrap the ciphertext and the initialization vector in a data class, convert this data class to JSON and store it in the DataStore. Full code might look like this:

val cipher = getInitializedCipherForEncryption() 
val encrypted = encryptData(secret, cipher)
val json = gson.toJson(encrypted)
datastore.edit { ds ->
ds[PREF_KEY] = json
}

To read the data, we are going the other way round

Decrypting data — the simple way

1. Reading the cipher:

val json = datastore.data.map { it[PREF_KEY] }.firstOrNull() 

json?.run {
val wrapper = gson.fromJson(this, Wrapper::class.java)
}

2. Initializing the cipher:

val cipher = Cipher.getInstance("AES/GCM/NoPadding") 
val secretKey = getOrCreateSecretKey()
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, wrapper.initializationVector))

3. Decrypting the data:

val plaintext = cipher.doFinal(wrapper.ciphertext) 
val plainTextString = String(plaintext, Charset.forName("UTF-8"))

A note on developing. We faced some issues during development because we were never deleting our key. So if you want to change some key parameters, make sure to also delete your key:

val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) 
keyStore.load(null)

keyStore.deleteEntry(SECRET_KEY_NAME)

Now that we are able to encrypt and decrypt data without any further user interaction, we will take this one step further and add biometric authentication to secure the cipher.

Biometry and Cipher

To ensure the cipher is unlocked by biometric authentication, a lambda will be passed to the encryption and decryption methods. This lambda accepts the cipher that needs to be unlocked and returns the unlocked cipher. For the storing method, the signature changes to a method like this.

suspend fun storeWithUserPrompt( 
secret: String,
requestAuthentication: suspend (Cipher) -> Cipher?
)

For reading, a secret is omitted but a String? Is returned.

suspend fun readWithUserPrompt(
requestAuthentication: suspend (Cipher) -> Cipher?,
): String?

So anytime a call to getInitializedCipherForEn/Decryption happens, this call will be wrapped like this:

val cipher = requestAuthentication(getInitializedCipherForEn/Decryption()) 

The calling instance needs to unlock the cipher. This will be done with a modified version of the earlier mentioned BiometricPrompt.

First, the method to create the BiometricPrompt will get a return type to make this easier to use. Without this tweak, we would have to pass lambdas into lambdas, which can become very confusing. For this, the creation of the BiometricPrompt will be wrapped with a suspendCancellableCoroutine. Another modification we want to apply is the cipher which will be unlocked. This cipher must be wrapped inside a BiometryPrompt.CryptoObject.

suspend fun createAppPasswordPrompt( 
context: FragmentActivity,
cipher: Cipher
): BiometricPrompt.AuthenticationResult? {
return suspendCancellableCoroutine { result ->
createBiometricPrompt(
context,
{ result.resume(it) },
{ result.resume(null) }
)
.authenticate(
createPromptInfo(context), BiometricPrompt.CryptoObject(cipher)
)
}
}

private fun createBiometricPrompt(
activity: FragmentActivity,
processSuccess: (BiometricPrompt.AuthenticationResult) -> Unit,
processError: () -> Unit
): BiometricPrompt

The result of this method is an optional, which contains the unlocked cipher.

Putting it all together, you can access the secure storage through this:

storeWithUserPrompt(secret){ cipher -> 
BiometricPromptUtils.createAppPasswordPrompt(context, cipher)?.cryptoObject?.cipher
}

After implementing this, you could now enforce biometric authentication by using.

setUserAuthenticationRequired(true) 

Conclusion

In this article, you learned about encrypting and storing data in Android and protecting the key used for encryption and decryption with user authentication using biometric data, for example, fingerprint or face.

Tell us in the comment section about your experiences with biometry and secure storage. What is your opinion or experience with storing sensitive information? (by Jonathan Merkel)

--

--

Passionate people @ Exxeta. Various topics around building great solutions for mobile devices. We enjoy: creating | sharing | exchanging. mobile@exxeta.com