PM-40295: bug: Add MP Policy checks on vault unlock

This commit is contained in:
David Perez committed 2026-08-04 09:40:13 -05:00
1 parent d029ebb0bf
commit 49aa64751a
16 files changed
+872 -465

No files matched your search

@@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
@@ -36,6 +37,7 @@ class UserLogoutManagerImpl(
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
private val passwordPolicyManager: PasswordPolicyManager,
dispatcherManager: DispatcherManager,
) : UserLogoutManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
@@ -116,6 +118,7 @@ class UserLogoutManagerImpl(
}
private fun clearData(userId: String) {
passwordPolicyManager.removePasswordToCheck(userId = userId)
vaultSdkSource.clearCrypto(userId = userId)
authDiskSource.clearData(userId = userId)
generatorDiskSource.clearData(userId = userId)
@@ -30,6 +30,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
@@ -124,6 +125,7 @@ object AuthManagerModule {
vaultSdkSource: VaultSdkSource,
dispatcherManager: DispatcherManager,
credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
passwordPolicyManager: PasswordPolicyManager,
): UserLogoutManager =
UserLogoutManagerImpl(
authDiskSource = authDiskSource,
@@ -136,6 +138,7 @@ object AuthManagerModule {
vaultSdkSource = vaultSdkSource,
dispatcherManager = dispatcherManager,
credentialExchangeRegistryManager = credentialExchangeRegistryManager,
passwordPolicyManager = passwordPolicyManager,
)
@Provides
@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KdfManager
@@ -19,8 +18,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
@@ -42,6 +39,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -54,6 +52,7 @@ interface AuthRepository :
AuthRequestManager,
BiometricsEncryptionManager,
KdfManager,
PasswordPolicyManager,
UserStateManager {
/**
* Models the current auth state.
@@ -121,16 +120,6 @@ interface AuthRepository :
*/
var shouldTrustDevice: Boolean
/**
* Return the cached password policies for the current user.
*/
val passwordPolicies: List<PolicyInformation.MasterPassword>
/**
* The reason for resetting the password.
*/
val passwordResetReason: ForcePasswordResetReason?
/**
* The organization for the active user.
*/
@@ -373,13 +362,6 @@ interface AuthRepository :
*/
suspend fun getPasswordBreachCount(password: String): BreachCountResult
/**
* Get the password strength for the given [email] and [password] combo.
* If no value is passed for the [email] will use the active email of the current active
* account via the [userStateFlow].
*/
suspend fun getPasswordStrength(email: String? = null, password: String): PasswordStrengthResult
/**
* Validates the master password for the current logged-in user.
*/
@@ -390,12 +372,6 @@ interface AuthRepository :
*/
suspend fun validatePinUserKey(pin: String): ValidatePinResult
/**
* Validates the given [password] against the master password
* policies for the current user.
*/
suspend fun validatePasswordAgainstPolicies(password: String): Boolean
/**
* Send a verification email.
*/
@@ -51,8 +51,6 @@ import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.util.isSslHandShakeError
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
@@ -61,7 +59,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetRea
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KdfManager
@@ -83,7 +80,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
@@ -105,7 +101,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toDeviceInfo
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
@@ -113,7 +108,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.updateForcePasswordReset
import com.x8bit.bitwarden.data.auth.repository.util.updateMasterPasswordUnlock
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
@@ -122,9 +116,8 @@ import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.appLinksScheme
@@ -143,13 +136,10 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
@@ -182,18 +172,19 @@ class AuthRepositoryImpl(
private val keyConnectorManager: KeyConnectorManager,
private val trustedDeviceManager: TrustedDeviceManager,
private val userLogoutManager: UserLogoutManager,
private val policyManager: PolicyManager,
private val userStateManager: UserStateManager,
private val kdfManager: KdfManager,
private val toastManager: ToastManager,
private val featureFlagManager: FeatureFlagManager,
logsManager: LogsManager,
pushManager: PushManager,
passwordPolicyManager: PasswordPolicyManager,
dispatcherManager: DispatcherManager,
) : AuthRepository,
AuthRequestManager by authRequestManager,
BiometricsEncryptionManager by biometricsEncryptionManager,
KdfManager by kdfManager,
PasswordPolicyManager by passwordPolicyManager,
UserStateManager by userStateManager {
/**
* A scope intended for use when simply collecting multiple flows in order to combine them. The
@@ -231,12 +222,6 @@ class AuthRepositoryImpl(
private var organizationIdentifier: String? = null
/**
* The password that needs to be checked against any organization policies before
* the user can complete the login flow. This value is stored using the user ID.
*/
private var passwordsToCheckMap = mutableMapOf<String, String>()
private var keyConnectorResponse: GetTokenResponseJson.Success? = null
override var twoFactorResponse: GetTokenResponseJson.TwoFactorRequired? = null
@@ -299,16 +284,6 @@ class AuthRepositoryImpl(
}
}
override val passwordPolicies: List<PolicyInformation.MasterPassword>
get() = policyManager.getActivePolicies()
override val passwordResetReason: ForcePasswordResetReason?
get() = authDiskSource
.userState
?.activeAccount
?.profile
?.forcePasswordResetReason
override val organizations: List<Organization>
get() = activeUserId
?.let { authDiskSource.getOrganizations(it) }
@@ -364,52 +339,6 @@ class AuthRepositoryImpl(
.logoutFlow
.onEach { logout(userId = it.userId, reason = LogoutReason.Notification) }
.launchIn(unconfinedScope)
// When the policies for the user have been set, complete the login process.
policyManager
.getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD)
.onEach { policies ->
val userId = activeUserId ?: return@onEach
// If the user is logging on without a password, the check should complete.
val passwordToCheck = passwordsToCheckMap.remove(key = userId) ?: return@onEach
// If the password already has to be reset for some other reason, there's no
// need to check the password policies.
if (passwordResetReason != null) return@onEach
// Otherwise check the user's password against the policies and set or
// clear the force reset reason accordingly.
authDiskSource.userState = authDiskSource.userState?.updateForcePasswordReset(
userId = userId,
reason = ForcePasswordResetReason
.WEAK_MASTER_PASSWORD_ON_LOGIN
.takeIf {
!passwordPassesPolicies(
password = passwordToCheck,
policies = policies,
)
},
)
}
.launchIn(unconfinedScope)
// Clear the cached password whenever the user is no longer active
// or the vault is locked for that user.
merge(
authDiskSource
.userSwitchingChangesFlow
.mapNotNull { it.previousActiveUserId },
vaultRepository
.vaultUnlockDataStateFlow
.filter { vaultUnlockDataList ->
// Clear if the active user is not currently unlocking or unlocked
vaultUnlockDataList.none { it.userId == activeUserId }
}
.mapNotNull { activeUserId },
)
.onEach { userId -> passwordsToCheckMap.remove(key = userId) }
.launchIn(unconfinedScope)
}
override suspend fun deleteAccountWithMasterPassword(
@@ -1537,11 +1466,6 @@ class AuthRepositoryImpl(
)
}
override suspend fun validatePasswordAgainstPolicies(
password: String,
): Boolean = passwordPolicies
.all { validatePasswordAgainstPolicy(password, it) }
override suspend fun sendVerificationEmail(
email: String,
name: String,
@@ -1619,62 +1543,6 @@ class AuthRepositoryImpl(
onFailure = { RevokeFromOrganizationResult.Error(error = it) },
)
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
policy: PolicyInformation.MasterPassword,
): Boolean {
// Check the password against all the enforced rules in the policy.
policy.minLength?.let { minLength ->
if (minLength > 0 && password.length < minLength) return false
}
policy.minComplexity?.let { minComplexity ->
// If there was a problem checking the complexity of the password, ignore
// the complexity checks and continue checking the other aspects of the policy.
val profile = authDiskSource.userState?.activeAccount?.profile ?: return@let
val passwordStrengthResult = getPasswordStrength(profile.email, password)
val passwordStrength = (passwordStrengthResult as? PasswordStrengthResult.Success)
?.passwordStrength
?.toInt()
?: return@let
if (minComplexity > 0 && passwordStrength < minComplexity) return false
}
policy.requireUpper?.let { requiresUpper ->
if (requiresUpper && !password.any { it.isUpperCase() }) return false
}
policy.requireLower?.let { requiresLower ->
if (requiresLower && !password.any { it.isLowerCase() }) return false
}
policy.requireNumbers?.let { requiresNumbers ->
if (requiresNumbers && !password.any { it.isDigit() }) return false
}
policy.requireSpecial?.let { requiresSpecial ->
if (requiresSpecial && !password.contains("^.*[!@#$%\\^&*].*$".toRegex())) return false
}
return true
}
/**
* Return true if there are any [PolicyInformation.MasterPassword] policies that the user's
* master password has failed to pass.
*/
private suspend fun passwordPassesPolicies(
password: String,
policies: List<PolicyView>,
): Boolean {
// If there are no master password policies that are enabled and should be
// enforced on login, the check should complete.
val passwordPolicies = policies
.mapNotNull { it.policyInformation as? PolicyInformation.MasterPassword }
.filter { it.enforceOnLogin == true }
// Check the password against all the policies.
return passwordPolicies.all { policy ->
validatePasswordAgainstPolicy(password, policy)
}
}
/**
* Enrolls the active user in password reset if their organization requires it.
*/
@@ -1895,7 +1763,7 @@ class AuthRepositoryImpl(
}
// Cache the password to verify against any password policies after the sync completes.
passwordsToCheckMap.put(userId, it)
storePasswordToCheck(userId = userId, password = it)
}
settingsRepository.hasUserLoggedInOrCreatedAccount = true
@@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@@ -69,7 +70,7 @@ object AuthRepositoryModule {
trustedDeviceManager: TrustedDeviceManager,
userLogoutManager: UserLogoutManager,
pushManager: PushManager,
policyManager: PolicyManager,
passwordPolicyManager: PasswordPolicyManager,
logsManager: LogsManager,
userStateManager: UserStateManager,
kdfManager: KdfManager,
@@ -97,7 +98,7 @@ object AuthRepositoryModule {
trustedDeviceManager = trustedDeviceManager,
userLogoutManager = userLogoutManager,
pushManager = pushManager,
policyManager = policyManager,
passwordPolicyManager = passwordPolicyManager,
logsManager = logsManager,
userStateManager = userStateManager,
kdfManager = kdfManager,
@@ -5,6 +5,7 @@ import android.content.Context
import androidx.core.content.getSystemService
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManagerImpl
import com.bitwarden.core.data.manager.encryption.EncryptionManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.core.data.manager.realtime.RealtimeManagerImpl
import com.bitwarden.core.data.manager.toast.ToastManager
@@ -77,6 +78,8 @@ import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManagerImpl
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManagerImpl
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
@@ -273,6 +276,22 @@ object PlatformManagerModule {
featureFlagManager = featureFlagManager,
)
@Provides
@Singleton
fun providePasswordPolicyManager(
authDiskSource: AuthDiskSource,
authSdkSource: AuthSdkSource,
policyManager: PolicyManager,
encryptionManager: EncryptionManager,
dispatcherManager: DispatcherManager,
): PasswordPolicyManager = PasswordPolicyManagerImpl(
authDiskSource = authDiskSource,
authSdkSource = authSdkSource,
policyManager = policyManager,
encryptionManager = encryptionManager,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun providePushManager(
@@ -0,0 +1,44 @@
package com.x8bit.bitwarden.data.platform.manager.policy
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
/**
* A manager for password policy requirements.
*/
interface PasswordPolicyManager {
/**
* Return the cached password policies for the current user.
*/
val passwordPolicies: List<PolicyInformation.MasterPassword>
/**
* The reason for resetting the password.
*/
val passwordResetReason: ForcePasswordResetReason?
/**
* Get the password strength for the given [email] and [password] combo. If no value is
* passed for the [email] will use the active email of the current active account.
*/
suspend fun getPasswordStrength(
email: String? = null,
password: String,
): PasswordStrengthResult
/**
* Remove the password to be validated against the Master Password Policy.
*/
fun removePasswordToCheck(userId: String)
/**
* Store the password to be validated against the Master Password Policy.
*/
fun storePasswordToCheck(userId: String, password: String)
/**
* Validates the given [password] against the master password policies for the current user.
*/
suspend fun validatePasswordAgainstPolicies(password: String): Boolean
}
@@ -0,0 +1,196 @@
package com.x8bit.bitwarden.data.platform.manager.policy
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.encryption.EncryptionManager
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.updateForcePasswordReset
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
private const val ALIAS: String = "PasswordPolicyManager"
/**
* The default [PasswordPolicyManager] implementation. This class is responsible for validating
* that password adhere to password policies.
*/
internal class PasswordPolicyManagerImpl(
private val authDiskSource: AuthDiskSource,
private val authSdkSource: AuthSdkSource,
private val policyManager: PolicyManager,
private val encryptionManager: EncryptionManager,
dispatcherManager: DispatcherManager,
) : PasswordPolicyManager {
private val unconfinedScope: CoroutineScope = CoroutineScope(dispatcherManager.unconfined)
private val userState: UserStateJson? get() = authDiskSource.userState
private val activeUserId: String? get() = userState?.activeUserId
/**
* The encrypted password that needs to be checked against any organization policies before
* the user can complete the login flow. This value is stored using the user ID.
*/
private val mutablePasswordsToCheckFlow = MutableStateFlow(value = mapOf<String, ByteArray>())
init {
// When the policies for the user have been set, complete the login process.
combine(
mutablePasswordsToCheckFlow,
policyManager.getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD),
) { map, policies ->
val userId = activeUserId ?: return@combine null
val encryptedPasswordToCheck = map[userId] ?: return@combine null
removePasswordToCheck(userId = userId)
if (passwordResetReason != null) return@combine null
Triple(userId, encryptedPasswordToCheck, policies)
}
.filterNotNull()
.onEach { (userId, encryptedPasswordToCheck, policies) ->
encryptionManager
.decrypt(alias = ALIAS, bytes = encryptedPasswordToCheck)
.getOrNull()
?.decodeToString()
?.let { passwordToCheck ->
// Otherwise check the user's password against the policies and set or
// clear the force reset reason accordingly.
storeUserResetPasswordReason(
userId = userId,
reason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN.takeIf {
!passwordPassesPolicies(
password = passwordToCheck,
policies = policies,
)
},
)
}
}
.launchIn(unconfinedScope)
authDiskSource
.userSwitchingChangesFlow
.mapNotNull { it.previousActiveUserId }
.onEach { userId -> removePasswordToCheck(userId = userId) }
.launchIn(unconfinedScope)
}
override val passwordPolicies: List<PolicyInformation.MasterPassword>
get() = policyManager.getActivePolicies()
override val passwordResetReason: ForcePasswordResetReason?
get() = userState?.activeAccount?.profile?.forcePasswordResetReason
override suspend fun getPasswordStrength(
email: String?,
password: String,
): PasswordStrengthResult =
authSdkSource
.passwordStrength(
email = email ?: userState?.activeAccount?.profile?.email.orEmpty(),
password = password,
)
.fold(
onSuccess = { PasswordStrengthResult.Success(passwordStrength = it) },
onFailure = { PasswordStrengthResult.Error(error = it) },
)
override fun removePasswordToCheck(userId: String) {
mutablePasswordsToCheckFlow.update { it.toMutableMap().apply { remove(key = userId) } }
}
override fun storePasswordToCheck(userId: String, password: String) {
encryptionManager
.encrypt(alias = ALIAS, bytes = password.encodeToByteArray())
.getOrNull()
?.let { encryptedPassword ->
mutablePasswordsToCheckFlow.update {
it.toMutableMap().apply { put(key = userId, value = encryptedPassword) }
}
}
}
override suspend fun validatePasswordAgainstPolicies(
password: String,
): Boolean = passwordPolicies.all {
validatePasswordAgainstPolicy(password = password, policy = it)
}
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
policy: PolicyInformation.MasterPassword,
): Boolean {
// Check the password against all the enforced rules in the policy.
policy.minLength?.let { minLength ->
if (minLength > 0 && password.length < minLength) return false
}
policy.minComplexity?.let { minComplexity ->
// If there was a problem checking the complexity of the password, ignore
// the complexity checks and continue checking the other aspects of the policy.
val profile = userState?.activeAccount?.profile ?: return@let
val passwordStrengthResult = getPasswordStrength(profile.email, password)
val passwordStrength = (passwordStrengthResult as? PasswordStrengthResult.Success)
?.passwordStrength
?.toInt()
?: return@let
if (minComplexity > 0 && passwordStrength < minComplexity) return false
}
policy.requireUpper?.let { requiresUpper ->
if (requiresUpper && !password.any { it.isUpperCase() }) return false
}
policy.requireLower?.let { requiresLower ->
if (requiresLower && !password.any { it.isLowerCase() }) return false
}
policy.requireNumbers?.let { requiresNumbers ->
if (requiresNumbers && !password.any { it.isDigit() }) return false
}
policy.requireSpecial?.let { requiresSpecial ->
if (requiresSpecial && !password.contains("^.*[!@#$%\\^&*].*$".toRegex())) return false
}
return true
}
/**
* Update the saved state with the force password reset reason.
*/
private fun storeUserResetPasswordReason(userId: String, reason: ForcePasswordResetReason?) {
authDiskSource.userState = authDiskSource.userState?.updateForcePasswordReset(
userId = userId,
reason = reason,
)
}
/**
* Return true if there are any [PolicyInformation.MasterPassword] policies that the user's
* master password has failed to pass.
*/
private suspend fun passwordPassesPolicies(
password: String,
policies: List<PolicyView>,
): Boolean {
// If there are no master password policies that are enabled and should be
// enforced on login, the check should complete.
val passwordPolicies = policies
.mapNotNull { it.policyInformation as? PolicyInformation.MasterPassword }
.filter { it.enforceOnLogin == true }
// Check the password against all the policies.
return passwordPolicies.all { policy -> validatePasswordAgainstPolicy(password, policy) }
}
}
@@ -32,6 +32,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@@ -41,6 +42,7 @@ import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.logTag
import com.x8bit.bitwarden.data.vault.repository.util.password
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeToken
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
@@ -94,6 +96,7 @@ class VaultLockManagerImpl(
private val trustedDeviceManager: TrustedDeviceManager,
private val kdfManager: KdfManager,
private val pinProtectedUserKeyManager: PinProtectedUserKeyManager,
private val passwordPolicyManager: PasswordPolicyManager,
dispatcherManager: DispatcherManager,
context: Context,
) : VaultLockManager {
@@ -222,7 +225,7 @@ class VaultLockManagerImpl(
initializeCryptoResult
.toVaultUnlockResult()
.also {
hashAndStoreMasterPassword(
processMasterPassword(
initUserCryptoMethod = initUserCryptoMethod,
email = email,
kdf = kdf,
@@ -254,29 +257,27 @@ class VaultLockManagerImpl(
/**
* Hashes a password and stores it as the master password hash for a given user.
* The password is also temporarily stored for validation against the Master Password policy.
*/
private suspend fun hashAndStoreMasterPassword(
private suspend fun processMasterPassword(
initUserCryptoMethod: InitUserCryptoMethod,
email: String,
kdf: Kdf,
userId: String,
) {
(initUserCryptoMethod as? InitUserCryptoMethod.MasterPasswordUnlock)?.let {
// Save the master password hash.
authSdkSource
.hashPassword(
email = email,
password = initUserCryptoMethod.password,
kdf = kdf,
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess {
authDiskSource.storeMasterPasswordHash(
userId = userId,
passwordHash = it,
)
}
}
val password = initUserCryptoMethod.password ?: return
// Save the master password hash and store the password for policy validation.
authSdkSource
.hashPassword(
email = email,
password = password,
kdf = kdf,
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { hash ->
authDiskSource.storeMasterPasswordHash(userId = userId, passwordHash = hash)
}
passwordPolicyManager.storePasswordToCheck(userId = userId, password = password)
}
override suspend fun waitUntilUnlocked(userId: String) {
@@ -350,6 +351,7 @@ class VaultLockManagerImpl(
userId = userId,
userAutoUnlockKey = null,
)
passwordPolicyManager.removePasswordToCheck(userId = userId)
if (!wasVaultLocked) {
mutableVaultStateEventSharedFlow.tryEmit(VaultStateEvent.Locked(userId = userId))
authDiskSource.storeLastLockTimestamp(
@@ -699,19 +701,7 @@ class VaultLockManagerImpl(
}
private suspend fun updateKdfIfNeeded(initUserCryptoMethod: InitUserCryptoMethod) {
val password = when (initUserCryptoMethod) {
is InitUserCryptoMethod.MasterPasswordUnlock -> initUserCryptoMethod.password
is InitUserCryptoMethod.AuthRequest,
is InitUserCryptoMethod.DecryptedKey,
is InitUserCryptoMethod.DeviceKey,
is InitUserCryptoMethod.KeyConnector,
is InitUserCryptoMethod.KeyConnectorUrl,
is InitUserCryptoMethod.Pin,
is InitUserCryptoMethod.PinEnvelope,
is InitUserCryptoMethod.PinState,
-> return
}
val password = initUserCryptoMethod.password ?: return
kdfManager
.updateKdfToMinimumsIfNeeded(
password = password,
@@ -768,7 +758,7 @@ class VaultLockManagerImpl(
* Indicates the app has entered a Created state.
*
* @param firstTimeCreation if this is the first time the process is being created.
* @param createdForAutofill if the the creation event is due to an activity being launched
* @param createdForAutofill if the creation event is due to an activity being launched
* for autofill.
*/
data class AppCreated(
@@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@@ -181,6 +182,7 @@ object VaultManagerModule {
trustedDeviceManager: TrustedDeviceManager,
kdfManager: KdfManager,
pinProtectedUserKeyManager: PinProtectedUserKeyManager,
passwordPolicyManager: PasswordPolicyManager,
): VaultLockManager =
VaultLockManagerImpl(
context = context,
@@ -196,6 +198,7 @@ object VaultManagerModule {
trustedDeviceManager = trustedDeviceManager,
kdfManager = kdfManager,
pinProtectedUserKeyManager = pinProtectedUserKeyManager,
passwordPolicyManager = passwordPolicyManager,
)
@Provides
@@ -18,3 +18,20 @@ val InitUserCryptoMethod.logTag: String
is InitUserCryptoMethod.KeyConnectorUrl -> "Key Connector Url"
is InitUserCryptoMethod.MasterPasswordUnlock -> "Master Password Unlock"
}
/**
* Returns the password for the given [InitUserCryptoMethod] if available.
*/
val InitUserCryptoMethod.password: String?
get() = when (this) {
is InitUserCryptoMethod.MasterPasswordUnlock -> this.password
is InitUserCryptoMethod.AuthRequest,
is InitUserCryptoMethod.DecryptedKey,
is InitUserCryptoMethod.DeviceKey,
is InitUserCryptoMethod.KeyConnector,
is InitUserCryptoMethod.KeyConnectorUrl,
is InitUserCryptoMethod.Pin,
is InitUserCryptoMethod.PinEnvelope,
is InitUserCryptoMethod.PinState,
-> null
}
@@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.model.UnregisterExportResult
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
@@ -63,6 +64,9 @@ class UserLogoutManagerTest {
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager = mockk {
coEvery { unregister() } returns UnregisterExportResult.Success
}
private val passwordPolicyManager: PasswordPolicyManager = mockk {
every { removePasswordToCheck(userId = any()) } just runs
}
private val userLogoutManager: UserLogoutManager =
UserLogoutManagerImpl(
@@ -76,6 +80,7 @@ class UserLogoutManagerTest {
vaultSdkSource = vaultSdkSource,
dispatcherManager = FakeDispatcherManager(),
credentialExchangeRegistryManager = credentialExchangeRegistryManager,
passwordPolicyManager = passwordPolicyManager,
)
@Suppress("MaxLineLength")
@@ -291,13 +296,16 @@ class UserLogoutManagerTest {
}
private fun assertDataCleared(userId: String) {
verify { vaultSdkSource.clearCrypto(userId = userId) }
verify { authDiskSource.clearData(userId = userId) }
verify { generatorDiskSource.clearData(userId = userId) }
verify { pushDiskSource.clearData(userId = userId) }
verify { settingsDiskSource.clearData(userId = userId) }
coVerify { passwordHistoryDiskSource.clearPasswordHistories(userId = userId) }
coVerify {
verify(exactly = 1) {
passwordPolicyManager.removePasswordToCheck(userId = userId)
vaultSdkSource.clearCrypto(userId = userId)
authDiskSource.clearData(userId = userId)
generatorDiskSource.clearData(userId = userId)
pushDiskSource.clearData(userId = userId)
settingsDiskSource.clearData(userId = userId)
}
coVerify(exactly = 1) {
passwordHistoryDiskSource.clearPasswordHistories(userId = userId)
vaultDiskSource.deleteVaultData(userId = userId)
}
}
@@ -71,8 +71,6 @@ import com.bitwarden.network.service.DevicesService
import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@@ -82,11 +80,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJso
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_1
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_2
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KdfManager
@@ -110,7 +103,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
@@ -140,13 +132,12 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSo
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
@@ -255,15 +246,12 @@ class AuthRepositoryTest {
private val mutableLogoutFlow = bufferedMutableSharedFlow<NotificationLogoutData>()
private val mutableSyncOrgKeysFlow = bufferedMutableSharedFlow<String>()
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<PolicyView>>()
private val pushManager: PushManager = mockk {
every { logoutFlow } returns mutableLogoutFlow
every { syncOrgKeysFlow } returns mutableSyncOrgKeysFlow
}
private val policyManager: PolicyManager = mockk {
every {
getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD)
} returns mutableActivePolicyFlow
private val passwordPolicyManager: PasswordPolicyManager = mockk {
every { storePasswordToCheck(userId = any(), password = any()) } just runs
}
private val logsManager: LogsManager = mockk {
every { setUserData(userId = any(), environmentType = any()) } just runs
@@ -318,7 +306,7 @@ class AuthRepositoryTest {
userLogoutManager = userLogoutManager,
dispatcherManager = dispatcherManager,
pushManager = pushManager,
policyManager = policyManager,
passwordPolicyManager = passwordPolicyManager,
logsManager = logsManager,
userStateManager = userStateManager,
kdfManager = kdfManager,
@@ -402,124 +390,6 @@ class AuthRepositoryTest {
}
}
@Test
@Suppress("MaxLineLength")
fun `loading the policies should emit masterPasswordPolicyFlow if the password fails any checks`() =
runTest {
val successResponse = GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS
coEvery {
identityService.preLogin(email = EMAIL)
} returns PRE_LOGIN_SUCCESS.asSuccess()
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
uniqueAppId = UNIQUE_APP_ID,
deeplinkScheme = DEEPLINK_SCHEME,
)
} returns successResponse.asSuccess()
coEvery {
vaultRepository.unlockVault(
accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2,
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock(
password = PASSWORD,
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK,
),
organizationKeys = null,
)
} returns VaultUnlockResult.Success
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
// Start the login flow so that all the necessary data is cached.
val result = repository.login(email = EMAIL, password = PASSWORD)
// Set policies that will fail the password.
mutableActivePolicyFlow.emit(
listOf(
createMockPolicyView(
type = PolicyType.MASTER_PASSWORD,
enabled = true,
data = """
{
"minLength":100,
"minComplexity":null,
"requireUpper":null,
"requireLower":null,
"requireNumbers":null,
"requireSpecial":null,
"enforceOnLogin":true
}
""",
),
),
)
// Verify the results.
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
coVerify { identityService.preLogin(email = EMAIL) }
fakeAuthDiskSource.assertAccountCryptographicState(
userId = USER_ID_1,
accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2,
)
fakeAuthDiskSource.assertMasterPasswordHash(
userId = USER_ID_1,
passwordHash = PASSWORD_HASH,
)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
uniqueAppId = UNIQUE_APP_ID,
deeplinkScheme = DEEPLINK_SCHEME,
)
vaultRepository.unlockVault(
accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE_V2,
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock(
password = PASSWORD,
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK,
),
organizationKeys = null,
)
vaultRepository.syncIfNecessary()
}
assertEquals(
UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1.copy(
profile = ACCOUNT_1.profile.copy(
forcePasswordResetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
),
),
),
),
fakeAuthDiskSource.userState,
)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@Test
fun `rememberedEmailAddress should pull from and update AuthDiskSource`() {
// AuthDiskSource and the repository start with the same value.
@@ -578,25 +448,6 @@ class AuthRepositoryTest {
assertEquals(false, repository.shouldTrustDevice)
}
@Test
fun `passwordResetReason should pull from the user's profile in AuthDiskSource`() = runTest {
val updatedProfile = ACCOUNT_1.profile.copy(
forcePasswordResetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
)
fakeAuthDiskSource.userState = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1.copy(
profile = updatedProfile,
),
),
)
assertEquals(
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
repository.passwordResetReason,
)
}
@Test
fun `organizations should return an empty list when there is no active user`() = runTest {
assertEquals(emptyList<Organization>(), repository.organizations)
@@ -2096,6 +1947,7 @@ class AuthRepositoryTest {
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = PASSWORD)
}
}
@@ -2185,6 +2037,7 @@ class AuthRepositoryTest {
)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = PASSWORD)
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@@ -2349,6 +2202,7 @@ class AuthRepositoryTest {
)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = PASSWORD)
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
coVerify(exactly = 0) {
@@ -2461,6 +2315,7 @@ class AuthRepositoryTest {
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition
userStateManager.hasPendingAccountAddition = true
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = PASSWORD)
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
}
@@ -2596,6 +2451,7 @@ class AuthRepositoryTest {
twoFactorToken = "twoFactorTokenToStore",
)
verify(exactly = 1) {
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = PASSWORD)
userStateManager.hasPendingAccountAddition = false
}
}
@@ -2780,6 +2636,7 @@ class AuthRepositoryTest {
)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = PASSWORD)
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1)
}
@@ -6485,54 +6342,6 @@ class AuthRepositoryTest {
assertEquals(BreachCountResult.Success(breachCount), result)
}
@Test
fun `getPasswordStrength returns expected results for various strength levels`() = runTest {
coEvery {
authSdkSource.passwordStrength(any(), eq("level_0"))
} returns LEVEL_0.asSuccess()
coEvery {
authSdkSource.passwordStrength(any(), eq("level_1"))
} returns LEVEL_1.asSuccess()
coEvery {
authSdkSource.passwordStrength(any(), eq("level_2"))
} returns LEVEL_2.asSuccess()
coEvery {
authSdkSource.passwordStrength(any(), eq("level_3"))
} returns LEVEL_3.asSuccess()
coEvery {
authSdkSource.passwordStrength(any(), eq("level_4"))
} returns LEVEL_4.asSuccess()
assertEquals(
PasswordStrengthResult.Success(LEVEL_0),
repository.getPasswordStrength(EMAIL, "level_0"),
)
assertEquals(
PasswordStrengthResult.Success(LEVEL_1),
repository.getPasswordStrength(EMAIL, "level_1"),
)
assertEquals(
PasswordStrengthResult.Success(LEVEL_2),
repository.getPasswordStrength(EMAIL, "level_2"),
)
assertEquals(
PasswordStrengthResult.Success(LEVEL_3),
repository.getPasswordStrength(EMAIL, "level_3"),
)
assertEquals(
PasswordStrengthResult.Success(LEVEL_4),
repository.getPasswordStrength(EMAIL, "level_4"),
)
}
@Test
fun `validatePassword with no current user returns ValidatePasswordResult Error`() = runTest {
val password = "password"
@@ -6853,66 +6662,6 @@ class AuthRepositoryTest {
)
}
@Test
fun `validatePasswordAgainstPolicy validates password against policy requirements`() = runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
// A helper method to set a policy with the given parameters.
fun setPolicy(
minLength: Int = 0,
minComplexity: Int? = null,
requireUpper: Boolean = false,
requireLower: Boolean = false,
requireNumbers: Boolean = false,
requireSpecial: Boolean = false,
) {
every {
policyManager.getActivePolicies(type = PolicyType.MASTER_PASSWORD)
} returns listOf(
createMockPolicyView(
type = PolicyType.MASTER_PASSWORD,
enabled = true,
data = """
{
"minLength":$minLength,
"minComplexity":$minComplexity,
"requireUpper":$requireUpper,
"requireLower":$requireLower,
"requireNumbers":$requireNumbers,
"requireSpecial":$requireSpecial,
"enforceOnLogin":true
}
""",
),
)
}
setPolicy(minLength = 10)
assertFalse(repository.validatePasswordAgainstPolicies(password = "123"))
val password = "simple"
coEvery {
authSdkSource.passwordStrength(
email = SINGLE_USER_STATE_1.activeAccount.profile.email,
password = password,
)
} returns LEVEL_0.asSuccess()
setPolicy(minComplexity = 10)
assertFalse(repository.validatePasswordAgainstPolicies(password = password))
setPolicy(requireUpper = true)
assertFalse(repository.validatePasswordAgainstPolicies(password = "lower"))
setPolicy(requireLower = true)
assertFalse(repository.validatePasswordAgainstPolicies(password = "UPPER"))
setPolicy(requireNumbers = true)
assertFalse(repository.validatePasswordAgainstPolicies(password = "letters"))
setPolicy(requireSpecial = true)
assertFalse(repository.validatePasswordAgainstPolicies(password = "letters"))
}
@Test
fun `sendVerificationEmail success should return success`() = runTest {
coEvery {
@@ -7198,6 +6947,7 @@ class AuthRepositoryTest {
assertNull(fakeAuthDiskSource.getOnboardingStatus(USER_ID_1))
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = PASSWORD)
}
}
@@ -7254,6 +7004,7 @@ class AuthRepositoryTest {
)
verify(exactly = 1) {
userStateManager.hasPendingAccountAddition = false
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = PASSWORD)
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
assertNull(fakeAuthDiskSource.getOnboardingStatus(USER_ID_1))
@@ -0,0 +1,419 @@
package com.x8bit.bitwarden.data.platform.manager.policy
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.manager.encryption.EncryptionManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
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.assertNull
import org.junit.jupiter.api.Test
import java.time.Instant
class PasswordPolicyManagerTest {
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val authSdkSource: AuthSdkSource = mockk()
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<PolicyView>>()
private val policyManager: PolicyManager = mockk {
every {
getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD)
} returns mutableActivePolicyFlow
}
private val encryptionManager: EncryptionManager = mockk {
every {
encrypt(alias = "PasswordPolicyManager", bytes = any())
} returns DEFAULT_ENCRYPTED_BYTE_ARRAY.asSuccess()
every {
decrypt(alias = "PasswordPolicyManager", bytes = any())
} returns DEFAULT_DECRYPTED_BYTE_ARRAY.asSuccess()
}
private val passwordPolicyManager: PasswordPolicyManager = PasswordPolicyManagerImpl(
authDiskSource = fakeAuthDiskSource,
authSdkSource = authSdkSource,
policyManager = policyManager,
encryptionManager = encryptionManager,
dispatcherManager = FakeDispatcherManager(),
)
@Test
fun `validatePasswordAgainstPolicy validates password against policy requirements`() = runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
// A helper method to set a policy with the given parameters.
fun setPolicy(
minLength: Int = 0,
minComplexity: Int? = null,
requireUpper: Boolean = false,
requireLower: Boolean = false,
requireNumbers: Boolean = false,
requireSpecial: Boolean = false,
) {
every {
policyManager.getActivePolicies(type = PolicyType.MASTER_PASSWORD)
} returns listOf(
createMockPolicyView(
type = PolicyType.MASTER_PASSWORD,
enabled = true,
data = """
{
"minLength":$minLength,
"minComplexity":$minComplexity,
"requireUpper":$requireUpper,
"requireLower":$requireLower,
"requireNumbers":$requireNumbers,
"requireSpecial":$requireSpecial,
"enforceOnLogin":true
}
""",
),
)
}
setPolicy(minLength = 10)
assertFalse(passwordPolicyManager.validatePasswordAgainstPolicies(password = "123"))
val password = "simple"
coEvery {
authSdkSource.passwordStrength(
email = SINGLE_USER_STATE_1.activeAccount.profile.email,
password = password,
)
} returns PasswordStrength.LEVEL_0.asSuccess()
setPolicy(minComplexity = 10)
assertFalse(passwordPolicyManager.validatePasswordAgainstPolicies(password = password))
setPolicy(requireUpper = true)
assertFalse(passwordPolicyManager.validatePasswordAgainstPolicies(password = "lower"))
setPolicy(requireLower = true)
assertFalse(passwordPolicyManager.validatePasswordAgainstPolicies(password = "UPPER"))
setPolicy(requireNumbers = true)
assertFalse(passwordPolicyManager.validatePasswordAgainstPolicies(password = "letters"))
setPolicy(requireSpecial = true)
assertFalse(passwordPolicyManager.validatePasswordAgainstPolicies(password = "letters"))
}
@Test
fun `passwordResetReason should pull from the user's profile in AuthDiskSource`() = runTest {
val updatedProfile = PROFILE_1.copy(
forcePasswordResetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
)
fakeAuthDiskSource.userState = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1.copy(
profile = updatedProfile,
),
),
)
assertEquals(
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
passwordPolicyManager.passwordResetReason,
)
}
@Test
fun `getPasswordStrength returns expected results for various strength levels`() = runTest {
coEvery {
authSdkSource.passwordStrength(email = any(), password = eq("level_0"))
} returns PasswordStrength.LEVEL_0.asSuccess()
coEvery {
authSdkSource.passwordStrength(email = any(), password = eq("level_1"))
} returns PasswordStrength.LEVEL_1.asSuccess()
coEvery {
authSdkSource.passwordStrength(email = any(), password = eq("level_2"))
} returns PasswordStrength.LEVEL_2.asSuccess()
coEvery {
authSdkSource.passwordStrength(email = any(), password = eq("level_3"))
} returns PasswordStrength.LEVEL_3.asSuccess()
coEvery {
authSdkSource.passwordStrength(email = any(), password = eq("level_4"))
} returns PasswordStrength.LEVEL_4.asSuccess()
assertEquals(
PasswordStrengthResult.Success(PasswordStrength.LEVEL_0),
passwordPolicyManager.getPasswordStrength(email = EMAIL, password = "level_0"),
)
assertEquals(
PasswordStrengthResult.Success(PasswordStrength.LEVEL_1),
passwordPolicyManager.getPasswordStrength(email = EMAIL, password = "level_1"),
)
assertEquals(
PasswordStrengthResult.Success(PasswordStrength.LEVEL_2),
passwordPolicyManager.getPasswordStrength(email = EMAIL, password = "level_2"),
)
assertEquals(
PasswordStrengthResult.Success(PasswordStrength.LEVEL_3),
passwordPolicyManager.getPasswordStrength(email = EMAIL, password = "level_3"),
)
assertEquals(
PasswordStrengthResult.Success(PasswordStrength.LEVEL_4),
passwordPolicyManager.getPasswordStrength(email = EMAIL, password = "level_4"),
)
}
@Test
fun `storePasswordToCheck encrypts the password using the EncryptionManager`() = runTest {
val password = "somePassword"
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = password)
verify(exactly = 1) {
encryptionManager.encrypt(
alias = "PasswordPolicyManager",
bytes = match { it.contentEquals(password.encodeToByteArray()) },
)
}
}
@Test
fun `storePasswordToCheck does not store the password when encryption fails`() = runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val password = "somePassword"
every {
encryptionManager.encrypt(
alias = "PasswordPolicyManager",
bytes = password.encodeToByteArray(),
)
} returns Throwable("Fail").asFailure()
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = password)
mutableActivePolicyFlow.tryEmit(listOf(createMasterPasswordPolicyView(minLength = 10)))
// With nothing stored, there is no encrypted password to decrypt.
verify(exactly = 0) {
encryptionManager.decrypt(alias = "PasswordPolicyManager", bytes = any())
}
assertNull(fakeAuthDiskSource.userState?.activeAccount?.profile?.forcePasswordResetReason)
}
@Test
fun `policy emission decrypts the stored password and sets reset reason when it fails`() =
runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val password = "123"
every {
encryptionManager.decrypt(alias = "PasswordPolicyManager", bytes = any())
} returns password.encodeToByteArray().asSuccess()
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = password)
mutableActivePolicyFlow.tryEmit(listOf(createMasterPasswordPolicyView(minLength = 10)))
verify(exactly = 1) {
encryptionManager.decrypt(
alias = "PasswordPolicyManager",
bytes = match { it.contentEquals(DEFAULT_ENCRYPTED_BYTE_ARRAY) },
)
}
assertEquals(
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
fakeAuthDiskSource.userState?.activeAccount?.profile?.forcePasswordResetReason,
)
}
@Test
fun `policy emission decrypts the stored password and clears reset reason when it passes`() =
runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val password = "aValidPassword"
every {
encryptionManager.decrypt(alias = "PasswordPolicyManager", bytes = any())
} returns password.encodeToByteArray().asSuccess()
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = password)
mutableActivePolicyFlow.tryEmit(listOf(createMasterPasswordPolicyView(minLength = 5)))
verify(exactly = 1) {
encryptionManager.decrypt(
alias = "PasswordPolicyManager",
bytes = match { it.contentEquals(DEFAULT_ENCRYPTED_BYTE_ARRAY) },
)
}
assertNull(
fakeAuthDiskSource.userState?.activeAccount?.profile?.forcePasswordResetReason,
)
}
@Test
fun `policy emission does not set a reset reason when decryption fails`() = runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
every {
encryptionManager.decrypt(alias = "PasswordPolicyManager", bytes = any())
} returns Throwable("Fail").asFailure()
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = "123")
mutableActivePolicyFlow.tryEmit(listOf(createMasterPasswordPolicyView(minLength = 10)))
verify(exactly = 1) {
encryptionManager.decrypt(
alias = "PasswordPolicyManager",
bytes = match { it.contentEquals(DEFAULT_ENCRYPTED_BYTE_ARRAY) },
)
}
assertNull(fakeAuthDiskSource.userState?.activeAccount?.profile?.forcePasswordResetReason)
}
@Test
fun `policy emission does not decrypt when a reset reason is already set`() = runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy(
accounts = mapOf(
USER_ID_1 to ACCOUNT_1.copy(
profile = PROFILE_1.copy(
forcePasswordResetReason =
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
),
),
),
)
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = "123")
mutableActivePolicyFlow.tryEmit(listOf(createMasterPasswordPolicyView(minLength = 10)))
// The user is already being forced to reset, so no re-check is performed.
verify(exactly = 0) {
encryptionManager.decrypt(alias = "PasswordPolicyManager", bytes = any())
}
}
@Test
fun `removePasswordToCheck prevents the stored password from being decrypted`() = runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = "123")
passwordPolicyManager.removePasswordToCheck(userId = USER_ID_1)
mutableActivePolicyFlow.tryEmit(listOf(createMasterPasswordPolicyView(minLength = 10)))
verify(exactly = 0) {
encryptionManager.decrypt(alias = "PasswordPolicyManager", bytes = any())
}
assertNull(fakeAuthDiskSource.userState?.activeAccount?.profile?.forcePasswordResetReason)
}
@Test
fun `switching users removes the stored password before it can be decrypted`() = runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
passwordPolicyManager.storePasswordToCheck(userId = USER_ID_1, password = "123")
// Switch away to a different active user and then back to the original user.
fakeAuthDiskSource.userState = UserStateJson(
activeUserId = USER_ID_2,
accounts = mapOf(USER_ID_2 to ACCOUNT_1),
)
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
mutableActivePolicyFlow.tryEmit(listOf(createMasterPasswordPolicyView(minLength = 10)))
// The password was purged when the previous active user changed away.
verify(exactly = 0) {
encryptionManager.decrypt(alias = "PasswordPolicyManager", bytes = any())
}
}
}
private const val EMAIL = "test@bitwarden.com"
private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181"
private const val USER_ID_2 = "b9d09b0a-4c3f-4b3d-9c2f-1b2d3e4f5a6b"
private const val ENCRYPTED_USER_KEY = "encryptedUserKey"
/**
* Creates a [PolicyView] with a master password policy enforcing the given [minLength] on login.
*/
private fun createMasterPasswordPolicyView(minLength: Int): PolicyView =
createMockPolicyView(
type = PolicyType.MASTER_PASSWORD,
enabled = true,
data = """
{
"minLength":$minLength,
"minComplexity":null,
"requireUpper":false,
"requireLower":false,
"requireNumbers":false,
"requireSpecial":false,
"enforceOnLogin":true
}
""",
)
private val BASE_PROFILE_1 = AccountJson.Profile(
userId = USER_ID_1,
email = EMAIL,
isEmailVerified = true,
name = "Bitwarden Tester",
hasPremiumPersonally = false,
hasPremiumFromOrganization = null,
stamp = null,
organizationId = null,
avatarColorHex = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = null,
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
)
private val PROFILE_1 = BASE_PROFILE_1.copy(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = MasterPasswordUnlockDataJson(
kdf = BASE_PROFILE_1.toSdkParams().toKdfRequestModel(),
masterKeyWrappedUserKey = ENCRYPTED_USER_KEY,
salt = EMAIL,
),
),
)
private val ACCOUNT_1 = AccountJson(
profile = PROFILE_1,
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
),
)
private val SINGLE_USER_STATE_1 = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1,
),
)
private val DEFAULT_ENCRYPTED_BYTE_ARRAY: ByteArray = ByteArray(4) { (0x80 + it).toByte() }
private val DEFAULT_DECRYPTED_BYTE_ARRAY: ByteArray = ByteArray(4) { (0xFF + it).toByte() }
@@ -31,6 +31,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@@ -118,6 +119,10 @@ class VaultLockManagerTest {
private val pinProtectedUserKeyManager: PinProtectedUserKeyManager = mockk {
coEvery { migratePinProtectedUserKeyIfNeeded(userId = any()) } just runs
}
private val passwordPolicyManager: PasswordPolicyManager = mockk {
every { storePasswordToCheck(userId = any(), password = any()) } just runs
every { removePasswordToCheck(userId = any()) } just runs
}
private val vaultLockManager: VaultLockManager = VaultLockManagerImpl(
context = context,
@@ -133,6 +138,7 @@ class VaultLockManagerTest {
dispatcherManager = fakeDispatcherManager,
kdfManager = kdfManager,
pinProtectedUserKeyManager = pinProtectedUserKeyManager,
passwordPolicyManager = passwordPolicyManager,
)
@Test
@@ -262,6 +268,7 @@ class VaultLockManagerTest {
// Will be used within each loop to reset the test to a suitable initial state.
fun resetTest(vaultTimeout: VaultTimeout) {
clearVerifications(userLogoutManager)
clearVerifications(passwordPolicyManager)
mutableVaultTimeoutStateFlow.value = vaultTimeout
fakeAppStateManager.appForegroundState = AppForegroundState.FOREGROUNDED
verifyUnlockedVaultBlocking(userId = USER_ID)
@@ -435,6 +442,7 @@ class VaultLockManagerTest {
// Will be used within each loop to reset the test to a suitable initial state.
fun resetTest(vaultTimeout: VaultTimeout) {
clearVerifications(passwordPolicyManager)
mutableVaultTimeoutStateFlow.value = vaultTimeout
fakeAppStateManager.appCreationState = AppCreationState.Destroyed
clearVerifications(userLogoutManager)
@@ -517,6 +525,7 @@ class VaultLockManagerTest {
// Will be used within each loop to reset the test to a suitable initial state.
fun resetTest(vaultTimeout: VaultTimeout) {
clearVerifications(passwordPolicyManager)
mutableVaultTimeoutStateFlow.value = vaultTimeout
fakeAppStateManager.appCreationState = AppCreationState.Destroyed
clearVerifications(userLogoutManager)
@@ -570,6 +579,7 @@ class VaultLockManagerTest {
// Will be used within each loop to reset the test to a suitable initial state.
fun resetTest(vaultTimeout: VaultTimeout) {
clearVerifications(userLogoutManager)
clearVerifications(passwordPolicyManager)
mutableVaultTimeoutStateFlow.value = vaultTimeout
verifyUnlockedVaultBlocking(userId = USER_ID)
verifyUnlockedVaultBlocking(userId = userId2)
@@ -876,7 +886,10 @@ class VaultLockManagerTest {
emptyList<VaultUnlockData>(),
vaultLockManager.vaultUnlockDataStateFlow.value,
)
verify { vaultSdkSource.clearCrypto(userId = USER_ID) }
verify(exactly = 1) {
passwordPolicyManager.removePasswordToCheck(userId = USER_ID)
vaultSdkSource.clearCrypto(userId = USER_ID)
}
}
@Suppress("MaxLineLength")
@@ -998,6 +1011,10 @@ class VaultLockManagerTest {
userId = USER_ID,
request = InitOrgCryptoRequest(organizationKeys = organizationKeys),
)
passwordPolicyManager.storePasswordToCheck(
userId = USER_ID,
password = masterPassword,
)
trustedDeviceManager.trustThisDeviceIfNecessary(userId = USER_ID)
kdfManager.updateKdfToMinimumsIfNeeded(masterPassword)
}
@@ -1628,6 +1645,7 @@ class VaultLockManagerTest {
// Confirm the vault is still locked
assertFalse(vaultLockManager.isVaultUnlocked(userId = USER_ID))
coVerify(exactly = 1) {
passwordPolicyManager.removePasswordToCheck(userId = USER_ID)
vaultSdkSource.getUserEncryptionKey(userId = USER_ID)
}
}
@@ -1971,6 +1989,9 @@ class VaultLockManagerTest {
),
)
}
verify(exactly = 1) {
passwordPolicyManager.storePasswordToCheck(userId = USER_ID, password = masterPassword)
}
}
private fun verifyUnlockedVaultBlocking(userId: String) {
@@ -0,0 +1,88 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.crypto.Kdf
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class InitUserCryptoMethodExtensionsTest {
@Test
fun `password returns the password for MasterPasswordUnlock`() {
assertEquals(
MASTER_PASSWORD,
MASTER_PASSWORD_UNLOCK_METHOD.password,
)
}
@Test
fun `password returns null for all non-MasterPasswordUnlock methods`() {
LOG_TAG_MAP
.keys
.filterNot { it is InitUserCryptoMethod.MasterPasswordUnlock }
.forEach { method -> assertNull(method.password) }
}
@Test
fun `logTag returns the correct label for each method`() {
LOG_TAG_MAP.forEach { (method, expectedLogTag) ->
assertEquals(expectedLogTag, method.logTag)
}
}
}
private const val MASTER_PASSWORD: String = "mockMasterPassword"
/**
* Must be declared as the [InitUserCryptoMethod] supertype. Typed as the concrete
* `MasterPasswordUnlock` subclass, `password` would resolve to that data class's own member
* property instead of the extension under test.
*/
private val MASTER_PASSWORD_UNLOCK_METHOD: InitUserCryptoMethod =
InitUserCryptoMethod.MasterPasswordUnlock(
password = MASTER_PASSWORD,
masterPasswordUnlock = MasterPasswordUnlockData(
kdf = Kdf.Pbkdf2(iterations = 1u),
masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey",
salt = "mockSalt",
),
)
/**
* Every [InitUserCryptoMethod] subclass mapped to its expected log tag. Adding a subclass to the
* SDK breaks the `when` blocks in the production code; this map must be updated alongside them.
*/
private val LOG_TAG_MAP: Map<InitUserCryptoMethod, String> = mapOf(
MASTER_PASSWORD_UNLOCK_METHOD to "Master Password Unlock",
InitUserCryptoMethod.AuthRequest(
requestPrivateKey = "mockRequestPrivateKey",
method = AuthRequestMethod.UserKey(protectedUserKey = "mockProtectedUserKey"),
) to "Auth Request",
InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = "mockDecryptedUserKey",
) to "Decrypted Key (Never Lock/Biometrics)",
InitUserCryptoMethod.DeviceKey(
deviceKey = "mockDeviceKey",
protectedDevicePrivateKey = "mockProtectedDevicePrivateKey",
deviceProtectedUserKey = "mockDeviceProtectedUserKey",
) to "Device Key",
InitUserCryptoMethod.KeyConnector(
masterKey = "mockMasterKey",
userKey = "mockUserKey",
) to "Key Connector",
InitUserCryptoMethod.KeyConnectorUrl(
url = "mockUrl",
keyConnectorKeyWrappedUserKey = "mockKeyConnectorKeyWrappedUserKey",
) to "Key Connector Url",
InitUserCryptoMethod.Pin(
pin = "mockPin",
pinProtectedUserKey = "mockPinProtectedUserKey",
) to "Pin",
InitUserCryptoMethod.PinEnvelope(
pin = "mockPin",
pinProtectedUserKeyEnvelope = "mockPinProtectedUserKeyEnvelope",
) to "Pin Envelope",
InitUserCryptoMethod.PinState(pin = "mockPin") to "Pin State",
)