[PM-10902] Base64 encode sensitive 2FA nav args (#3841)

This commit is contained in:
Patrick Honkonen 2024-08-29 08:45:54 -04:00 committed by GitHub
parent 4c983525d3
commit f778d7ecd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 113 additions and 44 deletions

View file

@ -110,7 +110,7 @@ fun NavGraphBuilder.authGraph(
onNavigateToTwoFactorLogin = { emailAddress ->
navController.navigateToTwoFactorLogin(
emailAddress = emailAddress,
password = null,
base64EncodedPassword = null,
)
},
)
@ -153,7 +153,7 @@ fun NavGraphBuilder.authGraph(
onNavigateToTwoFactorLogin = { emailAddress, password ->
navController.navigateToTwoFactorLogin(
emailAddress = emailAddress,
password = password,
base64EncodedPassword = password,
)
},
)
@ -162,7 +162,7 @@ fun NavGraphBuilder.authGraph(
onNavigateToTwoFactorLogin = {
navController.navigateToTwoFactorLogin(
emailAddress = it,
password = null,
base64EncodedPassword = null,
)
},
)

View file

@ -94,7 +94,7 @@ fun LoginScreen(
}
is LoginEvent.NavigateToTwoFactorLogin -> {
onNavigateToTwoFactorLogin(event.emailAddress, event.password)
onNavigateToTwoFactorLogin(event.emailAddress, event.base64EncodedPassword)
}
is LoginEvent.ShowToast -> {

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@ -159,7 +160,9 @@ class LoginViewModel @Inject constructor(
sendEvent(
LoginEvent.NavigateToTwoFactorLogin(
emailAddress = state.emailAddress,
password = state.passwordInput,
// Base64 URL encode the password to prevent corruption of escapable chars
// when sending via navArgs.
base64EncodedPassword = state.passwordInput.base64UrlEncode(),
),
)
}
@ -342,7 +345,7 @@ sealed class LoginEvent {
*/
data class NavigateToTwoFactorLogin(
val emailAddress: String,
val password: String?,
val base64EncodedPassword: String?,
) : LoginEvent()
/**

View file

@ -28,7 +28,7 @@ fun NavGraphBuilder.trustedDeviceGraph(navController: NavHostController) {
onNavigateToTwoFactorLogin = {
navController.navigateToTwoFactorLogin(
emailAddress = it,
password = null,
base64EncodedPassword = null,
)
},
)

View file

@ -15,12 +15,14 @@ private const val TWO_FACTOR_LOGIN_ROUTE =
/**
* Class to retrieve Two-Factor Login arguments from the [SavedStateHandle].
*
* @property base64EncodedPassword Base64 URL encoded password input.
*/
@OmitFromCoverage
data class TwoFactorLoginArgs(val emailAddress: String, val password: String?) {
data class TwoFactorLoginArgs(val emailAddress: String, val base64EncodedPassword: String?) {
constructor(savedStateHandle: SavedStateHandle) : this(
emailAddress = checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
password = savedStateHandle[PASSWORD],
base64EncodedPassword = savedStateHandle[PASSWORD],
)
}
@ -29,10 +31,13 @@ data class TwoFactorLoginArgs(val emailAddress: String, val password: String?) {
*/
fun NavController.navigateToTwoFactorLogin(
emailAddress: String,
password: String?,
base64EncodedPassword: String?,
navOptions: NavOptions? = null,
) {
this.navigate("$TWO_FACTOR_LOGIN_PREFIX/$emailAddress?$PASSWORD=$password", navOptions)
this.navigate(
route = "$TWO_FACTOR_LOGIN_PREFIX/$emailAddress?$PASSWORD=$base64EncodedPassword",
navOptions = navOptions,
)
}
/**

View file

@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlDecodeOrNull
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.button
@ -56,21 +57,24 @@ class TwoFactorLoginViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<TwoFactorLoginState, TwoFactorLoginEvent, TwoFactorLoginAction>(
initialState = savedStateHandle[KEY_STATE]
?: TwoFactorLoginState(
authMethod = authRepository.twoFactorResponse.preferredAuthMethod,
availableAuthMethods = authRepository.twoFactorResponse.availableAuthMethods,
codeInput = "",
displayEmail = authRepository.twoFactorResponse.twoFactorDisplayEmail,
dialogState = null,
isContinueButtonEnabled = authRepository
.twoFactorResponse
.preferredAuthMethod
.isContinueButtonEnabled,
isRememberMeEnabled = false,
captchaToken = null,
email = TwoFactorLoginArgs(savedStateHandle).emailAddress,
password = TwoFactorLoginArgs(savedStateHandle).password,
),
?: run {
val args = TwoFactorLoginArgs(savedStateHandle)
TwoFactorLoginState(
authMethod = authRepository.twoFactorResponse.preferredAuthMethod,
availableAuthMethods = authRepository.twoFactorResponse.availableAuthMethods,
codeInput = "",
displayEmail = authRepository.twoFactorResponse.twoFactorDisplayEmail,
dialogState = null,
isContinueButtonEnabled = authRepository
.twoFactorResponse
.preferredAuthMethod
.isContinueButtonEnabled,
isRememberMeEnabled = false,
captchaToken = null,
email = args.emailAddress,
password = args.base64EncodedPassword?.base64UrlDecodeOrNull(),
)
},
) {
private val recover2faUri: Uri

View file

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
@ -60,7 +61,7 @@ class LoginViewModelTest : BaseViewModelTest() {
@AfterEach
fun tearDown() {
unmockkStatic(::generateUriForCaptcha)
unmockkStatic(::generateUriForCaptcha, String::base64UrlEncode)
}
@Test
@ -333,6 +334,51 @@ class LoginViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `LoginButtonClick login returns TwoFactorRequired should base64 URL encode password and emit NavigateToTwoFactorLogin`() =
runTest {
mockkStatic(String::base64UrlEncode)
val decodedPassword = "password"
val encodedPassword = "base64EncodedPassword"
every { decodedPassword.base64UrlEncode() } returns encodedPassword
coEvery {
authRepository.login(
email = EMAIL,
password = decodedPassword,
captchaToken = null,
)
} returns LoginResult.TwoFactorRequired
val viewModel = createViewModel(
state = DEFAULT_STATE.copy(
passwordInput = decodedPassword,
),
)
viewModel.eventFlow.test {
viewModel.trySendAction(LoginAction.LoginButtonClick)
verify { decodedPassword.base64UrlEncode() }
assertEquals(
DEFAULT_STATE.copy(passwordInput = decodedPassword),
viewModel.stateFlow.value,
)
assertEquals(
LoginEvent.NavigateToTwoFactorLogin(
EMAIL,
encodedPassword,
),
awaitItem(),
)
}
coVerify {
authRepository.login(
email = EMAIL,
password = decodedPassword,
captchaToken = null,
)
}
}
@Test
fun `MasterPasswordHintClick should emit NavigateToMasterPasswordHint`() = runTest {
val viewModel = createViewModel()

View file

@ -17,6 +17,8 @@ import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlDecodeOrNull
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
@ -64,8 +66,12 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
mockkStatic(
::generateUriForCaptcha,
::generateUriForWebAuth,
String::base64UrlEncode,
)
mockkStatic(Uri::class)
every {
DEFAULT_ENCODED_PASSWORD.base64UrlDecodeOrNull()
} returns DEFAULT_PASSWORD
}
@AfterEach
@ -73,6 +79,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
unmockkStatic(
::generateUriForCaptcha,
::generateUriForWebAuth,
String::base64UrlDecodeOrNull,
)
unmockkStatic(Uri::class)
}
@ -81,6 +88,9 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
verify {
DEFAULT_ENCODED_PASSWORD.base64UrlDecodeOrNull()
}
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@ -107,7 +117,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = token,
method = TwoFactorAuthMethod.WEB_AUTH.value.toString(),
@ -127,7 +137,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coVerify(exactly = 1) {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = token,
method = TwoFactorAuthMethod.WEB_AUTH.value.toString(),
@ -158,7 +168,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -172,7 +182,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -188,7 +198,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "token",
method = TwoFactorAuthMethod.DUO.value.toString(),
@ -206,7 +216,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "token",
method = TwoFactorAuthMethod.DUO.value.toString(),
@ -274,7 +284,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -305,7 +315,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -460,7 +470,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -486,7 +496,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -502,7 +512,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -542,7 +552,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -559,7 +569,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coEvery {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -599,7 +609,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
coVerify {
authRepository.login(
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
twoFactorData = TwoFactorDataModel(
code = "",
method = TwoFactorAuthMethod.AUTHENTICATOR_APP.value.toString(),
@ -840,7 +850,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
savedStateHandle = SavedStateHandle().also {
it["state"] = state
it["email_address"] = "example@email.com"
it["password"] = "password123"
it["password"] = DEFAULT_ENCODED_PASSWORD
},
)
}
@ -858,7 +868,8 @@ private val TWO_FACTOR_RESPONSE = GetTokenResponseJson.TwoFactorRequired(
ssoToken = null,
twoFactorProviders = null,
)
private const val DEFAULT_PASSWORD = "password123"
private const val DEFAULT_ENCODED_PASSWORD = "base64EncodedPassword"
private val DEFAULT_STATE = TwoFactorLoginState(
authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP,
availableAuthMethods = listOf(
@ -873,5 +884,5 @@ private val DEFAULT_STATE = TwoFactorLoginState(
isRememberMeEnabled = false,
captchaToken = null,
email = "example@email.com",
password = "password123",
password = DEFAULT_PASSWORD,
)