From 3c88c69f926c53f5b7ea7995a23b86e69cd831a7 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 5 Dec 2025 16:28:43 -0600 Subject: [PATCH] PM-29172: Update Authenticator biometric encryption --- authenticator/build.gradle.kts | 1 + .../auth/datasource/disk/AuthDiskSource.kt | 5 + .../datasource/disk/AuthDiskSourceImpl.kt | 11 + .../data/auth/repository/AuthRepository.kt | 29 ++- .../auth/repository/AuthRepositoryImpl.kt | 99 ++++++++- .../repository/di/AuthRepositoryModule.kt | 9 + .../manager/BiometricsEncryptionManager.kt | 24 +- .../BiometricsEncryptionManagerImpl.kt | 175 ++++++++++++--- .../manager/di/PlatformManagerModule.kt | 7 +- .../platform/repository/SettingsRepository.kt | 22 -- .../repository/SettingsRepositoryImpl.kt | 39 +--- .../repository/di/PlatformRepositoryModule.kt | 9 - .../repository/model/BiometricsKeyResult.kt | 4 +- .../model/BiometricsUnlockResult.kt | 26 +++ .../ui/auth/unlock/UnlockScreen.kt | 109 +++++----- .../ui/auth/unlock/UnlockViewModel.kt | 126 +++++++++-- .../feature/rootnav/RootNavViewModel.kt | 8 +- .../feature/settings/SettingsScreen.kt | 45 ++-- .../feature/settings/SettingsViewModel.kt | 95 +++++--- .../manager/biometrics/BiometricsManager.kt | 4 +- .../biometrics/BiometricsManagerImpl.kt | 11 +- .../datasource/disk/AuthDiskSourceTest.kt | 22 ++ .../disk/util/FakeAuthDiskSource.kt | 39 +++- .../auth/repository/AuthRepositoryTest.kt | 205 ++++++++++++++++++ .../repository/SettingsRepositoryTest.kt | 25 --- .../feature/rootnav/RootNavViewModelTest.kt | 81 ++++--- .../feature/settings/SettingsViewModelTest.kt | 10 +- 27 files changed, 917 insertions(+), 323 deletions(-) create mode 100644 authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsUnlockResult.kt create mode 100644 authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryTest.kt diff --git a/authenticator/build.gradle.kts b/authenticator/build.gradle.kts index 0f7a3a63f1..280779066d 100644 --- a/authenticator/build.gradle.kts +++ b/authenticator/build.gradle.kts @@ -223,6 +223,7 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization) + implementation(libs.timber) // For now we are restricted to running Compose tests for debug builds only debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt index 6106452c06..97f52dcd59 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt @@ -39,6 +39,11 @@ interface AuthDiskSource : AppIdProvider { */ fun storeUserBiometricUnlockKey(biometricsKey: String?) + /** + * Gets and sets the biometrics initialization vector. + */ + var userBiometricKeyInitVector: ByteArray? + /** * Stores the symmetric key data used for encrypting TOTP data. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt index 94fa5b301b..c590cf4f67 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -10,6 +10,7 @@ import java.util.UUID private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey" private const val LAST_ACTIVE_TIME_KEY = "lastActiveTime" private const val BIOMETRICS_UNLOCK_KEY = "userKeyBiometricUnlock" +private const val BIOMETRICS_INIT_VECTOR_KEY = "biometricInitializationVector" private const val UNIQUE_APP_ID_KEY = "appId" /** @@ -60,6 +61,16 @@ class AuthDiskSourceImpl( mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey) } + override var userBiometricKeyInitVector: ByteArray? + get() = getEncryptedString(key = BIOMETRICS_INIT_VECTOR_KEY) + ?.toByteArray(Charsets.ISO_8859_1) + set(value) { + putEncryptedString( + key = BIOMETRICS_INIT_VECTOR_KEY, + value = value?.toString(Charsets.ISO_8859_1), + ) + } + override var authenticatorBridgeSymmetricSyncKey: ByteArray? set(value) { val asString = value?.let { value.toString(Charsets.ISO_8859_1) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepository.kt index e65ceb2098..50d1c2f9a6 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepository.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepository.kt @@ -1,9 +1,36 @@ package com.bitwarden.authenticator.data.auth.repository +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult +import kotlinx.coroutines.flow.StateFlow +import javax.crypto.Cipher + /** * Provides and API for modifying authentication state. */ -interface AuthRepository { +interface AuthRepository : BiometricsEncryptionManager { + + /** + * Whether or not biometric unlocking is enabled for the current user. + */ + val isUnlockWithBiometricsEnabled: Boolean + + /** + * Tracks whether or not biometric unlocking is enabled for the current user. + */ + val isUnlockWithBiometricsEnabledFlow: StateFlow + + /** + * Stores the encrypted user key for biometrics, allowing it to be used to unlock the current + * user's vault. + */ + suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult + + /** + * Attempt to unlock the vault using the stored biometric key. + */ + suspend fun unlockWithBiometrics(cipher: Cipher): BiometricsUnlockResult /** * Updates the "last active time" for the current user. diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryImpl.kt index 1560cbc5ae..550daef56c 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryImpl.kt @@ -1,7 +1,21 @@ package com.bitwarden.authenticator.data.auth.repository import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult +import com.bitwarden.core.data.manager.dispatcher.DispatcherManager import com.bitwarden.core.data.manager.realtime.RealtimeManager +import com.bitwarden.core.data.repository.error.MissingPropertyException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import timber.log.Timber +import java.security.GeneralSecurityException +import javax.crypto.Cipher import javax.inject.Inject /** @@ -9,12 +23,89 @@ import javax.inject.Inject */ class AuthRepositoryImpl @Inject constructor( private val authDiskSource: AuthDiskSource, + private val authenticatorSdkSource: AuthenticatorSdkSource, + private val biometricsEncryptionManager: BiometricsEncryptionManager, private val realtimeManager: RealtimeManager, -) : AuthRepository { + dispatcherManager: DispatcherManager, +) : AuthRepository, + BiometricsEncryptionManager by biometricsEncryptionManager { + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + + override val isUnlockWithBiometricsEnabled: Boolean + get() = authDiskSource.getUserBiometricUnlockKey() != null + + override val isUnlockWithBiometricsEnabledFlow: StateFlow + get() = authDiskSource + .userBiometricUnlockKeyFlow + .map { it != null } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = isUnlockWithBiometricsEnabled, + ) + + override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult = + authenticatorSdkSource + .generateBiometricsKey() + .onSuccess { biometricsKey -> + authDiskSource.storeUserBiometricUnlockKey( + biometricsKey = try { + cipher + .doFinal(biometricsKey.encodeToByteArray()) + .toString(Charsets.ISO_8859_1) + } catch (e: GeneralSecurityException) { + Timber.w(e, "setupBiometricsKey failed encrypt the biometric key") + return BiometricsKeyResult.Error(error = e) + }, + ) + authDiskSource.userBiometricKeyInitVector = cipher.iv + } + .fold( + onSuccess = { BiometricsKeyResult.Success }, + onFailure = { BiometricsKeyResult.Error(error = it) }, + ) + + override suspend fun unlockWithBiometrics(cipher: Cipher): BiometricsUnlockResult { + val biometricsKey = authDiskSource + .getUserBiometricUnlockKey() + ?: return BiometricsUnlockResult.InvalidStateError( + error = MissingPropertyException("Biometric key"), + ) + val iv = authDiskSource.userBiometricKeyInitVector + val decryptedUserKey = iv + ?.let { + try { + cipher + .doFinal(biometricsKey.toByteArray(Charsets.ISO_8859_1)) + .decodeToString() + } catch (e: GeneralSecurityException) { + Timber.w(e, "unlockWithBiometrics failed when decrypting biometrics key") + return BiometricsUnlockResult.BiometricDecodingError(error = e) + } + } + ?: biometricsKey + + if (iv == null) { + // Attempting to setup an encrypted pin before unlocking, if this fails we send back + // the biometrics error and users will need to sign in another way and re-setup + // biometrics. + val encryptedBiometricsKey = try { + cipher + .doFinal(decryptedUserKey.encodeToByteArray()) + .toString(Charsets.ISO_8859_1) + } catch (e: GeneralSecurityException) { + Timber.w(e, "unlockWithBiometrics failed to migrate the user to IV encryption") + return BiometricsUnlockResult.BiometricDecodingError(error = e) + } + // We now store the newly encrypted key and the associated IV for future use + // since we want to migrate the user to a more secure form of biometrics. + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = encryptedBiometricsKey) + authDiskSource.userBiometricKeyInitVector = cipher.iv + } + + return BiometricsUnlockResult.Success + } - /** - * Updates the "last active time" for the current user. - */ override fun updateLastActiveTime() { authDiskSource.storeLastActiveTimeMillis( lastActiveTimeMillis = realtimeManager.elapsedRealtimeMs, diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/di/AuthRepositoryModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/di/AuthRepositoryModule.kt index cd7ba5bd40..7433e9304a 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/di/AuthRepositoryModule.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/repository/di/AuthRepositoryModule.kt @@ -3,6 +3,9 @@ package com.bitwarden.authenticator.data.auth.repository.di import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource import com.bitwarden.authenticator.data.auth.repository.AuthRepository import com.bitwarden.authenticator.data.auth.repository.AuthRepositoryImpl +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.core.data.manager.dispatcher.DispatcherManager import com.bitwarden.core.data.manager.realtime.RealtimeManager import dagger.Module import dagger.Provides @@ -19,9 +22,15 @@ object AuthRepositoryModule { @Provides fun provideAuthRepository( authDiskSource: AuthDiskSource, + authenticatorSdkSource: AuthenticatorSdkSource, + biometricsEncryptionManager: BiometricsEncryptionManager, realtimeManager: RealtimeManager, + dispatcherManager: DispatcherManager, ): AuthRepository = AuthRepositoryImpl( authDiskSource = authDiskSource, + authenticatorSdkSource = authenticatorSdkSource, + biometricsEncryptionManager = biometricsEncryptionManager, realtimeManager = realtimeManager, + dispatcherManager = dispatcherManager, ) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManager.kt index 492c2297b1..2a6ab28128 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManager.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManager.kt @@ -1,19 +1,35 @@ package com.bitwarden.authenticator.data.platform.manager +import javax.crypto.Cipher + /** * Responsible for managing Android keystore encryption and decryption. */ interface BiometricsEncryptionManager { /** - * Sets up biometrics to ensure future integrity checks work properly. If this method has never - * been called [isBiometricIntegrityValid] will return false. + * Creates a [Cipher] built from a keystore. */ - fun setupBiometrics() + fun createCipherOrNull(): Cipher? + + /** + * Clears the data associated with the users biometrics. + */ + fun clearBiometrics() + + /** + * Gets the [Cipher] built from a keystore, or creates one if it doesn't already exist. + */ + fun getOrCreateCipher(): Cipher? /** * Checks to verify that the biometrics integrity is still valid. This returns `true` if the - * biometrics data has not change since the app setup biometrics, `false` will be returned if + * biometrics data has not changed since the app setup biometrics; `false` will be returned if * it has changed. */ fun isBiometricIntegrityValid(): Boolean + + /** + * Returns a boolean indicating whether the system reflects biometric availability. + */ + fun isAccountBiometricIntegrityValid(): Boolean } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManagerImpl.kt index eca5f498a1..33d795e666 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManagerImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/BiometricsEncryptionManagerImpl.kt @@ -3,20 +3,34 @@ package com.bitwarden.authenticator.data.platform.manager import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties +import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource +import timber.log.Timber +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.ProviderException import java.security.UnrecoverableKeyException import java.util.UUID import javax.crypto.Cipher import javax.crypto.KeyGenerator +import javax.crypto.NoSuchPaddingException +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec /** * Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption * and decryption. */ +@Suppress("TooManyFunctions") +@OmitFromCoverage class BiometricsEncryptionManagerImpl( + private val authDiskSource: AuthDiskSource, private val settingsDiskSource: SettingsDiskSource, ) : BiometricsEncryptionManager { private val keystore = KeyStore @@ -35,14 +49,59 @@ class BiometricsEncryptionManagerImpl( .setInvalidatedByBiometricEnrollment(true) .build() - override fun setupBiometrics() { + override fun createCipherOrNull(): Cipher? { + val secretKey: SecretKey = generateKeyOrNull() ?: run { + // user removed all biometrics from the device + destroyBiometrics() + return null + } + val cipher = try { + Cipher.getInstance(CIPHER_TRANSFORMATION) + } catch (nsae: NoSuchAlgorithmException) { + Timber.w(nsae, "createCipherOrNull failed to get cipher instance") + return null + } catch (nspe: NoSuchPaddingException) { + Timber.w(nspe, "createCipherOrNull failed to get cipher instance") + return null + } + // Instantiate integrity values. createIntegrityValues() + // This should never fail to initialize / return false because the cipher is newly generated + cipher.initializeCipher(secretKey = secretKey) + return cipher + } + + override fun clearBiometrics() { + settingsDiskSource.systemBiometricIntegritySource?.let { systemBioIntegrityState -> + settingsDiskSource.storeAccountBiometricIntegrityValidity( + systemBioIntegrityState = systemBioIntegrityState, + value = null, + ) + } + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null) + authDiskSource.userBiometricKeyInitVector = null + keystore.deleteEntry(ENCRYPTION_KEY_NAME) + } + + override fun getOrCreateCipher(): Cipher? { + // Attempt to get the key. If that fails, then we need to generate a new one. + val secretKey: SecretKey = getSecretKeyOrNull() + ?: generateKeyOrNull() + ?: run { + // user removed all biometrics from the device + destroyBiometrics() + return null + } + + val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) + val isCipherInitialized = cipher.initializeCipher(secretKey = secretKey) + return cipher?.takeIf { isCipherInitialized } } override fun isBiometricIntegrityValid(): Boolean = isSystemBiometricIntegrityValid() && isAccountBiometricIntegrityValid() - private fun isAccountBiometricIntegrityValid(): Boolean { + override fun isAccountBiometricIntegrityValid(): Boolean { val systemBioIntegrityState = settingsDiskSource .systemBiometricIntegritySource ?: return false @@ -53,28 +112,99 @@ class BiometricsEncryptionManagerImpl( ?: false } - private fun isSystemBiometricIntegrityValid(): Boolean = + /** + * Generates a [SecretKey] from which the [Cipher] will be generated, or `null` if a key cannot + * be generated. + */ + private fun generateKeyOrNull(): SecretKey? { + val keyGen = try { + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ENCRYPTION_KEYSTORE_NAME) + } catch (nsae: NoSuchAlgorithmException) { + Timber.w(nsae, "generateKeyOrNull failed to get key generator instance") + return null + } catch (nspe: NoSuchProviderException) { + Timber.w(nspe, "generateKeyOrNull failed to get key generator instance") + return null + } catch (iae: IllegalArgumentException) { + Timber.w(iae, "generateKeyOrNull failed to get key generator instance") + return null + } + + return try { + keyGen.init(keyGenParameterSpec) + keyGen.generateKey() + } catch (iape: InvalidAlgorithmParameterException) { + Timber.w(iape, "generateKeyOrNull failed to initialize and generate key") + null + } catch (pe: ProviderException) { + Timber.w(pe, "generateKeyOrNull failed to initialize and generate key") + null + } + } + + /** + * Returns the [SecretKey] stored in the keystore, or null if there isn't one. + */ + private fun getSecretKeyOrNull(): SecretKey? = try { - keystore.load(null) - keystore - .getKey(ENCRYPTION_KEY_NAME, null) - ?.let { Cipher.getInstance(CIPHER_TRANSFORMATION).init(Cipher.ENCRYPT_MODE, it) } + keystore.getKey(ENCRYPTION_KEY_NAME, null)?.let { it as SecretKey } + } catch (kse: KeyStoreException) { + // keystore was not loaded + Timber.w(kse, "getSecretKeyOrNull failed to retrieve secret key") + null + } catch (nsae: NoSuchAlgorithmException) { + // keystore algorithm cannot be found + Timber.w(nsae, "getSecretKeyOrNull failed to retrieve secret key") + null + } catch (uke: UnrecoverableKeyException) { + // key could not be recovered + Timber.w(uke, "getSecretKeyOrNull failed to retrieve secret key") + null + } + + /** + * Initialize a [Cipher] and return a boolean indicating whether it is valid. + */ + private fun Cipher.initializeCipher(secretKey: SecretKey): Boolean = + try { + authDiskSource + .userBiometricKeyInitVector + ?.let { init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(it)) } + ?: init(Cipher.ENCRYPT_MODE, secretKey) true - } catch (e: KeyPermanentlyInvalidatedException) { + } catch (kpie: KeyPermanentlyInvalidatedException) { // Biometric has changed - settingsDiskSource.systemBiometricIntegritySource = null + Timber.w(kpie, "initializeCipher failed to initialize cipher") + destroyBiometrics() false - } catch (e: UnrecoverableKeyException) { + } catch (uke: UnrecoverableKeyException) { // Biometric was disabled and re-enabled - settingsDiskSource.systemBiometricIntegritySource = null + Timber.w(uke, "initializeCipher failed to initialize cipher") + destroyBiometrics() false - } catch (e: InvalidKeyException) { - // Fallback for old bitwarden users without a key - createIntegrityValues() + } catch (ike: InvalidKeyException) { + // User has no key + Timber.w(ike, "initializeCipher failed to initialize cipher") + destroyBiometrics() true } - @Suppress("TooGenericExceptionCaught") + /** + * Validates the keystore key and decrypts it, if decryption is successful `true` is returned, + * `false` otherwise. + */ + private fun isSystemBiometricIntegrityValid(): Boolean { + // Attempt to get the user scoped key. If that fails, we check to see if a legacy key + // is available. + val cipher = getOrCreateCipher() + val secretKey = getSecretKeyOrNull() + return if (cipher != null && secretKey != null) { + cipher.initializeCipher(secretKey = secretKey) + } else { + false + } + } + private fun createIntegrityValues() { val systemBiometricIntegritySource = settingsDiskSource .systemBiometricIntegritySource @@ -84,18 +214,11 @@ class BiometricsEncryptionManagerImpl( systemBioIntegrityState = systemBiometricIntegritySource, value = true, ) + } - try { - val keyGen = KeyGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_AES, - ENCRYPTION_KEYSTORE_NAME, - ) - keyGen.init(keyGenParameterSpec) - keyGen.generateKey() - } catch (e: Exception) { - // Catch silently to allow biometrics to function on devices that are in - // a state where key generation is not functioning - } + private fun destroyBiometrics() { + clearBiometrics() + settingsDiskSource.systemBiometricIntegritySource = null } } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt index c90920da06..9272f48112 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt @@ -1,6 +1,7 @@ package com.bitwarden.authenticator.data.platform.manager.di import android.content.Context +import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager @@ -74,8 +75,12 @@ object PlatformManagerModule { @Provides @Singleton fun provideBiometricsEncryptionManager( + authDiskSource: AuthDiskSource, settingsDiskSource: SettingsDiskSource, - ): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl(settingsDiskSource) + ): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl( + authDiskSource = authDiskSource, + settingsDiskSource = settingsDiskSource, + ) @Provides @Singleton diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index 8a15884b0c..0b8cb26b61 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -1,6 +1,5 @@ package com.bitwarden.authenticator.data.platform.repository -import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme @@ -52,16 +51,6 @@ interface SettingsRepository { */ val defaultSaveOptionFlow: Flow - /** - * Whether or not biometric unlocking is enabled for the current user. - */ - val isUnlockWithBiometricsEnabled: Boolean - - /** - * Tracks whether or not biometric unlocking is enabled for the current user. - */ - val isUnlockWithBiometricsEnabledFlow: StateFlow - /** * Tracks changes to the expiration alert threshold. */ @@ -92,17 +81,6 @@ interface SettingsRepository { */ var previouslySyncedBitwardenAccountIds: Set - /** - * Clears any previously stored encrypted user key used with biometrics for the current user. - */ - fun clearBiometricsKey() - - /** - * Stores the encrypted user key for biometrics, allowing it to be used to unlock the current - * user's vault. - */ - suspend fun setupBiometricsKey(): BiometricsKeyResult - /** * The current setting for crash logging. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index b6b347d89d..65594f8a0b 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -1,11 +1,7 @@ package com.bitwarden.authenticator.data.platform.repository import com.bitwarden.authenticator.BuildConfig -import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource -import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource -import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager -import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption import com.bitwarden.core.data.manager.dispatcher.DispatcherManager @@ -24,9 +20,6 @@ private val DEFAULT_IS_SCREEN_CAPTURE_ALLOWED = BuildConfig.DEBUG */ class SettingsRepositoryImpl( private val settingsDiskSource: SettingsDiskSource, - private val authDiskSource: AuthDiskSource, - private val biometricsEncryptionManager: BiometricsEncryptionManager, - private val authenticatorSdkSource: AuthenticatorSdkSource, dispatcherManager: DispatcherManager, ) : SettingsRepository { @@ -63,20 +56,6 @@ class SettingsRepositoryImpl( initialValue = isDynamicColorsEnabled, ) - override val isUnlockWithBiometricsEnabled: Boolean - get() = authDiskSource.getUserBiometricUnlockKey() != null - - override val isUnlockWithBiometricsEnabledFlow: StateFlow - get() = - authDiskSource - .userBiometricUnlockKeyFlow - .map { it != null } - .stateIn( - scope = unconfinedScope, - started = SharingStarted.Eagerly, - initialValue = isUnlockWithBiometricsEnabled, - ) - override val appThemeStateFlow: StateFlow get() = settingsDiskSource .appThemeFlow @@ -119,6 +98,7 @@ class SettingsRepositoryImpl( isScreenCaptureAllowed = value, ) } + override var previouslySyncedBitwardenAccountIds: Set by settingsDiskSource::previouslySyncedBitwardenAccountIds @@ -132,23 +112,6 @@ class SettingsRepositoryImpl( ?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED, ) - override suspend fun setupBiometricsKey(): BiometricsKeyResult { - biometricsEncryptionManager.setupBiometrics() - return authenticatorSdkSource - .generateBiometricsKey() - .onSuccess { - authDiskSource.storeUserBiometricUnlockKey(biometricsKey = it) - } - .fold( - onSuccess = { BiometricsKeyResult.Success }, - onFailure = { BiometricsKeyResult.Error }, - ) - } - - override fun clearBiometricsKey() { - authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null) - } - override var isCrashLoggingEnabled: Boolean get() = settingsDiskSource.isCrashLoggingEnabled ?: true set(value) { diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt index 205d0026dc..31004a605b 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt @@ -1,10 +1,7 @@ package com.bitwarden.authenticator.data.platform.repository.di -import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource -import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource -import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepositoryImpl import com.bitwarden.authenticator.data.platform.repository.SettingsRepository @@ -28,17 +25,11 @@ object PlatformRepositoryModule { @Singleton fun provideSettingsRepository( settingsDiskSource: SettingsDiskSource, - authDiskSource: AuthDiskSource, dispatcherManager: DispatcherManager, - biometricsEncryptionManager: BiometricsEncryptionManager, - authenticatorSdkSource: AuthenticatorSdkSource, ): SettingsRepository = SettingsRepositoryImpl( settingsDiskSource = settingsDiskSource, - authDiskSource = authDiskSource, dispatcherManager = dispatcherManager, - biometricsEncryptionManager = biometricsEncryptionManager, - authenticatorSdkSource = authenticatorSdkSource, ) @Provides diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsKeyResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsKeyResult.kt index 2e79383ec7..13751971d5 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsKeyResult.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsKeyResult.kt @@ -12,5 +12,7 @@ sealed class BiometricsKeyResult { /** * Generic error while setting up the biometrics key. */ - data object Error : BiometricsKeyResult() + data class Error( + val error: Throwable, + ) : BiometricsKeyResult() } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsUnlockResult.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsUnlockResult.kt new file mode 100644 index 0000000000..294f828372 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/model/BiometricsUnlockResult.kt @@ -0,0 +1,26 @@ +package com.bitwarden.authenticator.data.platform.repository.model + +/** + * Models result of unlocking the app. + */ +sealed class BiometricsUnlockResult { + + /** + * Vault successfully unlocked. + */ + data object Success : BiometricsUnlockResult() + + /** + * Unable to decode biometrics key. + */ + data class BiometricDecodingError( + val error: Throwable?, + ) : BiometricsUnlockResult() + + /** + * Unable to access user state information. + */ + data class InvalidStateError( + val error: Throwable?, + ) : BiometricsUnlockResult() +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt index 0327a9b50b..d2b752a437 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt @@ -13,9 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -32,6 +30,7 @@ import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.bitwarden.ui.platform.components.util.rememberVectorPainter import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString +import javax.crypto.Cipher /** * Top level composable for the unlock screen. @@ -45,58 +44,40 @@ fun UnlockScreen( onUnlocked: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - var showBiometricsPrompt by remember { mutableStateOf(true) } - EventsEffect(viewModel = viewModel) { event -> - when (event) { - UnlockEvent.NavigateToItemListing -> onUnlocked() - } - } - - when (val dialog = state.dialog) { - is UnlockState.Dialog.Error -> BitwardenBasicDialog( - title = stringResource(id = BitwardenString.an_error_has_occurred), - message = dialog.message(), - onDismissRequest = remember(viewModel) { - { - viewModel.trySendAction(UnlockAction.DismissDialog) - } - }, - ) - - UnlockState.Dialog.Loading -> BitwardenLoadingDialog( - text = stringResource(id = BitwardenString.loading), - ) - - null -> Unit - } - - val onBiometricsUnlock: () -> Unit = remember(viewModel) { - { viewModel.trySendAction(UnlockAction.BiometricsUnlock) } + val onBiometricsUnlockSuccess: (cipher: Cipher) -> Unit = remember(viewModel) { + { viewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(it)) } } val onBiometricsLockOut: () -> Unit = remember(viewModel) { { viewModel.trySendAction(UnlockAction.BiometricsLockout) } } - if (showBiometricsPrompt) { - biometricsManager.promptBiometrics( - onSuccess = { - showBiometricsPrompt = false - onBiometricsUnlock() - }, - onCancel = { - showBiometricsPrompt = false - }, - onError = { - showBiometricsPrompt = false - }, - onLockOut = { - showBiometricsPrompt = false - onBiometricsLockOut() - }, - ) + EventsEffect(viewModel = viewModel) { event -> + when (event) { + UnlockEvent.NavigateToItemListing -> onUnlocked() + is UnlockEvent.PromptForBiometrics -> { + biometricsManager.promptBiometrics( + onSuccess = onBiometricsUnlockSuccess, + onCancel = { + // no-op + }, + onError = { + // no-op + }, + onLockOut = onBiometricsLockOut, + cipher = event.cipher, + ) + } + } } + UnlockDialogs( + dialog = state.dialog, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(UnlockAction.DismissDialog) } + }, + ) + BitwardenScaffold( modifier = Modifier .fillMaxSize(), @@ -118,17 +99,8 @@ fun UnlockScreen( Spacer(modifier = Modifier.height(32.dp)) BitwardenFilledButton( label = stringResource(id = BitwardenString.unlock), - onClick = { - biometricsManager.promptBiometrics( - onSuccess = onBiometricsUnlock, - onCancel = { - // no-op - }, - onError = { - // no-op - }, - onLockOut = onBiometricsLockOut, - ) + onClick = remember(viewModel) { + { viewModel.trySendAction(UnlockAction.BiometricsUnlockClick) } }, modifier = Modifier .padding(horizontal = 16.dp) @@ -140,3 +112,26 @@ fun UnlockScreen( } } } + +@Composable +private fun UnlockDialogs( + dialog: UnlockState.Dialog?, + onDismissRequest: () -> Unit, +) { + when (dialog) { + is UnlockState.Dialog.Error -> { + BitwardenBasicDialog( + title = dialog.title(), + message = dialog.message(), + throwable = dialog.throwable, + onDismissRequest = onDismissRequest, + ) + } + + UnlockState.Dialog.Loading -> { + BitwardenLoadingDialog(text = stringResource(id = BitwardenString.loading)) + } + + null -> Unit + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt index dc4c6c3e6e..e1ff54258d 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt @@ -3,8 +3,9 @@ package com.bitwarden.authenticator.ui.auth.unlock import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager -import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.auth.repository.AuthRepository +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult +import com.bitwarden.ui.platform.base.BackgroundEvent import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.Text @@ -13,7 +14,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import javax.crypto.Cipher import javax.inject.Inject private const val KEY_STATE = "state" @@ -24,13 +27,13 @@ private const val KEY_STATE = "state" @HiltViewModel class UnlockViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val settingsRepository: SettingsRepository, - private val biometricsEncryptionManager: BiometricsEncryptionManager, + private val authRepository: AuthRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: run { UnlockState( - isBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, - isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(), + isBiometricsEnabled = authRepository.isUnlockWithBiometricsEnabled, + isBiometricsValid = authRepository.isBiometricIntegrityValid(), + showBiometricInvalidatedMessage = false, dialog = null, ) }, @@ -40,29 +43,87 @@ class UnlockViewModel @Inject constructor( stateFlow .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + authRepository.getOrCreateCipher()?.let { + sendEvent(UnlockEvent.PromptForBiometrics(cipher = it)) + } } override fun handleAction(action: UnlockAction) { when (action) { - UnlockAction.BiometricsUnlock -> { - handleBiometricsUnlock() - } + is UnlockAction.BiometricsUnlockSuccess -> handleBiometricsUnlockSuccess(action) + UnlockAction.DismissDialog -> handleDismissDialog() + UnlockAction.BiometricsLockout -> handleBiometricsLockout() + UnlockAction.BiometricsUnlockClick -> handleBiometricsUnlockClick() + is UnlockAction.Internal -> handleInternalAction(action) + } + } - UnlockAction.DismissDialog -> { - handleDismissDialog() - } - - UnlockAction.BiometricsLockout -> { - handleBiometricsLockout() + private fun handleInternalAction(action: UnlockAction.Internal) { + when (action) { + is UnlockAction.Internal.ReceiveVaultUnlockResult -> { + handleReceiveVaultUnlockResult(action) } } } - private fun handleBiometricsUnlock() { - if (state.isBiometricsEnabled && !state.isBiometricsValid) { - biometricsEncryptionManager.setupBiometrics() + private fun handleReceiveVaultUnlockResult( + action: UnlockAction.Internal.ReceiveVaultUnlockResult, + ) { + when (val result = action.vaultUnlockResult) { + is BiometricsUnlockResult.BiometricDecodingError -> { + authRepository.clearBiometrics() + mutableStateFlow.update { + it.copy( + isBiometricsValid = false, + dialog = UnlockState.Dialog.Error( + title = BitwardenString.biometrics_failed.asText(), + message = BitwardenString.biometrics_decoding_failure.asText(), + ), + ) + } + } + + is BiometricsUnlockResult.InvalidStateError -> { + mutableStateFlow.update { + it.copy( + dialog = UnlockState.Dialog.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + throwable = result.error, + ), + ) + } + } + + BiometricsUnlockResult.Success -> { + mutableStateFlow.update { it.copy(dialog = null) } + sendEvent(UnlockEvent.NavigateToItemListing) + } + } + } + + private fun handleBiometricsUnlockClick() { + authRepository + .getOrCreateCipher() + ?.let { sendEvent(UnlockEvent.PromptForBiometrics(cipher = it)) } + ?: run { + mutableStateFlow.update { + it.copy( + isBiometricsValid = false, + showBiometricInvalidatedMessage = !authRepository + .isAccountBiometricIntegrityValid(), + dialog = null, + ) + } + } + } + + private fun handleBiometricsUnlockSuccess(action: UnlockAction.BiometricsUnlockSuccess) { + mutableStateFlow.update { it.copy(dialog = UnlockState.Dialog.Loading) } + viewModelScope.launch { + val vaultUnlockResult = authRepository.unlockWithBiometrics(cipher = action.cipher) + sendAction(UnlockAction.Internal.ReceiveVaultUnlockResult(vaultUnlockResult)) } - sendEvent(UnlockEvent.NavigateToItemListing) } private fun handleDismissDialog() { @@ -73,6 +134,7 @@ class UnlockViewModel @Inject constructor( mutableStateFlow.update { it.copy( dialog = UnlockState.Dialog.Error( + title = BitwardenString.an_error_has_occurred.asText(), message = BitwardenString.too_many_failed_biometric_attempts.asText(), ), ) @@ -87,6 +149,7 @@ class UnlockViewModel @Inject constructor( data class UnlockState( val isBiometricsEnabled: Boolean, val isBiometricsValid: Boolean, + val showBiometricInvalidatedMessage: Boolean, val dialog: Dialog?, ) : Parcelable { @@ -99,7 +162,9 @@ data class UnlockState( * Displays a generic error dialog to the user. */ data class Error( + val title: Text, val message: Text, + val throwable: Throwable? = null, ) : Dialog() /** @@ -113,6 +178,10 @@ data class UnlockState( * Models events for the Unlock screen. */ sealed class UnlockEvent { + /** + * Prompts the user for biometrics unlock. + */ + data class PromptForBiometrics(val cipher: Cipher) : UnlockEvent(), BackgroundEvent /** * Navigates to the item listing screen. @@ -135,8 +204,25 @@ sealed class UnlockAction { */ data object BiometricsLockout : UnlockAction() + /** + * The user has clicked the biometrics button. + */ + data object BiometricsUnlockClick : UnlockAction() + /** * The user has successfully unlocked the app with biometrics. */ - data object BiometricsUnlock : UnlockAction() + data class BiometricsUnlockSuccess(val cipher: Cipher) : UnlockAction() + + /** + * Models actions that the [UnlockViewModel] itself might send. + */ + sealed class Internal : UnlockAction() { + /** + * Indicates a vault unlock result has been received. + */ + data class ReceiveVaultUnlockResult( + val vaultUnlockResult: BiometricsUnlockResult, + ) : Internal() + } } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index 29c6746e55..ea79d61830 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -3,7 +3,6 @@ package com.bitwarden.authenticator.ui.platform.feature.rootnav import android.os.Parcelable import androidx.lifecycle.viewModelScope import com.bitwarden.authenticator.data.auth.repository.AuthRepository -import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -22,7 +21,6 @@ import javax.inject.Inject class RootNavViewModel @Inject constructor( private val authRepository: AuthRepository, private val settingsRepository: SettingsRepository, - private val biometricsEncryptionManager: BiometricsEncryptionManager, ) : BaseViewModel( initialState = RootNavState( hasSeenWelcomeGuide = settingsRepository.hasSeenWelcomeTutorial, @@ -76,8 +74,8 @@ class RootNavViewModel @Inject constructor( ) { settingsRepository.hasSeenWelcomeTutorial = action.hasSeenWelcomeGuide if (action.hasSeenWelcomeGuide) { - if (settingsRepository.isUnlockWithBiometricsEnabled && - biometricsEncryptionManager.isBiometricIntegrityValid() + if (authRepository.isUnlockWithBiometricsEnabled && + authRepository.isBiometricIntegrityValid() ) { mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) } } else { @@ -111,7 +109,7 @@ class RootNavViewModel @Inject constructor( action: RootNavAction.BiometricSupportChanged, ) { if (!action.isBiometricsSupported) { - settingsRepository.clearBiometricsKey() + authRepository.clearBiometrics() // If currently locked, navigate to unlocked since biometrics are no longer available if (mutableStateFlow.value.navState is RootNavState.NavState.Locked) { diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 73cde1d4fe..d60f9af6fa 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -76,6 +76,7 @@ import com.bitwarden.ui.platform.util.displayLabel import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import kotlinx.collections.immutable.toImmutableList +import javax.crypto.Cipher /** * Display the settings screen. @@ -92,8 +93,15 @@ fun SettingsScreen( onNavigateToImport: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val snackbarState = rememberBitwardenSnackbarHostState() + var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) } + val unlockWithBiometricToggle: (cipher: Cipher) -> Unit = remember(viewModel) { + { + viewModel.trySendAction( + SettingsAction.SecurityClick.UnlockWithBiometricToggleEnabled(cipher = it), + ) + } + } EventsEffect(viewModel = viewModel) { event -> when (event) { SettingsEvent.NavigateToTutorial -> onNavigateToTutorial() @@ -135,6 +143,20 @@ fun SettingsScreen( } is SettingsEvent.ShowSnackbar -> snackbarState.showSnackbar(event.data) + + is SettingsEvent.ShowBiometricsPrompt -> { + showBiometricsPrompt = true + biometricsManager.promptBiometrics( + onSuccess = { + unlockWithBiometricToggle(it) + showBiometricsPrompt = false + }, + onCancel = { showBiometricsPrompt = false }, + onLockOut = { showBiometricsPrompt = false }, + onError = { showBiometricsPrompt = false }, + cipher = event.cipher, + ) + } } } @@ -147,6 +169,7 @@ fun SettingsScreen( }, ) + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() BitwardenScaffold( modifier = Modifier .fillMaxSize() @@ -452,7 +475,6 @@ private fun UnlockWithBiometricsRow( modifier: Modifier = Modifier, ) { if (!biometricsManager.isBiometricsSupported) return - var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) } BitwardenSwitch( modifier = modifier, cardStyle = CardStyle.Top(), @@ -460,23 +482,8 @@ private fun UnlockWithBiometricsRow( subtext = stringResource( id = BitwardenString.use_your_devices_lock_method_to_unlock_the_app, ), - isChecked = isChecked || showBiometricsPrompt, - onCheckedChange = { toggled -> - if (toggled) { - showBiometricsPrompt = true - biometricsManager.promptBiometrics( - onSuccess = { - onBiometricToggle(true) - showBiometricsPrompt = false - }, - onCancel = { showBiometricsPrompt = false }, - onLockOut = { showBiometricsPrompt = false }, - onError = { showBiometricsPrompt = false }, - ) - } else { - onBiometricToggle(false) - } - }, + isChecked = isChecked, + onCheckedChange = onBiometricToggle, ) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index 91fc2f11a5..fb72dc9c54 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -7,6 +7,7 @@ import androidx.core.os.LocaleListCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.data.auth.repository.AuthRepository import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled @@ -37,6 +38,7 @@ import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import java.time.Clock import java.time.Year +import javax.crypto.Cipher import javax.inject.Inject private const val KEY_STATE = "state" @@ -52,6 +54,7 @@ class SettingsViewModel @Inject constructor( authenticatorRepository: AuthenticatorRepository, snackbarRelayManager: SnackbarRelayManager, private val authenticatorBridgeManager: AuthenticatorBridgeManager, + private val authRepository: AuthRepository, private val settingsRepository: SettingsRepository, private val clipboardManager: BitwardenClipboardManager, ) : BaseViewModel( @@ -60,7 +63,7 @@ class SettingsViewModel @Inject constructor( clock = clock, appLanguage = settingsRepository.appLanguage, appTheme = settingsRepository.appTheme, - unlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, + unlockWithBiometricsEnabled = authRepository.isUnlockWithBiometricsEnabled, isSubmitCrashLogsEnabled = settingsRepository.isCrashLoggingEnabled, accountSyncState = authenticatorBridgeManager.accountSyncStateFlow.value, defaultSaveOption = settingsRepository.defaultSaveOption, @@ -86,7 +89,7 @@ class SettingsViewModel @Inject constructor( .map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) } .onEach(::sendAction) .launchIn(viewModelScope) - settingsRepository + authRepository .isUnlockWithBiometricsEnabledFlow .map { SettingsAction.Internal.UnlockWithBiometricsUpdated(it) } .onEach(::sendAction) @@ -179,6 +182,10 @@ class SettingsViewModel @Inject constructor( is SettingsAction.SecurityClick.AllowScreenCaptureToggle -> { handleAllowScreenCaptureToggle(action) } + + is SettingsAction.SecurityClick.UnlockWithBiometricToggleEnabled -> { + handleUnlockWithBiometricToggleEnabled(action) + } } } @@ -186,18 +193,24 @@ class SettingsViewModel @Inject constructor( action: SettingsAction.SecurityClick.UnlockWithBiometricToggle, ) { if (action.enabled) { - mutableStateFlow.update { - it.copy( - dialog = SettingsState.Dialog.Loading(BitwardenString.saving.asText()), - isUnlockWithBiometricsEnabled = true, - ) - } - viewModelScope.launch { - val result = settingsRepository.setupBiometricsKey() - sendAction(SettingsAction.Internal.BiometricsKeyResultReceive(result)) - } + authRepository + .createCipherOrNull() + ?.let { + // Generate a new key in case the previous one was invalidated + sendEvent(SettingsEvent.ShowBiometricsPrompt(cipher = it)) + } + ?: run { + mutableStateFlow.update { + it.copy( + dialog = SettingsState.Dialog.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + ), + ) + } + } } else { - settingsRepository.clearBiometricsKey() + authRepository.clearBiometrics() mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) } } } @@ -206,7 +219,7 @@ class SettingsViewModel @Inject constructor( action: SettingsAction.Internal.BiometricsKeyResultReceive, ) { when (action.result) { - BiometricsKeyResult.Error -> { + is BiometricsKeyResult.Error -> { mutableStateFlow.update { it.copy( dialog = null, @@ -233,6 +246,21 @@ class SettingsViewModel @Inject constructor( mutableStateFlow.update { it.copy(allowScreenCapture = action.enabled) } } + private fun handleUnlockWithBiometricToggleEnabled( + action: SettingsAction.SecurityClick.UnlockWithBiometricToggleEnabled, + ) { + mutableStateFlow.update { + it.copy( + dialog = SettingsState.Dialog.Loading(BitwardenString.saving.asText()), + isUnlockWithBiometricsEnabled = true, + ) + } + viewModelScope.launch { + val result = authRepository.setupBiometricsKey(cipher = action.cipher) + sendAction(SettingsAction.Internal.BiometricsKeyResultReceive(result = result)) + } + } + private fun handleVaultClick(action: SettingsAction.DataClick) { when (action) { SettingsAction.DataClick.ExportClick -> handleExportClick() @@ -453,6 +481,13 @@ data class SettingsState( */ @Parcelize sealed class Dialog : Parcelable { + /** + * Displays an error dialog with a title and message. + */ + data class Error( + val title: Text, + val message: Text, + ) : Dialog() /** * Displays a loading dialog with a [message]. @@ -544,28 +579,19 @@ sealed class SettingsEvent { ), ) } + + /** + * Shows the prompt for biometrics using with the given [cipher]. + */ + data class ShowBiometricsPrompt( + val cipher: Cipher, + ) : SettingsEvent() } /** * Models actions for the settings screen. */ -sealed class SettingsAction( - val dialog: Dialog? = null, -) { - - /** - * Represents dialogs that may be displayed by the Settings screen. - */ - sealed class Dialog { - - /** - * Display the loading screen with a [message]. - */ - data class Loading( - val message: Text, - ) : Dialog() - } - +sealed class SettingsAction { /** * Indicates an update on device biometrics support. */ @@ -580,6 +606,13 @@ sealed class SettingsAction( */ data class UnlockWithBiometricToggle(val enabled: Boolean) : SecurityClick() + /** + * User toggled the unlock with biometrics switch to on. + */ + data class UnlockWithBiometricToggleEnabled( + val cipher: Cipher, + ) : SecurityClick() + /** * Indicates the user clicked allow screen capture toggle. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManager.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManager.kt index 616a8399c0..ffebabaaa0 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManager.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManager.kt @@ -1,6 +1,7 @@ package com.bitwarden.authenticator.ui.platform.manager.biometrics import androidx.compose.runtime.Immutable +import javax.crypto.Cipher /** * Interface to manage biometrics within the app. @@ -16,9 +17,10 @@ interface BiometricsManager { * Display a prompt for biometrics. */ fun promptBiometrics( - onSuccess: () -> Unit, + onSuccess: (Cipher) -> Unit, onCancel: () -> Unit, onLockOut: () -> Unit, onError: () -> Unit, + cipher: Cipher, ) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt index d78efe8b6b..f90dd53b56 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt @@ -7,11 +7,14 @@ import androidx.biometric.BiometricManager.Authenticators import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity +import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.ui.platform.resource.BitwardenString +import javax.crypto.Cipher /** * Default implementation of the [BiometricsManager] to manage biometrics within the app. */ +@OmitFromCoverage class BiometricsManagerImpl( private val activity: Activity, ) : BiometricsManager { @@ -46,10 +49,11 @@ class BiometricsManagerImpl( } override fun promptBiometrics( - onSuccess: () -> Unit, + onSuccess: (Cipher) -> Unit, onCancel: () -> Unit, onLockOut: () -> Unit, onError: () -> Unit, + cipher: Cipher, ) { val biometricPrompt = BiometricPrompt( fragmentActivity, @@ -57,7 +61,7 @@ class BiometricsManagerImpl( object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded( result: BiometricPrompt.AuthenticationResult, - ) = onSuccess() + ) = result.cryptoObject?.cipher?.let(onSuccess) ?: onError() override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { when (errorCode) { @@ -91,9 +95,10 @@ class BiometricsManagerImpl( val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(activity.getString(BitwardenString.bitwarden_authenticator)) .setDescription(activity.getString(BitwardenString.device_verification)) + .setConfirmationRequired(false) .setAllowedAuthenticators(allowedAuthenticators) .build() - biometricPrompt.authenticate(promptInfo) + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) } } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceTest.kt index 7faaff0cbd..4b97102b52 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -39,6 +39,28 @@ class AuthDiskSourceTest { assertTrue(authDiskSource.authenticatorBridgeSymmetricSyncKey.contentEquals(symmetricKey)) } + @Test + fun `userBiometricKeyInitVector should store and update from EncryptedSharedPreferences`() { + val sharedPrefsKey = "bwSecureStorage:biometricInitializationVector" + + // Shared preferences and the repository start with the same value: + assertNull(authDiskSource.userBiometricKeyInitVector) + assertNull(fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null)) + + // Updating the repository updates shared preferences: + val userBiometricKeyInitVector = byteArrayOf(3, 4) + authDiskSource.userBiometricKeyInitVector = userBiometricKeyInitVector + assertEquals( + userBiometricKeyInitVector.toString(Charsets.ISO_8859_1), + fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null), + ) + + // Retrieving the key from repository should give same byte array despite String conversion: + assertTrue( + authDiskSource.userBiometricKeyInitVector.contentEquals(userBiometricKeyInitVector), + ) + } + @Test fun `uniqueAppId should generate a new ID and update SharedPreferences if none exists`() { val rememberedUniqueAppIdKey = "bwPreferencesStorage:appId" diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 7a36a3bfcd..5c24e36acd 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -4,36 +4,53 @@ import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onSubscription +import org.junit.jupiter.api.Assertions.assertEquals import java.util.UUID class FakeAuthDiskSource : AuthDiskSource { - private var lastActiveTimeMillis: Long? = null - private var userBiometricUnlockKey: String? = null + private var storedLastActiveTimeMillis: Long? = null + private var storedUserBiometricUnlockKey: String? = null + private var storedUserBiometricInitVector: ByteArray? = null private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow(replay = 1) override val uniqueAppId: String get() = UUID.randomUUID().toString() - override fun getLastActiveTimeMillis(): Long? = lastActiveTimeMillis + override fun getLastActiveTimeMillis(): Long? = storedLastActiveTimeMillis override fun storeLastActiveTimeMillis(lastActiveTimeMillis: Long?) { - this@FakeAuthDiskSource.lastActiveTimeMillis = lastActiveTimeMillis + storedLastActiveTimeMillis = lastActiveTimeMillis } - override fun getUserBiometricUnlockKey(): String? = userBiometricUnlockKey + fun assertLastActiveTimeMillis(lastActiveTimeMillis: Long?) { + assertEquals(lastActiveTimeMillis, storedLastActiveTimeMillis) + } + + override fun getUserBiometricUnlockKey(): String? = storedUserBiometricUnlockKey override val userBiometricUnlockKeyFlow: Flow - get() = - mutableUserBiometricUnlockKeyFlow - .onSubscription { - emit(getUserBiometricUnlockKey()) - } + get() = mutableUserBiometricUnlockKeyFlow + .onSubscription { emit(getUserBiometricUnlockKey()) } override fun storeUserBiometricUnlockKey(biometricsKey: String?) { mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey) - this@FakeAuthDiskSource.userBiometricUnlockKey = biometricsKey + this@FakeAuthDiskSource.storedUserBiometricUnlockKey = biometricsKey + } + + fun assertUserBiometricUnlockKey(biometricsKey: String?) { + assertEquals(biometricsKey, storedUserBiometricUnlockKey) } override var authenticatorBridgeSymmetricSyncKey: ByteArray? = null + + override var userBiometricKeyInitVector: ByteArray? + get() = storedUserBiometricInitVector + set(value) { + storedUserBiometricInitVector = value + } + + fun assertUserBiometricKeyInitVector(iv: ByteArray?) { + assertEquals(iv, storedUserBiometricInitVector) + } } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryTest.kt new file mode 100644 index 0000000000..66e16e5725 --- /dev/null +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/repository/AuthRepositoryTest.kt @@ -0,0 +1,205 @@ +package com.bitwarden.authenticator.data.auth.repository + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult +import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager +import com.bitwarden.core.data.manager.realtime.RealtimeManager +import com.bitwarden.core.data.repository.error.MissingPropertyException +import com.bitwarden.core.data.util.asFailure +import com.bitwarden.core.data.util.asSuccess +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.security.GeneralSecurityException +import javax.crypto.Cipher + +class AuthRepositoryTest { + private val authDiskSource: FakeAuthDiskSource = FakeAuthDiskSource() + private val authenticatorSdkSource: AuthenticatorSdkSource = mockk() + private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk() + private val realtimeManager: RealtimeManager = mockk() + + private val authRepository: AuthRepository = AuthRepositoryImpl( + authDiskSource = authDiskSource, + authenticatorSdkSource = authenticatorSdkSource, + biometricsEncryptionManager = biometricsEncryptionManager, + realtimeManager = realtimeManager, + dispatcherManager = FakeDispatcherManager(), + ) + + @BeforeEach + fun setup() { + mockkConstructor(MissingPropertyException::class) + every { + anyConstructed() == any() + } returns true + } + + @Test + fun `isUnlockWithBiometricsEnabled should update based on AuthDiskSource`() { + assertFalse(authRepository.isUnlockWithBiometricsEnabled) + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey") + assertTrue(authRepository.isUnlockWithBiometricsEnabled) + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null) + assertFalse(authRepository.isUnlockWithBiometricsEnabled) + } + + @Test + fun `isUnlockWithBiometricsEnabledFlow should react to changes in AuthDiskSource`() = runTest { + authRepository.isUnlockWithBiometricsEnabledFlow.test { + assertFalse(awaitItem()) + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey") + assertTrue(awaitItem()) + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null) + assertFalse(awaitItem()) + } + } + + @Test + fun `setupBiometricsKey on generateBiometricsKey error returns Error`() = runTest { + val error = Throwable("Fail!") + coEvery { authenticatorSdkSource.generateBiometricsKey() } returns error.asFailure() + + val result = authRepository.setupBiometricsKey(cipher = CIPHER) + + assertEquals(BiometricsKeyResult.Error(error = error), result) + coVerify(exactly = 1) { + authenticatorSdkSource.generateBiometricsKey() + } + } + + @Test + fun `setupBiometricsKey on cipher encryption failure returns Error`() = runTest { + val biometricsKey = "biometricsKey" + val error = GeneralSecurityException("Fail!") + coEvery { authenticatorSdkSource.generateBiometricsKey() } returns biometricsKey.asSuccess() + every { CIPHER.doFinal(any()) } throws error + + val result = authRepository.setupBiometricsKey(cipher = CIPHER) + + assertEquals(BiometricsKeyResult.Error(error = error), result) + coVerify(exactly = 1) { + authenticatorSdkSource.generateBiometricsKey() + } + } + + @Test + fun `setupBiometricsKey on cipher encryption success returns Success`() = runTest { + val biometricsKey = "biometricsKey" + val encryptedBytes = byteArrayOf(1, 1) + val iv = byteArrayOf(2, 2) + coEvery { authenticatorSdkSource.generateBiometricsKey() } returns biometricsKey.asSuccess() + every { CIPHER.doFinal(any()) } returns encryptedBytes + every { CIPHER.iv } returns iv + + val result = authRepository.setupBiometricsKey(cipher = CIPHER) + + assertEquals(BiometricsKeyResult.Success, result) + authDiskSource.assertUserBiometricUnlockKey(encryptedBytes.toString(Charsets.ISO_8859_1)) + authDiskSource.assertUserBiometricKeyInitVector(iv) + coVerify(exactly = 1) { + authenticatorSdkSource.generateBiometricsKey() + } + } + + @Test + fun `unlockWithBiometrics without stored biometrics key returns InvalidStateError`() = runTest { + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null) + + val result = authRepository.unlockWithBiometrics(cipher = CIPHER) + + assertEquals( + BiometricsUnlockResult.InvalidStateError( + error = MissingPropertyException("Biometric key"), + ), + result, + ) + } + + @Test + fun `unlockWithBiometrics with iv and decryption error returns BiometricDecodingError`() = + runTest { + val biometricsKey = "biometricsKey" + val initVector = byteArrayOf(1, 2) + val error = GeneralSecurityException("Fail!") + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = biometricsKey) + authDiskSource.userBiometricKeyInitVector = initVector + every { CIPHER.doFinal(any()) } throws error + + val result = authRepository.unlockWithBiometrics(cipher = CIPHER) + + assertEquals(BiometricsUnlockResult.BiometricDecodingError(error), result) + } + + @Test + fun `unlockWithBiometrics with iv and decryption success returns Success`() = runTest { + val biometricsKey = "biometricsKey" + val initVector = byteArrayOf(1, 2) + val encryptedBytes = byteArrayOf(1, 1) + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = biometricsKey) + authDiskSource.userBiometricKeyInitVector = initVector + every { CIPHER.doFinal(any()) } returns encryptedBytes + + val result = authRepository.unlockWithBiometrics(cipher = CIPHER) + + assertEquals(BiometricsUnlockResult.Success, result) + } + + @Test + fun `unlockWithBiometrics without iv and encryption failure returns BiometricDecodingError`() = + runTest { + val biometricsKey = "biometricsKey" + val error = GeneralSecurityException("Fail!") + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = biometricsKey) + authDiskSource.userBiometricKeyInitVector = null + every { CIPHER.doFinal(any()) } throws error + + val result = authRepository.unlockWithBiometrics(cipher = CIPHER) + + assertEquals(BiometricsUnlockResult.BiometricDecodingError(error), result) + } + + @Test + fun `unlockWithBiometrics without iv and encryption success returns Success`() = runTest { + val biometricsKey = "biometricsKey" + val encryptedBytes = byteArrayOf(2, 2) + val initVector = byteArrayOf(3, 3) + authDiskSource.storeUserBiometricUnlockKey(biometricsKey = biometricsKey) + authDiskSource.userBiometricKeyInitVector = null + every { CIPHER.doFinal(any()) } returns encryptedBytes + every { CIPHER.iv } returns initVector + + val result = authRepository.unlockWithBiometrics(cipher = CIPHER) + + assertEquals(BiometricsUnlockResult.Success, result) + authDiskSource.assertUserBiometricUnlockKey(encryptedBytes.toString(Charsets.ISO_8859_1)) + authDiskSource.assertUserBiometricKeyInitVector(initVector) + } + + @Test + fun `updateLastActiveTime should store the current elapsedRealtimeMs`() { + val elapsedMs = 1234L + every { realtimeManager.elapsedRealtimeMs } returns elapsedMs + + authRepository.updateLastActiveTime() + + authDiskSource.assertLastActiveTimeMillis(elapsedMs) + coVerify(exactly = 1) { + realtimeManager.elapsedRealtimeMs + } + } +} + +private val CIPHER: Cipher = mockk() diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt index fa5e0d8808..59dd3c75a1 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt @@ -1,11 +1,7 @@ package com.bitwarden.authenticator.data.platform.repository import app.cash.turbine.test -import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource -import com.bitwarden.authenticator.data.auth.datasource.disk.util.FakeAuthDiskSource -import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource -import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow @@ -26,15 +22,9 @@ class SettingsRepositoryTest { private val settingsDiskSource: SettingsDiskSource = mockk { every { getAlertThresholdSeconds() } returns 7 } - private val authDiskSource: AuthDiskSource = FakeAuthDiskSource() - private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk() - private val authenticatorSdkSource: AuthenticatorSdkSource = mockk() private val settingsRepository = SettingsRepositoryImpl( settingsDiskSource = settingsDiskSource, - authDiskSource = authDiskSource, - biometricsEncryptionManager = biometricsEncryptionManager, - authenticatorSdkSource = authenticatorSdkSource, dispatcherManager = FakeDispatcherManager(), ) @@ -165,19 +155,4 @@ class SettingsRepositoryTest { settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") verify { settingsDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") } } - - @Test - fun `isUnlockWithBiometricsEnabledFlow should react to changes in AuthDiskSource`() = runTest { - settingsRepository.isUnlockWithBiometricsEnabledFlow.test { - assertFalse(awaitItem()) - authDiskSource.storeUserBiometricUnlockKey( - biometricsKey = "biometricsKey", - ) - assertTrue(awaitItem()) - authDiskSource.storeUserBiometricUnlockKey( - biometricsKey = null, - ) - assertFalse(awaitItem()) - } - } } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt index c691abb70d..902af2d8c7 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -2,7 +2,6 @@ package com.bitwarden.authenticator.ui.platform.feature.rootnav import app.cash.turbine.test import com.bitwarden.authenticator.data.auth.repository.AuthRepository -import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.every @@ -20,15 +19,14 @@ class RootNavViewModelTest : BaseViewModelTest() { private val mutableHasSeenWelcomeTutorialFlow = MutableStateFlow(false) private val authRepository: AuthRepository = mockk { every { updateLastActiveTime() } just runs + every { isUnlockWithBiometricsEnabled } returns false + every { clearBiometrics() } just runs } private val settingsRepository: SettingsRepository = mockk { every { hasSeenWelcomeTutorial } returns false every { hasSeenWelcomeTutorial = any() } just runs every { hasSeenWelcomeTutorialFlow } returns mutableHasSeenWelcomeTutorialFlow - every { isUnlockWithBiometricsEnabled } returns false - every { clearBiometricsKey() } just runs } - private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk() @Test fun `initialState should be correct when hasSeenWelcomeTutorial is false`() = runTest { @@ -70,8 +68,8 @@ class RootNavViewModelTest : BaseViewModelTest() { @Test @Suppress("MaxLineLength") fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled and valid should navigate to Locked`() { - every { settingsRepository.isUnlockWithBiometricsEnabled } returns true - every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true + every { authRepository.isUnlockWithBiometricsEnabled } returns true + every { authRepository.isBiometricIntegrityValid() } returns true val viewModel = createViewModel() viewModel.trySendAction( @@ -91,8 +89,8 @@ class RootNavViewModelTest : BaseViewModelTest() { @Test @Suppress("MaxLineLength") fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled but invalid should navigate to Unlocked`() { - every { settingsRepository.isUnlockWithBiometricsEnabled } returns true - every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns false + every { authRepository.isUnlockWithBiometricsEnabled } returns true + every { authRepository.isBiometricIntegrityValid() } returns false val viewModel = createViewModel() viewModel.trySendAction( @@ -112,7 +110,7 @@ class RootNavViewModelTest : BaseViewModelTest() { @Test @Suppress("MaxLineLength") fun `on HasSeenWelcomeTutorialChange with true and biometrics disabled should navigate to Unlocked`() { - every { settingsRepository.isUnlockWithBiometricsEnabled } returns false + every { authRepository.isUnlockWithBiometricsEnabled } returns false val viewModel = createViewModel() viewModel.trySendAction( @@ -255,7 +253,7 @@ class RootNavViewModelTest : BaseViewModelTest() { viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false)) - verify(exactly = 1) { settingsRepository.clearBiometricsKey() } + verify(exactly = 1) { authRepository.clearBiometrics() } } @Test @@ -264,40 +262,40 @@ class RootNavViewModelTest : BaseViewModelTest() { viewModel.trySendAction(RootNavAction.BiometricSupportChanged(true)) - verify(exactly = 0) { settingsRepository.clearBiometricsKey() } + verify(exactly = 0) { authRepository.clearBiometrics() } } @Test - @Suppress("MaxLineLength") - fun `on BiometricSupportChanged with false when Locked should navigate to Unlocked`() = runTest { - every { settingsRepository.hasSeenWelcomeTutorial } returns true - every { settingsRepository.isUnlockWithBiometricsEnabled } returns true - every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true - mutableHasSeenWelcomeTutorialFlow.value = true - val viewModel = createViewModel() + fun `on BiometricSupportChanged with false when Locked should navigate to Unlocked`() = + runTest { + every { settingsRepository.hasSeenWelcomeTutorial } returns true + every { authRepository.isUnlockWithBiometricsEnabled } returns true + every { authRepository.isBiometricIntegrityValid() } returns true + mutableHasSeenWelcomeTutorialFlow.value = true + val viewModel = createViewModel() - // Verify initial state is Locked - assertEquals( - RootNavState( - hasSeenWelcomeGuide = true, - navState = RootNavState.NavState.Locked, - ), - viewModel.stateFlow.value, - ) + // Verify initial state is Locked + assertEquals( + RootNavState( + hasSeenWelcomeGuide = true, + navState = RootNavState.NavState.Locked, + ), + viewModel.stateFlow.value, + ) - // Send BiometricSupportChanged with false - viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false)) + // Send BiometricSupportChanged with false + viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false)) - // Should navigate to Unlocked and clear biometric key - assertEquals( - RootNavState( - hasSeenWelcomeGuide = true, - navState = RootNavState.NavState.Unlocked, - ), - viewModel.stateFlow.value, - ) - verify(exactly = 1) { settingsRepository.clearBiometricsKey() } - } + // Should navigate to Unlocked and clear biometric key + assertEquals( + RootNavState( + hasSeenWelcomeGuide = true, + navState = RootNavState.NavState.Unlocked, + ), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { authRepository.clearBiometrics() } + } @Test @Suppress("MaxLineLength") @@ -327,14 +325,14 @@ class RootNavViewModelTest : BaseViewModelTest() { ), viewModel.stateFlow.value, ) - verify(exactly = 1) { settingsRepository.clearBiometricsKey() } + verify(exactly = 1) { authRepository.clearBiometrics() } } @Test @Suppress("MaxLineLength") fun `hasSeenWelcomeTutorialFlow updates should trigger HasSeenWelcomeTutorialChange action`() = runTest { - every { settingsRepository.isUnlockWithBiometricsEnabled } returns false + every { authRepository.isUnlockWithBiometricsEnabled } returns false val viewModel = createViewModel() viewModel.stateFlow.test { @@ -361,9 +359,8 @@ class RootNavViewModelTest : BaseViewModelTest() { } } - private fun createViewModel() = RootNavViewModel( + private fun createViewModel(): RootNavViewModel = RootNavViewModel( authRepository = authRepository, settingsRepository = settingsRepository, - biometricsEncryptionManager = biometricsEncryptionManager, ) } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt index a655b838cf..43fd0cd963 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -4,6 +4,7 @@ import android.os.Build import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.authenticator.BuildConfig +import com.bitwarden.authenticator.data.auth.repository.AuthRepository import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled @@ -55,12 +56,15 @@ class SettingsViewModelTest : BaseViewModelTest() { private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false) private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false) private val mutableIsUnlockWithBiometricsEnabledFlow = MutableStateFlow(true) + private val authRepository: AuthRepository = mockk { + every { isUnlockWithBiometricsEnabled } returns true + every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow + } private val settingsRepository: SettingsRepository = mockk { every { appLanguage } returns APP_LANGUAGE every { appTheme } returns APP_THEME every { defaultSaveOption } returns DEFAULT_SAVE_OPTION every { defaultSaveOptionFlow } returns mutableDefaultSaveOptionFlow - every { isUnlockWithBiometricsEnabled } returns true every { isCrashLoggingEnabled } returns true every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedStateFlow every { isScreenCaptureAllowed } answers { mutableScreenCaptureAllowedStateFlow.value } @@ -68,7 +72,6 @@ class SettingsViewModelTest : BaseViewModelTest() { every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value } every { isDynamicColorsEnabled = any() } just runs every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow - every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow } private val clipboardManager: BitwardenClipboardManager = mockk() private val mutableSnackbarFlow = bufferedMutableSharedFlow() @@ -303,9 +306,10 @@ class SettingsViewModelTest : BaseViewModelTest() { private fun createViewModel( savedState: SettingsState? = DEFAULT_STATE, - ) = SettingsViewModel( + ): SettingsViewModel = SettingsViewModel( savedStateHandle = SavedStateHandle().apply { this["state"] = savedState }, clock = CLOCK, + authRepository = authRepository, authenticatorBridgeManager = authenticatorBridgeManager, authenticatorRepository = authenticatorRepository, settingsRepository = settingsRepository,