mirror of
https://github.com/bitwarden/android.git
synced 2026-10-22 02:53:19 -05:00
PM-15890 🍒 TLS related error propagation (#4484)
This commit is contained in:
1 parent
53b389bcf2
commit
596039ab21
14 files changed
+381
-7
No files matched your search
@@ -94,6 +94,7 @@ import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITER
|
||||
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
|
||||
import com.x8bit.bitwarden.data.auth.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.util.isSslHandShakeError
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
@@ -625,7 +626,12 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onFailure = { LoginResult.Error(errorMessage = null) },
|
||||
onFailure = { throwable ->
|
||||
when {
|
||||
throwable.isSslHandShakeError() -> LoginResult.CertificateError
|
||||
else -> LoginResult.Error(errorMessage = null)
|
||||
}
|
||||
},
|
||||
onSuccess = { it },
|
||||
)
|
||||
|
||||
@@ -1508,9 +1514,12 @@ class AuthRepositoryImpl(
|
||||
captchaToken = captchaToken,
|
||||
)
|
||||
.fold(
|
||||
onFailure = {
|
||||
when (configDiskSource.serverConfig?.isOfficialBitwardenServer) {
|
||||
false -> LoginResult.UnofficialServerError
|
||||
onFailure = { throwable ->
|
||||
when {
|
||||
throwable.isSslHandShakeError() -> LoginResult.CertificateError
|
||||
configDiskSource.serverConfig?.isOfficialBitwardenServer == false -> {
|
||||
LoginResult.UnofficialServerError
|
||||
}
|
||||
else -> LoginResult.Error(errorMessage = null)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,4 +28,9 @@ sealed class LoginResult {
|
||||
* There was an error while logging into an unofficial Bitwarden server.
|
||||
*/
|
||||
data object UnofficialServerError : LoginResult()
|
||||
|
||||
/**
|
||||
* There was an error in validating the certificate chain for the server
|
||||
*/
|
||||
data object CertificateError : LoginResult()
|
||||
}
|
||||
+11
@@ -3,7 +3,9 @@ package com.x8bit.bitwarden.data.platform.datasource.network.util
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
import java.net.UnknownHostException
|
||||
import java.nio.charset.Charset
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import java.util.Base64
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
/**
|
||||
* Base 64 encode the string as well as make special modifications required by the backend:
|
||||
@@ -41,3 +43,12 @@ fun Throwable?.isNoConnectionError(): Boolean {
|
||||
return this is UnknownHostException ||
|
||||
this?.cause?.isNoConnectionError() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the throwable represents a SSL handshake error.
|
||||
*/
|
||||
fun Throwable?.isSslHandShakeError(): Boolean {
|
||||
return this is SSLHandshakeException ||
|
||||
this is CertPathValidatorException ||
|
||||
this?.cause?.isSslHandShakeError() ?: false
|
||||
}
|
||||
+11
@@ -194,6 +194,17 @@ class EnterpriseSignOnViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
LoginResult.CertificateError -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = EnterpriseSignOnState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.we_couldnt_verify_the_servers_certificate.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,17 @@ class LoginViewModel @Inject constructor(
|
||||
is LoginResult.Success -> {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
LoginResult.CertificateError -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LoginState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.we_couldnt_verify_the_servers_certificate.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +313,7 @@ data class LoginState(
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text?,
|
||||
val title: Text? = null,
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
|
||||
|
||||
+12
-1
@@ -254,6 +254,17 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
is LoginResult.Success -> {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
LoginResult.CertificateError -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LoginWithDeviceState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.we_couldnt_verify_the_servers_certificate.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +461,7 @@ data class LoginWithDeviceState(
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text?,
|
||||
val title: Text? = null,
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
|
||||
+10
@@ -323,6 +323,16 @@ class TwoFactorLoginViewModel @Inject constructor(
|
||||
|
||||
// NO-OP: Let the auth flow handle navigation after this.
|
||||
is LoginResult.Success -> Unit
|
||||
LoginResult.CertificateError -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.we_couldnt_verify_the_servers_certificate.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1089,5 +1089,5 @@ Do you want to switch to this account?</string>
|
||||
<string name="done_text">Done</string>
|
||||
<string name="biometrics_no_longer_supported_title">Biometrics are no longer supported on this device</string>
|
||||
<string name="biometrics_no_longer_supported">You’ve been logged out because your device’s biometrics don’t meet the latest security requirements. To update settings, log in once again or contact your administrator for access.</string>
|
||||
|
||||
<string name="we_couldnt_verify_the_servers_certificate">We couldn’t verify the server’s certificate. The certificate chain or proxy settings on your device or your Bitwarden server may not be set up correctly.</string>
|
||||
</resources>
|
||||
@@ -152,6 +152,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZonedDateTime
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class AuthRepositoryTest {
|
||||
@@ -1510,6 +1511,43 @@ class AuthRepositoryTest {
|
||||
coVerify { identityService.preLogin(email = EMAIL) }
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `login get token fails should return CertificateError when SSLHandshakeException is thrown`() =
|
||||
runTest {
|
||||
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 SSLHandshakeException("error").asFailure()
|
||||
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||
assertEquals(LoginResult.CertificateError, result)
|
||||
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||
coVerify { identityService.preLogin(email = EMAIL) }
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `prelogin fails should return CertificateError when SSLHandshakeException is thrown`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
identityService.preLogin(email = EMAIL)
|
||||
} returns SSLHandshakeException("error").asFailure()
|
||||
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||
assertEquals(LoginResult.CertificateError, result)
|
||||
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||
coVerify { identityService.preLogin(email = EMAIL) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login get token returns Invalid should return Error with correct message`() = runTest {
|
||||
coEvery {
|
||||
|
||||
+35
@@ -4,6 +4,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.net.UnknownHostException
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
class NetworkUtilsTest {
|
||||
@Test
|
||||
@@ -57,4 +59,37 @@ class NetworkUtilsTest {
|
||||
IllegalStateException().isNoConnectionError(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isSslHandshakeError should return return true for SSLHandshakeException`() {
|
||||
assertEquals(
|
||||
true,
|
||||
SSLHandshakeException("whoops").isSslHandShakeError(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isSslHandshakeError should return return true for CertPathValidatorException`() {
|
||||
assertEquals(
|
||||
true,
|
||||
CertPathValidatorException("whoops").isSslHandShakeError(),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `isSslHandshakeError should return return true if exceptions cause is SSLHandshakeException`() {
|
||||
assertEquals(
|
||||
true,
|
||||
Exception(SSLHandshakeException("whoops")).isSslHandShakeError(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isSslHandshakeError should return return false for not IllegalStateException`() {
|
||||
assertEquals(
|
||||
false,
|
||||
IllegalStateException().isSslHandShakeError(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+67
@@ -444,6 +444,73 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ssoCallbackResultFlow Success with same state with login CertificateError should show loading dialog then show certificate error dialog`() =
|
||||
runTest {
|
||||
val orgIdentifier = "Bitwarden"
|
||||
coEvery {
|
||||
authRepository.login(any(), any(), any(), any(), any(), any())
|
||||
} returns LoginResult.CertificateError
|
||||
|
||||
val viewModel = createViewModel(
|
||||
ssoData = DEFAULT_SSO_DATA,
|
||||
)
|
||||
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
EnterpriseSignOnAction.OrgIdentifierInputChange(orgIdentifier),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
orgIdentifierInput = orgIdentifier,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = EnterpriseSignOnState.DialogState.Loading(
|
||||
R.string.logging_in.asText(),
|
||||
),
|
||||
orgIdentifierInput = orgIdentifier,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = EnterpriseSignOnState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.we_couldnt_verify_the_servers_certificate.asText(),
|
||||
),
|
||||
orgIdentifierInput = orgIdentifier,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRepository.login(
|
||||
email = "test@gmail.com",
|
||||
ssoCode = "lmn",
|
||||
ssoCodeVerifier = "def",
|
||||
ssoRedirectUri = "bitwarden://sso-callback",
|
||||
captchaToken = null,
|
||||
organizationIdentifier = orgIdentifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ssoCallbackResultFlow Success with same state with login Success should show loading dialog, hide it, and save org identifier`() =
|
||||
|
||||
@@ -319,6 +319,44 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `LoginButtonClick login returns CertificateError should update errorDialogState`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
password = "",
|
||||
captchaToken = null,
|
||||
)
|
||||
} returns LoginResult.CertificateError
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
viewModel.trySendAction(LoginAction.LoginButtonClick)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = LoginState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = LoginState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.we_couldnt_verify_the_servers_certificate.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
coVerify {
|
||||
authRepository.login(email = EMAIL, password = "", captchaToken = null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LoginButtonClick login returns success should update loadingDialogState`() = runTest {
|
||||
coEvery {
|
||||
|
||||
+68
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class LoginWithDeviceViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mutableCreateAuthRequestWithUpdatesFlow =
|
||||
@@ -444,6 +445,73 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on createAuthRequestWithUpdates Success and login CertificateError should should update the state`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = DEFAULT_LOGIN_DATA.requestId,
|
||||
accessCode = DEFAULT_LOGIN_DATA.accessCode,
|
||||
asymmetricalKey = DEFAULT_LOGIN_DATA.asymmetricalKey,
|
||||
requestPrivateKey = DEFAULT_LOGIN_DATA.privateKey,
|
||||
masterPasswordHash = DEFAULT_LOGIN_DATA.masterPasswordHash,
|
||||
captchaToken = null,
|
||||
)
|
||||
} returns LoginResult.CertificateError
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
|
||||
CreateAuthRequestResult.Success(
|
||||
authRequest = AUTH_REQUEST,
|
||||
privateKey = AUTH_REQUEST_PRIVATE_KEY,
|
||||
accessCode = AUTH_REQUEST_ACCESS_CODE,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
fingerprintPhrase = "",
|
||||
),
|
||||
loginData = DEFAULT_LOGIN_DATA,
|
||||
dialogState = LoginWithDeviceState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
fingerprintPhrase = "",
|
||||
),
|
||||
dialogState = LoginWithDeviceState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.we_couldnt_verify_the_servers_certificate.asText(),
|
||||
),
|
||||
loginData = DEFAULT_LOGIN_DATA,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRepository.login(
|
||||
email = EMAIL,
|
||||
requestId = AUTH_REQUEST.id,
|
||||
accessCode = AUTH_REQUEST_ACCESS_CODE,
|
||||
asymmetricalKey = requireNotNull(AUTH_REQUEST.key),
|
||||
requestPrivateKey = AUTH_REQUEST_PRIVATE_KEY,
|
||||
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
|
||||
captchaToken = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on captchaTokenResultFlow missing token should should display error dialog`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
+60
@@ -706,6 +706,66 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ContinueButtonClick login returns CertificateError should update dialogState`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepository.login(
|
||||
email = DEFAULT_EMAIL_ADDRESS,
|
||||
password = DEFAULT_PASSWORD,
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = null,
|
||||
orgIdentifier = DEFAULT_ORG_IDENTIFIER,
|
||||
)
|
||||
} returns LoginResult.CertificateError
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(TwoFactorLoginAction.ContinueButtonClick)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Loading(
|
||||
message = R.string.logging_in.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = TwoFactorLoginState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.we_couldnt_verify_the_servers_certificate.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(TwoFactorLoginAction.DialogDismiss)
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
coVerify {
|
||||
authRepository.login(
|
||||
email = DEFAULT_EMAIL_ADDRESS,
|
||||
password = DEFAULT_PASSWORD,
|
||||
twoFactorData = TwoFactorDataModel(
|
||||
code = "",
|
||||
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
|
||||
remember = false,
|
||||
),
|
||||
captchaToken = null,
|
||||
orgIdentifier = DEFAULT_ORG_IDENTIFIER,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RememberMeToggle should update the state`() {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
Reference in new issue
Block a user