BIT-802: Enforce master password policy (#849)

Co-authored-by: Sean Weiser <125889608+sean-livefront@users.noreply.github.com>
This commit is contained in:
Shannon Draeker
2024-01-30 09:22:19 -07:00
committed by Álison Fernandes
parent b3f23ab172
commit 2be6c9042f
36 changed files with 640 additions and 20 deletions

View File

@@ -177,6 +177,14 @@ interface AuthDiskSource {
*/
fun getOrganizationsFlow(userId: String): Flow<List<SyncResponseJson.Profile.Organization>?>
/**
* Stores the organization data for the given [userId].
*/
fun storeOrganizations(
userId: String,
organizations: List<SyncResponseJson.Profile.Organization>?,
)
/**
* Gets the master password hash for the given [userId].
*/
@@ -188,10 +196,17 @@ interface AuthDiskSource {
fun storeMasterPasswordHash(userId: String, passwordHash: String?)
/**
* Stores the organization data for the given [userId].
* Gets the policies for the given [userId].
*/
fun storeOrganizations(
userId: String,
organizations: List<SyncResponseJson.Profile.Organization>?,
)
fun getPolicies(userId: String): List<SyncResponseJson.Policy>?
/**
* Emits updates that track [getPolicies]. This will replay the last known value, if any.
*/
fun getPoliciesFlow(userId: String): Flow<List<SyncResponseJson.Policy>?>
/**
* Stores the [policies] for the given [userId].
*/
fun storePolicies(userId: String, policies: List<SyncResponseJson.Policy>?)
}

View File

@@ -31,6 +31,7 @@ private const val ORGANIZATIONS_KEY = "$BASE_KEY:organizations"
private const val ORGANIZATION_KEYS_KEY = "$BASE_KEY:encOrgKeys"
private const val TWO_FACTOR_TOKEN_KEY = "$BASE_KEY:twoFactorToken"
private const val MASTER_PASSWORD_HASH_KEY = "$BASE_KEY:keyHash"
private const val POLICIES_KEY = "$BASE_KEY:policies"
/**
* Primary implementation of [AuthDiskSource].
@@ -56,6 +57,8 @@ class AuthDiskSourceImpl(
private val inMemoryPinProtectedUserKeys = mutableMapOf<String, String?>()
private val mutableOrganizationsFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
private val mutablePoliciesFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Policy>?>>()
override val uniqueAppId: String
get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId()
@@ -106,6 +109,7 @@ class AuthDiskSourceImpl(
storeOrganizations(userId = userId, organizations = null)
storeUserBiometricUnlockKey(userId = userId, biometricsKey = null)
storeMasterPasswordHash(userId = userId, passwordHash = null)
storePolicies(userId = userId, policies = null)
}
override fun getLastActiveTimeMillis(userId: String): Long? =
@@ -276,6 +280,33 @@ class AuthDiskSourceImpl(
putString(key = "${MASTER_PASSWORD_HASH_KEY}_$userId", value = passwordHash)
}
override fun getPolicies(userId: String): List<SyncResponseJson.Policy>? =
getString(key = "${POLICIES_KEY}_$userId")
?.let {
// The policies are stored as a map.
val policiesMap: Map<String, SyncResponseJson.Policy> =
json.decodeFromString(it)
policiesMap.values.toList()
}
override fun getPoliciesFlow(
userId: String,
): Flow<List<SyncResponseJson.Policy>?> =
getMutablePoliciesFlow(userId = userId)
.onSubscription { emit(getPolicies(userId = userId)) }
override fun storePolicies(userId: String, policies: List<SyncResponseJson.Policy>?) {
putString(
key = "${POLICIES_KEY}_$userId",
value = policies?.let { nonNullPolicies ->
// The policies are stored as a map.
val policiesMap = nonNullPolicies.associateBy { it.id }
json.encodeToString(policiesMap)
},
)
getMutablePoliciesFlow(userId = userId).tryEmit(policies)
}
private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()
@@ -290,4 +321,11 @@ class AuthDiskSourceImpl(
mutableOrganizationsFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePoliciesFlow(
userId: String,
): MutableSharedFlow<List<SyncResponseJson.Policy>?> =
mutablePoliciesFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
}

View File

@@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult
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.ResendEmailResult
@@ -227,4 +228,12 @@ interface AuthRepository : AuthenticatorProvider {
* Validates the master password for the current logged in user.
*/
suspend fun validatePassword(password: String): ValidatePasswordResult
/**
* Validates the given [password] against a MasterPassword [policy].
*/
suspend fun validatePasswordAgainstPolicy(
password: String,
policy: PolicyInformation.MasterPassword,
): Boolean
}

View File

@@ -4,6 +4,7 @@ import android.os.SystemClock
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
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.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
@@ -24,6 +25,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService
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.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
@@ -37,6 +39,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult
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.ResendEmailResult
@@ -47,6 +50,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.currentUserPoliciesListFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
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.toUserStateJson
@@ -61,6 +66,8 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import kotlinx.coroutines.CoroutineScope
@@ -116,6 +123,12 @@ class AuthRepositoryImpl(
*/
private var resendEmailJsonRequest: ResendEmailJsonRequest? = null
/**
* The password that needs to be checked against any organization policies before
* the user can complete the login flow.
*/
private var passwordToCheck: String? = null
/**
* A scope intended for use when simply collecting multiple flows in order to combine them. The
* use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of
@@ -219,6 +232,21 @@ class AuthRepositoryImpl(
.logoutFlow
.onEach { logout() }
.launchIn(unconfinedScope)
// When the policies for the user have been set, complete the login process.
authDiskSource.currentUserPoliciesListFlow
.onEach { policies ->
val userId = activeUserId ?: return@onEach
if (passwordPassesPolicies(policies)) {
vaultRepository.completeUnlock(userId = userId)
} else {
storeUserResetPasswordReason(
userId = userId,
reason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
)
}
}
.launchIn(unconfinedScope)
}
override fun clearPendingAccountDeletion() {
@@ -402,6 +430,10 @@ class AuthRepositoryImpl(
passwordHash = passwordHash,
)
}
// Cache the password to verify against any password policies
// after the sync completes.
passwordToCheck = password
}
authDiskSource.userState = userStateJson
@@ -821,6 +853,67 @@ class AuthRepositoryImpl(
)
}
@Suppress("CyclomaticComplexMethod", "ReturnCount")
override 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.
*/
@Suppress("ReturnCount")
private suspend fun passwordPassesPolicies(policies: List<SyncResponseJson.Policy>?): Boolean {
// If the user is logging on without a password or if there are no policies,
// the check should complete.
val password = passwordToCheck ?: return true
val policyList = policies ?: return true
// If there are no master password policies that are enabled and should be
// enforced on login, the check should complete.
val passwordPolicies = policyList
.filter { it.type == PolicyTypeJson.MASTER_PASSWORD && it.isEnabled }
.mapNotNull { it.policyInformation as? PolicyInformation.MasterPassword }
.filter { it.enforceOnLogin == true }
// Check the password against all the policies.
val failingPolicies = passwordPolicies.filter { policy ->
!validatePasswordAgainstPolicy(password, policy)
}
return failingPolicies.isEmpty()
}
private suspend fun getFingerprintPhrase(
publicKey: String,
): UserFingerprintResult {
@@ -866,4 +959,23 @@ class AuthRepositoryImpl(
VaultUnlockType.MASTER_PASSWORD
}
}
/**
* Update the saved state with the force password reset reason.
*/
private fun storeUserResetPasswordReason(userId: String, reason: ForcePasswordResetReason?) {
val accounts = authDiskSource
.userState
?.accounts
?.toMutableMap()
?: return
val account = accounts[userId] ?: return
val updatedProfile = account
.profile
.copy(forcePasswordResetReason = reason)
accounts[userId] = account.copy(profile = updatedProfile)
authDiskSource.userState = authDiskSource
.userState
?.copy(accounts = accounts)
}
}

View File

@@ -40,6 +40,8 @@ data class UserState(
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
* authentication to view their vault.
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
* @property isVaultPendingUnlock Whether or not the user's vault is currently pending being
* unlocked, such as when the password policy has not completed verification yet.
* @property organizations List of [Organization]s the user is associated with, if any.
* @property isBiometricsEnabled Indicates that the biometrics mechanism for unlocking the
* user's vault is enabled.
@@ -54,6 +56,7 @@ data class UserState(
val isPremium: Boolean,
val isLoggedIn: Boolean,
val isVaultUnlocked: Boolean,
val isVaultPendingUnlock: Boolean,
val organizations: List<Organization>,
val isBiometricsEnabled: Boolean,
val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD,

View File

@@ -2,10 +2,12 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -53,3 +55,22 @@ val AuthDiskSource.userOrganizationsListFlow: Flow<List<UserOrganizations>>
) { values -> values.toList() }
}
.distinctUntilChanged()
/**
* Returns a [Flow] that emits distinct updates to the
* current user's [SyncResponseJson.Policy] list.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val AuthDiskSource.currentUserPoliciesListFlow: Flow<List<SyncResponseJson.Policy>?>
get() =
this
.userStateFlow
.flatMapLatest { userStateJson ->
userStateJson
?.activeUserId
?.let { activeUserId ->
this.getPoliciesFlow(activeUserId)
}
?: emptyFlow()
}
.distinctUntilChanged()

View File

@@ -72,6 +72,9 @@ fun UserStateJson.toUserState(
isLoggedIn = accountJson.isLoggedIn,
isVaultUnlocked = vaultState.statusFor(userId) ==
VaultUnlockData.Status.UNLOCKED,
isVaultPendingUnlock = vaultState.statusFor(userId) ==
VaultUnlockData.Status.PENDING ||
accountJson.profile.forcePasswordResetReason != null,
organizations = userOrganizationsList
.find { it.userId == userId }
?.organizations

View File

@@ -30,6 +30,12 @@ interface VaultLockManager {
*/
fun lockVault(userId: String)
/**
* Complete the unlock flow for a given [userId], moving their pendingUnlock status
* to a full unlock.
*/
fun completeUnlock(userId: String)
/**
* Locks the vault for the current user if currently unlocked.
*/

View File

@@ -92,6 +92,10 @@ class VaultLockManagerImpl(
setVaultToLocked(userId = userId)
}
override fun completeUnlock(userId: String) {
setVaultToUnlocked(userId = userId)
}
override fun lockVaultForCurrentUser() {
activeUserId?.let {
lockVault(it)
@@ -164,7 +168,7 @@ class VaultLockManagerImpl(
.also {
if (it is VaultUnlockResult.Success) {
clearInvalidUnlockCount(userId = userId)
setVaultToUnlocked(userId = userId)
setVaultToPendingUnlocked(userId = userId)
} else {
incrementInvalidUnlockCount(userId = userId)
}
@@ -210,6 +214,12 @@ class VaultLockManagerImpl(
storeUserAutoUnlockKeyIfNecessary(userId = userId)
}
private fun setVaultToPendingUnlocked(userId: String) {
mutableVaultUnlockDataStateFlow.update {
it.update(userId, VaultUnlockData.Status.PENDING)
}
}
private fun setVaultToLocked(userId: String) {
vaultSdkSource.clearCrypto(userId = userId)
mutableVaultUnlockDataStateFlow.update {

View File

@@ -314,6 +314,10 @@ class VaultRepositoryImpl(
unlockVaultForOrganizationsIfNecessary(syncResponse = syncResponse)
storeProfileData(syncResponse = syncResponse)
authDiskSource.storePolicies(
userId = userId,
policies = syncResponse.policies,
)
vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse)
settingsDiskSource.storeLastSyncTime(userId = userId, clock.instant())
},

View File

@@ -61,6 +61,7 @@ class RootNavViewModel @Inject constructor(
val updatedRootNavState = when {
userState == null ||
!userState.activeAccount.isLoggedIn ||
userState.activeAccount.isVaultPendingUnlock ||
userState.hasPendingAccountAddition -> RootNavState.Auth
userState.activeAccount.isVaultUnlocked -> {