javaandroidexceptionbiometricsandroid-biometric-prompt

Android biometrics UserNotAuthenticatedException on fingerprint


I'm using a SecretKey in my sample app for Mac-signing. The key is generated with builder parameter

setUserAuthenticationValidityDurationSeconds(10)

to allow the usage of the fingerprint and the (un)lock device PIN to secure my key.

The UI does only contain a SIGN button to start the signature.

If I'm using the biometric prompt via "Use PIN", after input of the PIN I receive the signature (in my example "2BjEuSxl/bOTTUExE4vTX2rnRZEC1Zfa21FooKkBfnc=") [note: expected behaviour].

Pressing the SIGN-button again within the given 10 seconds "ValidityDuration" period I can use the fingerprint for authorization successfully [note: expected behaviour].

Pressing the SIGN button after 10 seconds and using the fingerprint for authorization [or use the fingerprint without prior usage of the PIN] gives an exception [note: not expected behaviour]:

android.security.keystore.UserNotAuthenticatedException: User not authenticated

So my question is: how can I use the same SecretKey to authorize the sign process (or better the release of the key from AndroidKeystore) with the PIN and fingerprint option ?

I'm testing on Android SDK 30 (target) and (minimum) 23, the biometric functions are available with implementation 'androidx.biometric:biometric:1.1.0'.

Below is the Logcat debug-output with some comments from my side:

>>> first start
2021-07-04 14:23:47.178 6980-6980/de.biometrics D/*** Biometric ***: sign started
2021-07-04 14:23:47.181 6980-6980/de.biometrics D/*** Biometric ***: try to load secretKey from keystore
2021-07-04 14:23:47.211 6980-6980/de.biometrics D/*** Biometric ***: generated fresh key, try to load
2021-07-04 14:23:47.223 6980-6980/de.biometrics D/*** Biometric ***: UserNotAuthenticatedException thrown, try to authenticate

>>> biometric prompt, used the PIN [authType = 1]:
2021-07-04 14:24:04.089 6980-6980/de.biometrics D/*** Biometric ***: Authentication succeeded with authType 1
2021-07-04 14:24:04.089 6980-6980/de.biometrics D/*** Biometric ***: (authType: 1=PIN, 2=fingerprint)
2021-07-04 14:24:04.092 6980-6980/de.biometrics D/*** Biometric ***: try to load secretKey from keystore
2021-07-04 14:24:04.097 6980-6980/de.biometrics D/signed data: a1wbBdybQkP30XWFBj0o8fiVrS8BXlREGmDHQQQhEwg=

>>> pressed "SIGN" again after 5 seconds
2021-07-04 14:24:09.725 6980-6980/de.biometrics D/*** Biometric ***: sign started
2021-07-04 14:24:09.730 6980-6980/de.biometrics D/*** Biometric ***: try to load secretKey from keystore
2021-07-04 14:24:13.421 6980-6980/de.biometrics D/*** Biometric ***: Authentication succeeded with authType 2
2021-07-04 14:24:13.422 6980-6980/de.biometrics D/*** Biometric ***: (authType: 1=PIN, 2=fingerprint)
2021-07-04 14:24:13.426 6980-6980/de.biometrics D/*** Biometric ***: try to load secretKey from keystore
2021-07-04 14:24:13.432 6980-6980/de.biometrics D/signed data: a1wbBdybQkP30XWFBj0o8fiVrS8BXlREGmDHQQQhEwg=

>>> pressed "SIGN" again 21 seconds after the PIN authorization
2021-07-04 14:24:23.348 6980-6980/de.biometrics D/*** Biometric ***: sign started
2021-07-04 14:24:23.359 6980-6980/de.biometrics D/*** Biometric ***: try to load secretKey from keystore
2021-07-04 14:24:23.366 6980-6980/de.biometrics D/*** Biometric ***: UserNotAuthenticatedException thrown, try to authenticate
>>> fingerprint is accepted [authType = 2]
2021-07-04 14:24:25.356 6980-6980/de.biometrics D/*** Biometric ***: Authentication succeeded with authType 2
2021-07-04 14:24:25.356 6980-6980/de.biometrics D/*** Biometric ***: (authType: 1=PIN, 2=fingerprint)
2021-07-04 14:24:25.361 6980-6980/de.biometrics D/*** Biometric ***: try to load secretKey from keystore
>>> the exception is thrown on line 86:
>>> mac.init(getOrCreateSecretKey(KEY_NAME_SIGN));
2021-07-04 14:24:25.377 6980-6980/de.biometrics W/System.err: android.security.keystore.UserNotAuthenticatedException: User not authenticated
2021-07-04 14:24:25.377 6980-6980/de.biometrics W/System.err:     at android.security.KeyStore.getInvalidKeyException(KeyStore.java:1346)
2021-07-04 14:24:25.378 6980-6980/de.biometrics W/System.err:     at android.security.KeyStore.getInvalidKeyException(KeyStore.java:1388)
2021-07-04 14:24:25.379 6980-6980/de.biometrics W/System.err:     at android.security.keystore.KeyStoreCryptoOperationUtils.getInvalidKeyExceptionForInit(KeyStoreCryptoOperationUtils.java:54)
2021-07-04 14:24:25.379 6980-6980/de.biometrics W/System.err:     at android.security.keystore.AndroidKeyStoreHmacSpi.ensureKeystoreOperationInitialized(AndroidKeyStoreHmacSpi.java:184)
2021-07-04 14:24:25.380 6980-6980/de.biometrics W/System.err:     at android.security.keystore.AndroidKeyStoreHmacSpi.engineInit(AndroidKeyStoreHmacSpi.java:101)
2021-07-04 14:24:25.380 6980-6980/de.biometrics W/System.err:     at javax.crypto.Mac.chooseProvider(Mac.java:443)
2021-07-04 14:24:25.380 6980-6980/de.biometrics W/System.err:     at javax.crypto.Mac.init(Mac.java:513)
2021-07-04 14:24:25.380 6980-6980/de.biometrics W/System.err:     at de.biometrics.MainActivity$1.onAuthenticationSucceeded(MainActivity.java:86)
2021-07-04 14:24:25.381 6980-6980/de.biometrics W/System.err:     at androidx.biometric.BiometricFragment$9.run(BiometricFragment.java:907)
2021-07-04 14:24:25.381 6980-6980/de.biometrics W/System.err:     at android.os.Handler.handleCallback(Handler.java:938)
2021-07-04 14:24:25.381 6980-6980/de.biometrics W/System.err:     at android.os.Handler.dispatchMessage(Handler.java:99)
2021-07-04 14:24:25.382 6980-6980/de.biometrics W/System.err:     at android.os.Looper.loop(Looper.java:223)
2021-07-04 14:24:25.384 6980-6980/de.biometrics W/System.err:     at android.app.ActivityThread.main(ActivityThread.java:7656)
2021-07-04 14:24:25.385 6980-6980/de.biometrics W/System.err:     at java.lang.reflect.Method.invoke(Native Method)
2021-07-04 14:24:25.386 6980-6980/de.biometrics W/System.err:     at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
2021-07-04 14:24:25.386 6980-6980/de.biometrics W/System.err:     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)

This is the complete source code (MainActivity.java):

Edit: I slightly changed the createKey-function to be more compliant with the documentation on https://developer.android.com/training/sign-in/biometric-auth#java

package de.biometrics;

import android.os.Bundle;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.security.keystore.UserNotAuthenticatedException;
import android.util.Base64;
import android.util.Log;
import android.view.View;
import android.widget.Button;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.biometric.BiometricPrompt;
import androidx.core.content.ContextCompat;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.concurrent.Executor;

import javax.crypto.KeyGenerator;
import javax.crypto.Mac;
import javax.crypto.SecretKey;

public class MainActivity extends AppCompatActivity {
    // use dependency in build.graddle:
    // implementation 'androidx.biometric:biometric:1.1.0'

    private static final String KEY_NAME_SIGN = "SignKey";
    private static final int VALIDITY_DURATION_SECONDS = 10;
    private static final String APP_TAG = "*** Biometric *** ";

    private BiometricPrompt biometricPrompt;
    private BiometricPrompt.PromptInfo promptInfo;
    private Executor executor;
    Button btnSign;
    Mac mac;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        executor = ContextCompat.getMainExecutor(this);
        btnSign = (Button) findViewById(R.id.button);

        // Allows user to authenticate using either a Class 3 biometric or
        // their lock screen credential (PIN, pattern, or password).
        promptInfo = new BiometricPrompt.PromptInfo.Builder()
                .setTitle("Biometric login for my app")
                .setSubtitle("Log in using your biometric credential")
                // Can't call setNegativeButtonText() and
                // setAllowedAuthenticators(...|DEVICE_CREDENTIAL) at the same time.
                // .setNegativeButtonText("Use account password")
                .setAllowedAuthenticators(
                        androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
                                | androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL)
                .build();

        biometricPrompt = new BiometricPrompt(MainActivity.this,
                executor, new BiometricPrompt.AuthenticationCallback() {
            @Override
            public void onAuthenticationError(int errorCode,
                                              @NonNull CharSequence errString) {
                super.onAuthenticationError(errorCode, errString);
                Log.d(APP_TAG, "Authentication succeeded!");
            }
            @Override
            public void onAuthenticationSucceeded(
                    @NonNull BiometricPrompt.AuthenticationResult result) {
                super.onAuthenticationSucceeded(result);
                int authorizationType = result.getAuthenticationType();
                Log.d(APP_TAG, "Authentication succeeded with authType " + authorizationType);
                Log.d(APP_TAG, "(authType: 1=PIN, 2=fingerprint)");
                try {
                    // init mac from scratch
                    mac = Mac.getInstance("HmacSHA256");
                    mac.init(getOrCreateSecretKey(KEY_NAME_SIGN));
                    byte[] bytes = "secret-text".getBytes(StandardCharsets.UTF_8);
                    byte[] macResult = mac.doFinal(bytes);
                    Log.d("signed data ", Base64.encodeToString(macResult, Base64.NO_WRAP));
                } catch (InvalidKeyException | NoSuchAlgorithmException e) {
                    e.printStackTrace();
                }
            }
            @Override
            public void onAuthenticationFailed() {
                super.onAuthenticationFailed();
                Log.d(APP_TAG, "Authentication failed");
            }
        });

        btnSign.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                sign();
            }
        });
    }

    private void sign() {
        // simple sign function
        Log.d(APP_TAG, "sign started");
        // setup the mac
        try {
            mac = Mac.getInstance("HmacSHA256");
            mac.init(getOrCreateSecretKey(KEY_NAME_SIGN));
        } catch (UserNotAuthenticatedException e) {
            Log.d(APP_TAG, "UserNotAuthenticatedException thrown, try to authenticate");
            biometricPrompt.authenticate(promptInfo);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            e.printStackTrace();
        }
        biometricPrompt.authenticate(promptInfo);
    }

    private SecretKey getOrCreateSecretKey(String keyName) {
        SecretKey secretKey = getSecretKey(keyName);
        Log.d(APP_TAG, "try to load secretKey from keystore");
        if (secretKey == null) {
            createSecretKey(keyName);
            secretKey = getSecretKey(keyName);
            Log.d(APP_TAG, "generated fresh key, try to load");
        }
        return secretKey;
    }

    private SecretKey getSecretKey(String keyName) {
        KeyStore keyStore = null;
        try {
            keyStore = KeyStore.getInstance("AndroidKeyStore");
            // Before the keystore can be accessed, it must be loaded.
            keyStore.load(null);
            return ((SecretKey) keyStore.getKey(keyName, null));
        } catch (KeyStoreException | CertificateException | UnrecoverableKeyException | NoSuchAlgorithmException | IOException e) {
            e.printStackTrace();
            return null;
        }
    }

private void createSecretKey(String keyName) {
        generateSecretKey(new KeyGenParameterSpec.Builder(
                keyName,
                KeyProperties.PURPOSE_SIGN)
                .setUserAuthenticationRequired(true)
                //.setInvalidatedByBiometricEnrollment(true)
                .setUserAuthenticationValidityDurationSeconds(10)
                .build());
    }// All exceptions unhandled

    private void generateSecretKey(KeyGenParameterSpec keyGenParameterSpec) {
        KeyGenerator keyGenerator = null;
        try {
            keyGenerator = KeyGenerator.getInstance(
                    KeyProperties.KEY_ALGORITHM_HMAC_SHA256, "AndroidKeyStore");
            keyGenerator.init(keyGenParameterSpec);
            keyGenerator.generateKey();
        } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchProviderException e) {
            e.printStackTrace();
        }
    }
}

activity_main.xml

<?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=".MainActivity">

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="32dp"
        android:layout_marginEnd="16dp"
        android:text="Sign"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

build.graddle (Module):

plugins {
    id 'com.android.application'
}

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"

    defaultConfig {
        applicationId "de.biometrics"
        minSdkVersion 23
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {

    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
    implementation 'androidx.biometric:biometric:1.1.0'
}

Note: this is just a very simplified program, just for testing the biometric prompt functions.


Solution

  • I'm answering my own question, as my findings maybe helpful to others.

    Up to now (July 6th 2021) I do have no idea why a SecretKey that is generated with the option

    .setUserAuthenticationValidityDurationSeconds(10);
    

    cannot get released by fingerprint first. As written in my question, when it's released first by PIN (device credential) it could get used ("authenticated") by fingerprint as long the duration period is not expired.

    Anyway, there is another option available in the docs Authenticate using auth-per-use keys (https://developer.android.com/training/sign-in/biometric-auth#auth-per-use-keys) and this option gives the expected result.

    Don't forget to generate a fresh key when updating your code with the new code!

    When using the new option

    .setUserAuthenticationParameters(0 /* duration */,
       KeyProperties.AUTH_BIOMETRIC_STRONG |
       KeyProperties.AUTH_DEVICE_CREDENTIAL)
    

    you will notice that the code requires SDK 30+ to run. For code that runs on SDK > 23 you can use

    .setUserAuthenticationValidityDurationSeconds(0)
    

    It's important to use "0" seconds as this (internally) defaults to "BIOMETRIC_STRONG | DEVICE_CREDENTIAL", when using "-1" it defaults to "DEVICE_CREDENTIAL" (without the fingerprint option).

    Below I'm providing code that checks for the SDK in use and chooses the correct generateKey-function:

    private SecretKey getOrCreateSecretKey(String keyName) {
        SecretKey secretKey = getSecretKey(keyName);
        Log.d(APP_TAG, "try to load secretKey from keystore");
        if (secretKey == null) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &
                    Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
                createSecretKeyApi2329(keyName);
                Log.d(APP_TAG, "createSecretKeyApi2329 SDK in use:  " + Build.VERSION.SDK_INT);
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                createSecretKeyApi30(keyName);
                Log.d(APP_TAG, "createSecretKeyApi30 SDK in use:  " + Build.VERSION.SDK_INT);
            }
            // as minimum SDK in build.gradle was set to 23 the version can't be below 23
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
                Log.d(APP_TAG, "SDK in use is to old, minimum SDK is 23 = M");
                finish();
            }
            secretKey = getSecretKey(keyName);
            Log.d(APP_TAG, "generated fresh key, try to load");
        }
        return secretKey;
    }
    
    @RequiresApi(api = Build.VERSION_CODES.R)
    private void createSecretKeyApi30(String keyName) {
        generateSecretKey(new KeyGenParameterSpec.Builder(
                keyName,
                KeyProperties.PURPOSE_SIGN)
                //.setInvalidatedByBiometricEnrollment(true)
                // Accept either a biometric credential or a device credential.
                // To accept only one type of credential, include only that type as the
                // second argument.
                // @RequiresApi(api = Build.VERSION_CODES.R)
                .setUserAuthenticationParameters(0 /* duration */,
                        KeyProperties.AUTH_BIOMETRIC_STRONG |
                                KeyProperties.AUTH_DEVICE_CREDENTIAL)
                .build());
    }// All exceptions unhandled
    
    //@RequiresApi(api = Build.VERSION_CODES.M)
    private void createSecretKeyApi2329(String keyName) {
        generateSecretKey(new KeyGenParameterSpec.Builder(
                keyName,
                KeyProperties.PURPOSE_SIGN)
                //.setInvalidatedByBiometricEnrollment(true)
                // Accept either a biometric credential or a device credential.
                // To accept only one type of credential, include only that type as the
                // second argument.
                // for SDK < 30 use .setUserAuthenticationValidityDurationSeconds(0)
                // see https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:frameworks/base/keystore/java/android/security/keystore/KeyGenParameterSpec.java;l=1236-1246;drc=a811787a9642e6a9e563f2b7dfb15b5ae27ebe98
                // parameter "0" defaults to AUTH_BIOMETRIC_STRONG | AUTH_DEVICE_CREDENTIAL
                // parameter "-1" default to AUTH_BIOMETRIC_STRONG
                .setUserAuthenticationValidityDurationSeconds(0)
                .build());
    }// All exceptions unhandled