Encrypting data with Fingerprint API

android fingerprint lock encryption

Android team had released Fingerprint API for a while already (since Android 6), but I haven’t had a chance to work with this feature yet. So I decided to take a look at this API and write a simple prototype app which can save user’s data, request for fingerprint authentication and encrypt/decrypt the data when needed.

User Interface

Let’s take a look at the UI first – it’s very simple and doesn’t clutter up the code. There’s a MainActivity screen with text input field (for your secret message) and a Save button (to save and encrypt your message). On the first launch you need to enter a text and click on Save button – the bottom text field will show ‘Touch the sensor’ message. After successful fingerprint authentication your message will be encrypted and stored in SharedPreferences.

There are two text fields: Message output and Status output (for system messages and errors).

On each of the next app launches, it will ask you to ‘Touch the sensor’ to show your saved message in a decrypted form.

Here’s the xml layout for the one and only screen:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="16dp">

    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">

        <EditText
                android:id="@+id/input"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:maxLines="1"
                android:singleLine="true"
                android:hint="Input your secret message"/>

        <Button
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Save"
                android:textAllCaps="false"
                android:onClick="onSaveMessage"/>

    </LinearLayout>

    <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:layout_marginTop="32dp"
            android:layout_marginBottom="8dp"
            android:text="Your secret message:"/>

    <TextView
            android:id="@+id/message_output"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:background="#f0f0f0"
            android:padding="16dp"
            android:gravity="center"/>

    <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:layout_marginTop="32dp"
            android:layout_marginBottom="8dp"
            android:text="Status output:"/>

    <TextView
            android:id="@+id/status_output"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:background="#f0f0f0"
            android:padding="16dp"
            android:gravity="center"/>

</LinearLayout>

Let me show you a simple setup code for the UI elements, and then we can start adding some logic to our app. Layout contains one text input (mInput) and two text fields (mMessageOutput, mStatusOutput). There’s also a Save button with onSaveMessage() callback function:

class MainActivity : AppCompatActivity() {

    private var mInput: EditText? = null
    private var mMessageOutput: TextView? = null
    private var mStatusOutput: TextView? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mInput = findViewById(R.id.input)
        mMessageOutput = findViewById(R.id.message_output)
        mStatusOutput = findViewById(R.id.status_output)
    }

    public fun onSaveMessage(view: View) {
        
    }
}

Read/write the secret message

Let’s start with a couple of utility methods for reading/writing the secret message to SharedPreferences:

private fun saveSecretMessage(msg: String) {
    val prefs = PreferenceManager.getDefaultSharedPreferences(this)
    prefs.edit().putString("secret_message", msg).commit()
}

private fun loadSecretMessage(): String? {
    val prefs = PreferenceManager.getDefaultSharedPreferences(this)
    return prefs.getString("secret_message", null)
}

There’s also should be a method to display the message in the Message output field:

private fun displayMessageOutput() {
    mMessageOutput?.text = loadSecretMessage() ?: "<Storage is empty>"
}

Now we can save and display our message in the text field.

Let’s change onSaveMessage() so that the app could read the secret message from mInput and save it to SharedPreferences:

public fun onSaveMessage(view: View) {
    val input = mInput?.text?.toString()?.trim()
    if(input == null || input.isEmpty()) {
        mStatusOutput?.text = "Cannot save empty message"
    } else {
        saveSecretMessage(input)
        displayMessageOutput()
        resetStatusOutput()
    }
}

resetStatusOutput() method simply writes an empty string to mStatusOutput field:

private fun resetStatusOutput() {
    mStatusOutput?.text = ""
}

Now we can test our app for the first time and check that it really allows to enter text and saves it to preferences:


Data encryption

Now we can start adding Fingerprint API to our project. First, add a fingerprint permission to app’s manifest:

<uses-permission android:name="android.permission.USE_FINGERPRINT"/>

USE_FINGERPRINT permission became deprecated since Android 9, because of new Biometrics API. But for simplicity let’s use old Fingerprint API for now. We could probably check the new API in one of the next posts.

Can we use Fingerprint API?

We need a method to check if there’s a fingerprint scanner hardware and can we use it at the moment:

private fun canUseFingerprints(): Boolean {
    val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
    if(keyguardManager.isKeyguardSecure) {
        mFingerprintMgr = FingerprintManagerCompat.from(this)
        if(mFingerprintMgr?.isHardwareDetected == true) {
            if(mFingerprintMgr?.hasEnrolledFingerprints() == true) {
                return true
            } else {
                displayStatusOutput("No fingerprints were setup")
            }
        } else {
            displayStatusOutput("No fingerprint hardware")
        }
    } else {
        displayStatusOutput("User didn't setup any device lock")
    }
    return false
}

There are 3 steps:

  1. keyguardManager.isKeyguardSecure – if the device is secured with a password, pattern, etc. This is required because intruder can’t add his fingerprint on a secured device and therefore cannot get access to our encrypted data.
  2. mFingerprintMgr.isHardwareDetected – if there is a suitable fingerprint authentication hardware installed.
  3. mFingerprintMgr.hasEnrolledFingerprints() – device must have at least one fingerprint added.

There’s also a call to one utility method:

private fun displayStatusOutput(output: String) {
    mStatusOutput?.text = output
}

Now we need to change onSaveMessage() method as follows:

public fun onSaveMessage(view: View) {
    val input = mInput?.text?.toString()?.trim()
    if(input == null || input.isEmpty()) {
        mStatusOutput?.text = "Cannot save empty message"
    } else {
        if(canUseFingerprints()) {
            askFingerprintForEncryption(input)
        }
    }
}

How to use Keystore and Cipher?

Let’s make the most interesting part – encrypting our data. After clicking on Save button we need to check if there’s possible to use Fingerprint API, and if so – ask for user’s fingerprint, encrypt the message and display it in Message output.

Therefore, onSaveMessage() method should be changed as follows:

public fun onSaveMessage(view: View) {
    val input = mInput?.text?.toString()?.trim()
    if(input == null || input.isEmpty()) {
        mStatusOutput?.text = "Cannot save empty message"
    } else {
        if(canUseFingerprints()) {
            askFingerprintForEncryption(input)
        }
    }
}

There is a method named askFingerprintForEncryption() which is responsible for generating a secret key and asking the user to authenticate using his fingerprint. Let’s look through it step by step:

Step 1. We need to get access to Android Keystore which will keep the secret key for a symmetric cipher:

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

Step 2. If the app have already encrypted something with our secret key, it means there is already a key and we don’t need to generate a new one. Otherwise, we generate a secret key. Let’s make a check for this as follows:

if (!keystore.containsAlias(KEY_ALIAS)) generateNewKey()

Step 3. generateNewKey() method creates a key named KEY_ALIAS to be used both for encryption and decryption with required parameters, such as block mode and padding. I use AES algorithm with CBC block mode and PKCS7 padding:

private fun generateNewKey() {
    val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
    val spec = KeyGenParameterSpec.Builder(
        KEY_ALIAS,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
        .setUserAuthenticationRequired(true)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
        .build()
    keyGenerator.init(spec)
    keyGenerator.generateKey()
}

Why we set setUserAuthenticationRequired() to true? Our secret key becomes irreversibly invalidated each time a user adds a new fingerprint, and (according to the documentation) this case always needs user authentication.

Step 4. As a next step, we create a Cipher object and initialize it with AES/CBC/PKCS7:

val transformationString = "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}"
var cipher = Cipher.getInstance(transformationString)

Step 5. We can get KeyPermanentlyInvalidatedException during the Cipher init() procedure in case there was a new fingerprint added after the secret key was already generated. And if such case happens, old key became invalidated and app needs a new key. So let’s wrap the initialization into a try/catch block to handle such exception:

try {
    cipher.init(KeyProperties.PURPOSE_ENCRYPT, key)
} catch (exc: KeyPermanentlyInvalidatedException) {
    generateNewKey()
    val newKey = keystore.getKey(KEY_ALIAS, null)
    cipher = Cipher.getInstance(transformationString)
    cipher.init(KeyProperties.PURPOSE_ENCRYPT, newKey)
}

Step 6. Now, let’s create a CryptoObject – a wrapper for our Cipher (you can also put there Signature or Mac):

val cryptoObject = FingerprintManagerCompat.CryptoObject(cipher)

Step 7. Next, we need a callback to use an asynchronous method FingerprintManagerCompat.authenticate(). It will be called after getting the result of fingerprint authentication procedure:

val encryptionCallback = object : FingerprintManagerCompat.AuthenticationCallback() {
    override fun onAuthenticationError(errMsgId: Int, errString: CharSequence?) {
        displayStatusOutput(errString?.toString() ?: "Unknown error")
    }

    override fun onAuthenticationSucceeded(result: FingerprintManagerCompat.AuthenticationResult?) {
        displayStatusOutput("Success!")
        result?.cryptoObject?.cipher?.let {encryptMessageWithCipher(input, it)}
    }

    override fun onAuthenticationHelp(helpMsgId: Int, helpString: CharSequence?) {
        displayStatusOutput(helpString?.toString() ?: "Unknown auth help message")
    }

    override fun onAuthenticationFailed() {
        displayStatusOutput("Authentication failed")
    }
}

In case if there was a successful fingerprint authentication, we put the cipher and the message to our encryptMessageWithCipher() method. If there are any errors – it will be displayed in ‘Status output’ text field.

Step 8. Finally, we can ask for a fingerprint and show a hint ‘Touch the sensor’ in Status output:

mFingerprintMgr?.authenticate(cryptoObject, 0, CancellationSignal(), encryptionCallback, null)
displayStatusOutput("Touch the sensor")

Here’s the full code for askFingerprintForEncryption():

private fun askFingerprintForEncryption(input: String) {
    try {
        val keystore = KeyStore.getInstance(ANDROID_KEYSTORE)
        keystore.load(null)

        if (!keystore.containsAlias(KEY_ALIAS)) generateNewKey()
        val key = keystore.getKey(KEY_ALIAS, null)

        val transformationString = "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}"
        var cipher = Cipher.getInstance(transformationString)

        try {
            cipher.init(KeyProperties.PURPOSE_ENCRYPT, key)
        } catch (exc: KeyPermanentlyInvalidatedException) {
            generateNewKey()
            val newKey = keystore.getKey(KEY_ALIAS, null)
            cipher = Cipher.getInstance(transformationString)
            cipher.init(KeyProperties.PURPOSE_ENCRYPT, newKey)
        }

        val cryptoObject = FingerprintManagerCompat.CryptoObject(cipher)
        val encryptionCallback = object : FingerprintManagerCompat.AuthenticationCallback() {
            override fun onAuthenticationError(errMsgId: Int, errString: CharSequence?) {
                displayStatusOutput(errString?.toString() ?: "Unknown error")
            }

            override fun onAuthenticationSucceeded(result: FingerprintManagerCompat.AuthenticationResult?) {
                displayStatusOutput("Success!")
                result?.cryptoObject?.cipher?.let { encryptMessageWithCipher(input, it) }
            }

            override fun onAuthenticationHelp(helpMsgId: Int, helpString: CharSequence?) {
                displayStatusOutput(helpString?.toString() ?: "Unknown auth help message")
            }

            override fun onAuthenticationFailed() {
                displayStatusOutput("Authentication failed")
            }
        }
        mFingerprintMgr?.authenticate(cryptoObject, 0, CancellationSignal(), encryptionCallback, null)
        displayStatusOutput("Touch the sensor")

    } catch (e: Exception) {
        e.printStackTrace()
        val errorMessage = e.message ?: "Unknown error"
        displayStatusOutput(errorMessage)
    }
}

Encryption

Now let’s take a look at what happens in encryptMessageWithCipher() which is called on successful fingerprint authentication:

private fun encryptMessageWithCipher(input: String, cipher: Cipher) {
    try {
        val iv = cipher.iv
        saveIV(iv)
        val encryptedContent = cipher.doFinal(input.toByteArray(Charset.forName("UTF-8")))
        val encryptedContentString = Base64.encodeToString(encryptedContent, 0)
        saveSecretMessage(encryptedContentString)
        displayMessageOutput("Encrypted message: $encryptedContentString\r\nOriginal message:$input")
        resetStatusOutput()
    } catch (e: Exception) {
        e.printStackTrace()
        val errorMessage = e.message ?: "Unknown error"
        displayStatusOutput(errorMessage)
    }
}

Here we save initialization vector (IV) from the Cipher to setup it later during decryption:

val iv = cipher.iv
saveIV(iv)

Finally, encrypting out secret message converted to ByteArray:

val encryptedContent = cipher.doFinal(input.toByteArray(Charset.forName("UTF-8")))

Saving it to Preferences and displaying the status:

val encryptedContentString = Base64.encodeToString(encryptedContent, 0)
saveSecretMessage(encryptedContentString)
displayMessageOutput("Encrypted message: $encryptedContentString\r\nOriginal message:$input")

Here’s also a code for utility methods of reading/writing the IV:

private fun saveIV(iv: ByteArray) {
    val ivString = Base64.encodeToString(iv, 0)
    val prefs = PreferenceManager.getDefaultSharedPreferences(this)
    prefs.edit().putString("iv", ivString).commit()
}

private fun loadIV(): ByteArray? {
    val prefs = PreferenceManager.getDefaultSharedPreferences(this)
    val ivString = prefs.getString("iv", null)
    ivString?.let { return Base64.decode(it, 0) }
    return null
}

Now let’s run the app and check how it can encrypt our secret message:

App should work as described, if not – please ask me in comments or on FB page!

Decryption

Let’s move to the decryption part. If there is an encrypted message saved in Preferences, we send an authentication request to the user and then try to decrypt the message.

First, we need a method to check if there is an encrypted message in Preferences:

private fun hasEncryptedMessage(): Boolean {
    val secretMessage = loadSecretMessage()
    val keystore = KeyStore.getInstance(ANDROID_KEYSTORE)
    keystore.load(null)
    return secretMessage != null && keystore.containsAlias(KEY_ALIAS)
}

And add the following code to onCreate():

if(hasEncryptedMessage() && canUseFingerprints()) {
    loadSecretMessage()?.let {askFingerprintForDecryption(it)}
}

Now about askFingerprintForDecryption() method – it looks very alike to askFingerprintForEncryption() except we don’t generate the secret key but get it from the keystore.

First, load the keystore and get the key:

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

val key = keystore.getKey(KEY_ALIAS, null)

Then we initialize a Cipher for decryption and also set the initialization vector:

val iv = loadIV()

val cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}")
cipher.init(KeyProperties.PURPOSE_DECRYPT, key, IvParameterSpec(iv))
val cryptoObject = FingerprintManagerCompat.CryptoObject(cipher)

Create a callback for successful fingerprint authentication to call decryptMessageWithCipher() method:

val decryptionCallback = object : FingerprintManagerCompat.AuthenticationCallback() {
    override fun onAuthenticationError(errMsgId: Int, errString: CharSequence?) {
        displayStatusOutput(errString?.toString() ?: "Unknown error")
    }

    override fun onAuthenticationSucceeded(result: FingerprintManagerCompat.AuthenticationResult?) {
        displayStatusOutput("Success!")
        result?.cryptoObject?.cipher?.let { decryptMessageWithCipher(input, it) }
    }

    override fun onAuthenticationHelp(helpMsgId: Int, helpString: CharSequence?) {
        displayStatusOutput(helpString?.toString() ?: "Unknown auth help message")
    }

    override fun onAuthenticationFailed() {
        displayStatusOutput("Authentication failed")
    }
}

And ask for a fingerprint:

mFingerprintMgr?.authenticate(cryptoObject, 0, CancellationSignal(), decryptionCallback, null)
displayStatusOutput("Touch the sensor")

Here’s the full code for this method:

private fun askFingerprintForDecryption(input: String) {
    try {
        val keystore = KeyStore.getInstance(ANDROID_KEYSTORE)
        keystore.load(null)

        val key = keystore.getKey(KEY_ALIAS, null)
        if(key == null) {
            displayStatusOutput("Can't found key for decryption")
            return
        }

        val iv = loadIV()

        val cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}")
        cipher.init(KeyProperties.PURPOSE_DECRYPT, key, IvParameterSpec(iv))
        val cryptoObject = FingerprintManagerCompat.CryptoObject(cipher)

        val decryptionCallback = object : FingerprintManagerCompat.AuthenticationCallback() {
            override fun onAuthenticationError(errMsgId: Int, errString: CharSequence?) {
                displayStatusOutput(errString?.toString() ?: "Unknown error")
            }

            override fun onAuthenticationSucceeded(result: FingerprintManagerCompat.AuthenticationResult?) {
                displayStatusOutput("Success!")
                result?.cryptoObject?.cipher?.let { decryptMessageWithCipher(input, it) }
            }

            override fun onAuthenticationHelp(helpMsgId: Int, helpString: CharSequence?) {
                displayStatusOutput(helpString?.toString() ?: "Unknown auth help message")
            }

            override fun onAuthenticationFailed() {
                displayStatusOutput("Authentication failed")
            }
        }

        mFingerprintMgr?.authenticate(cryptoObject, 0, CancellationSignal(), decryptionCallback, null)
        displayStatusOutput("Touch the sensor")
    } catch (e: Exception) {
        e.printStackTrace()
        val errorMessage = e.message ?: "Unknown error"
        displayStatusOutput(errorMessage)
    }
}

We decrypt the message then in the decryptMessageWithCipher():

private fun decryptMessageWithCipher(encryptedMessage: String, cipher: Cipher) {
    try {
        val decryptedContent = cipher.doFinal(Base64.decode(encryptedMessage, 0))
        val decryptedContentString = String(decryptedContent, Charset.forName("UTF-8"))
        displayMessageOutput("Encrypted message: $encryptedMessage\r\nOriginal message:$decryptedContentString")
    } catch (e: Exception) {
        e.printStackTrace()
        val errorMessage = e.message ?: "Unknown error"
        displayStatusOutput(errorMessage)
    }
}

I recommend to uninstall the app before the next launch, or at least clear the data, to make a clean check for the app’s workflow: enter the secret message -> save and encrypt -> close the app and reopen it -> decrypt the message.

Here are the screenshots for the last 2 steps:

Full source code can be found on Github page.

Feel free to ask me about any issues you found in this post 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *