Home Coding Authenticate Users With Android Biometrics
black smartphone

Authenticate Users With Android Biometrics

by Henry

Let’s admit it! We are getting lazy and lazier every day. Putting a password every single time we want to access an application could be annoying for us. If we take a look at the top apps in the App Store, they mostly strive to make user journeys seamless and straightforward. 

On the other hand, from a developer’s perspective, authentication could be a very challenging part of mobile app development. Yet, it is an essential feature that keeps our user data secure. So how can we improve it? Instead of using a password or pin every time our user wants to log in, we can switch to Biometrics! 

Biometrics (aka Fingerprint or Face match)

Since Android M, Google has introduced us with FingerprintManager, where we can use it to authenticate our user if the device has a fingerprint sensor. But it was set to deprecate and replaced by BiometricManager in API Level 28, aka Pie. 

It makes more sense since Biometric could grow from only using a fingerprint to using face ID or even maybe in the future using a retina scanner. 

Problems with Android Biometrics

Imagine if your backend has been hard wired since ancient times that it will store a hashed password value and PIN. Suddenly, you need to switch to Biometric. How should the API handle it? You can’t only send a flag isBiometricLoggedIn = true to server, right?

One of the solutions where you can have minimum changes in the backend API could be storing the Hashed Password / PIN in the Android Keystore and locking it using a biometric lock. So every time you want to authenticate the user, you can prompt the biometric. If the user successfully verified the biometric, you can retrieve the Hashed value and send it to your backend API.

Sidenotes Here: You can store the naked password or PIN. But what I am proposing is a slightly more secure way. Many security experts will argue that storing the user’s password or PIN in a local device is never a good idea, and I agree. But I will leave it at that and for you to decide.

Coding Time!

Here is my MainActivity layout file. It only has two fields here, username & password.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".activity.MainActivity">

    <TextView
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/test_biometric"
        android:textSize="30sp"
        android:textStyle="bold"
        android:gravity="center_horizontal"
        android:layout_marginTop="40dp"
        app:layout_constraintTop_toTopOf="parent"/>

    <androidx.appcompat.widget.AppCompatEditText
        android:id="@+id/password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="20dp"
        android:layout_marginVertical="10dp"
        android:hint="@string/password_hint"
        android:inputType="textPassword"
        app:layout_constraintTop_toBottomOf="@id/title"/> 

    <Button
        android:id="@+id/btn_save"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/save_password"
        app:layout_constraintTop_toBottomOf="@id/password"
        app:layout_constraintLeft_toLeftOf="@id/password"
        />

    <Button
        android:id="@+id/btn_load"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/load_password"
        app:layout_constraintTop_toBottomOf="@id/password"
        app:layout_constraintRight_toRightOf="@id/password"
        />
</androidx.constraintlayout.widget.ConstraintLayout>

Here is my MainActivity class

class MainActivity : AppCompatActivity() {
    private fun savePassword() {
    }

    private fun loadPassword() {
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        btn_load.setOnClickListener { loadPassword() }
        btn_save.setOnClickListener { savePassword() }
    }
}

Then we create a Keystore helper class with some cipher object. 

class KeyStoreHelper private constructor() {
    private val newCipherInstance: Cipher?
        get() = try {
            Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
        } catch (e: Exception) { //Bad example using general exception
            e.printStackTrace()
            null
        }

    companion object {
        private var myInstance: KeyStoreHelper? = null
        private const val AUTHENTICATION_KEYSTORE_NAME = "AndroidKeyStore"

        fun getInstance(): KeyStoreHelper {
            if(myInstance == null) {
                myInstance = KeyStoreHelper()
            }

            return myInstance!!
        }
    }
}

We will use the cipher later when we want to encode or decode value from KeyStore.

Then let’s add some methods to load KeyStore and handle key pair generation in the Keystore helper class

private fun loadKeyStore(): KeyStore? {
    return try {
        val keyStore = KeyStore.getInstance(AUTHENTICATION_KEYSTORE_NAME)
        keyStore.load(null)
        keyStore
    } catch (e: Exception) { //Bad example using general exception
        e.printStackTrace()
        null
    }
}

@TargetApi(Build.VERSION_CODES.M)
fun generateKey(alias: String){
    val keyGenerator = KeyPairGenerator.getInstance(
                       KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore")

    keyGenerator.initialize(
            KeyGenParameterSpec.Builder(alias,
                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                    .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
                    .setUserAuthenticationRequired(true)
                    .build()
            )
    keyGenerator.generateKeyPair()
}

private fun generateKeyIfNotExist(keyStore: KeyStore, alias: String) {
    try {
        if(!keyStore.containsAlias(alias))
            generateKey(alias)
    } catch (e: KeyStoreException) {
        e.printStackTrace()
    }
}

On this guide, we will be using an asymmetric key. With an asymmetric key, we can encode the value using public key where we don’t need biometric authentication to retrieve and only require biometric on decode.

Then we will need to create a method to initialize the cipher for decode & encode. Please note that there is a known issue in Android Marshmallow that even though we requested public key, it will still require authentication, so we will use a workaround to initialize the encode cipher. You can check further details here: https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.html#known-issues

fun getEncodeCipher(alias: String): Cipher? {
    val cipher = newCipherInstance
    val keyStore = loadKeyStore() ?: return null

    if(!keyStore.containsAlias(alias))
            generateKeyIfNotExist(keyStore, alias)

    return if(initEncodeCipher(cipher, alias, keyStore)) {
        cipher
    } else {
        null
    }
}

fun getDecodeCipher(alias: String, withCipher: Cipher? = null): Cipher? {
    val cipher: Cipher? = withCipher ?: newCipherInstance
    val keyStore = loadKeyStore() ?: return null

    if(!keyStore.containsAlias(alias))
            generateKeyIfNotExist(keyStore, alias)

    return if(initDecodeCipher(cipher, alias, keyStore)) {
        cipher
    } else {
        null
    }
}

private fun initDecodeCipher(cipher: Cipher?, alias: String, keyStore: KeyStore): Boolean {
    return try {
        val key: PrivateKey = keyStore.getKey(alias, null) as PrivateKey
        cipher!!.init(Cipher.DECRYPT_MODE, key)
        true
    } catch (e: Exception) { //Bad example using general exception
        e.printStackTrace()
        false
    }
}

private fun initEncodeCipher(cipher: Cipher?, alias: String, keyStore: KeyStore): Boolean {
    return try {
        val key = keyStore.getCertificate(alias).publicKey
//            https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.html#known-issues
        val unrestricted = KeyFactory.getInstance(key.algorithm).generatePublic(
                X509EncodedKeySpec(key.encoded))
        val spec = OAEPParameterSpec("SHA-256", "MGF1",
                MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
        cipher!!.init(Cipher.ENCRYPT_MODE, unrestricted, spec)
        true
    } catch (e: Exception) {
        e.printStackTrace()
        false
    }
}

And final touch in this KeyStoreHelper, we will implement the encode operation, decode, alias checking, and delete operations.

fun encodeSensitiveInformationWithCipher(input: String, cipher: Cipher): String? {
    return try {
        val bytes = cipher.doFinal(input.toByteArray())
        Base64.encodeToString(bytes, Base64.NO_WRAP)
    } catch (e: Exception) {
        e.printStackTrace()
        null
    }
}

fun decodeSensitiveInformationWithCipher(encodedString: String?, cipher: Cipher): String? {
    return try {
        val bytes: ByteArray = Base64.decode(encodedString, Base64.NO_WRAP)
        String(cipher.doFinal(bytes))
    } catch (e: Exception) { //Bad example using general exception
        e.printStackTrace()
        null
    }
}

fun isKeystoreContainAlias(alias: String?): Boolean? {
    return try {
        val keyStore = loadKeyStore()!!
        keyStore.containsAlias(alias)
    } catch (e: KeyStoreException) {
        e.printStackTrace()
        null
    }
}

fun deleteKey(alias: String?) {
    val keyStore = loadKeyStore()
    try {
        keyStore!!.deleteEntry(alias)
    } catch (e: KeyStoreException) {
        e.printStackTrace()
    }
}

Then let’s create the BiometricHelper class to help us handling all operations that are related to Android biometrics.

interface BiometricHelperCallback {
    fun biometricAuthenticationValueStored()
    fun biometricAuthenticationSucceed(authenticatedValue: String)
    fun biometricAuthenticationError(errorMessage: String)
    fun biometricAuthenticationFailed(errorMessage: String)
}

class BiometricHelper( private val context: Context,
                       private val callback: BiometricHelperCallback,
                       private val activity: AppCompatActivity ) {
    private val biometricKeyStoreAlias = "KEY_AUTH_ALIAS"
    private val biometricKeyStoreValue = "KEY_AUTH_VALUE"
    private var biometricPrompt:BiometricPrompt? = null
}

And then we add methods to load and store value from SharedPreferences

private fun getEncryptedValue(): String? {
    return context.getSharedPreferences("BIOMETRIC", Context.MODE_PRIVATE)
                  .getString(biometricKeyStoreValue, null)
}

private fun storeEncryptedValue(authValue: String) {
    val editor = context.getSharedPreferences("BIOMETRIC", Context.MODE_PRIVATE).edit()
    editor.putString(biometricKeyStoreValue, authValue)
    editor.apply()
}

Then we add methods to check if biometric is available and check whether the apps have saved password

private fun isFingeprintAuthAvailable(): Boolean {
    if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
        return false
    }
    val biometricStatus = BiometricManager.from(context).canAuthenticate()
    return biometricStatus == BiometricManager.BIOMETRIC_SUCCESS
}

fun isFingerprintDecodeAuthAvailable(): Boolean {
    val isContainAllias = KeyStoreHelper.getInstance()
              .isKeystoreContainAlias(biometricKeyStoreAlias) ?: false
              
    return isFingeprintAuthAvailable() &&
           isContainAllias && 
           getEncryptedValue() != null
}

Next, we can start adding methods to store our password to Android Keystore

fun saveAuthenticationValue(authValue: String){
    KeyStoreHelper.getInstance().deleteKey(biometricKeyStoreAlias)
    val cipher = KeyStoreHelper.getInstance()
                 .getEncodeCipher(biometricKeyStoreAlias)
                 
    if(cipher != null) {
        val encryptedValue = KeyStoreHelper.getInstance()
                .encodeSensitiveInformationWithCipher(authValue, cipher!!)
        if (encryptedValue != null) {
            storeEncryptedValue(encryptedValue)
            callback.biometricAuthenticationValueStored()
        } else {
            callback.biometricAuthenticationError("Cipher is null")
        }
    }
}

Here is the method that we use to start biometric scanning and if its success we will then proceed to decode the encrypted information

private fun getCryptoObject(cipher: Cipher): BiometricPrompt.CryptoObject{
    return BiometricPrompt.CryptoObject(cipher);
}

private fun provideBiometricCallback(): BiometricPrompt.AuthenticationCallback {
    return object : BiometricPrompt.AuthenticationCallback() {
    override fun onAuthenticationError(errorCode: Int,
                                       errString: CharSequence) {
        super.onAuthenticationError(errorCode, errString)
        callback.biometricAuthenticationError(errString.toString())
    }

    override fun onAuthenticationSucceeded(
                    result: BiometricPrompt.AuthenticationResult) {
        super.onAuthenticationSucceeded(result)
        val decodeCipher = result.cryptoObject?.cipher
        if(decodeCipher != null) {
            val authValue:String? = KeyStoreHelper.getInstance()
                        .decodeSensitiveInformationWithCipher(getEncryptedValue(), decodeCipher)
            callback.biometricAuthenticationSucceed(authValue!!)
        } else {
                callback.biometricAuthenticationError("DecodeCipher is null")
            }
        }

    override fun onAuthenticationFailed() {
        super.onAuthenticationFailed()
        callback.biometricAuthenticationFailed("Auth Failed")
        }
    }
}

fun startAuthenticationForDecode(){
    val cipher = KeyStoreHelper.getInstance().getDecodeCipher(biometricKeyStoreAlias)
    if(cipher != null) {
        try {
            val cryptoObject = getCryptoObject(cipher)
            val promptInfo = BiometricPrompt.PromptInfo.Builder()
                .setTitle("Continue with biometric")
                .setNegativeButtonText("Cancel")
                .build()
            val executor = ContextCompat.getMainExecutor(context)

            biometricPrompt = BiometricPrompt(activity, executor,
                              provideBiometricCallback())
            biometricPrompt?.authenticate(promptInfo, cryptoObject)
        } catch (e: Exception){
            e.printStackTrace()
            callback.biometricAuthenticationError(e.message!!)
        }
    } else {
        callback.biometricAuthenticationError("Cipher is null")
    }
}

Last part in this BiometricHelper we can add a method to dismiss the biometric prompt when the Activity lifecycle is onStop

private fun cancelBiometricAuthenticationSequence() {
    biometricPrompt?.cancelAuthentication()
}

fun onStop(){
    cancelBiometricAuthenticationSequence()
}

 Then we can start adjusting our MainActivity by adding BiometricHelper object and use it to load and save password

lateinit var biometricHelper: BiometricHelper

private fun savePassword() {
    val stringPass = password.text.toString()
    biometricHelper.saveAuthenticationValue(stringPass)
}

private fun loadPassword() {
    if(biometricHelper.isFingerprintDecodeAuthAvailable()) {
        biometricHelper.startAuthenticationForDecode()
    } else {
        Toast.makeText(this, "Its either you haven't store any password, or you haven't set any fingeprint, " +
                "or your device doesn't support biometric", Toast.LENGTH_SHORT).show()
    }
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    btn_load.setOnClickListener { loadPassword() }
    btn_save.setOnClickListener { savePassword() }
    biometricHelper = BiometricHelper(this, this, this)
}

BiometricHelper will require us to implement BiometricHelperCallback

override fun biometricAuthenticationValueStored() {
    Toast.makeText(this, "Password Stored!", Toast.LENGTH_SHORT).show()
}

override fun biometricAuthenticationSucceed(authenticatedValue: String) {
    Toast.makeText(this, "Passoword: $authenticatedValue", Toast.LENGTH_SHORT).show()
}

override fun biometricAuthenticationError(errorMessage: String) {
    Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show()
}

override fun biometricAuthenticationFailed(errorMessage: String) {
    Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show()
}

And don’t forget to handle onStop()

override fun onStop() {
    biometricHelper.onStop()
    super.onStop()
}

Now we run the code, and here is the result!

And with this, we can have a fully functioning Android Biometrics without altering anything in our backend side.

For full source code you can check our GitHub repository here: https://github.com/bird-ac/biometric-android

Find Out More

Leave a Comment

This website uses cookies to improve your experience. We'll assume you're ok with this, but you can opt-out if you wish. Accept Read More