Categories Security

Complete guide to authenticate users using Android Q Biometric Prompt

5 Apr. 2019
1
0
32 minutes
Android Q Biometric Prompt

Nowadays, most devices have an authentication mechanism to keep user data safe. This also applies to a bunch of apps that require a way to prove that you are you. There are three categories of factors that can be used to authenticate a user: knowledge factors, possession factors, and biometric factors. Knowledge factors ask for something you know. For instance, it could be a PIN or a password. Possession factors ask for something you own such as a token or a security key. Finally, there are Biometric factors. They ask for something you are. To clarify, it is something that is linked to your identity. It could be your fingerprint, iris, or face.

Authentication factors

Using a fingerprint to unlock a phone or authenticate in sensitive applications has become common now. Biometric authentication is becoming increasingly popular because it is way faster than typing a password or drawing a pattern. Furthermore, it prevents this risk of shoulder surfing which is one of the most common pitfalls of authentication mechanisms based on knowledge factors (PIN or password).

As Biometric Authentication is evolving quickly, Android tries to meet these new needs. Android released the BiometricPrompt API in Android P version. Even though fingerprint authentication has been available since Android 6.0 Marshmallow through the FingerprintMangager, which is now deprecated. Biometric Prompt is an API that app developers can use to integrate biometric authentication into their applications through a dialog. This is a system-provided authentication dialog that is consistent with different types of biometric authentication. This API has been tremendously improved since Android Q Beta 2, which has been released on April 3, 2019. This post exposes the basic behavior or the Biometric Prompt as well as all the news brought out with Android Q.

This course provides both an overview of biometric authentication and a detailed guide that demonstrates a practical approach to implementing biometric authentication through Biometric Prompt.

Android Q Biometric Prompt

Video

Setup

This part is only relevant if you want to take advantage of the enhanced BiometricPrompt released with Android Q.

First and foremost, you need to update the new Android Q SDK. In order to achieve this, two updates are required in Tools > SDK Manager. Android Q Preview API Level 2 (or higher) must be installed in SDK Platforms tab. Furthermore, Android SDK Build-Tools 28 (or higher) is also required. It can be installed in SDK Tools tab.

Afterwards, it is necessary to update the build configuration of your app as follows:

android {
    compileSdkVersion 'android-Q'

    defaultConfig {
        targetSdkVersion 'Q'
        minSdkVersion 'Q'
    }
    ...
}

Implementation

Fingerprint authentication is only available on devices containing a compliant fingerprint sensor. Therefore, some checks need to be done in order to know if a device is compliant. This part describes how to implement BiometricPrompt for the compliant devices.

Biometric Permission

In order to use the BiometricPrompt, your application must request the USE_BIOMETRIC permission in the manifest file. It allows apps to use device supported biometric features. The protection level of this permission is normal. It means that the system automatically grants this permission to the application at installation, without asking for the user’s explicit approval.

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

User Interface

The User Interface of this project comprises only a single button in the interest of keeping the course as simple as possible. Here is the layout of the project:

<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <Button
            android:id="@+id/authenticate_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/authenticate"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Compatibility check

Even though a lot of devices integrates biometric authentication, some are not compliant. Indeed, some devices may not have a fingerprint sensor, or may have a defective fingerprint sensor. They may also have no enrolled authentication method (no PIN, password or biometric authentication). That’s why some checks need to be done in order to have a defensive code.

Since Android Q, we can determine if biometrics can be used thanks to
BiometricManager.canAuthenticate(). This method checks if relevant hardware is available, biometrics are enrolled and the user enables the feature. It returns a result code whose value is among:

object BiometricUtils {
    fun isBiometricPromptSupported(context: Context): Boolean {
        val biometricManager = context.getSystemService(BIOMETRIC_SERVICE) as BiometricManager

        return (ActivityCompat.checkSelfPermission(context, 
            Manifest.permission.USE_BIOMETRIC) == PackageManager.PERMISSION_GRANTED
                && biometricManager.canAuthenticate() == BIOMETRIC_SUCCESS)
    }
}
public class BiometricUtils {

    public static boolean isBiometricPromptSupported(Context context) {
        BiometricManager biometricManager = (BiometricManager) context.getSystemService(BIOMETRIC_SERVICE);

        return ActivityCompat.checkSelfPermission(context,
                Manifest.permission.USE_BIOMETRIC) == PackageManager.PERMISSION_GRANTED
                && biometricManager != null
                && biometricManager.canAuthenticate() == BIOMETRIC_SUCCESS;
    }
}

Prior to Android Q , the compatibly check looked like this:

object BiometricUtils {
    fun isBiometricPromptSupported(context: Context): Boolean {
        val keyguardManager = context.getSystemService(KEYGUARD_SERVICE) as KeyguardManager
        val packageManager = context.packageManager

        if (!keyguardManager.isKeyguardSecure) {
            return false
        }

        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.USE_BIOMETRIC
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return false
        }

        return if (packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) {
            true
        } else true
    }
}
public class BiometricUtils {

    public static boolean isBiometricPromptSupported(Context context) {
        KeyguardManager keyguardManager = (KeyguardManager) context.getSystemService(KEYGUARD_SERVICE);
        PackageManager packageManager = context.getPackageManager();

        if (keyguardManager != null && !keyguardManager.isKeyguardSecure()) {
            return false;
        }

        if (ActivityCompat.checkSelfPermission(context,
                Manifest.permission.USE_BIOMETRIC) !=
                PackageManager.PERMISSION_GRANTED) {
            return false;
        }

        if (packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) {
            return true;
        }

        return true;
    }
}

Three checks are performed to determine whether a device supports Biometric Prompt. First, we check if the device has an authentication method using the isKeyguardSecure() method. Then, we need to check whether the USE_BIOMETRIC permission has been granted. Finally, we check if the device has a fingerprint sensor.

As you can see, the compatibility test is way easier since Android Q.

Authentication callbacks

Various callbacks come along with BiometricPrompt to handle authentication results. A special callback structure, BiometricPrompt.AuthenticationCallback must be implemented in order to show a BiometricPrompt. This callback includes fours methods:

Here is a list of the possible error code that can be passed to
onAuthenticationError(int errorCode, CharSequence errStrng) method :ssc

The possible help codes that can be passed toonAuthenticationHelp(int helpCode, CharSequence helpString) are listed below:

As this callback represents a substantial block of code, it should be placed inside a dedicated method in order to improve readability. Here is a callback example:

private fun getAuthenticationCallback(): BiometricPrompt.AuthenticationCallback {
    return object : BiometricPrompt.AuthenticationCallback() {

        // Called when an unrecoverable error has been encountered.
        override fun onAuthenticationError(
            errorCode: Int,
            errString: CharSequence
        ) {
            when (errorCode) {
                BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
                    // The hardware is unavailable.
                }
                BIOMETRIC_ERROR_HW_NOT_PRESENT -> {
                    // The device does not have a biometric sensor.
                }
                BIOMETRIC_ERROR_NO_BIOMETRICS -> {
                    // The user does not have any biometrics enrolled.
                }
                BIOMETRIC_ERROR_TIMEOUT -> {
                    // The current request has been running too long.
                }
                BIOMETRIC_ERROR_USER_CANCELED -> {
                    // The user canceled the operation.
                }
            }

            super.onAuthenticationError(errorCode, errString)
        }

        // Called when a recoverable error has been encountered during authentication.
        // The help string guide the user to fix what went wrong.
        override fun onAuthenticationHelp(
            helpCode: Int,
            helpString: CharSequence
        ) {
            when (helpCode) {
                BIOMETRIC_ACQUIRED_PARTIAL -> {
                    // Only a partial biometric image was detected.
                }
                BIOMETRIC_ACQUIRED_TOO_FAST -> {
                    // The biometric image was incomplete due to quick motion.
                }
                BIOMETRIC_ACQUIRED_INSUFFICIENT -> {
                    // The biometric image was too noisy to process due to a possibly dirty sensor.
                }
                BIOMETRIC_ERROR_TIMEOUT -> {
                    // The current request has been running too long.
                }
                BIOMETRIC_ERROR_USER_CANCELED -> {
                    // The user canceled the operation.
                }
            }

            super.onAuthenticationHelp(helpCode, helpString)
        }

        // Called when a biometric is valid but not recognized.
        override fun onAuthenticationFailed() {
            super.onAuthenticationFailed()
        }

        // Called when a biometric is valid and recognized.
        override fun onAuthenticationSucceeded(
            result: BiometricPrompt.AuthenticationResult
        ) {
            super.onAuthenticationSucceeded(result)
        }
    }
}
private BiometricPrompt.AuthenticationCallback getAuthenticationCallback() {
    return new BiometricPrompt.AuthenticationCallback() {

        // Called when an unrecoverable error has been encountered.
        @Override
        public void onAuthenticationError(int errorCode,
                                          CharSequence errString) {
            switch (errorCode) {
                case BIOMETRIC_ERROR_HW_UNAVAILABLE:
                    // The hardware is unavailable.
                    break;
                case BIOMETRIC_ERROR_HW_NOT_PRESENT:
                    // The device does not have a biometric sensor.
                    break;
                case BIOMETRIC_ERROR_NO_BIOMETRICS:
                    // The user does not have any biometrics enrolled.
                    break;
                case BIOMETRIC_ERROR_TIMEOUT:
                    // The current request has been running too long.
                    break;
                case BIOMETRIC_ERROR_USER_CANCELED:
                    // The user canceled the operation.
                    break;
            }

            super.onAuthenticationError(errorCode, errString);
        }

        // Called when a recoverable error has been encountered during authentication.
        // The help string guide the user to fix what went wrong.
        @Override
        public void onAuthenticationHelp(int helpCode,
                                         CharSequence helpString) {
            switch (helpCode) {
                case BIOMETRIC_ACQUIRED_PARTIAL:
                    // Only a partial biometric image was detected.
                    break;
                case BIOMETRIC_ACQUIRED_TOO_FAST:
                    // The biometric image was incomplete due to quick motion.
                    break;
                case BIOMETRIC_ACQUIRED_INSUFFICIENT:
                    // The biometric image was too noisy to process due to a possibly dirty sensor.
                    break;
                case BIOMETRIC_ERROR_TIMEOUT:
                    // The current request has been running too long.
                    break;
                case BIOMETRIC_ERROR_USER_CANCELED:
                    // The user canceled the operation.
                    break;
            }

            super.onAuthenticationHelp(helpCode, helpString);
        }

        // Called when a biometric is valid but not recognized.
        @Override
        public void onAuthenticationFailed() {
            super.onAuthenticationFailed();
        }

        // Called when a biometric is valid and recognized.
        @Override
        public void onAuthenticationSucceeded(
                BiometricPrompt.AuthenticationResult result) {
            super.onAuthenticationSucceeded(result);
        }
    };
}

Cancellation signal

A CancellationSignal is an object that allow the app to cancel the authentication while it is in process. A cancel callback can be configured in order to perform an action when the authentication is cancelled. Note that the authentication can be cancelled using the cancel() method on the CancellationSignal‘s instance.

An instance of CancellationSignal must be passed to BiometricPrompt.authenticate(), which is the method that initialises the BiometricPrompt process.

Note that the CancellationSignal passed to BiometricPrompt.authenticate() must never be null.
private var cancellationSignal: CancellationSignal? = null

private fun getCancellationSignal(): CancellationSignal? {
    cancellationSignal = CancellationSignal()
    cancellationSignal?.setOnCancelListener {
        // Action
    }
    return cancellationSignal
}
private CancellationSignal cancellationSignal;

private CancellationSignal getCancellationSignal() {

    cancellationSignal = new CancellationSignal();
    cancellationSignal.setOnCancelListener(new CancellationSignal.OnCancelListener() {
        @Override
        public void onCancel() {
            // Action
        }
    });
    return cancellationSignal;
}

Crypto object

Note that this part is not needed if you want to use the new method
setAllowDeviceCredential() brought with Android Q because device credentials do not support BiometricPrompt.CryptoObject.

A BiometricPrompt might need a BiometricPrompt.CryptoObject according to your needs. Indeed there are two BiometricPrompt.authenticate() methods. One that takes a BiometricPrompt.CryptoObject and another that doesn’t as you can see below:

The BiometricPrompt.CryptoObject class is a wrapper class for the crypto objects supported by BiometricPrompt. This wrapper supports Signature, Cipher and Mac objects. Now you must be wondering why you would you need such an object. Actually, the BiometricPrompt.CryptoObject is used by the BiometricPrompt to protect the integrity of an authentication request. It ensures that the response in not tampered. The purpose of the CryptoObject is to manage a key which gets assigned to the Android KeyStore and which can only be used once the user has actually authenticated using Biometric Prompt. 

Typically, a Cipher object is the mechanism for encrypting the results of the fingerprint scanner. The Cipher object itself relies on a key that is stored on the Android keystore. This key can be invalidated by the Android system in several cases:

  • A new fingerprint has been enrolled on the device.
  • There are no fingerprints enrolled on the device.
  • The user has disabled the screen lock.
  • The user has changed the screen lock (the type of the screenlock or the PIN/pattern used).

When this happens, a new key must be created.

For instance, we can use a Cipher as you can see below:

private void generateKey() {
    try {
        KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
        keyStore.load(null);

        KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
                "AndroidKeyStore");
        keyGenerator.init(new
                KeyGenParameterSpec.Builder(KEYSTORE_ALIAS,
                KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
                .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                .setUserAuthenticationRequired(true)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
                .build());

        keyGenerator.generateKey();

    } catch (KeyStoreException e) {
        e.printStackTrace();
    } catch (CertificateException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
    } catch (NoSuchProviderException e) {
        e.printStackTrace();
    } catch (InvalidAlgorithmParameterException e) {
        e.printStackTrace();
    }
}

private Cipher generateCipher() {
    KeyStore keyStore;
    Cipher cipher = null;

    try {

        keyStore = KeyStore.getInstance("AndroidKeyStore");
        keyStore.load(null);
        SecretKey secretKey = (SecretKey) keyStore.getKey(KEYSTORE_ALIAS, null);

        cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);

    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
    } catch (NoSuchPaddingException e) {
        e.printStackTrace();
    } catch (CertificateException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    } catch (KeyStoreException e) {
        e.printStackTrace();
    } catch (UnrecoverableKeyException e) {
        e.printStackTrace();
    } catch (InvalidKeyException e) {
        e.printStackTrace();
    }

    return cipher;
}
private void generateKey() {
    try {
        KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
        keyStore.load(null);

        KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
                "AndroidKeyStore");
        keyGenerator.init(new
                KeyGenParameterSpec.Builder(KEYSTORE_ALIAS,
                KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
                .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                .setUserAuthenticationRequired(true)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
                .build());

        keyGenerator.generateKey();

    } catch (KeyStoreException e) {
        e.printStackTrace();
    } catch (CertificateException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
    } catch (NoSuchProviderException e) {
        e.printStackTrace();
    } catch (InvalidAlgorithmParameterException e) {
        e.printStackTrace();
    }
}

private Cipher generateCipher() {
    KeyStore keyStore;
    Cipher cipher = null;

    try {

        keyStore = KeyStore.getInstance("AndroidKeyStore");
        keyStore.load(null);
        SecretKey secretKey = (SecretKey) keyStore.getKey(KEYSTORE_ALIAS, null);

        cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);

    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
    } catch (NoSuchPaddingException e) {
        e.printStackTrace();
    } catch (CertificateException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    } catch (KeyStoreException e) {
        e.printStackTrace();
    } catch (UnrecoverableKeyException e) {
        e.printStackTrace();
    } catch (InvalidKeyException e) {
        e.printStackTrace();
    }

    return cipher;
}

BiometricPrompt setup

Once the above setup is done, we can create a BiometricPrompt using th BiometricPrompt.Builder. This builder collects arguments to be shown on the system-provided biometric dialog. It has several methods that allow developers to customize the biometric dialog.

Note that device credentials are not supported with crypto. Therefore, if setAllowDeviceCredential() is set to true, you must create the biometricprompt via the authenticate() method which doesn’t take a CryptoObject.

The authenticate() method requires an Executor to handle callbacks events. To dispatch events to the main thread of your application, you can use Context#getMainExecutor(). We chose to dispatch callbacks to the main thread in our example.

Here is the main snippet extracted from the project that relies on Android Q news.

private fun authenticateUser() {
    val biometricPrompt = BiometricPrompt.Builder(this)
        .setTitle(getString(R.string.biometric_authentication))
        .setSubtitle(getString(R.string.required_auth))
        .setDescription(getString(R.string.biometric_auth_description))
        .setAllowDeviceCredential(true)
        .build()

    biometricPrompt.authenticate(
        getCancellationSignal(), mainExecutor,
        getAuthenticationCallback()
    )
}
public void authenticateUser() {
    BiometricPrompt biometricPrompt = new Builder(this)
            .setTitle(getString(R.string.biometric_authentication))
            .setSubtitle(getString(R.string.required_auth))
            .setDescription(getString(R.string.biometric_auth_description))
            .setAllowDeviceCredential(true)
            .build();

    biometricPrompt.authenticate(
            getCancellationSignal(), getMainExecutor(),
            getAuthenticationCallback());
}

The output is shown below:

Android Q Biometric Prompt

When a bad fingerprint is provided, the following output is shown:

Biomeric Prompt – Bad fingerprint

Here is another snippet that creates a BiometricPrompt that has a negative button and works with a CryptoObject:

fun authenticateUser() {
    val biometricPrompt = Builder(this)
        .setTitle(getString(R.string.biometric_authentication))
        .setSubtitle(getString(R.string.required_auth))
        .setDescription(getString(R.string.biometric_auth_description))
        .setNegativeButton(getString(R.string.cancel), this.mainExecutor,
            DialogInterface.OnClickListener { dialogInterface, i ->
                // Action
            })
        .build()

    generateKey()
    val cipher = generateCipher()

    if (cipher != null) {
        biometricPrompt.authenticate(
            CryptoObject(cipher),
            getCancellationSignal(), mainExecutor,
            getAuthenticationCallback()
        )
    }
}
public void authenticateUser() {
    BiometricPrompt biometricPrompt = new Builder(this)
            .setTitle(getString(R.string.biometric_authentication))
            .setSubtitle(getString(R.string.required_auth))
            .setDescription(getString(R.string.biometric_auth_description))
            .setNegativeButton(getString(R.string.cancel), this.getMainExecutor(),
                    new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialogInterface, int i) {
                            // Action
                        }
                    })
            .build();

    generateKey();
    Cipher cipher = generateCipher();

    if (cipher != null) {
        biometricPrompt.authenticate(new CryptoObject(cipher),
                getCancellationSignal(), getMainExecutor(),
                getAuthenticationCallback());
    }
}

The output is shown below:

Biometric Prompt with negative button

Test

The final steps consists in putting all this together. When a user clicks on the button, we should at first check if the device supports Biometric Prompt using the BiometricUtils.isBiometricPromptSupported() method that we created earlier. Once we are sure that the device supports Biometric Prompt, we can show the Biometric Prompt by calling the authenticateUser() method. That’s all folks, now you can authenticate your users through a BiometricPrompt.

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

    authenticate_button.setOnClickListener {
        if (BiometricUtils.isBiometricPromptSupported(applicationContext)) {
            authenticateUser()
        }
    }
}
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    Button authenticateButton = findViewById(R.id.authenticate_button);

    authenticateButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (BiometricUtils.isBiometricPromptSupported(getApplicationContext())) {
                authenticateUser();
            }
        }
    });
}

Download

  • Kotlin
  • Java

Conclusion

In conclusion, Biometrics both simplifies and strengthens the authentication process. The BiometricPrompt is designed securely and implemented in a privacy-preserving manner. It provides a nice User Interface which is managed at system level. Android provided a proper solution to the rapid emergence of Biometrics.

Leave a Reply

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