From 44229aad6cf73bf712ca4447e3c22e5666a7b88d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=C2=A0Koby=C5=82ko?= Date: Sat, 6 Jan 2024 16:36:20 +0100 Subject: [PATCH] Improve error handling on BiometricsPrompt --- .../security/ui/biometric/BiometricDialog.kt | 148 ++++++++++-------- .../ui/biometric/OldBiometricDialog.kt | 82 ++++++++++ .../feature/security/ui/pin/PinScreen.kt | 22 +-- .../security/ui/security/SecurityScreen.kt | 29 ++-- gradle/libs.versions.toml | 2 +- 5 files changed, 195 insertions(+), 88 deletions(-) create mode 100644 feature/security/src/main/java/com/twofasapp/feature/security/ui/biometric/OldBiometricDialog.kt diff --git a/feature/security/src/main/java/com/twofasapp/feature/security/ui/biometric/BiometricDialog.kt b/feature/security/src/main/java/com/twofasapp/feature/security/ui/biometric/BiometricDialog.kt index 8df9bc65..41bda72a 100644 --- a/feature/security/src/main/java/com/twofasapp/feature/security/ui/biometric/BiometricDialog.kt +++ b/feature/security/src/main/java/com/twofasapp/feature/security/ui/biometric/BiometricDialog.kt @@ -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() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - onSuccess.invoke() - } + val promptInfo = PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setNegativeButtonText(negative) + .build() - 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() - } + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onSuccess() } - biometricPrompt = if (fragment != null) { - BiometricPrompt(fragment, executor, callback) - } else { - BiometricPrompt(activity, executor, callback) + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + // Failed eg. due to wrong fingerprint + onDismiss() } - 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() + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + if (errorCode == 13 || errorCode == 10) { + // Cancel action + onDismiss() + return + } + context.toastLong("Error: $errString") + onDismiss() } } -} \ No newline at end of file + + val biometricPrompt = BiometricPrompt(activity, executor, callback) + + LaunchedEffect(Unit) { + try { + 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() + 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)) +} diff --git a/feature/security/src/main/java/com/twofasapp/feature/security/ui/biometric/OldBiometricDialog.kt b/feature/security/src/main/java/com/twofasapp/feature/security/ui/biometric/OldBiometricDialog.kt new file mode 100644 index 00000000..4fe7ef84 --- /dev/null +++ b/feature/security/src/main/java/com/twofasapp/feature/security/ui/biometric/OldBiometricDialog.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/feature/security/src/main/java/com/twofasapp/feature/security/ui/pin/PinScreen.kt b/feature/security/src/main/java/com/twofasapp/feature/security/ui/pin/PinScreen.kt index 2facc07c..45ef80fa 100644 --- a/feature/security/src/main/java/com/twofasapp/feature/security/ui/pin/PinScreen.kt +++ b/feature/security/src/main/java/com/twofasapp/feature/security/ui/pin/PinScreen.kt @@ -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 diff --git a/feature/security/src/main/java/com/twofasapp/feature/security/ui/security/SecurityScreen.kt b/feature/security/src/main/java/com/twofasapp/feature/security/ui/security/SecurityScreen.kt index 97079888..ab081f66 100644 --- a/feature/security/src/main/java/com/twofasapp/feature/security/ui/security/SecurityScreen.kt +++ b/feature/security/src/main/java/com/twofasapp/feature/security/ui/security/SecurityScreen.kt @@ -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) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d6c2e02b..89db76c6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }