diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt index 4131fecca47..2e785759f9e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreen.kt @@ -7,24 +7,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -58,7 +53,6 @@ import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme -import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.description import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.title import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers @@ -126,19 +120,18 @@ fun TwoFactorLoginScreen( }, ) - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) - val title = if (state.isNewDeviceVerification) { - BitwardenString.verify_your_identity.asText() - } else { - state.authMethod.title - } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() BitwardenScaffold( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { BitwardenTopAppBar( - title = title(), + title = if (state.isNewDeviceVerification) { + stringResource(id = BitwardenString.verify_your_identity) + } else { + state.authMethod.title() + }, scrollBehavior = scrollBehavior, navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_close), navigationIconContentDescription = stringResource(id = BitwardenString.close), @@ -230,55 +223,45 @@ private fun TwoFactorLoginScreenContent( modifier = modifier .verticalScroll(rememberScrollState()), ) { - if (state.authMethod != TwoFactorAuthMethod.YUBI_KEY) { - state.imageRes?.let { - Spacer(modifier = Modifier.height(12.dp)) - Image( - painter = painterResource(id = it), - contentDescription = null, - modifier = Modifier - .standardHorizontalMargin() - .size(124.dp), - ) - Spacer(modifier = Modifier.height(12.dp)) - } + state.imageRes?.let { + Spacer(modifier = Modifier.height(12.dp)) + Image( + painter = painterResource(id = it), + contentDescription = null, + modifier = Modifier + .standardHorizontalMargin() + .size(124.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) } - Spacer(modifier = Modifier.height(height = 12.dp)) - Text( - text = if (state.isNewDeviceVerification) { - stringResource(BitwardenString.enter_verification_code_new_device) - } else { - state.authMethod.description( - state.displayEmail, - ).invoke() - }, - textAlign = TextAlign.Center, - style = BitwardenTheme.typography.bodyMedium, - color = BitwardenTheme.colorScheme.text.primary, - modifier = Modifier - .standardHorizontalMargin() - .fillMaxWidth(), - ) - - Spacer(modifier = Modifier.height(12.dp)) - if (state.authMethod == TwoFactorAuthMethod.YUBI_KEY) { - state.imageRes?.let { - Spacer(modifier = Modifier.height(12.dp)) - Image( - painter = painterResource(id = it), - contentDescription = null, - alignment = Alignment.Center, - contentScale = ContentScale.FillWidth, + if (state.isNewDeviceVerification) { + Spacer(modifier = Modifier.height(height = 12.dp)) + Text( + text = stringResource(id = BitwardenString.enter_verification_code_new_device), + textAlign = TextAlign.Center, + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + } else { + state.authMethod.description(email = state.displayEmail)?.let { text -> + Spacer(modifier = Modifier.height(height = 12.dp)) + Text( + text = text(), + textAlign = TextAlign.Center, + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, modifier = Modifier - .padding(horizontal = 24.dp) - .clip(RoundedCornerShape(4.dp)) + .standardHorizontalMargin() .fillMaxWidth(), ) - Spacer(modifier = Modifier.height(24.dp)) } } + Spacer(modifier = Modifier.height(12.dp)) if (state.shouldShowCodeInput) { BitwardenPasswordField( value = state.codeInput, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt index b4ebf42216d..6e162851105 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensions.kt @@ -29,11 +29,12 @@ val TwoFactorAuthMethod.title: Text /** * Get the description for the given auth method. */ -fun TwoFactorAuthMethod.description(email: String): Text = when (this) { +fun TwoFactorAuthMethod.description(email: String): Text? = when (this) { TwoFactorAuthMethod.AUTHENTICATOR_APP -> BitwardenString.enter_verification_code_app.asText() TwoFactorAuthMethod.DUO -> { BitwardenString.follow_the_steps_from_duo_to_finish_logging_in.asText() } + TwoFactorAuthMethod.DUO_ORGANIZATION -> { BitwardenString.duo_two_step_login_is_required_for_your_account .asText() @@ -45,8 +46,12 @@ fun TwoFactorAuthMethod.description(email: String): Text = when (this) { TwoFactorAuthMethod.WEB_AUTH -> { BitwardenString.continue_to_complete_web_authn_verification.asText() } + TwoFactorAuthMethod.YUBI_KEY -> BitwardenString.yubi_key_instruction.asText() - else -> "".asText() + TwoFactorAuthMethod.U2F, + TwoFactorAuthMethod.REMEMBER, + TwoFactorAuthMethod.RECOVERY_CODE, + -> null } /** @@ -122,7 +127,7 @@ val TwoFactorAuthMethod.shouldUseNfc: Boolean @get:DrawableRes val TwoFactorAuthMethod.imageRes: Int? get() = when (this) { - TwoFactorAuthMethod.YUBI_KEY -> BitwardenDrawable.img_yubi_key + TwoFactorAuthMethod.YUBI_KEY -> BitwardenDrawable.ill_yubi_key TwoFactorAuthMethod.EMAIL -> BitwardenDrawable.ill_new_device_verification TwoFactorAuthMethod.AUTHENTICATOR_APP -> BitwardenDrawable.ill_authenticator else -> null diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt index 1b1e7983daa..de6f0da5b41 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt @@ -1,21 +1,14 @@ package com.x8bit.bitwarden.ui.vault.feature.qrcodescan -import android.net.Uri import com.bitwarden.ui.platform.base.BaseViewModel +import com.bitwarden.ui.platform.util.getTotpDataOrNull import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -private const val ALGORITHM = "algorithm" -private const val DIGITS = "digits" -private const val PERIOD = "period" -private const val SECRET = "secret" -private const val TOTP_CODE_PREFIX = "otpauth://totp" - /** - * Handles [QrCodeScanAction], - * and launches [QrCodeScanEvent] for the [QrCodeScanScreen]. + * Handles [QrCodeScanAction] and launches [QrCodeScanEvent] for the [QrCodeScanScreen]. */ @HiltViewModel class QrCodeScanViewModel @Inject constructor( @@ -33,83 +26,25 @@ class QrCodeScanViewModel @Inject constructor( } private fun handleCloseClick() { - sendEvent( - QrCodeScanEvent.NavigateBack, - ) + sendEvent(QrCodeScanEvent.NavigateBack) } private fun handleManualEntryTextClick() { - sendEvent( - QrCodeScanEvent.NavigateToManualCodeEntry, - ) + sendEvent(QrCodeScanEvent.NavigateToManualCodeEntry) } // For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) { - var result: TotpCodeResult = TotpCodeResult.Success(action.qrCode) - val scannedCode = action.qrCode - - if (scannedCode.isBlank() || !scannedCode.startsWith(TOTP_CODE_PREFIX)) { - vaultRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError()) - sendEvent(QrCodeScanEvent.NavigateBack) - return - } - - val scannedCodeUri = Uri.parse(scannedCode) - val secretValue = scannedCodeUri.getQueryParameter(SECRET) - if (secretValue == null || !secretValue.isBase32()) { - vaultRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError()) - sendEvent(QrCodeScanEvent.NavigateBack) - return - } - - val values = scannedCodeUri.queryParameterNames - if (!areParametersValid(scannedCode, values)) { - result = TotpCodeResult.CodeScanningError() - } - - vaultRepository.emitTotpCodeResult(result) + val qrCode = action.qrCode + qrCode + .getTotpDataOrNull() + ?.let { vaultRepository.emitTotpCodeResult(TotpCodeResult.Success(code = qrCode)) } + ?: run { vaultRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError()) } sendEvent(QrCodeScanEvent.NavigateBack) } private fun handleCameraErrorReceive() { - sendEvent( - QrCodeScanEvent.NavigateToManualCodeEntry, - ) - } - - @Suppress("NestedBlockDepth", "MagicNumber") - private fun areParametersValid(scannedCode: String, parameters: Set): Boolean { - parameters.forEach { parameter -> - Uri.parse(scannedCode).getQueryParameter(parameter)?.let { value -> - when (parameter) { - DIGITS -> { - val digit = value.toInt() - if (digit > 10 || digit < 1) { - return false - } - } - - PERIOD -> { - val period = value.toInt() - if (period < 1) { - return false - } - } - - ALGORITHM -> { - val lowercaseAlgo = value.lowercase() - if (lowercaseAlgo != "sha1" && - lowercaseAlgo != "sha256" && - lowercaseAlgo != "sha512" - ) { - return false - } - } - } - } - } - return true + sendEvent(QrCodeScanEvent.NavigateToManualCodeEntry) } } @@ -154,11 +89,3 @@ sealed class QrCodeScanAction { */ data object CameraSetupErrorReceive : QrCodeScanAction() } - -/** - * Checks if a string is using base32 digits. - */ -private fun String.isBase32(): Boolean { - val regex = ("^[A-Za-z2-7]+=*$").toRegex() - return regex.matches(this) -} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt index 86f070bcdc2..305adb895cd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/util/TwoFactorAuthMethodExtensionTest.kt @@ -43,8 +43,8 @@ class TwoFactorAuthMethodExtensionTest { TwoFactorAuthMethod.DUO to BitwardenString.follow_the_steps_from_duo_to_finish_logging_in.asText(), TwoFactorAuthMethod.YUBI_KEY to BitwardenString.yubi_key_instruction.asText(), - TwoFactorAuthMethod.U2F to "".asText(), - TwoFactorAuthMethod.REMEMBER to "".asText(), + TwoFactorAuthMethod.U2F to null, + TwoFactorAuthMethod.REMEMBER to null, TwoFactorAuthMethod.DUO_ORGANIZATION to BitwardenString.duo_two_step_login_is_required_for_your_account .asText() @@ -54,7 +54,7 @@ class TwoFactorAuthMethodExtensionTest { ), TwoFactorAuthMethod.WEB_AUTH to BitwardenString.continue_to_complete_web_authn_verification.asText(), - TwoFactorAuthMethod.RECOVERY_CODE to "".asText(), + TwoFactorAuthMethod.RECOVERY_CODE to null, ) .forEach { (type, title) -> assertEquals( @@ -142,7 +142,7 @@ class TwoFactorAuthMethodExtensionTest { TwoFactorAuthMethod.AUTHENTICATOR_APP to BitwardenDrawable.ill_authenticator, TwoFactorAuthMethod.EMAIL to BitwardenDrawable.ill_new_device_verification, TwoFactorAuthMethod.DUO to null, - TwoFactorAuthMethod.YUBI_KEY to BitwardenDrawable.img_yubi_key, + TwoFactorAuthMethod.YUBI_KEY to BitwardenDrawable.ill_yubi_key, TwoFactorAuthMethod.U2F to null, TwoFactorAuthMethod.REMEMBER to null, TwoFactorAuthMethod.DUO_ORGANIZATION to null, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt index af781be1231..c7ae00af478 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt @@ -1,9 +1,9 @@ package com.x8bit.bitwarden.ui.vault.feature.qrcodescan -import android.net.Uri import app.cash.turbine.test import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.bitwarden.ui.platform.util.getTotpDataOrNull import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import io.mockk.every @@ -27,16 +27,15 @@ class QrCodeScanViewModelTest : BaseViewModelTest() { every { totpCodeFlow } returns totpTestCodeFlow every { emitTotpCodeResult(any()) } just runs } - private val uriMock = mockk() @BeforeEach fun setup() { - mockkStatic(Uri::class) + mockkStatic(String::getTotpDataOrNull) } @AfterEach fun tearDown() { - unmockkStatic(Uri::class) + unmockkStatic(String::getTotpDataOrNull) } @Test @@ -76,32 +75,11 @@ class QrCodeScanViewModelTest : BaseViewModelTest() { } @Test - fun `QrCodeScan should emit new code and NavigateBack with a valid code with all values`() = - runTest { - setupMockUri() - - val validCode = - "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&algorithm=sha256&digits=8&period=60" - val viewModel = createViewModel() - val result = TotpCodeResult.Success(validCode) - - viewModel.eventFlow.test { - viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(validCode)) - - verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } - assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) - } - } - - @Test - fun `QrCodeScan should emit new code and NavigateBack without optional values`() = runTest { - setupMockUri( - queryParameterNames = setOf(SECRET), - ) - - val validCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP" + fun `QrCodeScanReceive with valid code should emit new code and NavigateBack`() = runTest { val viewModel = createViewModel() + val validCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP" val result = TotpCodeResult.Success(validCode) + every { validCode.getTotpDataOrNull() } returns mockk() viewModel.eventFlow.test { viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(validCode)) @@ -112,60 +90,11 @@ class QrCodeScanViewModelTest : BaseViewModelTest() { } @Test - fun `QrCodeScan should emit failure result and NavigateBack with invalid algorithm`() = - runTest { - setupMockUri(algorithm = "SHA-224") - - val viewModel = createViewModel() - val result = TotpCodeResult.CodeScanningError() - val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&algorithm=sha224" - - viewModel.eventFlow.test { - viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode)) - - verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } - assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) - } - } - - @Test - fun `QrCodeScan should emit failure result and NavigateBack with invalid digits`() = runTest { - setupMockUri(digits = "11") - - val viewModel = createViewModel() - val result = TotpCodeResult.CodeScanningError() - val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&digits=11" - - viewModel.eventFlow.test { - viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode)) - - verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } - assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) - } - } - - @Test - fun `QrCodeScan should emit failure result and NavigateBack with invalid period`() = runTest { - setupMockUri(period = "0") - - val viewModel = createViewModel() - val result = TotpCodeResult.CodeScanningError() - val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&period=0" - - viewModel.eventFlow.test { - viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode)) - - verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } - assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) - } - } - - @Test - fun `QrCodeScan should emit failure result without correct prefix`() = runTest { - + fun `QrCodeScanReceive with invalid totp should emit failure result`() = runTest { val viewModel = createViewModel() val result = TotpCodeResult.CodeScanningError() val invalidCode = "nototpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP" + every { invalidCode.getTotpDataOrNull() } returns null viewModel.eventFlow.test { viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode)) @@ -175,94 +104,8 @@ class QrCodeScanViewModelTest : BaseViewModelTest() { } } - @Test - fun `QrCodeScan should emit failure result with non base32 secret`() = runTest { - setupMockUri(secret = "JBSWY3dpeHPK3PXP1") - - val viewModel = createViewModel() - val result = TotpCodeResult.CodeScanningError() - val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP1" - - viewModel.eventFlow.test { - viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode)) - - verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } - assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) - } - } - - @Test - fun `QrCodeScan should emit failure result and NavigateBack without Secret`() = runTest { - setupMockUri(secret = null) - - val viewModel = createViewModel() - val result = TotpCodeResult.CodeScanningError() - val invalidCode = "otpauth://totp/Test:me" - - viewModel.eventFlow.test { - viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode)) - - verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } - assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) - } - } - - @Test - fun `QrCodeScan should emit failure result and NavigateBack if secret is empty`() = runTest { - setupMockUri(secret = "") - - val viewModel = createViewModel() - val result = TotpCodeResult.CodeScanningError() - val invalidCode = "otpauth://totp/Test:me?secret= " - - viewModel.eventFlow.test { - viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode)) - - verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } - assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) - } - } - - @Test - fun `QrCodeScan should emit failure result and NavigateBack if code is empty`() = runTest { - val viewModel = createViewModel() - val result = TotpCodeResult.CodeScanningError() - val invalidCode = "" - - viewModel.eventFlow.test { - viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode)) - - verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) } - assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) - } - } - - private fun setupMockUri( - secret: String? = "JBSWY3dpeHPK3PXP", - algorithm: String = "SHA256", - digits: String = "8", - period: String = "60", - queryParameterNames: Set = setOf( - ALGORITHM, PERIOD, DIGITS, SECRET, - ), - ) { - every { Uri.parse(any()) } returns uriMock - every { uriMock.getQueryParameter(SECRET) } returns secret - every { uriMock.getQueryParameter(ALGORITHM) } returns algorithm - every { uriMock.getQueryParameter(DIGITS) } returns digits - every { uriMock.getQueryParameter(PERIOD) } returns period - every { uriMock.queryParameterNames } returns queryParameterNames - } - private fun createViewModel(): QrCodeScanViewModel = QrCodeScanViewModel( vaultRepository = vaultRepository, ) - - companion object { - private const val ALGORITHM = "algorithm" - private const val DIGITS = "digits" - private const val PERIOD = "period" - private const val SECRET = "secret" - } } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt index 4dbf1e77e3f..3cc496d6f0f 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt @@ -1,9 +1,7 @@ package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan -import android.net.Uri import android.os.Parcelable -import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.text.toUpperCase +import androidx.core.net.toUri import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult @@ -12,15 +10,14 @@ import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import com.bitwarden.ui.platform.base.BaseViewModel -import com.bitwarden.ui.platform.base.util.isBase32 +import com.bitwarden.ui.platform.util.getTotpDataOrNull import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize import javax.inject.Inject /** - * Handles [QrCodeScanAction], - * and launches [QrCodeScanEvent] for the [QrCodeScanScreen]. + * Handles [QrCodeScanAction] and launches [QrCodeScanEvent] for the [QrCodeScanScreen]. */ @HiltViewModel @Suppress("TooManyFunctions") @@ -77,15 +74,11 @@ class QrCodeScanViewModel @Inject constructor( } private fun handleCloseClick() { - sendEvent( - QrCodeScanEvent.NavigateBack, - ) + sendEvent(QrCodeScanEvent.NavigateBack) } private fun handleManualEntryTextClick() { - sendEvent( - QrCodeScanEvent.NavigateToManualCodeEntry, - ) + sendEvent(QrCodeScanEvent.NavigateToManualCodeEntry) } private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) { @@ -103,96 +96,48 @@ class QrCodeScanViewModel @Inject constructor( // For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters private fun handleTotpUriReceive(scannedCode: String) { - val result = TotpCodeResult.TotpCodeScan(scannedCode) - val scannedCodeUri = Uri.parse(scannedCode) - val secretValue = scannedCodeUri - .getQueryParameter(TotpCodeManager.SECRET_PARAM) - .orEmpty() - .toUpperCase(Locale.current) - - if (secretValue.isEmpty() || !secretValue.isBase32()) { - authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) - sendEvent(QrCodeScanEvent.NavigateBack) - return - } - - val values = scannedCodeUri.queryParameterNames - // If the parameters are not valid, - if (!areParametersValid(scannedCode, values)) { - authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) - sendEvent(QrCodeScanEvent.NavigateBack) - return - } - if (authenticatorRepository.sharedCodesStateFlow.value.isSyncWithBitwardenEnabled) { - when (settingsRepository.defaultSaveOption) { - DefaultSaveOption.BITWARDEN_APP -> saveCodeToBitwardenAndNavigateBack(result) - DefaultSaveOption.LOCAL -> saveCodeLocallyAndNavigateBack(result) + scannedCode + .getTotpDataOrNull() + ?.let { + val result = TotpCodeResult.TotpCodeScan(code = scannedCode) + if (authenticatorRepository.sharedCodesStateFlow.value.isSyncWithBitwardenEnabled) { + when (settingsRepository.defaultSaveOption) { + DefaultSaveOption.BITWARDEN_APP -> { + saveCodeToBitwardenAndNavigateBack(result = result) + } - DefaultSaveOption.NONE -> { - pendingSuccessfulScan = result - mutableStateFlow.update { - it.copy( - dialog = QrCodeScanState.DialogState.ChooseSaveLocation, - ) + DefaultSaveOption.LOCAL -> saveCodeLocallyAndNavigateBack(result = result) + DefaultSaveOption.NONE -> { + pendingSuccessfulScan = result + mutableStateFlow.update { + it.copy(dialog = QrCodeScanState.DialogState.ChooseSaveLocation) + } + } } + } else { + // Syncing with Bitwarden not enabled, save code locally: + saveCodeLocallyAndNavigateBack(result = result) } } - } else { - // Syncing with Bitwarden not enabled, save code locally: - saveCodeLocallyAndNavigateBack(result) - } + ?: run { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + sendEvent(QrCodeScanEvent.NavigateBack) + } } private fun handleGoogleExportUriReceive(scannedCode: String) { - val uri = Uri.parse(scannedCode) + val uri = scannedCode.toUri() val encodedData = uri.getQueryParameter(TotpCodeManager.DATA_PARAM) - val result: TotpCodeResult = if (encodedData.isNullOrEmpty()) { - TotpCodeResult.CodeScanningError + if (encodedData.isNullOrEmpty()) { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) } else { - TotpCodeResult.GoogleExportScan(encodedData) + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.GoogleExportScan(encodedData)) } - authenticatorRepository.emitTotpCodeResult(result) sendEvent(QrCodeScanEvent.NavigateBack) } private fun handleCameraErrorReceive() { - sendEvent( - QrCodeScanEvent.NavigateToManualCodeEntry, - ) - } - - @Suppress("NestedBlockDepth", "ReturnCount", "MagicNumber") - private fun areParametersValid(scannedCode: String, parameters: Set): Boolean { - parameters.forEach { parameter -> - Uri.parse(scannedCode).getQueryParameter(parameter)?.let { value -> - when (parameter) { - TotpCodeManager.DIGITS_PARAM -> { - val digit = value.toInt() - if (digit > 10 || digit < 1) { - return false - } - } - - TotpCodeManager.PERIOD_PARAM -> { - val period = value.toInt() - if (period < 1) { - return false - } - } - - TotpCodeManager.ALGORITHM_PARAM -> { - val lowercaseAlgo = value.lowercase() - if (lowercaseAlgo != "sha1" && - lowercaseAlgo != "sha256" && - lowercaseAlgo != "sha512" - ) { - return false - } - } - } - } - } - return true + sendEvent(QrCodeScanEvent.NavigateToManualCodeEntry) } private fun saveCodeToBitwardenAndNavigateBack(result: TotpCodeResult.TotpCodeScan) { diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt index 81914661e96..0ed1948bf35 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt @@ -9,6 +9,7 @@ import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.bitwarden.ui.platform.util.getTotpDataOrNull import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -37,13 +38,20 @@ class QrCodeScanViewModelTest : BaseViewModelTest() { @BeforeEach fun setup() { - mockkStatic(Uri::parse) + mockkStatic( + String::getTotpDataOrNull, + Uri::parse, + ) + every { VALID_TOTP_CODE.getTotpDataOrNull() } returns mockk() every { Uri.parse(VALID_TOTP_CODE) } returns VALID_TOTP_URI } @AfterEach fun teardown() { - unmockkStatic(Uri::parse) + unmockkStatic( + String::getTotpDataOrNull, + Uri::parse, + ) } @Test @@ -242,13 +250,8 @@ class QrCodeScanViewModelTest : BaseViewModelTest() { every { authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) } just runs - val invalidUri: Uri = mockk { - every { getQueryParameter("secret") } returns "SECRET" - every { queryParameterNames } returns setOf("digits") - every { getQueryParameter("digits") } returns "100" - } val invalidQrCode = "otpauth://totp/secret=SECRET" - every { Uri.parse(invalidQrCode) } returns invalidUri + every { invalidQrCode.getTotpDataOrNull() } returns null viewModel.eventFlow.test { viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidQrCode)) assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/util/TotpUriUtils.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/util/TotpUriUtils.kt index db0dadf4d5e..298ed66ae45 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/util/TotpUriUtils.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/util/TotpUriUtils.kt @@ -1,6 +1,7 @@ package com.bitwarden.ui.platform.util import android.net.Uri +import androidx.core.net.toUri import com.bitwarden.ui.platform.base.util.isBase32 import com.bitwarden.ui.platform.model.TotpData @@ -12,6 +13,12 @@ private const val PARAM_NAME_ISSUER: String = "issuer" private const val PARAM_NAME_PERIOD: String = "period" private const val PARAM_NAME_SECRET: String = "secret" +/** + * Checks if the given [String] contains valid data for a TOTP. The [TotpData] will be returned + * when the correct data is present or `null` if data is invalid or missing. + */ +fun String.getTotpDataOrNull(): TotpData? = this.toUri().getTotpDataOrNull() + /** * Checks if the given [Uri] contains valid data for a TOTP. The [TotpData] will be returned when * the correct data is present or `null` if data is invalid or missing. diff --git a/ui/src/main/res/drawable-hdpi/img_yubi_key.png b/ui/src/main/res/drawable-hdpi/img_yubi_key.png deleted file mode 100644 index ca8936de154..00000000000 Binary files a/ui/src/main/res/drawable-hdpi/img_yubi_key.png and /dev/null differ diff --git a/ui/src/main/res/drawable-mdpi/img_yubi_key.png b/ui/src/main/res/drawable-mdpi/img_yubi_key.png deleted file mode 100644 index ec44159a709..00000000000 Binary files a/ui/src/main/res/drawable-mdpi/img_yubi_key.png and /dev/null differ diff --git a/ui/src/main/res/drawable-xhdpi/img_yubi_key.png b/ui/src/main/res/drawable-xhdpi/img_yubi_key.png deleted file mode 100644 index a69444039e1..00000000000 Binary files a/ui/src/main/res/drawable-xhdpi/img_yubi_key.png and /dev/null differ diff --git a/ui/src/main/res/drawable-xxhdpi/img_yubi_key.png b/ui/src/main/res/drawable-xxhdpi/img_yubi_key.png deleted file mode 100644 index fa02ce2c9b1..00000000000 Binary files a/ui/src/main/res/drawable-xxhdpi/img_yubi_key.png and /dev/null differ diff --git a/ui/src/main/res/drawable-xxxhdpi/img_yubi_key.png b/ui/src/main/res/drawable-xxxhdpi/img_yubi_key.png deleted file mode 100644 index cb1776b3a5b..00000000000 Binary files a/ui/src/main/res/drawable-xxxhdpi/img_yubi_key.png and /dev/null differ diff --git a/ui/src/main/res/drawable/ill_yubi_key.xml b/ui/src/main/res/drawable/ill_yubi_key.xml new file mode 100644 index 00000000000..c21b987f00e --- /dev/null +++ b/ui/src/main/res/drawable/ill_yubi_key.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 855a987f0f7..d1dfee2d6fd 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -179,8 +179,8 @@ Resend code Could not send verification email. Try again. Verification email sent - To continue, hold your YubiKey NEO against the back of the device or insert your YubiKey into your device’s USB port, then touch its button. - YubiKey security key + To continue, hold your Yubico security key against the back of the device or insert it into your device’s USB port, then touch its button. + Yubico security key Add new attachment Attachments Unable to download file.