PM-9532: pt2. separate vault unlock logic and fail out on error during login. (#3609)

This commit is contained in:
Dave Severns 2024-08-06 14:42:05 -04:00 committed by GitHub
parent a090000826
commit 18cd66a34b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 749 additions and 182 deletions

View file

@ -58,6 +58,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult 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.SsoCallbackResult
@ -91,6 +92,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository 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.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -509,6 +511,12 @@ class AuthRepositoryImpl(
val userId = profile.userId val userId = profile.userId
val privateKey = authDiskSource.getPrivateKey(userId = userId) val privateKey = authDiskSource.getPrivateKey(userId = userId)
?: return LoginResult.Error(errorMessage = null) ?: return LoginResult.Error(errorMessage = null)
checkForVaultUnlockError(
onVaultUnlockError = { error ->
return error.toLoginErrorResult()
},
) {
vaultRepository.unlockVault( vaultRepository.unlockVault(
userId = userId, userId = userId,
email = profile.email, email = profile.email,
@ -521,6 +529,7 @@ class AuthRepositoryImpl(
// We should already have the org keys from the login sync. // We should already have the org keys from the login sync.
organizationKeys = authDiskSource.getOrganizationKeys(userId = userId), organizationKeys = authDiskSource.getOrganizationKeys(userId = userId),
) )
}
authDiskSource.storeUserKey(userId = userId, userKey = asymmetricalKey) authDiskSource.storeUserKey(userId = userId, userKey = asymmetricalKey)
vaultRepository.syncIfNecessary() vaultRepository.syncIfNecessary()
@ -947,9 +956,9 @@ class AuthRepositoryImpl(
) )
} }
VaultUnlockResult.AuthenticationError, is VaultUnlockResult.AuthenticationError,
VaultUnlockResult.GenericError,
VaultUnlockResult.InvalidStateError, VaultUnlockResult.InvalidStateError,
VaultUnlockResult.GenericError,
-> { -> {
IllegalStateException("Failed to unlock vault").asFailure() IllegalStateException("Failed to unlock vault").asFailure()
} }
@ -1132,7 +1141,7 @@ class AuthRepositoryImpl(
ValidatePinResult.Success(isValid = true) ValidatePinResult.Success(isValid = true)
} }
InitializeCryptoResult.AuthenticationError -> { is InitializeCryptoResult.AuthenticationError -> {
ValidatePinResult.Success(isValid = false) ValidatePinResult.Success(isValid = false)
} }
} }
@ -1350,28 +1359,41 @@ class AuthRepositoryImpl(
deviceData: DeviceDataModel?, deviceData: DeviceDataModel?,
orgIdentifier: String?, orgIdentifier: String?,
): LoginResult = userStateTransaction { ): LoginResult = userStateTransaction {
val userStateJson = loginResponse.toUserState( val userStateJson = loginResponse.toUserState(
previousUserState = authDiskSource.userState, previousUserState = authDiskSource.userState,
environmentUrlData = environmentRepository.environment.environmentUrlData, environmentUrlData = environmentRepository.environment.environmentUrlData,
) )
val userId = userStateJson.activeUserId val userId = userStateJson.activeUserId
// Attempt to unlock the vault with password if possible. checkForVaultUnlockError(
password?.let { onVaultUnlockError = { vaultUnlockError ->
if (loginResponse.privateKey != null && loginResponse.key != null) { return@userStateTransaction vaultUnlockError.toLoginErrorResult()
vaultRepository.unlockVault( },
) {
val isDeviceUnlockAvailable = deviceData != null ||
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions != null
// if possible attempt to unlock the vault with trusted device data
if (isDeviceUnlockAvailable) {
unlockVaultWithTdeOnLoginSuccess(
loginResponse = loginResponse,
userId = userId, userId = userId,
email = userStateJson.activeAccount.profile.email, userStateJson = userStateJson,
kdf = userStateJson.activeAccount.profile.toSdkParams(), deviceData = deviceData,
userKey = loginResponse.key, )
privateKey = loginResponse.privateKey, } else {
masterPassword = it, password?.let {
// We can separately unlock vault for organization data after unlockVaultWithPasswordOnLoginSuccess(
// receiving the sync response if this data is currently absent. loginResponse = loginResponse,
organizationKeys = null, userId = userId,
userStateJson = userStateJson,
password = password,
) )
} }
}
}
password?.let {
// Save the master password hash. // Save the master password hash.
authSdkSource authSdkSource
.hashPassword( .hashPassword(
@ -1391,48 +1413,6 @@ class AuthRepositoryImpl(
passwordsToCheckMap.put(userId, it) passwordsToCheckMap.put(userId, it)
} }
// Attempt to unlock the vault with auth request if possible.
// These values will only be null during the Just-in-Time provisioning flow.
if (loginResponse.privateKey != null && loginResponse.key != null) {
deviceData?.let { model ->
vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
.masterPasswordHash
?.let {
AuthRequestMethod.MasterKey(
protectedMasterKey = model.asymmetricalKey,
authRequestKey = loginResponse.key,
)
}
?: AuthRequestMethod.UserKey(protectedUserKey = model.asymmetricalKey),
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
// We are purposely not storing the master password hash here since it is not
// formatted in in a manner that we can use. We will store it properly the next
// time the user enters their master password and it is validated.
}
}
// Handle the Trusted Device Encryption flow
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions?.let { options ->
loginResponse.privateKey?.let { privateKey ->
handleLoginCommonSuccessTrustedDeviceUserDecryptionOptions(
trustedDeviceDecryptionOptions = options,
userStateJson = userStateJson,
privateKey = privateKey,
)
}
}
authDiskSource.storeAccountTokens( authDiskSource.storeAccountTokens(
userId = userId, userId = userId,
accountTokens = AccountTokensJson( accountTokens = AccountTokensJson(
@ -1466,79 +1446,12 @@ class AuthRepositoryImpl(
twoFactorResponse = null twoFactorResponse = null
resendEmailRequestJson = null resendEmailRequestJson = null
twoFactorDeviceData = null twoFactorDeviceData = null
settingsRepository.setDefaultsIfNecessary(userId = userId) settingsRepository.setDefaultsIfNecessary(userId = userId)
vaultRepository.syncIfNecessary() vaultRepository.syncIfNecessary()
hasPendingAccountAddition = false hasPendingAccountAddition = false
LoginResult.Success LoginResult.Success
} }
/**
* A helper method to handle the [TrustedDeviceUserDecryptionOptionsJson] specific to TDE.
*/
private suspend fun handleLoginCommonSuccessTrustedDeviceUserDecryptionOptions(
trustedDeviceDecryptionOptions: TrustedDeviceUserDecryptionOptionsJson,
userStateJson: UserStateJson,
privateKey: String,
) {
val userId = userStateJson.activeUserId
val deviceKey = authDiskSource.getDeviceKey(userId = userId)
if (deviceKey == null) {
// A null device key means this device is not trusted.
val pendingRequest = authDiskSource.getPendingAuthRequest(userId = userId) ?: return
authRequestManager
.getAuthRequestIfApproved(pendingRequest.requestId)
.getOrNull()
?.let { request ->
// For approved requests the key will always be present.
val userKey = requireNotNull(request.key)
vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = pendingRequest.requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
}
authDiskSource.storePendingAuthRequest(
userId = userId,
pendingAuthRequest = null,
)
return
}
val encryptedPrivateKey = trustedDeviceDecryptionOptions.encryptedPrivateKey
val encryptedUserKey = trustedDeviceDecryptionOptions.encryptedUserKey
if (encryptedPrivateKey == null || encryptedUserKey == null) {
// If we have a device key but server is missing private key and user key, we
// need to clear the device key and let the user go through the TDE flow again.
authDiskSource.storeDeviceKey(userId = userId, deviceKey = null)
return
}
vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
deviceKey = deviceKey,
protectedDevicePrivateKey = encryptedPrivateKey,
deviceProtectedUserKey = encryptedUserKey,
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
}
/** /**
* A helper method that processes the [GetTokenResponseJson.TwoFactorRequired] when logging in. * A helper method that processes the [GetTokenResponseJson.TwoFactorRequired] when logging in.
*/ */
@ -1564,6 +1477,175 @@ class AuthRepositoryImpl(
return LoginResult.TwoFactorRequired return LoginResult.TwoFactorRequired
} }
private suspend fun unlockVaultWithPasswordOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
userId: String,
userStateJson: UserStateJson,
password: String?,
): VaultUnlockResult? {
// Attempt to unlock the vault with password if possible.
val masterPassword = password ?: return null
val privateKey = loginResponse.privateKey ?: return null
val key = loginResponse.key ?: return null
return vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
userKey = key,
privateKey = privateKey,
masterPassword = masterPassword,
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
}
/**
* Attempt to unlock the vault with trusted device specific data.
*/
private suspend fun unlockVaultWithTdeOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
userId: String,
userStateJson: UserStateJson,
deviceData: DeviceDataModel?,
): VaultUnlockResult? {
// Attempt to unlock the vault with auth request if possible.
// These values will only be null during the Just-in-Time provisioning flow.
if (loginResponse.privateKey != null && loginResponse.key != null) {
deviceData?.let { model ->
return vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
.masterPasswordHash
?.let {
AuthRequestMethod.MasterKey(
protectedMasterKey = model.asymmetricalKey,
authRequestKey = loginResponse.key,
)
}
?: AuthRequestMethod.UserKey(protectedUserKey = model.asymmetricalKey),
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
// We are purposely not storing the master password hash here since it is not
// formatted in in a manner that we can use. We will store it properly the next
// time the user enters their master password and it is validated.
}
}
// Handle the Trusted Device Encryption flow
return loginResponse
.userDecryptionOptions
?.trustedDeviceUserDecryptionOptions
?.let { options ->
loginResponse.privateKey?.let { privateKey ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
userStateJson = userStateJson,
privateKey = privateKey,
)
}
}
}
/**
* A helper method to handle the [TrustedDeviceUserDecryptionOptionsJson] specific to TDE
* and store the necessary keys when appropriate.
*/
private suspend fun unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options: TrustedDeviceUserDecryptionOptionsJson,
userStateJson: UserStateJson,
privateKey: String,
): VaultUnlockResult? {
var vaultUnlockResult: VaultUnlockResult? = null
val userId = userStateJson.activeUserId
val deviceKey = authDiskSource.getDeviceKey(userId = userId)
if (deviceKey == null) {
// A null device key means this device is not trusted.
val pendingRequest = authDiskSource
.getPendingAuthRequest(userId = userId)
?: return null
authRequestManager
.getAuthRequestIfApproved(pendingRequest.requestId)
.getOrNull()
?.let { request ->
// For approved requests the key will always be present.
val userKey = requireNotNull(request.key)
vaultUnlockResult = vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = pendingRequest.requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
}
authDiskSource.storePendingAuthRequest(
userId = userId,
pendingAuthRequest = null,
)
return vaultUnlockResult
}
val encryptedPrivateKey = options.encryptedPrivateKey
val encryptedUserKey = options.encryptedUserKey
if (encryptedPrivateKey == null || encryptedUserKey == null) {
// If we have a device key but server is missing private key and user key, we
// need to clear the device key and let the user go through the TDE flow again.
authDiskSource.storeDeviceKey(userId = userId, deviceKey = null)
return null
}
vaultUnlockResult = vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
deviceKey = deviceKey,
protectedDevicePrivateKey = encryptedPrivateKey,
deviceProtectedUserKey = encryptedUserKey,
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
if (vaultUnlockResult is VaultUnlockResult.Success) {
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
}
return vaultUnlockResult
}
/**
* A helper function to check for a vault unlock related error when logging in.
*
* @param onVaultUnlockError a lambda function to be invoked in the event a [VaultUnlockError]
* is produced via the passed in [block]
* @param block a lambda representing logic which produces either a [VaultUnlockResult] which
* is castable to [VaultUnlockError] or `null`
*/
private inline fun checkForVaultUnlockError(
onVaultUnlockError: (VaultUnlockError) -> Unit,
block: () -> VaultUnlockResult?,
) {
(block() as? VaultUnlockError)?.also(onVaultUnlockError)
}
//endregion LoginCommon //endregion LoginCommon
/** /**

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
/**
* Helper function to map a [VaultUnlockError] to a [LoginResult.Error] with
* the necessary `message` if applicable.
*/
fun VaultUnlockError.toLoginErrorResult(): LoginResult.Error = when (this) {
is VaultUnlockResult.AuthenticationError -> LoginResult.Error(this.message)
VaultUnlockResult.GenericError,
VaultUnlockResult.InvalidStateError,
-> LoginResult.Error(errorMessage = null)
}

View file

@ -134,7 +134,7 @@ class VaultSdkSourceImpl(
InitializeCryptoResult.Success InitializeCryptoResult.Success
} catch (exception: BitwardenException) { } catch (exception: BitwardenException) {
// The only truly expected error from the SDK is an incorrect key/password. // The only truly expected error from the SDK is an incorrect key/password.
InitializeCryptoResult.AuthenticationError InitializeCryptoResult.AuthenticationError(message = exception.message)
} }
} }
@ -150,7 +150,9 @@ class VaultSdkSourceImpl(
InitializeCryptoResult.Success InitializeCryptoResult.Success
} catch (exception: BitwardenException) { } catch (exception: BitwardenException) {
// The only truly expected error from the SDK is for incorrect keys. // The only truly expected error from the SDK is for incorrect keys.
InitializeCryptoResult.AuthenticationError InitializeCryptoResult.AuthenticationError(
message = exception.message,
)
} }
} }

View file

@ -13,5 +13,7 @@ sealed class InitializeCryptoResult {
/** /**
* Incorrect password or key(s) provided. * Incorrect password or key(s) provided.
*/ */
data object AuthenticationError : InitializeCryptoResult() data class AuthenticationError(
val message: String? = null,
) : InitializeCryptoResult()
} }

View file

@ -13,15 +13,22 @@ sealed class VaultUnlockResult {
/** /**
* Incorrect password provided. * Incorrect password provided.
*/ */
data object AuthenticationError : VaultUnlockResult() data class AuthenticationError(
val message: String? = null,
) : VaultUnlockResult(), VaultUnlockError
/** /**
* Unable to access user state information. * Unable to access user state information.
*/ */
data object InvalidStateError : VaultUnlockResult() data object InvalidStateError : VaultUnlockResult(), VaultUnlockError
/** /**
* Generic error thrown by Bitwarden SDK. * Generic error thrown by Bitwarden SDK.
*/ */
data object GenericError : VaultUnlockResult() data object GenericError : VaultUnlockResult(), VaultUnlockError
} }
/**
* Sealed interface to denote that a [VaultUnlockResult] is an error result.
*/
sealed interface VaultUnlockError

View file

@ -8,6 +8,11 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
*/ */
fun InitializeCryptoResult.toVaultUnlockResult(): VaultUnlockResult = fun InitializeCryptoResult.toVaultUnlockResult(): VaultUnlockResult =
when (this) { when (this) {
InitializeCryptoResult.AuthenticationError -> VaultUnlockResult.AuthenticationError is InitializeCryptoResult.AuthenticationError -> {
VaultUnlockResult.AuthenticationError(
message = this.message,
)
}
InitializeCryptoResult.Success -> VaultUnlockResult.Success InitializeCryptoResult.Success -> VaultUnlockResult.Success
} }

View file

@ -43,6 +43,7 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/** /**
* The top level composable for the Reset Password screen. * The top level composable for the Reset Password screen.
@ -230,6 +231,7 @@ private fun TrustedDeviceDialogs(
@Preview @Preview
@Composable @Composable
private fun TrustedDeviceScaffold_preview() { private fun TrustedDeviceScaffold_preview() {
BitwardenTheme {
TrustedDeviceScaffold( TrustedDeviceScaffold(
state = TrustedDeviceState( state = TrustedDeviceState(
dialogState = null, dialogState = null,
@ -253,3 +255,4 @@ private fun TrustedDeviceScaffold_preview() {
), ),
) )
} }
}

View file

@ -260,7 +260,7 @@ class VaultUnlockViewModel @Inject constructor(
action.vaultUnlockResult is VaultUnlockResult.Success action.vaultUnlockResult is VaultUnlockResult.Success
when (action.vaultUnlockResult) { when (action.vaultUnlockResult) {
VaultUnlockResult.AuthenticationError -> { is VaultUnlockResult.AuthenticationError -> {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error( dialog = VaultUnlockState.VaultUnlockDialog.Error(

View file

@ -1306,6 +1306,57 @@ class AuthRepositoryTest {
assertEquals(LoginResult.Success, result) assertEquals(LoginResult.Success, result)
} }
@Test
fun `completeTdeLogin where vault unlock fails should return LoginResult error`() = runTest {
val requestPrivateKey = "requestPrivateKey"
val asymmetricalKey = "asymmetricalKey"
val privateKey = "privateKey"
val orgKeys = mapOf("orgId" to "orgKey")
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
fakeAuthDiskSource.storePrivateKey(userId = USER_ID_1, privateKey = privateKey)
fakeAuthDiskSource.storeOrganizationKeys(userId = USER_ID_1, organizationKeys = orgKeys)
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = SINGLE_USER_STATE_1.activeAccount.profile.email,
kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
),
organizationKeys = orgKeys,
)
} returns VaultUnlockResult.AuthenticationError(message = null)
coEvery { vaultRepository.syncIfNecessary() } just runs
val result = repository.completeTdeLogin(
requestPrivateKey = requestPrivateKey,
asymmetricalKey = asymmetricalKey,
)
coVerify(exactly = 1) {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = SINGLE_USER_STATE_1.activeAccount.profile.email,
kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
),
organizationKeys = orgKeys,
)
}
coVerify(exactly = 0) {
vaultRepository.syncIfNecessary()
}
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null)
assertEquals(LoginResult.Error(errorMessage = null), result)
}
@Test @Test
fun `login when pre login fails should return Error with no message`() = runTest { fun `login when pre login fails should return Error with no message`() = runTest {
coEvery { coEvery {
@ -1471,6 +1522,94 @@ class AuthRepositoryTest {
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) } verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
} }
@Test
@Suppress("MaxLineLength")
fun `login should return Error result when get token succeeds but unlock vault fails`() =
runTest {
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
val expectedErrorMessage = "crypto key failure"
coEvery {
identityService.preLogin(email = EMAIL)
} returns PRE_LOGIN_SUCCESS.asSuccess()
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
} returns VaultUnlockResult.AuthenticationError(expectedErrorMessage)
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.Error(errorMessage = expectedErrorMessage), result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify { identityService.preLogin(email = EMAIL) }
fakeAuthDiskSource.assertPrivateKey(
userId = USER_ID_1,
privateKey = null,
)
fakeAuthDiskSource.assertUserKey(
userId = USER_ID_1,
userKey = null,
)
fakeAuthDiskSource.assertMasterPasswordHash(
userId = USER_ID_1,
passwordHash = null,
)
coVerify(exactly = 1) {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
}
coVerify(exactly = 0) {
vaultRepository.syncIfNecessary()
settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1)
}
assertEquals(
null,
fakeAuthDiskSource.userState,
)
}
@Test @Test
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
fun `login get token succeeds with null keys and hasMasterPassword false should not call unlockVault`() = fun `login get token succeeds with null keys and hasMasterPassword false should not call unlockVault`() =
@ -1796,6 +1935,97 @@ class AuthRepositoryTest {
) )
} }
@Test
@Suppress("MaxLineLength")
fun `login two factor should return Error result when get token succeeds but unlock vault fails`() = runTest {
val twoFactorResponse = GetTokenResponseJson
.TwoFactorRequired(
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
// Attempt a normal login with a two factor error first, so that the auth
// data will be cached.
coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess()
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns twoFactorResponse
.asSuccess()
val firstResult = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.TwoFactorRequired, firstResult)
coVerify { identityService.preLogin(email = EMAIL) }
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
}
// Login with two factor data.
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
twoFactorToken = "twoFactorTokenToStore",
)
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
twoFactorData = TWO_FACTOR_DATA,
)
} returns successResponse.asSuccess()
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
} returns VaultUnlockResult.InvalidStateError
every {
successResponse.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val finalResult = repository.login(
email = EMAIL,
password = PASSWORD,
twoFactorData = TWO_FACTOR_DATA,
captchaToken = null,
)
assertEquals(LoginResult.Error(errorMessage = null), finalResult)
assertEquals(twoFactorResponse, repository.twoFactorResponse)
fakeAuthDiskSource.assertTwoFactorToken(
email = EMAIL,
twoFactorToken = null,
)
coVerify(exactly = 0) {
vaultRepository.syncIfNecessary()
}
}
@Test @Test
fun `login uses remembered two factor tokens`() = runTest { fun `login uses remembered two factor tokens`() = runTest {
fakeAuthDiskSource.storeTwoFactorToken(EMAIL, "storedTwoFactorToken") fakeAuthDiskSource.storeTwoFactorToken(EMAIL, "storedTwoFactorToken")
@ -1978,7 +2208,100 @@ class AuthRepositoryTest {
@Test @Test
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync`() = fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync with MasteryKey`() =
runTest {
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = successResponse.privateKey!!,
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
method = AuthRequestMethod.MasterKey(
authRequestKey = successResponse.key!!,
protectedMasterKey = DEVICE_ASYMMETRICAL_KEY,
),
),
)
} returns VaultUnlockResult.Success
val result = repository.login(
email = EMAIL,
requestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
masterPasswordHash = PASSWORD_HASH,
captchaToken = null,
)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
fakeAuthDiskSource.assertPrivateKey(
userId = USER_ID_1,
privateKey = "privateKey",
)
fakeAuthDiskSource.assertUserKey(
userId = USER_ID_1,
userKey = "key",
)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
vaultRepository.syncIfNecessary()
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = successResponse.privateKey!!,
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
method = AuthRequestMethod.MasterKey(
authRequestKey = successResponse.key!!,
protectedMasterKey = DEVICE_ASYMMETRICAL_KEY,
),
),
)
}
assertEquals(
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
}
@Test
@Suppress("MaxLineLength")
fun `login with device should return Error result when get token succeeds but unlock vault fails`() =
runTest { runTest {
val successResponse = GET_TOKEN_RESPONSE_SUCCESS val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery { coEvery {
@ -2410,6 +2733,97 @@ class AuthRepositoryTest {
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) } verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
} }
@Test
@Suppress("MaxLineLength")
fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync with UserKey`() =
runTest {
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = successResponse.privateKey!!,
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
method = AuthRequestMethod.UserKey(
protectedUserKey = DEVICE_ASYMMETRICAL_KEY,
),
),
)
} returns VaultUnlockResult.Success
val result = repository.login(
email = EMAIL,
requestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
asymmetricalKey = DEVICE_ASYMMETRICAL_KEY,
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
masterPasswordHash = null,
captchaToken = null,
)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
fakeAuthDiskSource.assertPrivateKey(
userId = USER_ID_1,
privateKey = "privateKey",
)
fakeAuthDiskSource.assertUserKey(
userId = USER_ID_1,
userKey = "key",
)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.AuthRequest(
username = EMAIL,
authRequestId = DEVICE_REQUEST_ID,
accessCode = DEVICE_ACCESS_CODE,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
vaultRepository.syncIfNecessary()
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = successResponse.privateKey!!,
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
method = AuthRequestMethod.UserKey(
protectedUserKey = DEVICE_ASYMMETRICAL_KEY,
),
),
)
}
assertEquals(
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
}
@Test @Test
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
fun `SSO login get token succeeds with trusted device key and no keys should return Success, clear device key, update AuthState, update stored keys, and sync`() = fun `SSO login get token succeeds with trusted device key and no keys should return Success, clear device key, update AuthState, update stored keys, and sync`() =
@ -2539,7 +2953,7 @@ class AuthRepositoryTest {
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey) fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey)
fakeAuthDiskSource.assertDeviceKey(userId = USER_ID_1, deviceKey = deviceKey) fakeAuthDiskSource.assertDeviceKey(userId = USER_ID_1, deviceKey = deviceKey)
assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState)
coVerify(exactly = 1) { coVerify {
identityService.getToken( identityService.getToken(
email = EMAIL, email = EMAIL,
authModel = IdentityTokenAuthModel.SingleSignOn( authModel = IdentityTokenAuthModel.SingleSignOn(
@ -4739,7 +5153,7 @@ class AuthRepositoryTest {
userId = SINGLE_USER_STATE_1.activeUserId, userId = SINGLE_USER_STATE_1.activeUserId,
request = any(), request = any(),
) )
} returns InitializeCryptoResult.AuthenticationError.asSuccess() } returns InitializeCryptoResult.AuthenticationError().asSuccess()
val result = repository.validatePin(pin = pin) val result = repository.validatePin(pin = pin)

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class LoginResultExtensionsTest {
@Test
fun `VaultUnlockResult with error message maps to LoginResult Error with correct message`() {
val errorMessage = "foo"
val result = VaultUnlockResult.AuthenticationError(errorMessage).toLoginErrorResult()
assertEquals(LoginResult.Error(errorMessage), result)
}
@Test
@Suppress("MaxLineLength")
fun `VaultUnlockResult with null error message as default maps to LoginResult Error with null message`() {
val result = VaultUnlockResult.AuthenticationError().toLoginErrorResult()
assertEquals(LoginResult.Error(errorMessage = null), result)
}
@Test
@Suppress("MaxLineLength")
fun `VaultUnlockResult with no message value are mapped to LoginResult with null error message`() {
val invalidStateResult = VaultUnlockResult.InvalidStateError.toLoginErrorResult()
val genericErrorResult = VaultUnlockResult.GenericError.toLoginErrorResult()
val expectedResult = LoginResult.Error(errorMessage = null)
assertEquals(expectedResult, invalidStateResult)
assertEquals(expectedResult, genericErrorResult)
}
}

View file

@ -330,11 +330,13 @@ class VaultSdkSourceTest {
} }
@Test @Test
fun `initializeUserCrypto with BitwardenException failure should return AuthenticationError`() = @Suppress("MaxLineLength")
fun `initializeUserCrypto with BitwardenException failure should return AuthenticationError with message`() =
runBlocking { runBlocking {
val userId = "userId" val userId = "userId"
val mockInitCryptoRequest = mockk<InitUserCryptoRequest>() val mockInitCryptoRequest = mockk<InitUserCryptoRequest>()
val expectedException = BitwardenException.E(message = "") val expectedErrorMessage = "Whoopsy"
val expectedException = BitwardenException.E(message = expectedErrorMessage)
coEvery { coEvery {
clientCrypto.initializeUserCrypto( clientCrypto.initializeUserCrypto(
req = mockInitCryptoRequest, req = mockInitCryptoRequest,
@ -345,7 +347,7 @@ class VaultSdkSourceTest {
request = mockInitCryptoRequest, request = mockInitCryptoRequest,
) )
assertEquals( assertEquals(
InitializeCryptoResult.AuthenticationError.asSuccess(), InitializeCryptoResult.AuthenticationError(expectedErrorMessage).asSuccess(),
result, result,
) )
coVerify { coVerify {
@ -409,11 +411,13 @@ class VaultSdkSourceTest {
} }
@Test @Test
fun `initializeOrgCrypto with BitwardenException failure should return AuthenticationError`() = @Suppress("MaxLineLength")
fun `initializeOrgCrypto with BitwardenException failure should return AuthenticationError with correct message`() =
runBlocking { runBlocking {
val userId = "userId" val userId = "userId"
val mockInitCryptoRequest = mockk<InitOrgCryptoRequest>() val mockInitCryptoRequest = mockk<InitOrgCryptoRequest>()
val expectedException = BitwardenException.E(message = "") val expectedErrorMessage = "Whoopsy2"
val expectedException = BitwardenException.E(message = expectedErrorMessage)
coEvery { coEvery {
clientCrypto.initializeOrgCrypto( clientCrypto.initializeOrgCrypto(
req = mockInitCryptoRequest, req = mockInitCryptoRequest,
@ -424,7 +428,7 @@ class VaultSdkSourceTest {
request = mockInitCryptoRequest, request = mockInitCryptoRequest,
) )
assertEquals( assertEquals(
InitializeCryptoResult.AuthenticationError.asSuccess(), InitializeCryptoResult.AuthenticationError(expectedErrorMessage).asSuccess(),
result, result,
) )
coVerify { coVerify {

View file

@ -938,7 +938,7 @@ class VaultLockManagerTest {
), ),
), ),
) )
} returns InitializeCryptoResult.AuthenticationError.asSuccess() } returns InitializeCryptoResult.AuthenticationError().asSuccess()
assertEquals( assertEquals(
emptyList<VaultUnlockData>(), emptyList<VaultUnlockData>(),
@ -961,7 +961,7 @@ class VaultLockManagerTest {
organizationKeys = organizationKeys, organizationKeys = organizationKeys,
) )
assertEquals(VaultUnlockResult.AuthenticationError, result) assertEquals(VaultUnlockResult.AuthenticationError(), result)
assertEquals( assertEquals(
emptyList<VaultUnlockData>(), emptyList<VaultUnlockData>(),
vaultLockManager.vaultUnlockDataStateFlow.value, vaultLockManager.vaultUnlockDataStateFlow.value,
@ -1015,7 +1015,7 @@ class VaultLockManagerTest {
userId = USER_ID, userId = USER_ID,
request = InitOrgCryptoRequest(organizationKeys = organizationKeys), request = InitOrgCryptoRequest(organizationKeys = organizationKeys),
) )
} returns InitializeCryptoResult.AuthenticationError.asSuccess() } returns InitializeCryptoResult.AuthenticationError().asSuccess()
assertEquals( assertEquals(
emptyList<VaultUnlockData>(), emptyList<VaultUnlockData>(),
@ -1038,7 +1038,7 @@ class VaultLockManagerTest {
organizationKeys = organizationKeys, organizationKeys = organizationKeys,
) )
assertEquals(VaultUnlockResult.AuthenticationError, result) assertEquals(VaultUnlockResult.AuthenticationError(), result)
assertEquals( assertEquals(
emptyList<VaultUnlockData>(), emptyList<VaultUnlockData>(),
vaultLockManager.vaultUnlockDataStateFlow.value, vaultLockManager.vaultUnlockDataStateFlow.value,

View file

@ -440,7 +440,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(state = initialState) val viewModel = createViewModel(state = initialState)
coEvery { coEvery {
vaultRepository.unlockVaultWithMasterPassword(password) vaultRepository.unlockVaultWithMasterPassword(password)
} returns VaultUnlockResult.AuthenticationError } returns VaultUnlockResult.AuthenticationError()
viewModel.trySendAction(VaultUnlockAction.UnlockClick) viewModel.trySendAction(VaultUnlockAction.UnlockClick)
assertEquals( assertEquals(
@ -577,7 +577,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(state = initialState) val viewModel = createViewModel(state = initialState)
coEvery { coEvery {
vaultRepository.unlockVaultWithPin(pin) vaultRepository.unlockVaultWithPin(pin)
} returns VaultUnlockResult.AuthenticationError } returns VaultUnlockResult.AuthenticationError()
viewModel.trySendAction(VaultUnlockAction.UnlockClick) viewModel.trySendAction(VaultUnlockAction.UnlockClick)
assertEquals( assertEquals(
@ -726,7 +726,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(state = initialState) val viewModel = createViewModel(state = initialState)
coEvery { coEvery {
vaultRepository.unlockVaultWithBiometrics() vaultRepository.unlockVaultWithBiometrics()
} returns VaultUnlockResult.AuthenticationError } returns VaultUnlockResult.AuthenticationError()
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER)) viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))