Improve error handling on BiometricsPrompt

This commit is contained in:
Rafał Kobyłko 2024-01-06 16:36:20 +01:00
parent c85d30420c
commit 44229aad6c
5 changed files with 195 additions and 88 deletions

View File

@ -1,82 +1,106 @@
package com.twofasapp.feature.security.ui.biometric
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.widget.Toast
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.PromptInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.twofasapp.designsystem.ktx.currentActivity
import com.twofasapp.designsystem.ktx.toastLong
import com.twofasapp.feature.security.biometric.BiometricKeyProvider
import com.twofasapp.locale.R
import java.util.concurrent.Executor
import javax.crypto.Cipher
import javax.crypto.SecretKey
internal class BiometricDialog(
private val activity: FragmentActivity,
private val fragment: Fragment? = null,
private val titleRes: Int = R.string.biometric_dialog_auth_title,
private val subtitleRes: Int = R.string.biometric_dialog_auth_subtitle,
private val cancelRes: Int = R.string.biometric_dialog_auth_cancel,
private val onSuccess: () -> Unit,
private val onFailed: () -> Unit,
private val onError: () -> Unit,
private val onDismiss: () -> Unit = {},
private val onBiometricInvalidated: () -> Unit = {},
private val biometricKeyProvider: BiometricKeyProvider,
@Composable
fun BiometricDialog(
title: String,
subtitle: String,
negative: String,
requireKeyValidation: Boolean,
onSuccess: () -> Unit,
onDismiss: () -> Unit,
onBiometricInvalidated: () -> Unit = {},
biometricKeyProvider: BiometricKeyProvider,
) {
val context = LocalContext.current
val activity = LocalContext.currentActivity as? FragmentActivity
private lateinit var executor: Executor
private lateinit var biometricPrompt: BiometricPrompt
private lateinit var promptInfo: BiometricPrompt.PromptInfo
if (activity == null) {
context.toastLong("Could not find FragmentActivity. Restart the app and try again.")
onDismiss()
return
}
fun show() {
executor = ContextCompat.getMainExecutor(activity)
val executor = ContextCompat.getMainExecutor(activity)
val callback: BiometricPrompt.AuthenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
val promptInfo = PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setNegativeButtonText(negative)
.build()
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onSuccess.invoke()
onSuccess()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onFailed.invoke()
// Failed eg. due to wrong fingerprint
onDismiss()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
if (errorCode == 13 || errorCode == 10) {
// Cancel action
onDismiss.invoke()
onDismiss()
return
}
onError.invoke()
Toast.makeText(activity, "$errString", Toast.LENGTH_SHORT).show()
context.toastLong("Error: $errString")
onDismiss()
}
}
biometricPrompt = if (fragment != null) {
BiometricPrompt(fragment, executor, callback)
} else {
BiometricPrompt(activity, executor, callback)
}
promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(activity.getString(titleRes))
.setSubtitle(activity.getString(subtitleRes))
.setNegativeButtonText(activity.getString(cancelRes))
.build()
val biometricPrompt = BiometricPrompt(activity, executor, callback)
LaunchedEffect(Unit) {
try {
val cipher = Cipher.getInstance(BiometricKeyProvider.TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, biometricKeyProvider.getSecretKey())
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
authenticate(
biometricPrompt = biometricPrompt,
promptInfo = promptInfo,
secretKey = biometricKeyProvider.getSecretKey()
)
} catch (e: KeyPermanentlyInvalidatedException) {
if (requireKeyValidation) {
onBiometricInvalidated()
} else {
biometricKeyProvider.deleteSecretKey()
authenticate(
biometricPrompt = biometricPrompt,
promptInfo = promptInfo,
secretKey = biometricKeyProvider.getSecretKey()
)
}
} catch (e: Exception) {
e.printStackTrace()
onError()
context.toastLong("Error: ${e.message}")
onDismiss()
}
}
}
private fun authenticate(
biometricPrompt: BiometricPrompt,
promptInfo: PromptInfo,
secretKey: SecretKey,
) {
val cipher = Cipher.getInstance(BiometricKeyProvider.TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}

View File

@ -0,0 +1,82 @@
package com.twofasapp.feature.security.ui.biometric
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.widget.Toast
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.twofasapp.feature.security.biometric.BiometricKeyProvider
import com.twofasapp.locale.R
import java.util.concurrent.Executor
import javax.crypto.Cipher
internal class OldBiometricDialog(
private val activity: FragmentActivity,
private val fragment: Fragment? = null,
private val titleRes: Int = R.string.biometric_dialog_auth_title,
private val subtitleRes: Int = R.string.biometric_dialog_auth_subtitle,
private val cancelRes: Int = R.string.biometric_dialog_auth_cancel,
private val onSuccess: () -> Unit,
private val onFailed: () -> Unit,
private val onError: () -> Unit,
private val onDismiss: () -> Unit = {},
private val onBiometricInvalidated: () -> Unit = {},
private val biometricKeyProvider: BiometricKeyProvider,
) {
private lateinit var executor: Executor
private lateinit var biometricPrompt: BiometricPrompt
private lateinit var promptInfo: BiometricPrompt.PromptInfo
fun show() {
executor = ContextCompat.getMainExecutor(activity)
val callback: BiometricPrompt.AuthenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onSuccess.invoke()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onFailed.invoke()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
if (errorCode == 13 || errorCode == 10) {
// Cancel action
onDismiss.invoke()
return
}
onError.invoke()
Toast.makeText(activity, "$errString", Toast.LENGTH_SHORT).show()
}
}
biometricPrompt = if (fragment != null) {
BiometricPrompt(fragment, executor, callback)
} else {
BiometricPrompt(activity, executor, callback)
}
promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(activity.getString(titleRes))
.setSubtitle(activity.getString(subtitleRes))
.setNegativeButtonText(activity.getString(cancelRes))
.build()
try {
val cipher = Cipher.getInstance(BiometricKeyProvider.TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, biometricKeyProvider.getSecretKey())
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
} catch (e: KeyPermanentlyInvalidatedException) {
onBiometricInvalidated()
} catch (e: Exception) {
e.printStackTrace()
onError()
}
}
}

View File

@ -23,16 +23,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import com.twofasapp.feature.security.biometric.BiometricKeyProvider
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.common.TwCircularProgressIndicator
import com.twofasapp.feature.security.biometric.BiometricKeyProvider
import com.twofasapp.feature.security.ui.biometric.BiometricDialog
import com.twofasapp.locale.R
internal sealed interface PinScreenState {
object Loading : PinScreenState
@ -132,19 +132,21 @@ internal fun PinScreen(
if (showBiometricDialog && showBiometrics && biometricKeyProvider != null) {
BiometricDialog(
activity = LocalContext.current as FragmentActivity,
title = stringResource(id = R.string.biometric_dialog_auth_title),
subtitle = stringResource(id = R.string.biometric_dialog_auth_subtitle),
negative = stringResource(id = R.string.biometric_dialog_auth_cancel),
onSuccess = {
onBiometricsVerified.invoke()
showBiometricDialog = false
},
onFailed = { showBiometricDialog = false },
onError = { showBiometricDialog = false },
onDismiss = { showBiometricDialog = false },
onDismiss = {
showBiometricDialog = false
},
onBiometricInvalidated = { onBiometricsInvalidated() },
biometricKeyProvider = biometricKeyProvider,
).show()
requireKeyValidation = true,
biometricKeyProvider = biometricKeyProvider
)
}
}
@Stable

View File

@ -18,8 +18,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
import com.twofasapp.feature.security.biometric.BiometricKeyProvider
import com.twofasapp.data.session.domain.LockMethod
import com.twofasapp.data.session.domain.PinTimeout
import com.twofasapp.data.session.domain.PinTrials
import com.twofasapp.designsystem.TwIcons
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.common.TwTopAppBar
@ -29,11 +30,9 @@ import com.twofasapp.designsystem.settings.SettingsDivider
import com.twofasapp.designsystem.settings.SettingsHeader
import com.twofasapp.designsystem.settings.SettingsLink
import com.twofasapp.designsystem.settings.SettingsSwitch
import com.twofasapp.locale.R
import com.twofasapp.data.session.domain.LockMethod
import com.twofasapp.data.session.domain.PinTimeout
import com.twofasapp.data.session.domain.PinTrials
import com.twofasapp.feature.security.biometric.BiometricKeyProvider
import com.twofasapp.feature.security.ui.biometric.BiometricDialog
import com.twofasapp.locale.R
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@ -232,19 +231,19 @@ internal fun SecurityScreen(
if (showBiometricDialog) {
BiometricDialog(
activity = activity!! as FragmentActivity,
titleRes = R.string.biometric_dialog_setup_title,
cancelRes = R.string.biometric_dialog_setup_cancel,
title = stringResource(id = R.string.biometric_dialog_setup_title),
subtitle = stringResource(id = R.string.biometric_dialog_auth_subtitle),
negative = stringResource(id = R.string.biometric_dialog_setup_cancel),
onSuccess = {
viewModel.updateBiometricLock(true)
showBiometricDialog = false
},
onFailed = { showBiometricDialog = false },
onError = { showBiometricDialog = false },
onDismiss = { showBiometricDialog = false },
onBiometricInvalidated = {},
biometricKeyProvider = biometricKeyProvider,
).show()
onDismiss = {
showBiometricDialog = false
},
requireKeyValidation = false,
biometricKeyProvider = biometricKeyProvider
)
}
if (showScreenshotsConfirmDialog) {

View File

@ -30,7 +30,7 @@ activityX = { module = "androidx.activity:activity-ktx", version.ref = "composeA
apacheCommonsCodec = "commons-codec:commons-codec:1.15"
appcompat = "androidx.appcompat:appcompat:1.6.1"
barcodeScanning = "com.google.mlkit:barcode-scanning:17.2.0"
biometric = "androidx.biometric:biometric:1.1.0"
biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" }
camera2Lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraX" }
camera2View = { module = "androidx.camera:camera-view", version.ref = "cameraX" }