mirror of
https://github.com/bitwarden/android.git
synced 2025-12-11 04:39:19 -06:00
PM-29172: Update Authenticator biometric encryption
This commit is contained in:
parent
cd27fe339d
commit
3c88c69f92
@ -223,6 +223,7 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.collections.immutable)
|
implementation(libs.kotlinx.collections.immutable)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.kotlinx.serialization)
|
implementation(libs.kotlinx.serialization)
|
||||||
|
implementation(libs.timber)
|
||||||
|
|
||||||
// For now we are restricted to running Compose tests for debug builds only
|
// For now we are restricted to running Compose tests for debug builds only
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
|||||||
@ -39,6 +39,11 @@ interface AuthDiskSource : AppIdProvider {
|
|||||||
*/
|
*/
|
||||||
fun storeUserBiometricUnlockKey(biometricsKey: String?)
|
fun storeUserBiometricUnlockKey(biometricsKey: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets and sets the biometrics initialization vector.
|
||||||
|
*/
|
||||||
|
var userBiometricKeyInitVector: ByteArray?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the symmetric key data used for encrypting TOTP data.
|
* Stores the symmetric key data used for encrypting TOTP data.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import java.util.UUID
|
|||||||
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey"
|
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey"
|
||||||
private const val LAST_ACTIVE_TIME_KEY = "lastActiveTime"
|
private const val LAST_ACTIVE_TIME_KEY = "lastActiveTime"
|
||||||
private const val BIOMETRICS_UNLOCK_KEY = "userKeyBiometricUnlock"
|
private const val BIOMETRICS_UNLOCK_KEY = "userKeyBiometricUnlock"
|
||||||
|
private const val BIOMETRICS_INIT_VECTOR_KEY = "biometricInitializationVector"
|
||||||
private const val UNIQUE_APP_ID_KEY = "appId"
|
private const val UNIQUE_APP_ID_KEY = "appId"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -60,6 +61,16 @@ class AuthDiskSourceImpl(
|
|||||||
mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey)
|
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?
|
override var authenticatorBridgeSymmetricSyncKey: ByteArray?
|
||||||
set(value) {
|
set(value) {
|
||||||
val asString = value?.let { value.toString(Charsets.ISO_8859_1) }
|
val asString = value?.let { value.toString(Charsets.ISO_8859_1) }
|
||||||
|
|||||||
@ -1,9 +1,36 @@
|
|||||||
package com.bitwarden.authenticator.data.auth.repository
|
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.
|
* 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<Boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Updates the "last active time" for the current user.
|
||||||
|
|||||||
@ -1,7 +1,21 @@
|
|||||||
package com.bitwarden.authenticator.data.auth.repository
|
package com.bitwarden.authenticator.data.auth.repository
|
||||||
|
|
||||||
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
|
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.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
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -9,12 +23,89 @@ import javax.inject.Inject
|
|||||||
*/
|
*/
|
||||||
class AuthRepositoryImpl @Inject constructor(
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
private val authDiskSource: AuthDiskSource,
|
private val authDiskSource: AuthDiskSource,
|
||||||
|
private val authenticatorSdkSource: AuthenticatorSdkSource,
|
||||||
|
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||||
private val realtimeManager: RealtimeManager,
|
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<Boolean>
|
||||||
|
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() {
|
override fun updateLastActiveTime() {
|
||||||
authDiskSource.storeLastActiveTimeMillis(
|
authDiskSource.storeLastActiveTimeMillis(
|
||||||
lastActiveTimeMillis = realtimeManager.elapsedRealtimeMs,
|
lastActiveTimeMillis = realtimeManager.elapsedRealtimeMs,
|
||||||
|
|||||||
@ -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.datasource.disk.AuthDiskSource
|
||||||
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
|
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
|
||||||
import com.bitwarden.authenticator.data.auth.repository.AuthRepositoryImpl
|
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 com.bitwarden.core.data.manager.realtime.RealtimeManager
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@ -19,9 +22,15 @@ object AuthRepositoryModule {
|
|||||||
@Provides
|
@Provides
|
||||||
fun provideAuthRepository(
|
fun provideAuthRepository(
|
||||||
authDiskSource: AuthDiskSource,
|
authDiskSource: AuthDiskSource,
|
||||||
|
authenticatorSdkSource: AuthenticatorSdkSource,
|
||||||
|
biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||||
realtimeManager: RealtimeManager,
|
realtimeManager: RealtimeManager,
|
||||||
|
dispatcherManager: DispatcherManager,
|
||||||
): AuthRepository = AuthRepositoryImpl(
|
): AuthRepository = AuthRepositoryImpl(
|
||||||
authDiskSource = authDiskSource,
|
authDiskSource = authDiskSource,
|
||||||
|
authenticatorSdkSource = authenticatorSdkSource,
|
||||||
|
biometricsEncryptionManager = biometricsEncryptionManager,
|
||||||
realtimeManager = realtimeManager,
|
realtimeManager = realtimeManager,
|
||||||
|
dispatcherManager = dispatcherManager,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,35 @@
|
|||||||
package com.bitwarden.authenticator.data.platform.manager
|
package com.bitwarden.authenticator.data.platform.manager
|
||||||
|
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Responsible for managing Android keystore encryption and decryption.
|
* Responsible for managing Android keystore encryption and decryption.
|
||||||
*/
|
*/
|
||||||
interface BiometricsEncryptionManager {
|
interface BiometricsEncryptionManager {
|
||||||
/**
|
/**
|
||||||
* Sets up biometrics to ensure future integrity checks work properly. If this method has never
|
* Creates a [Cipher] built from a keystore.
|
||||||
* been called [isBiometricIntegrityValid] will return false.
|
|
||||||
*/
|
*/
|
||||||
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
|
* 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.
|
* it has changed.
|
||||||
*/
|
*/
|
||||||
fun isBiometricIntegrityValid(): Boolean
|
fun isBiometricIntegrityValid(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a boolean indicating whether the system reflects biometric availability.
|
||||||
|
*/
|
||||||
|
fun isAccountBiometricIntegrityValid(): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,20 +3,34 @@ package com.bitwarden.authenticator.data.platform.manager
|
|||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
|
import com.bitwarden.annotation.OmitFromCoverage
|
||||||
import com.bitwarden.authenticator.BuildConfig
|
import com.bitwarden.authenticator.BuildConfig
|
||||||
|
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
|
||||||
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
|
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.security.InvalidAlgorithmParameterException
|
||||||
import java.security.InvalidKeyException
|
import java.security.InvalidKeyException
|
||||||
import java.security.KeyStore
|
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.security.UnrecoverableKeyException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.KeyGenerator
|
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
|
* Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption
|
||||||
* and decryption.
|
* and decryption.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
|
@OmitFromCoverage
|
||||||
class BiometricsEncryptionManagerImpl(
|
class BiometricsEncryptionManagerImpl(
|
||||||
|
private val authDiskSource: AuthDiskSource,
|
||||||
private val settingsDiskSource: SettingsDiskSource,
|
private val settingsDiskSource: SettingsDiskSource,
|
||||||
) : BiometricsEncryptionManager {
|
) : BiometricsEncryptionManager {
|
||||||
private val keystore = KeyStore
|
private val keystore = KeyStore
|
||||||
@ -35,14 +49,59 @@ class BiometricsEncryptionManagerImpl(
|
|||||||
.setInvalidatedByBiometricEnrollment(true)
|
.setInvalidatedByBiometricEnrollment(true)
|
||||||
.build()
|
.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()
|
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 =
|
override fun isBiometricIntegrityValid(): Boolean =
|
||||||
isSystemBiometricIntegrityValid() && isAccountBiometricIntegrityValid()
|
isSystemBiometricIntegrityValid() && isAccountBiometricIntegrityValid()
|
||||||
|
|
||||||
private fun isAccountBiometricIntegrityValid(): Boolean {
|
override fun isAccountBiometricIntegrityValid(): Boolean {
|
||||||
val systemBioIntegrityState = settingsDiskSource
|
val systemBioIntegrityState = settingsDiskSource
|
||||||
.systemBiometricIntegritySource
|
.systemBiometricIntegritySource
|
||||||
?: return false
|
?: return false
|
||||||
@ -53,28 +112,99 @@ class BiometricsEncryptionManagerImpl(
|
|||||||
?: false
|
?: 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 {
|
try {
|
||||||
keystore.load(null)
|
keystore.getKey(ENCRYPTION_KEY_NAME, null)?.let { it as SecretKey }
|
||||||
keystore
|
} catch (kse: KeyStoreException) {
|
||||||
.getKey(ENCRYPTION_KEY_NAME, null)
|
// keystore was not loaded
|
||||||
?.let { Cipher.getInstance(CIPHER_TRANSFORMATION).init(Cipher.ENCRYPT_MODE, it) }
|
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
|
true
|
||||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
} catch (kpie: KeyPermanentlyInvalidatedException) {
|
||||||
// Biometric has changed
|
// Biometric has changed
|
||||||
settingsDiskSource.systemBiometricIntegritySource = null
|
Timber.w(kpie, "initializeCipher failed to initialize cipher")
|
||||||
|
destroyBiometrics()
|
||||||
false
|
false
|
||||||
} catch (e: UnrecoverableKeyException) {
|
} catch (uke: UnrecoverableKeyException) {
|
||||||
// Biometric was disabled and re-enabled
|
// Biometric was disabled and re-enabled
|
||||||
settingsDiskSource.systemBiometricIntegritySource = null
|
Timber.w(uke, "initializeCipher failed to initialize cipher")
|
||||||
|
destroyBiometrics()
|
||||||
false
|
false
|
||||||
} catch (e: InvalidKeyException) {
|
} catch (ike: InvalidKeyException) {
|
||||||
// Fallback for old bitwarden users without a key
|
// User has no key
|
||||||
createIntegrityValues()
|
Timber.w(ike, "initializeCipher failed to initialize cipher")
|
||||||
|
destroyBiometrics()
|
||||||
true
|
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() {
|
private fun createIntegrityValues() {
|
||||||
val systemBiometricIntegritySource = settingsDiskSource
|
val systemBiometricIntegritySource = settingsDiskSource
|
||||||
.systemBiometricIntegritySource
|
.systemBiometricIntegritySource
|
||||||
@ -84,18 +214,11 @@ class BiometricsEncryptionManagerImpl(
|
|||||||
systemBioIntegrityState = systemBiometricIntegritySource,
|
systemBioIntegrityState = systemBiometricIntegritySource,
|
||||||
value = true,
|
value = true,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
private fun destroyBiometrics() {
|
||||||
val keyGen = KeyGenerator.getInstance(
|
clearBiometrics()
|
||||||
KeyProperties.KEY_ALGORITHM_AES,
|
settingsDiskSource.systemBiometricIntegritySource = null
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.bitwarden.authenticator.data.platform.manager.di
|
package com.bitwarden.authenticator.data.platform.manager.di
|
||||||
|
|
||||||
import android.content.Context
|
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.authenticator.datasource.disk.AuthenticatorDiskSource
|
||||||
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
|
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
|
||||||
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
|
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
|
||||||
@ -74,8 +75,12 @@ object PlatformManagerModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideBiometricsEncryptionManager(
|
fun provideBiometricsEncryptionManager(
|
||||||
|
authDiskSource: AuthDiskSource,
|
||||||
settingsDiskSource: SettingsDiskSource,
|
settingsDiskSource: SettingsDiskSource,
|
||||||
): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl(settingsDiskSource)
|
): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl(
|
||||||
|
authDiskSource = authDiskSource,
|
||||||
|
settingsDiskSource = settingsDiskSource,
|
||||||
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package com.bitwarden.authenticator.data.platform.repository
|
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.appearance.model.AppLanguage
|
||||||
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
|
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
|
||||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||||
@ -52,16 +51,6 @@ interface SettingsRepository {
|
|||||||
*/
|
*/
|
||||||
val defaultSaveOptionFlow: Flow<DefaultSaveOption>
|
val defaultSaveOptionFlow: Flow<DefaultSaveOption>
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<Boolean>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks changes to the expiration alert threshold.
|
* Tracks changes to the expiration alert threshold.
|
||||||
*/
|
*/
|
||||||
@ -92,17 +81,6 @@ interface SettingsRepository {
|
|||||||
*/
|
*/
|
||||||
var previouslySyncedBitwardenAccountIds: Set<String>
|
var previouslySyncedBitwardenAccountIds: Set<String>
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
* The current setting for crash logging.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
package com.bitwarden.authenticator.data.platform.repository
|
package com.bitwarden.authenticator.data.platform.repository
|
||||||
|
|
||||||
import com.bitwarden.authenticator.BuildConfig
|
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.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.appearance.model.AppLanguage
|
||||||
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
|
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
|
||||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||||
@ -24,9 +20,6 @@ private val DEFAULT_IS_SCREEN_CAPTURE_ALLOWED = BuildConfig.DEBUG
|
|||||||
*/
|
*/
|
||||||
class SettingsRepositoryImpl(
|
class SettingsRepositoryImpl(
|
||||||
private val settingsDiskSource: SettingsDiskSource,
|
private val settingsDiskSource: SettingsDiskSource,
|
||||||
private val authDiskSource: AuthDiskSource,
|
|
||||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
|
||||||
private val authenticatorSdkSource: AuthenticatorSdkSource,
|
|
||||||
dispatcherManager: DispatcherManager,
|
dispatcherManager: DispatcherManager,
|
||||||
) : SettingsRepository {
|
) : SettingsRepository {
|
||||||
|
|
||||||
@ -63,20 +56,6 @@ class SettingsRepositoryImpl(
|
|||||||
initialValue = isDynamicColorsEnabled,
|
initialValue = isDynamicColorsEnabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
override val isUnlockWithBiometricsEnabled: Boolean
|
|
||||||
get() = authDiskSource.getUserBiometricUnlockKey() != null
|
|
||||||
|
|
||||||
override val isUnlockWithBiometricsEnabledFlow: StateFlow<Boolean>
|
|
||||||
get() =
|
|
||||||
authDiskSource
|
|
||||||
.userBiometricUnlockKeyFlow
|
|
||||||
.map { it != null }
|
|
||||||
.stateIn(
|
|
||||||
scope = unconfinedScope,
|
|
||||||
started = SharingStarted.Eagerly,
|
|
||||||
initialValue = isUnlockWithBiometricsEnabled,
|
|
||||||
)
|
|
||||||
|
|
||||||
override val appThemeStateFlow: StateFlow<AppTheme>
|
override val appThemeStateFlow: StateFlow<AppTheme>
|
||||||
get() = settingsDiskSource
|
get() = settingsDiskSource
|
||||||
.appThemeFlow
|
.appThemeFlow
|
||||||
@ -119,6 +98,7 @@ class SettingsRepositoryImpl(
|
|||||||
isScreenCaptureAllowed = value,
|
isScreenCaptureAllowed = value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override var previouslySyncedBitwardenAccountIds: Set<String> by
|
override var previouslySyncedBitwardenAccountIds: Set<String> by
|
||||||
settingsDiskSource::previouslySyncedBitwardenAccountIds
|
settingsDiskSource::previouslySyncedBitwardenAccountIds
|
||||||
|
|
||||||
@ -132,23 +112,6 @@ class SettingsRepositoryImpl(
|
|||||||
?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED,
|
?: 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
|
override var isCrashLoggingEnabled: Boolean
|
||||||
get() = settingsDiskSource.isCrashLoggingEnabled ?: true
|
get() = settingsDiskSource.isCrashLoggingEnabled ?: true
|
||||||
set(value) {
|
set(value) {
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
package com.bitwarden.authenticator.data.platform.repository.di
|
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.FeatureFlagOverrideDiskSource
|
||||||
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
|
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.DebugMenuRepository
|
||||||
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepositoryImpl
|
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepositoryImpl
|
||||||
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
|
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
|
||||||
@ -28,17 +25,11 @@ object PlatformRepositoryModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideSettingsRepository(
|
fun provideSettingsRepository(
|
||||||
settingsDiskSource: SettingsDiskSource,
|
settingsDiskSource: SettingsDiskSource,
|
||||||
authDiskSource: AuthDiskSource,
|
|
||||||
dispatcherManager: DispatcherManager,
|
dispatcherManager: DispatcherManager,
|
||||||
biometricsEncryptionManager: BiometricsEncryptionManager,
|
|
||||||
authenticatorSdkSource: AuthenticatorSdkSource,
|
|
||||||
): SettingsRepository =
|
): SettingsRepository =
|
||||||
SettingsRepositoryImpl(
|
SettingsRepositoryImpl(
|
||||||
settingsDiskSource = settingsDiskSource,
|
settingsDiskSource = settingsDiskSource,
|
||||||
authDiskSource = authDiskSource,
|
|
||||||
dispatcherManager = dispatcherManager,
|
dispatcherManager = dispatcherManager,
|
||||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
|
||||||
authenticatorSdkSource = authenticatorSdkSource,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@ -12,5 +12,7 @@ sealed class BiometricsKeyResult {
|
|||||||
/**
|
/**
|
||||||
* Generic error while setting up the biometrics key.
|
* Generic error while setting up the biometrics key.
|
||||||
*/
|
*/
|
||||||
data object Error : BiometricsKeyResult()
|
data class Error(
|
||||||
|
val error: Throwable,
|
||||||
|
) : BiometricsKeyResult()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
@ -13,9 +13,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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.components.util.rememberVectorPainter
|
||||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top level composable for the unlock screen.
|
* Top level composable for the unlock screen.
|
||||||
@ -45,58 +44,40 @@ fun UnlockScreen(
|
|||||||
onUnlocked: () -> Unit,
|
onUnlocked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||||
var showBiometricsPrompt by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
EventsEffect(viewModel = viewModel) { event ->
|
val onBiometricsUnlockSuccess: (cipher: Cipher) -> Unit = remember(viewModel) {
|
||||||
when (event) {
|
{ viewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(it)) }
|
||||||
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 onBiometricsLockOut: () -> Unit = remember(viewModel) {
|
val onBiometricsLockOut: () -> Unit = remember(viewModel) {
|
||||||
{ viewModel.trySendAction(UnlockAction.BiometricsLockout) }
|
{ viewModel.trySendAction(UnlockAction.BiometricsLockout) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showBiometricsPrompt) {
|
EventsEffect(viewModel = viewModel) { event ->
|
||||||
biometricsManager.promptBiometrics(
|
when (event) {
|
||||||
onSuccess = {
|
UnlockEvent.NavigateToItemListing -> onUnlocked()
|
||||||
showBiometricsPrompt = false
|
is UnlockEvent.PromptForBiometrics -> {
|
||||||
onBiometricsUnlock()
|
biometricsManager.promptBiometrics(
|
||||||
},
|
onSuccess = onBiometricsUnlockSuccess,
|
||||||
onCancel = {
|
onCancel = {
|
||||||
showBiometricsPrompt = false
|
// no-op
|
||||||
},
|
},
|
||||||
onError = {
|
onError = {
|
||||||
showBiometricsPrompt = false
|
// no-op
|
||||||
},
|
},
|
||||||
onLockOut = {
|
onLockOut = onBiometricsLockOut,
|
||||||
showBiometricsPrompt = false
|
cipher = event.cipher,
|
||||||
onBiometricsLockOut()
|
)
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UnlockDialogs(
|
||||||
|
dialog = state.dialog,
|
||||||
|
onDismissRequest = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(UnlockAction.DismissDialog) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
BitwardenScaffold(
|
BitwardenScaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
@ -118,17 +99,8 @@ fun UnlockScreen(
|
|||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
BitwardenFilledButton(
|
BitwardenFilledButton(
|
||||||
label = stringResource(id = BitwardenString.unlock),
|
label = stringResource(id = BitwardenString.unlock),
|
||||||
onClick = {
|
onClick = remember(viewModel) {
|
||||||
biometricsManager.promptBiometrics(
|
{ viewModel.trySendAction(UnlockAction.BiometricsUnlockClick) }
|
||||||
onSuccess = onBiometricsUnlock,
|
|
||||||
onCancel = {
|
|
||||||
// no-op
|
|
||||||
},
|
|
||||||
onError = {
|
|
||||||
// no-op
|
|
||||||
},
|
|
||||||
onLockOut = onBiometricsLockOut,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,8 +3,9 @@ package com.bitwarden.authenticator.ui.auth.unlock
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
|
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
|
||||||
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
|
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.base.BaseViewModel
|
||||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||||
import com.bitwarden.ui.util.Text
|
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.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import javax.crypto.Cipher
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val KEY_STATE = "state"
|
private const val KEY_STATE = "state"
|
||||||
@ -24,13 +27,13 @@ private const val KEY_STATE = "state"
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class UnlockViewModel @Inject constructor(
|
class UnlockViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
|
||||||
) : BaseViewModel<UnlockState, UnlockEvent, UnlockAction>(
|
) : BaseViewModel<UnlockState, UnlockEvent, UnlockAction>(
|
||||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||||
UnlockState(
|
UnlockState(
|
||||||
isBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled,
|
isBiometricsEnabled = authRepository.isUnlockWithBiometricsEnabled,
|
||||||
isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(),
|
isBiometricsValid = authRepository.isBiometricIntegrityValid(),
|
||||||
|
showBiometricInvalidatedMessage = false,
|
||||||
dialog = null,
|
dialog = null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -40,29 +43,87 @@ class UnlockViewModel @Inject constructor(
|
|||||||
stateFlow
|
stateFlow
|
||||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
|
authRepository.getOrCreateCipher()?.let {
|
||||||
|
sendEvent(UnlockEvent.PromptForBiometrics(cipher = it))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleAction(action: UnlockAction) {
|
override fun handleAction(action: UnlockAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
UnlockAction.BiometricsUnlock -> {
|
is UnlockAction.BiometricsUnlockSuccess -> handleBiometricsUnlockSuccess(action)
|
||||||
handleBiometricsUnlock()
|
UnlockAction.DismissDialog -> handleDismissDialog()
|
||||||
}
|
UnlockAction.BiometricsLockout -> handleBiometricsLockout()
|
||||||
|
UnlockAction.BiometricsUnlockClick -> handleBiometricsUnlockClick()
|
||||||
|
is UnlockAction.Internal -> handleInternalAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
UnlockAction.DismissDialog -> {
|
private fun handleInternalAction(action: UnlockAction.Internal) {
|
||||||
handleDismissDialog()
|
when (action) {
|
||||||
}
|
is UnlockAction.Internal.ReceiveVaultUnlockResult -> {
|
||||||
|
handleReceiveVaultUnlockResult(action)
|
||||||
UnlockAction.BiometricsLockout -> {
|
|
||||||
handleBiometricsLockout()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleBiometricsUnlock() {
|
private fun handleReceiveVaultUnlockResult(
|
||||||
if (state.isBiometricsEnabled && !state.isBiometricsValid) {
|
action: UnlockAction.Internal.ReceiveVaultUnlockResult,
|
||||||
biometricsEncryptionManager.setupBiometrics()
|
) {
|
||||||
|
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() {
|
private fun handleDismissDialog() {
|
||||||
@ -73,6 +134,7 @@ class UnlockViewModel @Inject constructor(
|
|||||||
mutableStateFlow.update {
|
mutableStateFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
dialog = UnlockState.Dialog.Error(
|
dialog = UnlockState.Dialog.Error(
|
||||||
|
title = BitwardenString.an_error_has_occurred.asText(),
|
||||||
message = BitwardenString.too_many_failed_biometric_attempts.asText(),
|
message = BitwardenString.too_many_failed_biometric_attempts.asText(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -87,6 +149,7 @@ class UnlockViewModel @Inject constructor(
|
|||||||
data class UnlockState(
|
data class UnlockState(
|
||||||
val isBiometricsEnabled: Boolean,
|
val isBiometricsEnabled: Boolean,
|
||||||
val isBiometricsValid: Boolean,
|
val isBiometricsValid: Boolean,
|
||||||
|
val showBiometricInvalidatedMessage: Boolean,
|
||||||
val dialog: Dialog?,
|
val dialog: Dialog?,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
@ -99,7 +162,9 @@ data class UnlockState(
|
|||||||
* Displays a generic error dialog to the user.
|
* Displays a generic error dialog to the user.
|
||||||
*/
|
*/
|
||||||
data class Error(
|
data class Error(
|
||||||
|
val title: Text,
|
||||||
val message: Text,
|
val message: Text,
|
||||||
|
val throwable: Throwable? = null,
|
||||||
) : Dialog()
|
) : Dialog()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -113,6 +178,10 @@ data class UnlockState(
|
|||||||
* Models events for the Unlock screen.
|
* Models events for the Unlock screen.
|
||||||
*/
|
*/
|
||||||
sealed class UnlockEvent {
|
sealed class UnlockEvent {
|
||||||
|
/**
|
||||||
|
* Prompts the user for biometrics unlock.
|
||||||
|
*/
|
||||||
|
data class PromptForBiometrics(val cipher: Cipher) : UnlockEvent(), BackgroundEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigates to the item listing screen.
|
* Navigates to the item listing screen.
|
||||||
@ -135,8 +204,25 @@ sealed class UnlockAction {
|
|||||||
*/
|
*/
|
||||||
data object BiometricsLockout : 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.
|
* 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package com.bitwarden.authenticator.ui.platform.feature.rootnav
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
|
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.authenticator.data.platform.repository.SettingsRepository
|
||||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@ -22,7 +21,6 @@ import javax.inject.Inject
|
|||||||
class RootNavViewModel @Inject constructor(
|
class RootNavViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
|
||||||
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
|
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
|
||||||
initialState = RootNavState(
|
initialState = RootNavState(
|
||||||
hasSeenWelcomeGuide = settingsRepository.hasSeenWelcomeTutorial,
|
hasSeenWelcomeGuide = settingsRepository.hasSeenWelcomeTutorial,
|
||||||
@ -76,8 +74,8 @@ class RootNavViewModel @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
settingsRepository.hasSeenWelcomeTutorial = action.hasSeenWelcomeGuide
|
settingsRepository.hasSeenWelcomeTutorial = action.hasSeenWelcomeGuide
|
||||||
if (action.hasSeenWelcomeGuide) {
|
if (action.hasSeenWelcomeGuide) {
|
||||||
if (settingsRepository.isUnlockWithBiometricsEnabled &&
|
if (authRepository.isUnlockWithBiometricsEnabled &&
|
||||||
biometricsEncryptionManager.isBiometricIntegrityValid()
|
authRepository.isBiometricIntegrityValid()
|
||||||
) {
|
) {
|
||||||
mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) }
|
mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) }
|
||||||
} else {
|
} else {
|
||||||
@ -111,7 +109,7 @@ class RootNavViewModel @Inject constructor(
|
|||||||
action: RootNavAction.BiometricSupportChanged,
|
action: RootNavAction.BiometricSupportChanged,
|
||||||
) {
|
) {
|
||||||
if (!action.isBiometricsSupported) {
|
if (!action.isBiometricsSupported) {
|
||||||
settingsRepository.clearBiometricsKey()
|
authRepository.clearBiometrics()
|
||||||
|
|
||||||
// If currently locked, navigate to unlocked since biometrics are no longer available
|
// If currently locked, navigate to unlocked since biometrics are no longer available
|
||||||
if (mutableStateFlow.value.navState is RootNavState.NavState.Locked) {
|
if (mutableStateFlow.value.navState is RootNavState.NavState.Locked) {
|
||||||
|
|||||||
@ -76,6 +76,7 @@ import com.bitwarden.ui.platform.util.displayLabel
|
|||||||
import com.bitwarden.ui.util.Text
|
import com.bitwarden.ui.util.Text
|
||||||
import com.bitwarden.ui.util.asText
|
import com.bitwarden.ui.util.asText
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the settings screen.
|
* Display the settings screen.
|
||||||
@ -92,8 +93,15 @@ fun SettingsScreen(
|
|||||||
onNavigateToImport: () -> Unit,
|
onNavigateToImport: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val snackbarState = rememberBitwardenSnackbarHostState()
|
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 ->
|
EventsEffect(viewModel = viewModel) { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
SettingsEvent.NavigateToTutorial -> onNavigateToTutorial()
|
SettingsEvent.NavigateToTutorial -> onNavigateToTutorial()
|
||||||
@ -135,6 +143,20 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SettingsEvent.ShowSnackbar -> snackbarState.showSnackbar(event.data)
|
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(
|
BitwardenScaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -452,7 +475,6 @@ private fun UnlockWithBiometricsRow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (!biometricsManager.isBiometricsSupported) return
|
if (!biometricsManager.isBiometricsSupported) return
|
||||||
var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) }
|
|
||||||
BitwardenSwitch(
|
BitwardenSwitch(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
cardStyle = CardStyle.Top(),
|
cardStyle = CardStyle.Top(),
|
||||||
@ -460,23 +482,8 @@ private fun UnlockWithBiometricsRow(
|
|||||||
subtext = stringResource(
|
subtext = stringResource(
|
||||||
id = BitwardenString.use_your_devices_lock_method_to_unlock_the_app,
|
id = BitwardenString.use_your_devices_lock_method_to_unlock_the_app,
|
||||||
),
|
),
|
||||||
isChecked = isChecked || showBiometricsPrompt,
|
isChecked = isChecked,
|
||||||
onCheckedChange = { toggled ->
|
onCheckedChange = onBiometricToggle,
|
||||||
if (toggled) {
|
|
||||||
showBiometricsPrompt = true
|
|
||||||
biometricsManager.promptBiometrics(
|
|
||||||
onSuccess = {
|
|
||||||
onBiometricToggle(true)
|
|
||||||
showBiometricsPrompt = false
|
|
||||||
},
|
|
||||||
onCancel = { showBiometricsPrompt = false },
|
|
||||||
onLockOut = { showBiometricsPrompt = false },
|
|
||||||
onError = { showBiometricsPrompt = false },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
onBiometricToggle(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import androidx.core.os.LocaleListCompat
|
|||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitwarden.authenticator.BuildConfig
|
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.AuthenticatorRepository
|
||||||
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
|
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
|
||||||
import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled
|
import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled
|
||||||
@ -37,6 +38,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import java.time.Year
|
import java.time.Year
|
||||||
|
import javax.crypto.Cipher
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val KEY_STATE = "state"
|
private const val KEY_STATE = "state"
|
||||||
@ -52,6 +54,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
authenticatorRepository: AuthenticatorRepository,
|
authenticatorRepository: AuthenticatorRepository,
|
||||||
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
|
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
|
||||||
private val authenticatorBridgeManager: AuthenticatorBridgeManager,
|
private val authenticatorBridgeManager: AuthenticatorBridgeManager,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val clipboardManager: BitwardenClipboardManager,
|
private val clipboardManager: BitwardenClipboardManager,
|
||||||
) : BaseViewModel<SettingsState, SettingsEvent, SettingsAction>(
|
) : BaseViewModel<SettingsState, SettingsEvent, SettingsAction>(
|
||||||
@ -60,7 +63,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
clock = clock,
|
clock = clock,
|
||||||
appLanguage = settingsRepository.appLanguage,
|
appLanguage = settingsRepository.appLanguage,
|
||||||
appTheme = settingsRepository.appTheme,
|
appTheme = settingsRepository.appTheme,
|
||||||
unlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled,
|
unlockWithBiometricsEnabled = authRepository.isUnlockWithBiometricsEnabled,
|
||||||
isSubmitCrashLogsEnabled = settingsRepository.isCrashLoggingEnabled,
|
isSubmitCrashLogsEnabled = settingsRepository.isCrashLoggingEnabled,
|
||||||
accountSyncState = authenticatorBridgeManager.accountSyncStateFlow.value,
|
accountSyncState = authenticatorBridgeManager.accountSyncStateFlow.value,
|
||||||
defaultSaveOption = settingsRepository.defaultSaveOption,
|
defaultSaveOption = settingsRepository.defaultSaveOption,
|
||||||
@ -86,7 +89,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
.map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) }
|
.map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) }
|
||||||
.onEach(::sendAction)
|
.onEach(::sendAction)
|
||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
settingsRepository
|
authRepository
|
||||||
.isUnlockWithBiometricsEnabledFlow
|
.isUnlockWithBiometricsEnabledFlow
|
||||||
.map { SettingsAction.Internal.UnlockWithBiometricsUpdated(it) }
|
.map { SettingsAction.Internal.UnlockWithBiometricsUpdated(it) }
|
||||||
.onEach(::sendAction)
|
.onEach(::sendAction)
|
||||||
@ -179,6 +182,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
is SettingsAction.SecurityClick.AllowScreenCaptureToggle -> {
|
is SettingsAction.SecurityClick.AllowScreenCaptureToggle -> {
|
||||||
handleAllowScreenCaptureToggle(action)
|
handleAllowScreenCaptureToggle(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is SettingsAction.SecurityClick.UnlockWithBiometricToggleEnabled -> {
|
||||||
|
handleUnlockWithBiometricToggleEnabled(action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,18 +193,24 @@ class SettingsViewModel @Inject constructor(
|
|||||||
action: SettingsAction.SecurityClick.UnlockWithBiometricToggle,
|
action: SettingsAction.SecurityClick.UnlockWithBiometricToggle,
|
||||||
) {
|
) {
|
||||||
if (action.enabled) {
|
if (action.enabled) {
|
||||||
mutableStateFlow.update {
|
authRepository
|
||||||
it.copy(
|
.createCipherOrNull()
|
||||||
dialog = SettingsState.Dialog.Loading(BitwardenString.saving.asText()),
|
?.let {
|
||||||
isUnlockWithBiometricsEnabled = true,
|
// Generate a new key in case the previous one was invalidated
|
||||||
)
|
sendEvent(SettingsEvent.ShowBiometricsPrompt(cipher = it))
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
?: run {
|
||||||
val result = settingsRepository.setupBiometricsKey()
|
mutableStateFlow.update {
|
||||||
sendAction(SettingsAction.Internal.BiometricsKeyResultReceive(result))
|
it.copy(
|
||||||
}
|
dialog = SettingsState.Dialog.Error(
|
||||||
|
title = BitwardenString.an_error_has_occurred.asText(),
|
||||||
|
message = BitwardenString.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
settingsRepository.clearBiometricsKey()
|
authRepository.clearBiometrics()
|
||||||
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
|
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -206,7 +219,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
action: SettingsAction.Internal.BiometricsKeyResultReceive,
|
action: SettingsAction.Internal.BiometricsKeyResultReceive,
|
||||||
) {
|
) {
|
||||||
when (action.result) {
|
when (action.result) {
|
||||||
BiometricsKeyResult.Error -> {
|
is BiometricsKeyResult.Error -> {
|
||||||
mutableStateFlow.update {
|
mutableStateFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
dialog = null,
|
dialog = null,
|
||||||
@ -233,6 +246,21 @@ class SettingsViewModel @Inject constructor(
|
|||||||
mutableStateFlow.update { it.copy(allowScreenCapture = action.enabled) }
|
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) {
|
private fun handleVaultClick(action: SettingsAction.DataClick) {
|
||||||
when (action) {
|
when (action) {
|
||||||
SettingsAction.DataClick.ExportClick -> handleExportClick()
|
SettingsAction.DataClick.ExportClick -> handleExportClick()
|
||||||
@ -453,6 +481,13 @@ data class SettingsState(
|
|||||||
*/
|
*/
|
||||||
@Parcelize
|
@Parcelize
|
||||||
sealed class Dialog : Parcelable {
|
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].
|
* 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.
|
* Models actions for the settings screen.
|
||||||
*/
|
*/
|
||||||
sealed class SettingsAction(
|
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates an update on device biometrics support.
|
* Indicates an update on device biometrics support.
|
||||||
*/
|
*/
|
||||||
@ -580,6 +606,13 @@ sealed class SettingsAction(
|
|||||||
*/
|
*/
|
||||||
data class UnlockWithBiometricToggle(val enabled: Boolean) : SecurityClick()
|
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.
|
* Indicates the user clicked allow screen capture toggle.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.bitwarden.authenticator.ui.platform.manager.biometrics
|
package com.bitwarden.authenticator.ui.platform.manager.biometrics
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface to manage biometrics within the app.
|
* Interface to manage biometrics within the app.
|
||||||
@ -16,9 +17,10 @@ interface BiometricsManager {
|
|||||||
* Display a prompt for biometrics.
|
* Display a prompt for biometrics.
|
||||||
*/
|
*/
|
||||||
fun promptBiometrics(
|
fun promptBiometrics(
|
||||||
onSuccess: () -> Unit,
|
onSuccess: (Cipher) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onLockOut: () -> Unit,
|
onLockOut: () -> Unit,
|
||||||
onError: () -> Unit,
|
onError: () -> Unit,
|
||||||
|
cipher: Cipher,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,11 +7,14 @@ import androidx.biometric.BiometricManager.Authenticators
|
|||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.bitwarden.annotation.OmitFromCoverage
|
||||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default implementation of the [BiometricsManager] to manage biometrics within the app.
|
* Default implementation of the [BiometricsManager] to manage biometrics within the app.
|
||||||
*/
|
*/
|
||||||
|
@OmitFromCoverage
|
||||||
class BiometricsManagerImpl(
|
class BiometricsManagerImpl(
|
||||||
private val activity: Activity,
|
private val activity: Activity,
|
||||||
) : BiometricsManager {
|
) : BiometricsManager {
|
||||||
@ -46,10 +49,11 @@ class BiometricsManagerImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun promptBiometrics(
|
override fun promptBiometrics(
|
||||||
onSuccess: () -> Unit,
|
onSuccess: (Cipher) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onLockOut: () -> Unit,
|
onLockOut: () -> Unit,
|
||||||
onError: () -> Unit,
|
onError: () -> Unit,
|
||||||
|
cipher: Cipher,
|
||||||
) {
|
) {
|
||||||
val biometricPrompt = BiometricPrompt(
|
val biometricPrompt = BiometricPrompt(
|
||||||
fragmentActivity,
|
fragmentActivity,
|
||||||
@ -57,7 +61,7 @@ class BiometricsManagerImpl(
|
|||||||
object : BiometricPrompt.AuthenticationCallback() {
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
override fun onAuthenticationSucceeded(
|
override fun onAuthenticationSucceeded(
|
||||||
result: BiometricPrompt.AuthenticationResult,
|
result: BiometricPrompt.AuthenticationResult,
|
||||||
) = onSuccess()
|
) = result.cryptoObject?.cipher?.let(onSuccess) ?: onError()
|
||||||
|
|
||||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
when (errorCode) {
|
when (errorCode) {
|
||||||
@ -91,9 +95,10 @@ class BiometricsManagerImpl(
|
|||||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
.setTitle(activity.getString(BitwardenString.bitwarden_authenticator))
|
.setTitle(activity.getString(BitwardenString.bitwarden_authenticator))
|
||||||
.setDescription(activity.getString(BitwardenString.device_verification))
|
.setDescription(activity.getString(BitwardenString.device_verification))
|
||||||
|
.setConfirmationRequired(false)
|
||||||
.setAllowedAuthenticators(allowedAuthenticators)
|
.setAllowedAuthenticators(allowedAuthenticators)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
biometricPrompt.authenticate(promptInfo)
|
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,28 @@ class AuthDiskSourceTest {
|
|||||||
assertTrue(authDiskSource.authenticatorBridgeSymmetricSyncKey.contentEquals(symmetricKey))
|
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
|
@Test
|
||||||
fun `uniqueAppId should generate a new ID and update SharedPreferences if none exists`() {
|
fun `uniqueAppId should generate a new ID and update SharedPreferences if none exists`() {
|
||||||
val rememberedUniqueAppIdKey = "bwPreferencesStorage:appId"
|
val rememberedUniqueAppIdKey = "bwPreferencesStorage:appId"
|
||||||
|
|||||||
@ -4,36 +4,53 @@ import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
|
|||||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.onSubscription
|
import kotlinx.coroutines.flow.onSubscription
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class FakeAuthDiskSource : AuthDiskSource {
|
class FakeAuthDiskSource : AuthDiskSource {
|
||||||
|
|
||||||
private var lastActiveTimeMillis: Long? = null
|
private var storedLastActiveTimeMillis: Long? = null
|
||||||
private var userBiometricUnlockKey: String? = null
|
private var storedUserBiometricUnlockKey: String? = null
|
||||||
|
private var storedUserBiometricInitVector: ByteArray? = null
|
||||||
private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)
|
private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)
|
||||||
|
|
||||||
override val uniqueAppId: String
|
override val uniqueAppId: String
|
||||||
get() = UUID.randomUUID().toString()
|
get() = UUID.randomUUID().toString()
|
||||||
|
|
||||||
override fun getLastActiveTimeMillis(): Long? = lastActiveTimeMillis
|
override fun getLastActiveTimeMillis(): Long? = storedLastActiveTimeMillis
|
||||||
|
|
||||||
override fun storeLastActiveTimeMillis(lastActiveTimeMillis: Long?) {
|
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<String?>
|
override val userBiometricUnlockKeyFlow: Flow<String?>
|
||||||
get() =
|
get() = mutableUserBiometricUnlockKeyFlow
|
||||||
mutableUserBiometricUnlockKeyFlow
|
.onSubscription { emit(getUserBiometricUnlockKey()) }
|
||||||
.onSubscription {
|
|
||||||
emit(getUserBiometricUnlockKey())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun storeUserBiometricUnlockKey(biometricsKey: String?) {
|
override fun storeUserBiometricUnlockKey(biometricsKey: String?) {
|
||||||
mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey)
|
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 authenticatorBridgeSymmetricSyncKey: ByteArray? = null
|
||||||
|
|
||||||
|
override var userBiometricKeyInitVector: ByteArray?
|
||||||
|
get() = storedUserBiometricInitVector
|
||||||
|
set(value) {
|
||||||
|
storedUserBiometricInitVector = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertUserBiometricKeyInitVector(iv: ByteArray?) {
|
||||||
|
assertEquals(iv, storedUserBiometricInitVector)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<MissingPropertyException>() == any<MissingPropertyException>()
|
||||||
|
} 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()
|
||||||
@ -1,11 +1,7 @@
|
|||||||
package com.bitwarden.authenticator.data.platform.repository
|
package com.bitwarden.authenticator.data.platform.repository
|
||||||
|
|
||||||
import app.cash.turbine.test
|
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.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.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
|
||||||
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
|
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
|
||||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||||
@ -26,15 +22,9 @@ class SettingsRepositoryTest {
|
|||||||
private val settingsDiskSource: SettingsDiskSource = mockk {
|
private val settingsDiskSource: SettingsDiskSource = mockk {
|
||||||
every { getAlertThresholdSeconds() } returns 7
|
every { getAlertThresholdSeconds() } returns 7
|
||||||
}
|
}
|
||||||
private val authDiskSource: AuthDiskSource = FakeAuthDiskSource()
|
|
||||||
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk()
|
|
||||||
private val authenticatorSdkSource: AuthenticatorSdkSource = mockk()
|
|
||||||
|
|
||||||
private val settingsRepository = SettingsRepositoryImpl(
|
private val settingsRepository = SettingsRepositoryImpl(
|
||||||
settingsDiskSource = settingsDiskSource,
|
settingsDiskSource = settingsDiskSource,
|
||||||
authDiskSource = authDiskSource,
|
|
||||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
|
||||||
authenticatorSdkSource = authenticatorSdkSource,
|
|
||||||
dispatcherManager = FakeDispatcherManager(),
|
dispatcherManager = FakeDispatcherManager(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -165,19 +155,4 @@ class SettingsRepositoryTest {
|
|||||||
settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3")
|
settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3")
|
||||||
verify { settingsDiskSource.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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package com.bitwarden.authenticator.ui.platform.feature.rootnav
|
|||||||
|
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
|
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.authenticator.data.platform.repository.SettingsRepository
|
||||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@ -20,15 +19,14 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||||||
private val mutableHasSeenWelcomeTutorialFlow = MutableStateFlow(false)
|
private val mutableHasSeenWelcomeTutorialFlow = MutableStateFlow(false)
|
||||||
private val authRepository: AuthRepository = mockk {
|
private val authRepository: AuthRepository = mockk {
|
||||||
every { updateLastActiveTime() } just runs
|
every { updateLastActiveTime() } just runs
|
||||||
|
every { isUnlockWithBiometricsEnabled } returns false
|
||||||
|
every { clearBiometrics() } just runs
|
||||||
}
|
}
|
||||||
private val settingsRepository: SettingsRepository = mockk {
|
private val settingsRepository: SettingsRepository = mockk {
|
||||||
every { hasSeenWelcomeTutorial } returns false
|
every { hasSeenWelcomeTutorial } returns false
|
||||||
every { hasSeenWelcomeTutorial = any() } just runs
|
every { hasSeenWelcomeTutorial = any() } just runs
|
||||||
every { hasSeenWelcomeTutorialFlow } returns mutableHasSeenWelcomeTutorialFlow
|
every { hasSeenWelcomeTutorialFlow } returns mutableHasSeenWelcomeTutorialFlow
|
||||||
every { isUnlockWithBiometricsEnabled } returns false
|
|
||||||
every { clearBiometricsKey() } just runs
|
|
||||||
}
|
}
|
||||||
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk()
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initialState should be correct when hasSeenWelcomeTutorial is false`() = runTest {
|
fun `initialState should be correct when hasSeenWelcomeTutorial is false`() = runTest {
|
||||||
@ -70,8 +68,8 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled and valid should navigate to Locked`() {
|
fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled and valid should navigate to Locked`() {
|
||||||
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
|
every { authRepository.isUnlockWithBiometricsEnabled } returns true
|
||||||
every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true
|
every { authRepository.isBiometricIntegrityValid() } returns true
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
viewModel.trySendAction(
|
viewModel.trySendAction(
|
||||||
@ -91,8 +89,8 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled but invalid should navigate to Unlocked`() {
|
fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled but invalid should navigate to Unlocked`() {
|
||||||
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
|
every { authRepository.isUnlockWithBiometricsEnabled } returns true
|
||||||
every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns false
|
every { authRepository.isBiometricIntegrityValid() } returns false
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
viewModel.trySendAction(
|
viewModel.trySendAction(
|
||||||
@ -112,7 +110,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
fun `on HasSeenWelcomeTutorialChange with true and biometrics disabled should navigate to Unlocked`() {
|
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()
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
viewModel.trySendAction(
|
viewModel.trySendAction(
|
||||||
@ -255,7 +253,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||||||
|
|
||||||
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
|
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
|
||||||
|
|
||||||
verify(exactly = 1) { settingsRepository.clearBiometricsKey() }
|
verify(exactly = 1) { authRepository.clearBiometrics() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -264,40 +262,40 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||||||
|
|
||||||
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(true))
|
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(true))
|
||||||
|
|
||||||
verify(exactly = 0) { settingsRepository.clearBiometricsKey() }
|
verify(exactly = 0) { authRepository.clearBiometrics() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
fun `on BiometricSupportChanged with false when Locked should navigate to Unlocked`() =
|
||||||
fun `on BiometricSupportChanged with false when Locked should navigate to Unlocked`() = runTest {
|
runTest {
|
||||||
every { settingsRepository.hasSeenWelcomeTutorial } returns true
|
every { settingsRepository.hasSeenWelcomeTutorial } returns true
|
||||||
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
|
every { authRepository.isUnlockWithBiometricsEnabled } returns true
|
||||||
every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true
|
every { authRepository.isBiometricIntegrityValid() } returns true
|
||||||
mutableHasSeenWelcomeTutorialFlow.value = true
|
mutableHasSeenWelcomeTutorialFlow.value = true
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
// Verify initial state is Locked
|
// Verify initial state is Locked
|
||||||
assertEquals(
|
assertEquals(
|
||||||
RootNavState(
|
RootNavState(
|
||||||
hasSeenWelcomeGuide = true,
|
hasSeenWelcomeGuide = true,
|
||||||
navState = RootNavState.NavState.Locked,
|
navState = RootNavState.NavState.Locked,
|
||||||
),
|
),
|
||||||
viewModel.stateFlow.value,
|
viewModel.stateFlow.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send BiometricSupportChanged with false
|
// Send BiometricSupportChanged with false
|
||||||
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
|
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
|
||||||
|
|
||||||
// Should navigate to Unlocked and clear biometric key
|
// Should navigate to Unlocked and clear biometric key
|
||||||
assertEquals(
|
assertEquals(
|
||||||
RootNavState(
|
RootNavState(
|
||||||
hasSeenWelcomeGuide = true,
|
hasSeenWelcomeGuide = true,
|
||||||
navState = RootNavState.NavState.Unlocked,
|
navState = RootNavState.NavState.Unlocked,
|
||||||
),
|
),
|
||||||
viewModel.stateFlow.value,
|
viewModel.stateFlow.value,
|
||||||
)
|
)
|
||||||
verify(exactly = 1) { settingsRepository.clearBiometricsKey() }
|
verify(exactly = 1) { authRepository.clearBiometrics() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
@ -327,14 +325,14 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||||||
),
|
),
|
||||||
viewModel.stateFlow.value,
|
viewModel.stateFlow.value,
|
||||||
)
|
)
|
||||||
verify(exactly = 1) { settingsRepository.clearBiometricsKey() }
|
verify(exactly = 1) { authRepository.clearBiometrics() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
fun `hasSeenWelcomeTutorialFlow updates should trigger HasSeenWelcomeTutorialChange action`() =
|
fun `hasSeenWelcomeTutorialFlow updates should trigger HasSeenWelcomeTutorialChange action`() =
|
||||||
runTest {
|
runTest {
|
||||||
every { settingsRepository.isUnlockWithBiometricsEnabled } returns false
|
every { authRepository.isUnlockWithBiometricsEnabled } returns false
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
viewModel.stateFlow.test {
|
viewModel.stateFlow.test {
|
||||||
@ -361,9 +359,8 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createViewModel() = RootNavViewModel(
|
private fun createViewModel(): RootNavViewModel = RootNavViewModel(
|
||||||
authRepository = authRepository,
|
authRepository = authRepository,
|
||||||
settingsRepository = settingsRepository,
|
settingsRepository = settingsRepository,
|
||||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import android.os.Build
|
|||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.bitwarden.authenticator.BuildConfig
|
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.AuthenticatorRepository
|
||||||
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
|
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
|
||||||
import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled
|
import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled
|
||||||
@ -55,12 +56,15 @@ class SettingsViewModelTest : BaseViewModelTest() {
|
|||||||
private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false)
|
private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false)
|
||||||
private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false)
|
private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false)
|
||||||
private val mutableIsUnlockWithBiometricsEnabledFlow = MutableStateFlow(true)
|
private val mutableIsUnlockWithBiometricsEnabledFlow = MutableStateFlow(true)
|
||||||
|
private val authRepository: AuthRepository = mockk {
|
||||||
|
every { isUnlockWithBiometricsEnabled } returns true
|
||||||
|
every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow
|
||||||
|
}
|
||||||
private val settingsRepository: SettingsRepository = mockk {
|
private val settingsRepository: SettingsRepository = mockk {
|
||||||
every { appLanguage } returns APP_LANGUAGE
|
every { appLanguage } returns APP_LANGUAGE
|
||||||
every { appTheme } returns APP_THEME
|
every { appTheme } returns APP_THEME
|
||||||
every { defaultSaveOption } returns DEFAULT_SAVE_OPTION
|
every { defaultSaveOption } returns DEFAULT_SAVE_OPTION
|
||||||
every { defaultSaveOptionFlow } returns mutableDefaultSaveOptionFlow
|
every { defaultSaveOptionFlow } returns mutableDefaultSaveOptionFlow
|
||||||
every { isUnlockWithBiometricsEnabled } returns true
|
|
||||||
every { isCrashLoggingEnabled } returns true
|
every { isCrashLoggingEnabled } returns true
|
||||||
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedStateFlow
|
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedStateFlow
|
||||||
every { isScreenCaptureAllowed } answers { mutableScreenCaptureAllowedStateFlow.value }
|
every { isScreenCaptureAllowed } answers { mutableScreenCaptureAllowedStateFlow.value }
|
||||||
@ -68,7 +72,6 @@ class SettingsViewModelTest : BaseViewModelTest() {
|
|||||||
every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value }
|
every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value }
|
||||||
every { isDynamicColorsEnabled = any() } just runs
|
every { isDynamicColorsEnabled = any() } just runs
|
||||||
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
|
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
|
||||||
every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow
|
|
||||||
}
|
}
|
||||||
private val clipboardManager: BitwardenClipboardManager = mockk()
|
private val clipboardManager: BitwardenClipboardManager = mockk()
|
||||||
private val mutableSnackbarFlow = bufferedMutableSharedFlow<BitwardenSnackbarData>()
|
private val mutableSnackbarFlow = bufferedMutableSharedFlow<BitwardenSnackbarData>()
|
||||||
@ -303,9 +306,10 @@ class SettingsViewModelTest : BaseViewModelTest() {
|
|||||||
|
|
||||||
private fun createViewModel(
|
private fun createViewModel(
|
||||||
savedState: SettingsState? = DEFAULT_STATE,
|
savedState: SettingsState? = DEFAULT_STATE,
|
||||||
) = SettingsViewModel(
|
): SettingsViewModel = SettingsViewModel(
|
||||||
savedStateHandle = SavedStateHandle().apply { this["state"] = savedState },
|
savedStateHandle = SavedStateHandle().apply { this["state"] = savedState },
|
||||||
clock = CLOCK,
|
clock = CLOCK,
|
||||||
|
authRepository = authRepository,
|
||||||
authenticatorBridgeManager = authenticatorBridgeManager,
|
authenticatorBridgeManager = authenticatorBridgeManager,
|
||||||
authenticatorRepository = authenticatorRepository,
|
authenticatorRepository = authenticatorRepository,
|
||||||
settingsRepository = settingsRepository,
|
settingsRepository = settingsRepository,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user