How to integrate cryptographic keys with fingerprint authentication in your Android app

by: | Feb 26, 2020

Recently, I wrote about how to add fingerprint authentication to an Android app — and why that’s a good thing for user experience and app security. If you want to take security to the next level, the Android M API provides us with another useful feature: the ability to generate and use cryptographic keys that are only accessible through fingerprint authentication.

Why cryptographic keys?

As an Android app development company, we’re always on the lookout for tools that can help keep user data safe and protect phones from malicious software. By encrypting data within an app, it is much harder for anyone (or anything) to access it without user permission.

The simplest use case: By encrypting private digital files (e.g. photos or documents) in an app and decrypting them only when needed, no one will be able to identify the content of these files without decrypting them first — even if they get access to the phone itself.

A more complex use case: Using an asymmetric cryptographic key stored within the AndroidKeyStore in an app that supports financial transactions, to ensure authentic signatures for contracts or legal documents.

Now that you have a better understanding why app developers may want to use cryptographic keys, let’s review step-by-step instructions how to create cryptographic keys and secure them with fingerprint authentication.

Step 1: Generate or retrieve cryptographic keys

This is a highly business dependent step, and it varies according to the type of encryption required for specific situations. I’ll explain the role of the main objects used here, but I won’t dive too deeply into cryptographic details.

To start, we’ll create a Cipher instance. This class provides the functionality of a cryptographic Cipher for encryption and decryption. The instance we get will be locked at the beginning of the authentication process and unlocked at the end. In other words, we can’t use this Cipher to encrypt or decrypt anything before the user is successfully authenticated. The Cipher needs a cryptographic key. If we haven’t created one yet, we first need to create it and give it an alias. Otherwise, we just need to retrieve it by using its alias.

Generating a cryptographic key

To be able to create a cryptographic key, we need to set up its specifications using a KeyGenParameterSpec.

val keySpec = KeyGenParameterSpec.Builder(
  KEY_ALIAS, // (1)
  KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT // (2)
)
  .setKeySize(KEY_SIZE_IN_BITS) // (3)
  .setBlockModes(KeyProperties.BLOCK_MODE_CBC) // (4)
  .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) // (5)
  .setUserAuthenticationRequired(true) // (6)
  .build()

At line (1), we set an alias for this key. We’ll use that later to retrieve it.

At line (2), we specify the purpose of this key. In this case we’ll be using a symmetrical cryptographic algorithm, which means that we can encrypt and decrypt using the same key, so we set it for PURPOSE_ENCRYPT or PURPOSE_DECRYPT.

With lines (3), (4) and (5), we configure some parameters related to the cryptographic algorithm itself. Android’s security documentation references the supported configurations.

The configuration for fingerprint authentication is set at line (6). With this line, we tell the system to put this cryptographic key in a special vault that can only be accessible if the user is authenticated by the system

For the next step, we create an instance of the KeyGenerator, initialize it with the keySpec, and then create a cryptographic key.

val keygen = KeyGenerator.getInstance(
  KeyProperties.KEY_ALGORITHM_AES, // (1)
  "AndroidKeyStore" // (2)
).apply { init(keySpec) } // (3)

val key: SecretKey = keygen.generateKey() // (4)

At line (1), we set the encryption algorithm. In this case, we’re using the AES (Advanced Encryption Standard) algorithm. Pay attention to the previous step’s lines (3), (4), and (5) — and their compatibility with the chosen algorithm.

At line (2), we set the keystore provider to be AndroidKeyStore, which is managed by the Android system. A keystore is a repository for storing keys. Here is a list of supported keystore algorithms.

At line (3), we initialize the KeyGenerator with the KeyGenParameterSpec, and that sets the desired properties to our key.

Finally, at line (4), we generate the key.

Retrieving a key

To retrieve cryptographic keys we need a reference to the AndroidKeyStore instance, which is where we stored our key. We retrieve it using the same alias from the KeyGenParameterSpec.

val keystore = KeyStore.getInstance("AndroidKeyStore")
  .apply { load(null) }

val key = keystore.getKey(KEY_ALIAS, null)

Creating the Cipher

This step varies depending upon whether you are creating a Cipher for encryption or decryption and the chosen algorithm.

// Encryption
val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding")
  .apply { init(Cipher.ENCRYPT_MODE, key) }

// Decryption
val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding")
  .apply { init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) }

When creating a Cipher, we first set an instance for the configured algorithm, in this case AES/CBC/PKCS7Padding. If it’s for encryption, we initialize it with Cipher.ENCRYPT_MODE and pass the key we have. Otherwise, we initialize it with Cipher.DECRYPT_MODE, the key and the specification for the iv.

The IV stands for initialization vector. It’s an arbitrary number, usually random or pseudo-random, which is used along with the key. It’s not a secret — it can be appended to the encrypted data and sliced off and used for decryption. The use of an IV prevents the repetition of data in the encryption, making it more difficult to hack it using dictionaries. So, if we use the same key to encrypt the same data, it’s going to generate different encrypted data every time, as long as we use a different iv. We don’t set it when creating a Cipher for encryption because the Cipher can create it automatically using a SecureRandom. However, you can set it manually. When creating a Cipher for decryption, we need to initialize it with the IV that was used for encryption. After the encryption is done, we’ll save the IV together with the encrypted data.

Step 2: Check Android device support

This step is similar to how you would add Android fingerprint authentication to an app. Start by checking if the device supports fingerprint authentication by calling:

BiometricManager.from(context).canAuthenticate()

This method returns an int, which can have one of these values:

public static final int BIOMETRIC_ERROR_HW_UNAVAILABLE = 1;
public static final int BIOMETRIC_ERROR_NONE_ENROLLED = 11;
public static final int BIOMETRIC_ERROR_NO_HARDWARE = 12;
public static final int BIOMETRIC_SUCCESS = 0;

Obviously, you should only show the option for biometric authentication if it’s available on the device, that is, if canAuthenticate()returns BIOMETRIC_SUCCESS. Some smartphones that had fingerprint scanners before Android officially supported it will return BIOMETRIC_ERROR_HW_UNAVAILABLE. Generally speaking, those smartphones were released in APIs before 23 and could be updated to 23 (like the Samsung Galaxy Note 4). They can’t be used by the official API because they don’t attend to some specifications given by the Android M Compatibility Definition Document, including this one:

“MUST have a hardware-backed keystore implementation, and perform the fingerprint matching in a Trusted Execution Environment (TEE) or on a chip with a secure channel to the TEE.”

Step 3: Create a BiometricPrompt instance in the Android app

We need to create an instance of the BiometricPrompt in the Android app that will prompt the user for fingerprint authentication. Its constructor has three parameters:

  • Fragment or FragmentActivity: A reference to the client’s activity
  • Executor: An executor to handle callback events
  • BiometricPrompt.AuthenticationCallback: An object to receive authentication events

The first two arguments are quite direct. For the first argument, use the calling FragmentActivity or Fragment. For the second one, you should use the executor that best fits your needs. For simplicity, we’re going to use the mainExecutor.

The third argument deserves more attention. It will receive the authentication results (such as if there was an error, or if it was canceled or successful) and can give us a CryptoObject associated with the authentication.

val biometricPrompt = BiometricPrompt(
  this, // assuming it's being called inside an Activity
  mainExecutor,
  callback
)

For the callback, we have:

val callback = object : BiometricPrompt.AuthenticationCallback() {

  override fun onAuthenticationSucceeded(
    result: BiometricPrompt.AuthenticationResult
  ) {  
    // user authenticated
    val cipher = result.cryptoObject?.cipher
    ...
  }  
  
  override fun onAuthenticationError(
    errorCode: Int,
    errString: CharSequence
  ) {  
    // called when an unrecoverable error has been encountered 
    // and the operation is complete
    // example: user clicked the negative button or 
    // tried too many times and is locked up
  }  
  
  override fun onAuthenticationFailed() {  
    // called when biometric was valid but not recognized
    // example: the user tried to authenticate 
    // with a finger that isn't registered
  }
	
}

You should be asking yourself, “Where does this Cipher come from?” For now, just keep in mind that this is the same Cipher from Step 1, and you should know what type of Cipher you’re expecting. For example, if you have a Cipher configured for encryption on Step 1, this callback returns a Cipher that can only be used for encryption. For better organization, you can create two callbacks — one for encryption and one for decryption.

With the unlocked Cipher at hand, you can do the actual encryption or decryption.

This is how the code for encrypting might look:

// Encrypting
val unencryptedBytes = "some blob of data".toByteArray(Charset.forName("UTF-8"))
val encryptedBytes = cipher.doFinal(bytes)
saveEncryptedData(encryptedBytes, cipher.iv)

And for decryption:

// Decrypting
val encryptedBytes : ByteArray = loadEncryptedData().bytes
val unencryptedBytes = cipher.doFinal(bytes)

Note: We’re saving the IV together with the data, and that the process is identical (symmetrical).

Step 4: Build the app dialog

The dialog is built using the BiometricPrompt.PromptInfo.Builder. It follows the commonly seen builder pattern and can be as simple and direct as:

val promptInfo = BiometricPrompt.PromptInfo.Builder()
  .setTitle("Title")
  .setNegativeButtonText("Negative Button Text")
  .build()

But it also provides some optional methods for displaying a description and assistive text, such as allowing the device’s credential authentication (PIN, pattern or password). Those methods are described in the BiometricPrompt developer documentation. Notice that if you enable the use of the device’s credentials to authenticate, you can’t set a negative button.

Step 5: Authenticate the user

Now for the last step. Just call authenticate(…), passing the Cipher acquired on Step 1, wrapped inside a BiometricPrompt.CryptoObject:

biometricPrompt.authenticate(
  promptInfo,
  BiometricPrompt.CryptoObject(cipher)
)

This will authenticate the user and return a usable Cipher on the callback, as specified on the promptInfo created during Step 3.

As mentioned in Step 3, you must keep in mind which type of Cipher you’re trying to use here — and whether it’s for encryption or decryption.

You can see it in action in the example below:

an image of a cat in an Android app that is protected through cryptographic keys and fingerprint authentication

The pixels of the cat’s image are being encrypted and decrypted through a key that is protected by fingerprint authentication.


Need help with your Android app project?

ArcTouch’s Android developers have been building lovable apps for more than a decade. If you need help with your Android project or some advice on how to support the latest features in Android 10, contact us to set up a free consultation.