androidauthenticationbiometricsandroid-biometric-promptandroid-biometric

BiometricManager.canAuthenticate(BIOMETRIC_STRONG) == BIOMETRIC_SUCCESS but KeyPairGenerator.initialize() => java.lang.IllegalStateException on API 29


Sorry for such a long question. I've tried to include all relevant information and it is quite a lot. I've been working on this issue for a few weeks now and am in desperate need of help.

General info

I am developing a flutter app that requires authentication with a CryptoObject for certain functionality. This means for Android that setUserAuthenticationRequired(true) needs to be set on the KeyGenParameterSpec that's used to create the key. On Android API >=30 this works fine and I can authenticate myself with either fingerprint or Device Credentials (PIN, pattern, password).

The problem

The problem is that I can't get Biometrics with setUserAuthenticationRequired(true) to work on emulators with API 29, even if they have fingerprints set up. I have not been able to test on emulators with an even lower API, so I don't know if that would work or not.

Calling BiometricManager.canAuthenticate(BIOMETRIC_STRONG) == BIOMETRIC_SUCCESS as below returns true. The else case is run since Build.VERSION_CODES.R = API 30. According to the documentation of BiometricPrompt.authenticate(), only BIOMETRIC_STRONG is allowed for devices with API <30.

fun canAuthenticate(context: Context): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            BiometricManager.from(context)
                .canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS
        } else {
            BiometricManager.from(context)
                .canAuthenticate(BIOMETRIC_STRONG) == BIOMETRIC_SUCCESS // <----- this returns true!
        }
    }

However, even though a fingerprint is registered in the emulator and BiometricManager.canAuthenticate(BIOMETRIC_STRONG) == BIOMETRIC_SUCCESS, calling keyPairGenerator.initialize() throws java.lang.IllegalStateException: At least one biometric must be enrolled to create keys requiring user authentication for every use.

This is the code (restricted is true so setUserAuthenticationRequired(true) gets set):

private fun initializeKeyPairGenerator(withStrongBox: Boolean = true): KeyPairGenerator {
    val keyPairGenerator = KeyPairGenerator.getInstance(keyGenAlgorithm, provider)

    try {
        val parameterSpec = createParameterSpec(withStrongBox)
        keyPairGenerator.initialize(parameterSpec) // <-------- It throws the exception here
    } catch (e: Exception) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && e is StrongBoxUnavailableException) {
            val parameterSpec = createParameterSpec(false)
            keyPairGenerator.initialize(parameterSpec)
        } else {
            throw Exception("Cannot create key", e)
        }
    }

    return keyPairGenerator
}

private fun createParameterSpec(withStrongBox: Boolean): KeyGenParameterSpec {
    val purposes = KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
    return KeyGenParameterSpec.Builder(alias, purposes)
        .run {
            setAlgorithmParameterSpec(ECGenParameterSpec(ecCurveName))
            setDigests(KeyProperties.DIGEST_SHA256)
            setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PSS)
            setBlockModes(encryptionBlockMode)
            setEncryptionPaddings(encryptionPadding)

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                setIsStrongBoxBacked(withStrongBox)
            }

            if (restricted) {
                setUserAuthenticationRequired(true)
            }
            build()
        }
}

The issue seems very related to this issue https://issuetracker.google.com/issues/147374428.

Some things I've tried and an ugly way to make it work with two biometric prompts

Setting setUserAuthenticationValidityDurationSeconds(10) on the KeyGenParameterSpec makes keyPairGenerator.initialize() not throw an exception.


private fun createParameterSpec(withStrongBox: Boolean): KeyGenParameterSpec {
    val purposes = KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
    return KeyGenParameterSpec.Builder(alias, purposes)
        .run {
            setAlgorithmParameterSpec(ECGenParameterSpec(ecCurveName))
            setDigests(KeyProperties.DIGEST_SHA256)
            setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PSS)
            setBlockModes(encryptionBlockMode)
            setEncryptionPaddings(encryptionPadding)

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                setIsStrongBoxBacked(withStrongBox)
            }

            if (restricted) {
                setUserAuthenticationRequired(true)

                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                    setUserAuthenticationParameters(
                        0 /* duration */,
                        KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
                    )
                }
                else { // API <= Q
                    // parameter "0" defaults to AUTH_BIOMETRIC_STRONG | AUTH_DEVICE_CREDENTIAL
                    // parameter "-1" default to AUTH_BIOMETRIC_STRONG
                    // source: 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
                    setUserAuthenticationValidityDurationSeconds(10) // <-- Allow device credential authentication
                }
            }

            build()
        }
}

However, it instead throws the following exception when calling initSign(privateKey): ((PlatformException(SIGNING_FAILED, User not authenticated, android.security.keystore.UserNotAuthenticatedException: User not authenticated, null)).

Here is the code:

val signature: Signature
    get() = Signature.getInstance(signAlgorithm)
        .apply {
            val privateKey = asymmetricKeyPair.privateKey
            initSign(privateKey) <--- Throws an exception 
        }

This behavior matches with the documentation of setUserAuthenticationValidityDurationSeconds():

Cryptographic operations involving keys which are authorized to be used for a duration of time after a successful user authentication event can only use secure lock screen authentication. These cryptographic operations will throw UserNotAuthenticatedException during initialization if the user needs to be authenticated to proceed.

The documentation continues with:

This situation can be resolved by the user unlocking the secure lock screen of the Android or by going through the confirm credential flow initiated by KeyguardManager.createConfirmDeviceCredentialIntent(CharSequence, CharSequence). Once resolved, initializing a new cryptographic operation using this key (or any other key which is authorized to be used for a fixed duration of time after user authentication) should succeed provided the user authentication flow completed successfully.

Following these instructions to show a biometric prompt and listening to the result before doing initSign(privateKey) makes initSign(privateKey) not throw an exception, if the user authenticates themself in the prompt by fingerprint.

The code:

private fun triggerBiometricPrompt() {
    val bio = BiometricAuthenticator()
    val intent = bio.createConfirmDeviceCredentialIntent(activity)
    activity.startActivityForResult(intent, 0)
}

In the class FlutterFragmentActivity()

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (IdNowMethodCallHandler.handler.onActivityResult(requestCode, resultCode, data)) {
        return
    }

    if (resultCode == Activity.RESULT_OK) {
        handler.signWithRestrictedKey(handler.methodCall, handler.methodResult) // <-- The result gets handled here
    }

    super.onActivityResult(requestCode, resultCode, data)
}

However, this means the user needs to authenticate themself twice, as a second prompt of course is shown when calling BiometricPrompt.authenticate().

The code:

private fun authenticate(
    activity: FragmentActivity,
    promptInfo: BiometricPrompt.PromptInfo = createPromptInfo(),
    signature: Signature?,
    onError: (Int, CharSequence) -> Unit,
    onSuccess: (BiometricPrompt.AuthenticationResult) -> Unit,
) {
    val callback = object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationError(errorCode: Int, errString: CharSequence) = onError(errorCode, errString)

        override fun onAuthenticationFailed() {
            // Called when a biometric (e.g. fingerprint, face, etc.) is presented but not recognized as belonging to the user.
            // We want to omit it because the fingerprint maybe just failed to be read in which case the user retries.
            // Also, after multiple attempts, the user can use credentials instead.
        }

        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = onSuccess(result)
    }
    val executor = ContextCompat.getMainExecutor(activity)
    val prompt = BiometricPrompt(activity, executor, callback)

    if (signature == null) {
        prompt.authenticate(promptInfo) // TODO: We never do this since signature is never null.
    } else {
        prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(signature)) // <-- Another prompt is shown here to authenticate 
    }
}

fun createPromptInfo(
    title: String = "Authorize", 
    subtitle: String = "Please, authorise yourself", 
    description: String = "This is needed to perform cryptographic operations.", 
): BiometricPrompt.PromptInfo {
    val builder = BiometricPrompt.PromptInfo.Builder()
        .setTitle(title)
        .setSubtitle(subtitle)
        .setDescription(description)
        .setConfirmationRequired(true)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        builder.apply {
            setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
        }
    } else {
        builder.setNegativeButtonText("Cancel")
    }

    return builder.build()
}

Needing the user to authenticate twice in a row with biometrics is of course a very poor user experience. It won't even work if the user authenticates with device credentials in the first prompt, and I've found no way to hide that option.

Questions

  1. Why does KeyPairGenerator.initialize() throw the exception java.lang.IllegalStateException: At least one biometric must be enrolled to create keys requiring user authentication for every use on emulators with API 29 with fingerprint set up, even though BiometricManager.canAuthenticate(BIOMETRIC_STRONG) == BIOMETRIC_SUCCESS? Is this simply a bug in the Android system?
  2. Is there a way to make keys with setUserAuthenticationRequired(true) (crypto-based authentication) work on API 29 (or APIs <30)?

I am deeply grateful for any help I can get.


Solution

  • Finally found a solution thanks to https://www.iedigital.com/resources/archive/create-rsa-key-pair-on-android/.

    Basically, for API <30, the trick is to use keyGuardManager.createConfirmDeviceCredentialIntent() instead of using BiometricPrompt.authenticate(). The article explains it best but here are the basic steps with some code:

    1. Do setUserAuthenticationValidityDurationSeconds(0) when creating the key.
    private fun createParameterSpec(withStrongBox: Boolean): KeyGenParameterSpec {
        val purposes = KeyProperties.PURPOSE_SIGN
        return KeyGenParameterSpec.Builder(alias, purposes)
            .run {
                setAlgorithmParameterSpec(ECGenParameterSpec(ecCurveName))
                setDigests(KeyProperties.DIGEST_SHA256)
                setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PSS)
                setBlockModes(encryptionBlockMode)
                setEncryptionPaddings(encryptionPadding)
    
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                    setIsStrongBoxBacked(withStrongBox)
                }
    
                if (restricted) {
                    setUserAuthenticationRequired(true)
    
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                        setUserAuthenticationParameters(
                            0 /* duration */,
                            KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
                        )
                    }
                    else { // API <= Q
                        // parameter "0" defaults to AUTH_BIOMETRIC_STRONG | AUTH_DEVICE_CREDENTIAL
                        // parameter "-1" default to AUTH_BIOMETRIC_STRONG
                        // source: 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
                        setUserAuthenticationValidityDurationSeconds(0)
                    }
                }
    
                build()
            }
    }
    
    1. Use the deprecated keyGuardManager.createConfirmDeviceCredentialIntent() to display a prompt and let the user authenticate themselves.
    
    fun createConfirmDeviceCredentialIntent(context: Context): Intent {
        val keyGuardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
    
        return keyGuardManager.createConfirmDeviceCredentialIntent(
            "Authorize", // TODO: Add and use Phrase string https://jimplan.atlassian.net/browse/FS-946
            "Please, authorise yourself", // TODO: Add and use Phrase string https://jimplan.atlassian.net/browse/FS-946
        )
    }
    
    1. Sign the data
    fun sign(signature: Signature, data: ByteArray): ByteArray {
        val signedData = signature.run {
            update(data)
            sign()
        }
        return signedData
    }
    

    Annoying that it doesn't work for all APIs to use BiometricPrompt.authenticate(). I wish this was clearer in the documentation!