diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt index 1b43657834..8b6bfb7119 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt @@ -113,6 +113,14 @@ abstract class BaseDiskSource( value: String?, ): Unit = sharedPreferences.edit { putString(key, value) } + protected fun removeWithPrefix(prefix: String) { + sharedPreferences + .all + .keys + .filter { it.startsWith(prefix) } + .forEach { sharedPreferences.edit { remove(it) } } + } + companion object { const val BASE_KEY: String = "bwPreferencesStorage" } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index 9e2aa0262d..3319e7b103 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -28,6 +28,11 @@ interface SettingsDiskSource { */ val appThemeFlow: Flow + /** + * The currently persisted biometric integrity source for the system. + */ + var systemBiometricIntegritySource: String? + /** * The currently persisted setting for getting login item icons (or `null` if not set). */ @@ -43,6 +48,24 @@ interface SettingsDiskSource { */ fun clearData(userId: String) + /** + * Retrieves the biometric integrity validity for the given [userId] and + * [systemBioIntegrityState]. + */ + fun getAccountBiometricIntegrityValidity( + userId: String, + systemBioIntegrityState: String, + ): Boolean? + + /** + * Stores the biometric integrity validity for the given [userId] and [systemBioIntegrityState]. + */ + fun storeAccountBiometricIntegrityValidity( + userId: String, + systemBioIntegrityState: String, + value: Boolean?, + ) + /** * Gets the last time the app synced the vault data for a given [userId] (or `null` if the * vault has never been synced). diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 95841062d1..45a471932d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -27,6 +27,8 @@ private const val DISABLE_AUTOFILL_SAVE_PROMPT_KEY = "$BASE_KEY:autofillDisableS private const val DISABLE_ICON_LOADING_KEY = "$BASE_KEY:disableFavicon" private const val APPROVE_PASSWORDLESS_LOGINS_KEY = "$BASE_KEY:approvePasswordlessLogins" private const val SCREEN_CAPTURE_ALLOW_KEY = "$BASE_KEY:screenCaptureAllowed" +private const val SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY = "$BASE_KEY:biometricIntegritySource" +private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid" /** * Primary implementation of [SettingsDiskSource]. @@ -69,6 +71,12 @@ class SettingsDiskSourceImpl( ) } + override var systemBiometricIntegritySource: String? + get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY) + set(value) { + putString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY, value = value) + } + override var appTheme: AppTheme get() = getString(key = APP_THEME_KEY) ?.let { storedValue -> @@ -112,6 +120,26 @@ class SettingsDiskSourceImpl( ) storeLastSyncTime(userId = userId, lastSyncTime = null) storeScreenCaptureAllowed(userId = userId, isScreenCaptureAllowed = null) + removeWithPrefix(prefix = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_$userId") + } + + override fun getAccountBiometricIntegrityValidity( + userId: String, + systemBioIntegrityState: String, + ): Boolean? = + getBoolean( + key = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_${userId}_$systemBioIntegrityState", + ) + + override fun storeAccountBiometricIntegrityValidity( + userId: String, + systemBioIntegrityState: String, + value: Boolean?, + ) { + putBoolean( + key = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_${userId}_$systemBioIntegrityState", + value = value, + ) } override fun getLastSyncTime(userId: String): Instant? = diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt new file mode 100644 index 0000000000..38532cc894 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.platform.manager + +/** + * 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. + */ + fun setupBiometrics(userId: String) + + /** + * 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 + * it has changed. + */ + fun isBiometricIntegrityValid(userId: String): Boolean +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt new file mode 100644 index 0000000000..6bce970af3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt @@ -0,0 +1,111 @@ +package com.x8bit.bitwarden.data.platform.manager + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import com.x8bit.bitwarden.BuildConfig +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import java.security.InvalidKeyException +import java.security.KeyStore +import java.security.UnrecoverableKeyException +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator + +/** + * Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption + * and decryption. + */ +@OmitFromCoverage +class BiometricsEncryptionManagerImpl( + private val settingsDiskSource: SettingsDiskSource, +) : BiometricsEncryptionManager { + private val keystore = KeyStore + .getInstance(ENCRYPTION_KEYSTORE_NAME) + .also { it.load(null) } + + private val keyGenParameterSpec: KeyGenParameterSpec + get() = KeyGenParameterSpec + .Builder( + ENCRYPTION_KEY_NAME, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + .build() + + override fun setupBiometrics(userId: String) { + createIntegrityValues(userId) + } + + override fun isBiometricIntegrityValid(userId: String): Boolean = + isSystemBiometricIntegrityValid(userId) && isAccountBiometricIntegrityValid(userId) + + private fun isAccountBiometricIntegrityValid(userId: String): Boolean { + val systemBioIntegrityState = settingsDiskSource + .systemBiometricIntegritySource + ?: return false + return settingsDiskSource + .getAccountBiometricIntegrityValidity( + userId = userId, + systemBioIntegrityState = systemBioIntegrityState, + ) + ?: false + } + + private fun isSystemBiometricIntegrityValid(userId: String): Boolean = + try { + keystore.load(null) + keystore + .getKey(ENCRYPTION_KEY_NAME, null) + ?.let { Cipher.getInstance(CIPHER_TRANSFORMATION).init(Cipher.ENCRYPT_MODE, it) } + true + } catch (e: KeyPermanentlyInvalidatedException) { + // Biometric has changed + settingsDiskSource.systemBiometricIntegritySource = null + false + } catch (e: UnrecoverableKeyException) { + // Biometric was disabled and re-enabled + settingsDiskSource.systemBiometricIntegritySource = null + false + } catch (e: InvalidKeyException) { + // Fallback for old bitwarden users without a key + createIntegrityValues(userId) + true + } + + @Suppress("TooGenericExceptionCaught") + private fun createIntegrityValues(userId: String) { + val systemBiometricIntegritySource = settingsDiskSource + .systemBiometricIntegritySource + ?: UUID.randomUUID().toString() + settingsDiskSource.systemBiometricIntegritySource = systemBiometricIntegritySource + settingsDiskSource.storeAccountBiometricIntegrityValidity( + userId = userId, + 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 const val ENCRYPTION_KEYSTORE_NAME: String = "AndroidKeyStore" +private const val ENCRYPTION_KEY_NAME: String = "${BuildConfig.APPLICATION_ID}.biometric_integrity" +private const val CIPHER_TRANSFORMATION = + KeyProperties.KEY_ALGORITHM_AES + "/" + + KeyProperties.BLOCK_MODE_CBC + "/" + + KeyProperties.ENCRYPTION_PADDING_PKCS7 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index f8aba96d21..f3f254f906 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -5,12 +5,15 @@ import android.content.Context import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager import com.x8bit.bitwarden.data.platform.manager.AppForegroundManagerImpl +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManagerImpl import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager @@ -49,6 +52,14 @@ object PlatformManagerModule { @Singleton fun provideClock(): Clock = Clock.systemDefaultZone() + @Provides + @Singleton + fun provideBiometricsEncryptionManager( + settingsDiskSource: SettingsDiskSource, + ): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl( + settingsDiskSource = settingsDiskSource, + ) + @Provides @Singleton fun provideBitwardenClipboardManager( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index 14667d0664..6341df51aa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType @@ -35,13 +36,14 @@ private val DEFAULT_IS_SCREEN_CAPTURE_ALLOWED = BuildConfig.DEBUG /** * Primary implementation of [SettingsRepository]. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") class SettingsRepositoryImpl( private val autofillManager: AutofillManager, private val appForegroundManager: AppForegroundManager, private val authDiskSource: AuthDiskSource, private val settingsDiskSource: SettingsDiskSource, private val vaultSdkSource: VaultSdkSource, + private val biometricsEncryptionManager: BiometricsEncryptionManager, private val dispatcherManager: DispatcherManager, ) : SettingsRepository { private val activeUserId: String? get() = authDiskSource.userState?.activeUserId @@ -352,6 +354,7 @@ class SettingsRepositoryImpl( override suspend fun setupBiometricsKey(): BiometricsKeyResult { val userId = activeUserId ?: return BiometricsKeyResult.Error + biometricsEncryptionManager.setupBiometrics(userId) return vaultSdkSource .getUserEncryptionKey(userId = userId) .onSuccess { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt index 5152c322ea..f9dec39ccc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl @@ -45,6 +46,7 @@ object PlatformRepositoryModule { authDiskSource: AuthDiskSource, settingsDiskSource: SettingsDiskSource, vaultSdkSource: VaultSdkSource, + encryptionManager: BiometricsEncryptionManager, dispatcherManager: DispatcherManager, ): SettingsRepository = SettingsRepositoryImpl( @@ -53,6 +55,7 @@ object PlatformRepositoryModule { authDiskSource = authDiskSource, settingsDiskSource = settingsDiskSource, vaultSdkSource = vaultSdkSource, + biometricsEncryptionManager = encryptionManager, dispatcherManager = dispatcherManager, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt index 6ade84225c..470a24442e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt @@ -192,7 +192,7 @@ fun VaultUnlockScreen( .fillMaxWidth(), ) Spacer(modifier = Modifier.height(24.dp)) - if (state.isBiometricEnabled) { + if (state.showBiometricLogin && biometricsManager.isBiometricsSupported) { BitwardenOutlinedButton( label = stringResource(id = R.string.use_biometrics_to_unlock), onClick = { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index aed1958bff..58817ff8c2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -8,6 +8,7 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult @@ -39,12 +40,16 @@ class VaultUnlockViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val authRepository: AuthRepository, private val vaultRepo: VaultRepository, + private val biometricsEncryptionManager: BiometricsEncryptionManager, environmentRepo: EnvironmentRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: run { val userState = requireNotNull(authRepository.userStateFlow.value) val accountSummaries = userState.toAccountSummaries() val activeAccountSummary = userState.toActiveAccountSummary() + val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid( + userId = userState.activeUserId, + ) VaultUnlockState( accountSummaries = accountSummaries, avatarColorString = activeAccountSummary.avatarColorHex, @@ -54,6 +59,7 @@ class VaultUnlockViewModel @Inject constructor( environmentUrl = environmentRepo.environment.label, input = "", isBiometricEnabled = userState.activeAccount.isBiometricsEnabled, + isBiometricsValid = isBiometricsValid, vaultUnlockType = userState.activeAccount.vaultUnlockType, ) }, @@ -130,6 +136,10 @@ class VaultUnlockViewModel @Inject constructor( private fun handleBiometricsUnlockClick() { val activeUserId = authRepository.activeUserId ?: return + if (!biometricsEncryptionManager.isBiometricIntegrityValid(activeUserId)) { + mutableStateFlow.update { it.copy(isBiometricsValid = false) } + return + } mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) } viewModelScope.launch { val vaultUnlockResult = vaultRepo.unlockVaultWithBiometrics() @@ -221,6 +231,9 @@ class VaultUnlockViewModel @Inject constructor( VaultUnlockResult.Success -> { mutableStateFlow.update { it.copy(dialog = null) } + if (state.isBiometricEnabled && !state.isBiometricsValid) { + biometricsEncryptionManager.setupBiometrics(action.userId) + } // Don't do anything, we'll navigate to the right place. } } @@ -263,6 +276,7 @@ data class VaultUnlockState( val environmentUrl: String, val dialog: VaultUnlockDialog?, val input: String, + val isBiometricsValid: Boolean, val isBiometricEnabled: Boolean, val vaultUnlockType: VaultUnlockType, ) : Parcelable { @@ -272,6 +286,11 @@ data class VaultUnlockState( */ val avatarColor: Color get() = avatarColorString.hexToColor() + /** + * Indicates if we should display the button login with biometrics. + */ + val showBiometricLogin: Boolean get() = isBiometricEnabled && isBiometricsValid + /** * Represents the various dialogs the vault unlock screen can display. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index fdfd9154ea..b83b2f01c8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -65,6 +65,32 @@ class SettingsDiskSourceTest { ) } + @Test + fun `systemBiometricIntegritySource should pull from SharedPreferences`() { + val biometricIntegritySource = "bwPreferencesStorage:biometricIntegritySource" + val expected = "mockBiometricIntegritySource" + + // Verify initial value is null and disk source matches shared preferences. + assertNull(fakeSharedPreferences.getString(biometricIntegritySource, null)) + assertNull(settingsDiskSource.systemBiometricIntegritySource) + + // Updating the shared preferences should update disk source. + fakeSharedPreferences.edit { + putString(biometricIntegritySource, expected) + } + val actual = settingsDiskSource.systemBiometricIntegritySource + assertEquals(expected, actual) + } + + @Test + fun `setting systemBiometricIntegritySource should update SharedPreferences`() { + val biometricIntegritySource = "bwPreferencesStorage:biometricIntegritySource" + val expected = "mockBiometricIntegritySource" + settingsDiskSource.systemBiometricIntegritySource = expected + val actual = fakeSharedPreferences.getString(biometricIntegritySource, null) + assertEquals(expected, actual) + } + @Test fun `clearData should clear all necessary data for the given user`() { val userId = "userId" @@ -108,6 +134,12 @@ class SettingsDiskSourceTest { userId = userId, isScreenCaptureAllowed = true, ) + val systemBioIntegrityState = "system_biometrics_integrity_state" + settingsDiskSource.storeAccountBiometricIntegrityValidity( + userId = userId, + systemBioIntegrityState = systemBioIntegrityState, + value = true, + ) settingsDiskSource.clearData(userId = userId) @@ -121,6 +153,51 @@ class SettingsDiskSourceTest { assertNull(settingsDiskSource.getApprovePasswordlessLoginsEnabled(userId = userId)) assertNull(settingsDiskSource.getLastSyncTime(userId = userId)) assertNull(settingsDiskSource.getScreenCaptureAllowed(userId = userId)) + assertNull( + settingsDiskSource.getAccountBiometricIntegrityValidity( + userId = userId, + systemBioIntegrityState = systemBioIntegrityState, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `getAccountBiometricIntegrityValidity should pull from and update SharedPreferences`() { + val userId = "userId-1234" + val systemBiometricIntegritySource = "systemValidity" + val accountBioIntegrityValid = + "bwPreferencesStorage:accountBiometricIntegrityValid_${userId}_$systemBiometricIntegritySource" + val isValid = true + + // Assert that the default value in disk source is null + assertNull( + settingsDiskSource.getAccountBiometricIntegrityValidity( + userId = userId, + systemBioIntegrityState = systemBiometricIntegritySource, + ), + ) + + // Updating the shared preferences should update disk source. + fakeSharedPreferences.edit { putBoolean(accountBioIntegrityValid, isValid) } + assertEquals( + isValid, + settingsDiskSource.getAccountBiometricIntegrityValidity( + userId = userId, + systemBioIntegrityState = systemBiometricIntegritySource, + ), + ) + + // Updating the disk source updates the shared preferences + settingsDiskSource.storeAccountBiometricIntegrityValidity( + userId = userId, + systemBioIntegrityState = systemBiometricIntegritySource, + value = isValid, + ) + assertEquals( + fakeSharedPreferences.getBoolean(accountBioIntegrityValid, false), + isValid, + ) } @Test diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index 9afc2c4ebc..f5833cd222 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -48,6 +48,8 @@ class FakeSettingsDiskSource : SettingsDiskSource { private var storedIsIconLoadingDisabled: Boolean? = null private val storedApprovePasswordLoginsEnabled = mutableMapOf() private val storedScreenCaptureAllowed = mutableMapOf() + private var storedSystemBiometricIntegritySource: String? = null + private val storedAccountBiometricIntegrityValidity = mutableMapOf() override var appLanguage: AppLanguage? = null @@ -63,6 +65,12 @@ class FakeSettingsDiskSource : SettingsDiskSource { emit(appTheme) } + override var systemBiometricIntegritySource: String? + get() = storedSystemBiometricIntegritySource + set(value) { + storedSystemBiometricIntegritySource = value + } + override var isIconLoadingDisabled: Boolean? get() = storedIsIconLoadingDisabled set(value) { @@ -75,6 +83,19 @@ class FakeSettingsDiskSource : SettingsDiskSource { emit(isIconLoadingDisabled) } + override fun getAccountBiometricIntegrityValidity( + userId: String, + systemBioIntegrityState: String, + ): Boolean? = storedAccountBiometricIntegrityValidity["${userId}_$systemBioIntegrityState"] + + override fun storeAccountBiometricIntegrityValidity( + userId: String, + systemBioIntegrityState: String, + value: Boolean?, + ) { + storedAccountBiometricIntegrityValidity["${userId}_$systemBioIntegrityState"] = value + } + override fun clearData(userId: String) { storedVaultTimeoutActions.remove(userId) storedVaultTimeoutInMinutes.remove(userId) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index 5357e7e84a..6c29e9ba41 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType @@ -50,6 +51,7 @@ class SettingsRepositoryTest { private val fakeAuthDiskSource = FakeAuthDiskSource() private val fakeSettingsDiskSource = FakeSettingsDiskSource() private val vaultSdkSource: VaultSdkSource = mockk() + private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk() private var isAutofillEnabledAndSupported = false @@ -59,6 +61,7 @@ class SettingsRepositoryTest { authDiskSource = fakeAuthDiskSource, settingsDiskSource = fakeSettingsDiskSource, vaultSdkSource = vaultSdkSource, + biometricsEncryptionManager = biometricsEncryptionManager, dispatcherManager = FakeDispatcherManager(), ) @@ -687,6 +690,7 @@ class SettingsRepositoryTest { runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE val userId = MOCK_USER_STATE.activeUserId + every { biometricsEncryptionManager.setupBiometrics(userId) } just runs coEvery { vaultSdkSource.getUserEncryptionKey(userId = userId) } returns Throwable("Fail").asFailure() @@ -694,6 +698,9 @@ class SettingsRepositoryTest { val result = settingsRepository.setupBiometricsKey() assertEquals(BiometricsKeyResult.Error, result) + verify(exactly = 1) { + biometricsEncryptionManager.setupBiometrics(userId) + } coVerify(exactly = 1) { vaultSdkSource.getUserEncryptionKey(userId = userId) } @@ -706,6 +713,7 @@ class SettingsRepositoryTest { fakeAuthDiskSource.userState = MOCK_USER_STATE val userId = MOCK_USER_STATE.activeUserId val encryptedKey = "asdf1234" + every { biometricsEncryptionManager.setupBiometrics(userId) } just runs coEvery { vaultSdkSource.getUserEncryptionKey(userId = userId) } returns encryptedKey.asSuccess() @@ -714,6 +722,9 @@ class SettingsRepositoryTest { assertEquals(BiometricsKeyResult.Success, result) fakeAuthDiskSource.assertBiometricsKey(userId = userId, biometricsKey = encryptedKey) + verify(exactly = 1) { + biometricsEncryptionManager.setupBiometrics(userId) + } coVerify(exactly = 1) { vaultSdkSource.getUserEncryptionKey(userId = userId) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt index 78a545ab36..915611e2ac 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt @@ -421,6 +421,7 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( environmentUrl = DEFAULT_ENVIRONMENT_URL, initials = "AU", input = "", + isBiometricsValid = true, isBiometricEnabled = true, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index 2f5a7c4172..9cfcc29e81 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository @@ -46,6 +47,9 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { private val vaultRepository: VaultRepository = mockk(relaxed = true) { every { lockVault(any()) } just runs } + private val encryptionManager: BiometricsEncryptionManager = mockk { + every { isBiometricIntegrityValid(userId = DEFAULT_USER_STATE.activeUserId) } returns true + } @Test fun `initial state should be correct when not set`() { @@ -678,11 +682,13 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { state: VaultUnlockState? = DEFAULT_STATE, environmentRepo: EnvironmentRepository = environmentRepository, vaultRepo: VaultRepository = vaultRepository, + biometricsEncryptionManager: BiometricsEncryptionManager = encryptionManager, ): VaultUnlockViewModel = VaultUnlockViewModel( savedStateHandle = SavedStateHandle().apply { set("state", state) }, authRepository = authRepository, vaultRepo = vaultRepo, environmentRepo = environmentRepo, + biometricsEncryptionManager = biometricsEncryptionManager, ) } @@ -705,6 +711,7 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( dialog = null, environmentUrl = Environment.Us.label, input = "", + isBiometricsValid = true, isBiometricEnabled = false, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, )