diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fc02932b6b5..158d32f482c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -82,10 +82,20 @@ + + + + + - + + + + + + diff --git a/app/src/main/java/com/x8bit/bitwarden/BitwardenAppComponentFactory.kt b/app/src/main/java/com/x8bit/bitwarden/BitwardenAppComponentFactory.kt index fc4cdc89fd0..c8644f40aad 100644 --- a/app/src/main/java/com/x8bit/bitwarden/BitwardenAppComponentFactory.kt +++ b/app/src/main/java/com/x8bit/bitwarden/BitwardenAppComponentFactory.kt @@ -7,7 +7,7 @@ import androidx.annotation.Keep import androidx.core.app.AppComponentFactory import com.x8bit.bitwarden.data.autofill.BitwardenAutofillService import com.x8bit.bitwarden.data.autofill.accessibility.BitwardenAccessibilityService -import com.x8bit.bitwarden.data.autofill.fido2.BitwardenFido2ProviderService +import com.x8bit.bitwarden.data.autofill.credential.BitwardenCredentialProviderService import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.tiles.BitwardenAutofillTileService import com.x8bit.bitwarden.data.tiles.BitwardenGeneratorTileService @@ -30,7 +30,7 @@ class BitwardenAppComponentFactory : AppComponentFactory() { * * [BitwardenAccessibilityService] * * [BitwardenAutofillService] * * [BitwardenAutofillTileService] - * * [BitwardenFido2ProviderService] + * * [BitwardenCredentialProviderService] * * [BitwardenVaultTileService] * * [BitwardenGeneratorTileService] */ @@ -63,7 +63,7 @@ class BitwardenAppComponentFactory : AppComponentFactory() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { super.instantiateServiceCompat( cl, - BitwardenFido2ProviderService::class.java.name, + BitwardenCredentialProviderService::class.java.name, intent, ) } else { diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 8432a388d70..fbce306f4fb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -16,6 +16,9 @@ import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2AssertionRequestOrNu import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2GetCredentialsRequestOrNull import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager +import com.x8bit.bitwarden.data.autofill.password.util.getPasswordAssertionRequestOrNull +import com.x8bit.bitwarden.data.autofill.password.util.getPasswordCredentialRequestOrNull +import com.x8bit.bitwarden.data.autofill.password.util.getPasswordGetCredentialsRequestOrNull import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager @@ -257,10 +260,13 @@ class MainViewModel @Inject constructor( val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut val hasVaultShortcut = intent.isMyVaultShortcut val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut - val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull() val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull() + val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull() val fido2CredentialAssertionRequest = intent.getFido2AssertionRequestOrNull() val fido2GetCredentialsRequest = intent.getFido2GetCredentialsRequestOrNull() + val passwordCredentialRequestData = intent.getPasswordCredentialRequestOrNull() + val passwordCredentialAssertionRequest = intent.getPasswordAssertionRequestOrNull() + val passwordGetCredentialsRequest = intent.getPasswordGetCredentialsRequestOrNull() when { passwordlessRequestData != null -> { authRepository.activeUserId?.let { @@ -343,10 +349,35 @@ class MainViewModel @Inject constructor( ) } - fido2GetCredentialsRequest != null -> { + fido2GetCredentialsRequest != null || passwordGetCredentialsRequest != null -> { specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.Fido2GetCredentials( + SpecialCircumstance.GetCredentials( fido2GetCredentialsRequest = fido2GetCredentialsRequest, + passwordGetCredentialsRequest = passwordGetCredentialsRequest, + ) + } + + passwordCredentialRequestData != null -> { + // Set the user's verification status when a new FIDO 2 request is received to force + // explicit verification if the user's vault is unlocked when the request is + // received. + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.PasswordSave( + passwordCredentialRequest = passwordCredentialRequestData, + ) + + // Switch accounts if the selected user is not the active user. + if (authRepository.activeUserId != null && + authRepository.activeUserId != passwordCredentialRequestData.userId + ) { + authRepository.switchAccount(passwordCredentialRequestData.userId) + } + } + + passwordCredentialAssertionRequest != null -> { + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.PasswordAssertion( + passwordAssertionRequest = passwordCredentialAssertionRequest, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/BitwardenFido2ProviderService.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/BitwardenCredentialProviderService.kt similarity index 81% rename from app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/BitwardenFido2ProviderService.kt rename to app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/BitwardenCredentialProviderService.kt index 1d66d2e11d4..01c66ad36db 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/BitwardenFido2ProviderService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/BitwardenCredentialProviderService.kt @@ -1,4 +1,4 @@ -package com.x8bit.bitwarden.data.autofill.fido2 +package com.x8bit.bitwarden.data.autofill.credential import android.os.Build import android.os.CancellationSignal @@ -14,11 +14,14 @@ import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetCredentialResponse import androidx.credentials.provider.CredentialProviderService import androidx.credentials.provider.ProviderClearCredentialStateRequest -import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor +import com.x8bit.bitwarden.data.autofill.credential.processor.BitwardenCredentialProcessor import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +const val UNLOCK_ACCOUNT_INTENT = + "com.x8bit.bitwarden.data.autofill.credential.ACTION_UNLOCK_ACCOUNT" + /** * The [CredentialProviderService] for the app. This fulfills FIDO2 credential requests from other * applications. @@ -27,14 +30,14 @@ import javax.inject.Inject @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @Keep @AndroidEntryPoint -class BitwardenFido2ProviderService : CredentialProviderService() { +class BitwardenCredentialProviderService : CredentialProviderService() { /** - * A processor to handle the FIDO2 credential fulfillment. We keep the service light because it - * isn't easily testable. + * A processor to handle the FIDO2 and/or Password credential fulfillment. We keep the service + * light because it isn't easily testable. */ @Inject - lateinit var processor: Fido2ProviderProcessor + lateinit var processor: BitwardenCredentialProcessor override fun onBeginCreateCredentialRequest( request: BeginCreateCredentialRequest, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/di/CredentialProviderModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/di/CredentialProviderModule.kt new file mode 100644 index 00000000000..b9f2f609bfc --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/di/CredentialProviderModule.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.data.autofill.credential.di + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.autofill.credential.processor.BitwardenCredentialProcessor +import com.x8bit.bitwarden.data.autofill.credential.processor.BitwardenCredentialProcessorImpl +import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor +import com.x8bit.bitwarden.data.autofill.password.processor.PasswordProviderProcessor +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Provides dependencies within the credential package. + */ +@Module +@InstallIn(SingletonComponent::class) +object CredentialProviderModule { + + @RequiresApi(Build.VERSION_CODES.S) + @Provides + @Singleton + fun provideCredentialProviderProcessor( + @ApplicationContext context: Context, + authRepository: AuthRepository, + intentManager: IntentManager, + fido2ProviderProcessor: Fido2ProviderProcessor, + passwordProviderProcessor: PasswordProviderProcessor, + dispatcherManager: DispatcherManager, + ): BitwardenCredentialProcessor = + BitwardenCredentialProcessorImpl( + context, + authRepository, + intentManager, + fido2ProviderProcessor, + passwordProviderProcessor, + dispatcherManager, + ) + +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/model/CredentialResponseAction.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/model/CredentialResponseAction.kt new file mode 100644 index 00000000000..9625c20b268 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/model/CredentialResponseAction.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.autofill.credential.model + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.credentials.provider.Action +import com.x8bit.bitwarden.MainActivity +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag +import kotlin.random.Random + +fun getCredentialResponseAction( + context: Context, +) = Action( + title = context.getString(R.string.open_bitwarden), + pendingIntent = PendingIntent.getActivity( + context, + Random.nextInt(), + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(), + ), +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/processor/BitwardenCredentialProcessor.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/processor/BitwardenCredentialProcessor.kt new file mode 100644 index 00000000000..7ed5c234389 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/processor/BitwardenCredentialProcessor.kt @@ -0,0 +1,63 @@ +package com.x8bit.bitwarden.data.autofill.credential.processor + +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.ProviderClearCredentialStateRequest + +/** + * A class to handle FIDO2 or Password credential request processing. This includes save and autofill requests. + */ +interface BitwardenCredentialProcessor { + + /** + * Process the [BeginCreateCredentialRequest] and invoke the [callback] with the result. + * + * @param request The request data from the OS that contains data about the requesting provider. + * @param cancellationSignal signal for observing cancellation requests. The system will use + * this to notify us that the result is no longer needed and we should stop handling it in order + * to save our resources. + * @param callback the callback object to be used to notify the response or error + */ + fun processCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) + + /** + * Process the [BeginGetCredentialRequest] and invoke the [callback] with the result. + * + * @param request data from the OS that contains data about the requesting provider. + * @param cancellationSignal signal for observing cancellation requests. The system will use + * this to notify us that the result is no longer needed and we should stop handling it in order + * to save our resources. + * @param callback the callback object to be used to notify the response or error + */ + fun processGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) + + /** + * Process the [ProviderClearCredentialStateRequest] and invoke the [callback] with the result. + * + * @param request The request data form the OS that contains data about the requesting provider. + * @param cancellationSignal signal for observing cancellation requests. The system will use + * this to notify us that the result is no longer needed and we should stop handling it in order + * to save our resources. + * @param callback the callback object to be used to notify the response or error + */ + fun processClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/processor/BitwardenCredentialProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/processor/BitwardenCredentialProcessorImpl.kt new file mode 100644 index 00000000000..e66c114c73b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/credential/processor/BitwardenCredentialProcessorImpl.kt @@ -0,0 +1,173 @@ +package com.x8bit.bitwarden.data.autofill.credential.processor + +import android.content.Context +import android.os.Build +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.ClearCredentialUnsupportedException +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.AuthenticationAction +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePasswordCredentialRequest +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.autofill.credential.UNLOCK_ACCOUNT_INTENT +import com.x8bit.bitwarden.data.autofill.credential.model.getCredentialResponseAction +import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor +import com.x8bit.bitwarden.data.autofill.password.processor.PasswordProviderProcessor +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicInteger + +/** + * The default implementation of [BitwardenCredentialProcessor]. Its purpose is to handle FIDO2 or Password related + * processing. + */ +@RequiresApi(Build.VERSION_CODES.S) +class BitwardenCredentialProcessorImpl( + private val context: Context, + private val authRepository: AuthRepository, + private val intentManager: IntentManager, + private val fido2Processor: Fido2ProviderProcessor, + private val passwordProcessor: PasswordProviderProcessor, + dispatcherManager: DispatcherManager, +) : BitwardenCredentialProcessor { + + private val requestCode = AtomicInteger() + + private val scope = CoroutineScope(dispatcherManager.unconfined) + + override fun processCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + val userState = authRepository.userStateFlow.value + if (userState == null) { + callback.onError(CreateCredentialUnknownException("Active user is required.")) + return + } + + val createCredentialJob = scope.launch { + when (request) { + is BeginCreatePublicKeyCredentialRequest -> { + fido2Processor.processCreateCredentialRequest( + requestCode = requestCode, + userState = userState, + request = request, + ) + } + + is BeginCreatePasswordCredentialRequest -> { + passwordProcessor.processCreateCredentialRequest( + requestCode = requestCode, + userState = userState, + request = request, + ) + } + + else -> null + }?.let { + callback.onResult(it) + } ?: callback.onError(CreateCredentialUnknownException()) + } + + cancellationSignal.setOnCancelListener { + if (createCredentialJob.isActive) { + createCredentialJob.cancel() + } + callback.onError(CreateCredentialCancellationException()) + } + } + + override fun processGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + // If the user is not logged in, return an error. + val userState = authRepository.userStateFlow.value + if (userState == null) { + callback.onError(GetCredentialUnknownException("Active user is required.")) + return + } + + // Return an unlock action if the current account is locked. + if (!userState.activeAccount.isVaultUnlocked) { + val authenticationAction = AuthenticationAction( + title = context.getString(R.string.unlock), + pendingIntent = intentManager.createCredentialUnlockPendingIntent( + action = UNLOCK_ACCOUNT_INTENT, + userId = userState.activeUserId, + requestCode = requestCode.getAndIncrement(), + ), + ) + + callback.onResult( + BeginGetCredentialResponse( + authenticationActions = listOf(authenticationAction), + ), + ) + return + } + + val getCredentialJob = scope.launch { + try { + val fidoCredentials = fido2Processor.processGetCredentialRequest( + requestCode = requestCode, + activeUserId = userState.activeUserId, + beginGetCredentialOptions = request.beginGetCredentialOptions.filterIsInstance(), + ) ?: emptyList() + val passwordCredentials = passwordProcessor.processGetCredentialRequest( + requestCode = requestCode, + activeUserId = userState.activeUserId, + callingAppInfo = request.callingAppInfo, + beginGetPasswordOptions = request.beginGetCredentialOptions.filterIsInstance(), + ) ?: emptyList() + + callback.onResult( + BeginGetCredentialResponse( + credentialEntries = fidoCredentials.plus(passwordCredentials), + // Explicitly clear any pending authentication actions since we only + // display results from the active account. + authenticationActions = emptyList(), + actions = listOf(getCredentialResponseAction(context)), + ), + ) + } catch (e: GetCredentialException) { + callback.onError(e) + } + } + + cancellationSignal.setOnCancelListener { + callback.onError(GetCredentialCancellationException()) + getCredentialJob.cancel() + } + + } + + override fun processClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + // no-op: RFU + callback.onError(ClearCredentialUnsupportedException()) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt index 7388e783179..f9a8c94ac5c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt @@ -4,14 +4,12 @@ import android.content.Context import android.os.Build import androidx.annotation.RequiresApi import com.bitwarden.sdk.Fido2CredentialStore -import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManagerImpl import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorImpl import com.x8bit.bitwarden.data.platform.manager.AssetManager -import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -36,23 +34,17 @@ object Fido2ProviderModule { @Singleton fun provideCredentialProviderProcessor( @ApplicationContext context: Context, - authRepository: AuthRepository, vaultRepository: VaultRepository, - fido2CredentialStore: Fido2CredentialStore, fido2CredentialManager: Fido2CredentialManager, - dispatcherManager: DispatcherManager, intentManager: IntentManager, clock: Clock, ): Fido2ProviderProcessor = Fido2ProviderProcessorImpl( context, - authRepository, vaultRepository, - fido2CredentialStore, fido2CredentialManager, intentManager, clock, - dispatcherManager, ) @Provides diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsResult.kt index 0751f83be4a..6523cb7b666 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsResult.kt @@ -1,7 +1,6 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import androidx.credentials.provider.BeginGetPublicKeyCredentialOption -import com.bitwarden.fido.Fido2CredentialAutofillView +import androidx.credentials.provider.CredentialEntry /** * Represents the result of a FIDO 2 Get Credentials request. @@ -10,15 +9,11 @@ sealed class Fido2GetCredentialsResult { /** * Indicates credentials were successfully queried. * - * @param userId ID of the user whose credentials were queried. - * @param options Original request options provided by the relying party. - * @param credentials Collection of [Fido2CredentialAutofillView]s matching the original request + * @param credentials Collection of [CredentialEntry]s matching the original request * parameters. This may be an empty list if no matching values were found. */ data class Success( - val userId: String, - val options: BeginGetPublicKeyCredentialOption, - val credentials: List, + val credentials: List, ) : Fido2GetCredentialsResult() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessor.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessor.kt index d88a2b3311f..e2651c1be12 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessor.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessor.kt @@ -1,15 +1,13 @@ package com.x8bit.bitwarden.data.autofill.fido2.processor -import android.os.CancellationSignal -import android.os.OutcomeReceiver -import androidx.credentials.exceptions.ClearCredentialException -import androidx.credentials.exceptions.CreateCredentialException -import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.provider.BeginCreateCredentialRequest import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest import androidx.credentials.provider.BeginGetCredentialRequest -import androidx.credentials.provider.BeginGetCredentialResponse -import androidx.credentials.provider.ProviderClearCredentialStateRequest +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.CredentialEntry +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import java.util.concurrent.atomic.AtomicInteger /** * A class to handle FIDO2 credential request processing. This includes save and autofill requests. @@ -17,47 +15,29 @@ import androidx.credentials.provider.ProviderClearCredentialStateRequest interface Fido2ProviderProcessor { /** - * Process the [BeginCreateCredentialRequest] and invoke the [callback] with the result. + * Process the [BeginCreateCredentialRequest] and returns the result. * + * @param requestCode The requestCode to be used for pending intents. + * @param userState The userState of the currently active user. * @param request The request data from the OS that contains data about the requesting provider. - * @param cancellationSignal signal for observing cancellation requests. The system will use - * this to notify us that the result is no longer needed and we should stop handling it in order - * to save our resources. - * @param callback the callback object to be used to notify the response or error */ - fun processCreateCredentialRequest( - request: BeginCreateCredentialRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver, - ) + suspend fun processCreateCredentialRequest( + requestCode: AtomicInteger, + userState: UserState, + request: BeginCreatePublicKeyCredentialRequest, + ): BeginCreateCredentialResponse? /** - * Process the [BeginGetCredentialRequest] and invoke the [callback] with the result. + * Process the [BeginGetCredentialRequest] and returns the result. * - * @param request The request data form the OS that contains data about the requesting provider. - * @param cancellationSignal signal for observing cancellation requests. The system will use - * this to notify us that the result is no longer needed and we should stop handling it in order - * to save our resources. - * @param callback the callback object to be used to notify the response or error + * @param requestCode The requestCode to be used for pending intents. + * @param activeUserId The id of the currently active user. + * @param beginGetCredentialOptions The request data from the OS that contains data about the requesting provider. */ - fun processGetCredentialRequest( - request: BeginGetCredentialRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver, - ) + suspend fun processGetCredentialRequest( + requestCode: AtomicInteger, + activeUserId: String, + beginGetCredentialOptions: List, + ): List? - /** - * Process the [ProviderClearCredentialStateRequest] and invoke the [callback] with the result. - * - * @param request The request data form the OS that contains data about the requesting provider. - * @param cancellationSignal signal for observing cancellation requests. The system will use - * this to notify us that the result is no longer needed and we should stop handling it in order - * to save our resources. - * @param callback the callback object to be used to notify the response or error - */ - fun processClearCredentialStateRequest( - request: ProviderClearCredentialStateRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver, - ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt index b3c27c024c5..e0f2945bd6b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt @@ -2,48 +2,28 @@ package com.x8bit.bitwarden.data.autofill.fido2.processor import android.content.Context import android.os.Build -import android.os.CancellationSignal -import android.os.OutcomeReceiver import androidx.annotation.RequiresApi -import androidx.credentials.exceptions.ClearCredentialException -import androidx.credentials.exceptions.ClearCredentialUnsupportedException -import androidx.credentials.exceptions.CreateCredentialCancellationException -import androidx.credentials.exceptions.CreateCredentialException -import androidx.credentials.exceptions.CreateCredentialUnknownException -import androidx.credentials.exceptions.GetCredentialCancellationException -import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.exceptions.GetCredentialUnsupportedException -import androidx.credentials.provider.AuthenticationAction -import androidx.credentials.provider.BeginCreateCredentialRequest import androidx.credentials.provider.BeginCreateCredentialResponse import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest -import androidx.credentials.provider.BeginGetCredentialRequest -import androidx.credentials.provider.BeginGetCredentialResponse import androidx.credentials.provider.BeginGetPublicKeyCredentialOption import androidx.credentials.provider.CreateEntry import androidx.credentials.provider.CredentialEntry -import androidx.credentials.provider.ProviderClearCredentialStateRequest import androidx.credentials.provider.PublicKeyCredentialEntry import com.bitwarden.fido.Fido2CredentialAutofillView -import com.bitwarden.sdk.Fido2CredentialStore import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials -import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import java.time.Clock import java.util.concurrent.atomic.AtomicInteger private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" const val GET_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY" -const val UNLOCK_ACCOUNT_INTENT = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT" /** * The default implementation of [Fido2ProviderProcessor]. Its purpose is to handle FIDO2 related @@ -53,55 +33,27 @@ const val UNLOCK_ACCOUNT_INTENT = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOU @RequiresApi(Build.VERSION_CODES.S) class Fido2ProviderProcessorImpl( private val context: Context, - private val authRepository: AuthRepository, private val vaultRepository: VaultRepository, - private val fido2CredentialStore: Fido2CredentialStore, private val fido2CredentialManager: Fido2CredentialManager, private val intentManager: IntentManager, private val clock: Clock, - dispatcherManager: DispatcherManager, ) : Fido2ProviderProcessor { - private val requestCode = AtomicInteger() - private val scope = CoroutineScope(dispatcherManager.unconfined) - - override fun processCreateCredentialRequest( - request: BeginCreateCredentialRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver, - ) { - val userId = authRepository.activeUserId - if (userId == null) { - callback.onError(CreateCredentialUnknownException("Active user is required.")) - return - } - - val createCredentialJob = scope.launch { - processCreateCredentialRequest(request = request) - ?.let { callback.onResult(it) } - ?: callback.onError(CreateCredentialUnknownException()) - } - cancellationSignal.setOnCancelListener { - if (createCredentialJob.isActive) { - createCredentialJob.cancel() - } - callback.onError(CreateCredentialCancellationException()) - } - } - - private fun processCreateCredentialRequest( - request: BeginCreateCredentialRequest, + override suspend fun processCreateCredentialRequest( + requestCode: AtomicInteger, + userState: UserState, + request: BeginCreatePublicKeyCredentialRequest, ): BeginCreateCredentialResponse? { - return when (request) { - is BeginCreatePublicKeyCredentialRequest -> { - handleCreatePasskeyQuery(request) - } - - else -> null - } + return handleCreatePasskeyQuery( + requestCode = requestCode, + userState = userState, + request = request, + ) } private fun handleCreatePasskeyQuery( + requestCode: AtomicInteger, + userState: UserState, request: BeginCreatePublicKeyCredentialRequest, ): BeginCreateCredentialResponse? { val requestJson = request @@ -110,17 +62,30 @@ class Fido2ProviderProcessorImpl( if (requestJson.isNullOrEmpty()) return null - val userState = authRepository.userStateFlow.value ?: return null - return BeginCreateCredentialResponse.Builder() - .setCreateEntries(userState.accounts.toCreateEntries(userState.activeUserId)) + .setCreateEntries( + userState.accounts.toCreateEntries( + activeUserId = userState.activeUserId, + requestCode = requestCode, + ) + ) .build() } - private fun List.toCreateEntries(activeUserId: String) = - map { it.toCreateEntry(isActive = activeUserId == it.userId) } + private fun List.toCreateEntries( + activeUserId: String, + requestCode: AtomicInteger, + ) = map { + it.toCreateEntry( + isActive = activeUserId == it.userId, + requestCode = requestCode, + ) + } - private fun UserState.Account.toCreateEntry(isActive: Boolean): CreateEntry { + private fun UserState.Account.toCreateEntry( + isActive: Boolean, + requestCode: AtomicInteger, + ): CreateEntry { val accountName = name ?: email return CreateEntry .Builder( @@ -143,80 +108,35 @@ class Fido2ProviderProcessorImpl( .build() } - override fun processGetCredentialRequest( - request: BeginGetCredentialRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver, - ) { - // If the user is not logged in, return an error. - val userState = authRepository.userStateFlow.value - if (userState == null) { - callback.onError(GetCredentialUnknownException("Active user is required.")) - return - } - - // Return an unlock action if the current account is locked. - if (!userState.activeAccount.isVaultUnlocked) { - val authenticationAction = AuthenticationAction( - title = context.getString(R.string.unlock), - pendingIntent = intentManager.createFido2UnlockPendingIntent( - action = UNLOCK_ACCOUNT_INTENT, - userId = userState.activeUserId, - requestCode = requestCode.getAndIncrement(), - ), - ) - - callback.onResult( - BeginGetCredentialResponse( - authenticationActions = listOf(authenticationAction), - ), - ) - return - } - - // Otherwise, find all matching credentials from the current vault. - val getCredentialJob = scope.launch { - try { - val credentialEntries = getMatchingFido2CredentialEntries( - userId = userState.activeUserId, - request = request, - ) - - callback.onResult( - BeginGetCredentialResponse( - credentialEntries = credentialEntries, - ), - ) - } catch (e: GetCredentialException) { - callback.onError(e) - } - } - cancellationSignal.setOnCancelListener { - callback.onError(GetCredentialCancellationException()) - getCredentialJob.cancel() - } + override suspend fun processGetCredentialRequest( + requestCode: AtomicInteger, + activeUserId: String, + beginGetCredentialOptions: List, + ): List { + return getMatchingFido2CredentialEntries( + requestCode = requestCode, + userId = activeUserId, + beginGetCredentialOptions = beginGetCredentialOptions, + ) } @Throws(GetCredentialUnsupportedException::class) private suspend fun getMatchingFido2CredentialEntries( + requestCode: AtomicInteger, userId: String, - request: BeginGetCredentialRequest, + beginGetCredentialOptions: List, ): List = - request - .beginGetCredentialOptions + beginGetCredentialOptions .flatMap { option -> - if (option is BeginGetPublicKeyCredentialOption) { - val relyingPartyId = fido2CredentialManager - .getPasskeyAssertionOptionsOrNull(requestJson = option.requestJson) - ?.relyingPartyId - ?: throw GetCredentialUnknownException("Invalid data.") - buildCredentialEntries(userId, relyingPartyId, option) - } else { - throw GetCredentialUnsupportedException("Unsupported option.") - } + val relyingPartyId = fido2CredentialManager + .getPasskeyAssertionOptionsOrNull(requestJson = option.requestJson) + ?.relyingPartyId + ?: throw GetCredentialUnknownException("Invalid data.") + buildCredentialEntries(requestCode, userId, relyingPartyId, option) } private suspend fun buildCredentialEntries( + requestCode: AtomicInteger, userId: String, relyingPartyId: String, option: BeginGetPublicKeyCredentialOption, @@ -239,6 +159,7 @@ class Fido2ProviderProcessorImpl( .fido2CredentialAutofillViews .filter { it.rpId == relyingPartyId } .toCredentialEntries( + requestCode = requestCode, userId = userId, option = option, ) @@ -247,6 +168,7 @@ class Fido2ProviderProcessorImpl( } private fun List.toCredentialEntries( + requestCode: AtomicInteger, userId: String, option: BeginGetPublicKeyCredentialOption, ): List = @@ -269,12 +191,4 @@ class Fido2ProviderProcessorImpl( .build() } - override fun processClearCredentialStateRequest( - request: ProviderClearCredentialStateRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver, - ) { - // no-op: RFU - callback.onError(ClearCredentialUnsupportedException()) - } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/di/PasswordProviderModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/di/PasswordProviderModule.kt new file mode 100644 index 00000000000..948092443d1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/di/PasswordProviderModule.kt @@ -0,0 +1,41 @@ +package com.x8bit.bitwarden.data.autofill.password.di + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import com.x8bit.bitwarden.data.autofill.password.processor.PasswordProviderProcessor +import com.x8bit.bitwarden.data.autofill.password.processor.PasswordProviderProcessorImpl +import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.time.Clock +import javax.inject.Singleton + +/** + * Provides dependencies within the password package. + */ +@Module +@InstallIn(SingletonComponent::class) +object PasswordProviderModule { + + @RequiresApi(Build.VERSION_CODES.S) + @Provides + @Singleton + fun providePasswordCredentialProviderProcessor( + @ApplicationContext context: Context, + autofillCipherProvider: AutofillCipherProvider, + intentManager: IntentManager, + clock: Clock, + ): PasswordProviderProcessor = + PasswordProviderProcessorImpl( + context, + autofillCipherProvider, + intentManager, + clock, + ) + +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordCredentialAssertionRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordCredentialAssertionRequest.kt new file mode 100644 index 00000000000..27b3132a3ad --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordCredentialAssertionRequest.kt @@ -0,0 +1,24 @@ +package com.x8bit.bitwarden.data.autofill.password.model + +import android.content.pm.SigningInfo +import android.os.Bundle +import android.os.Parcelable +import androidx.credentials.provider.CallingAppInfo +import kotlinx.parcelize.Parcelize + +/** + * Models a Password credential authentication request parsed from the launching intent. + */ +@Parcelize +data class PasswordCredentialAssertionRequest( + val candidateQueryData: Bundle, + val userId: String, + val cipherId: String, + val allowedUserIds: Set, + val packageName: String, + val signingInfo: SigningInfo, + val origin: String?, +) : Parcelable { + val callingAppInfo: CallingAppInfo + get() = CallingAppInfo(packageName, signingInfo, origin) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordCredentialAssertionResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordCredentialAssertionResult.kt new file mode 100644 index 00000000000..8d2a88804f9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordCredentialAssertionResult.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.autofill.password.model + +import com.bitwarden.vault.LoginView + +/** + * Represents possible outcomes of a Password credential assertion request. + */ +sealed class PasswordCredentialAssertionResult { + + /** + * Indicates the assertion request completed and [credential] was successfully generated. + */ + data class Success(val credential: LoginView) : PasswordCredentialAssertionResult() + + /** + * Indicates there was an error and the assertion was not successful. + */ + data object Error : PasswordCredentialAssertionResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordCredentialRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordCredentialRequest.kt new file mode 100644 index 00000000000..ad40626ef3e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordCredentialRequest.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.data.autofill.password.model + +import android.content.pm.SigningInfo +import android.os.Parcelable +import androidx.credentials.provider.CallingAppInfo +import kotlinx.parcelize.Parcelize + +/** + * Represents raw data from the a user deciding to create a password in their vault via the + * credential manager framework. + * + * @property userId The user under which the password should be saved. + * @property userName containing the username from the request. + * @property password containing the password from the request. + * @property callingAppInfo Information about the application that initiated the request. + */ +@Parcelize +data class PasswordCredentialRequest( + val userId: String, + val userName: String, + val password: String, + val packageName: String, + val signingInfo: SigningInfo, + val origin: String?, +) : Parcelable { + val callingAppInfo: CallingAppInfo + get() = CallingAppInfo( + packageName = packageName, + signingInfo = signingInfo, + origin = origin, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordGetCredentialsRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordGetCredentialsRequest.kt new file mode 100644 index 00000000000..747ac8c382d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordGetCredentialsRequest.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.data.autofill.password.model + +import android.content.pm.SigningInfo +import android.os.Bundle +import android.os.Parcelable +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.CallingAppInfo +import kotlinx.parcelize.Parcelize + +/** + * Models a Password request to retrieve Password credentials parsed from the launching intent. + */ +@Parcelize +data class PasswordGetCredentialsRequest( + val candidateQueryData: Bundle, + val userId: String, + val id: String, + val allowedUserIds: Set, + val packageName: String, + val signingInfo: SigningInfo, + val origin: String?, +) : Parcelable { + val callingAppInfo: CallingAppInfo + get() = CallingAppInfo(packageName, signingInfo, origin) + + val option: BeginGetPasswordOption + get() = BeginGetPasswordOption( + allowedUserIds, + candidateQueryData, + id, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordGetCredentialsResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordGetCredentialsResult.kt new file mode 100644 index 00000000000..9c97329e1cd --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordGetCredentialsResult.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.data.autofill.password.model + +import androidx.credentials.provider.CredentialEntry + +/** + * Represents the result of a Password Get Credentials request. + */ +sealed class PasswordGetCredentialsResult { + /** + * Indicates credentials were successfully queried. + */ + data class Success( + val credentials: List, + ) : PasswordGetCredentialsResult() + + /** + * Indicates an error was encountered when querying for matching credentials. + */ + data object Error : PasswordGetCredentialsResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordRegisterCredentialResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordRegisterCredentialResult.kt new file mode 100644 index 00000000000..6343141a205 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/model/PasswordRegisterCredentialResult.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.autofill.password.model + +/** + * Models the data returned from creating a Password credential. + */ +sealed class PasswordRegisterCredentialResult { + + /** + * Indicates the credential has been successfully registered. + */ + data object Success : PasswordRegisterCredentialResult() + + /** + * Indicates there was an error and the credential was not registered. + */ + data object Error : PasswordRegisterCredentialResult() + +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/processor/PasswordProviderProcessor.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/processor/PasswordProviderProcessor.kt new file mode 100644 index 00000000000..24522799ed4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/processor/PasswordProviderProcessor.kt @@ -0,0 +1,46 @@ +package com.x8bit.bitwarden.data.autofill.password.processor + +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePasswordCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.CredentialEntry +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import java.util.concurrent.atomic.AtomicInteger + +/** + * A class to handle Password credential request processing. This includes save and autofill requests. + */ +interface PasswordProviderProcessor { + + /** + * Process the [BeginCreateCredentialRequest] and returns the result. + * + * @param requestCode The requestCode to be used for pending intents. + * @param userState The userState of the currently active user. + * @param request The request data from the OS that contains data about the requesting provider. + */ + suspend fun processCreateCredentialRequest( + requestCode: AtomicInteger, + userState: UserState, + request: BeginCreatePasswordCredentialRequest, + ): BeginCreateCredentialResponse + + /** + * Process the [BeginGetCredentialRequest] and returns the result. + * + * @param requestCode The requestCode to be used for pending intents. + * @param activeUserId The id of the currently active user. + * @param callingAppInfo The info of the callingAppInfo because it's not present in [BeginGetPasswordOption]. + * @param beginGetPasswordOptions The request data from the OS that contains data about the requesting provider. + */ + suspend fun processGetCredentialRequest( + requestCode: AtomicInteger, + activeUserId: String, + callingAppInfo: CallingAppInfo?, + beginGetPasswordOptions: List, + ): List? + +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/processor/PasswordProviderProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/processor/PasswordProviderProcessorImpl.kt new file mode 100644 index 00000000000..fee8e861c8a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/processor/PasswordProviderProcessorImpl.kt @@ -0,0 +1,165 @@ +package com.x8bit.bitwarden.data.autofill.password.processor + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.GetCredentialUnsupportedException +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePasswordCredentialRequest +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.CredentialEntry +import androidx.credentials.provider.PasswordCredentialEntry +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.autofill.model.AutofillCipher +import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider +import com.x8bit.bitwarden.ui.platform.base.util.toAndroidAppUriString +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import java.time.Clock +import java.util.concurrent.atomic.AtomicInteger + +private const val CREATE_PASSWORD_INTENT = + "com.x8bit.bitwarden.data.autofill.password.ACTION_CREATE_PASSWORD" +const val GET_PASSWORD_INTENT = "com.x8bit.bitwarden.data.autofill.password.ACTION_GET_PASSWORD" + +/** + * The default implementation of [PasswordProviderProcessor]. Its purpose is to handle Password related + * processing. + */ +@RequiresApi(Build.VERSION_CODES.S) +class PasswordProviderProcessorImpl( + private val context: Context, + private val autofillCipherProvider: AutofillCipherProvider, + private val intentManager: IntentManager, + private val clock: Clock, +) : PasswordProviderProcessor { + + override suspend fun processCreateCredentialRequest( + requestCode: AtomicInteger, + userState: UserState, + request: BeginCreatePasswordCredentialRequest, + ): BeginCreateCredentialResponse { + return BeginCreateCredentialResponse.Builder() + .setCreateEntries( + userState.accounts.toCreateEntries( + requestCode = requestCode, + activeUserId = userState.activeUserId + ) + ) + .build() + } + + private fun List.toCreateEntries( + requestCode: AtomicInteger, + activeUserId: String, + ) = map { + it.toCreateEntry( + requestCode = requestCode, + isActive = activeUserId == it.userId, + ) + } + + private fun UserState.Account.toCreateEntry( + requestCode: AtomicInteger, + isActive: Boolean, + ): CreateEntry { + val accountName = name ?: email + return CreateEntry + .Builder( + accountName = accountName, + pendingIntent = intentManager.createPasswordCreationPendingIntent( + CREATE_PASSWORD_INTENT, + userId, + requestCode.getAndIncrement(), + ), + ) + .setDescription( + context.getString( + R.string.your_username_and_password_will_be_saved_to_your_bitwarden_vault_for_x, + accountName, + ), + ) + // Set the last used time to "now" so the active account is the default option in the + // system prompt. + .setLastUsedTime(if (isActive) clock.instant() else null) + .build() + } + + override suspend fun processGetCredentialRequest( + requestCode: AtomicInteger, + activeUserId: String, + callingAppInfo: CallingAppInfo?, + beginGetPasswordOptions: List, + ): List { + return getMatchingPasswordCredentialEntries( + requestCode = requestCode, + userId = activeUserId, + callingAppInfo = callingAppInfo, + beginGetPasswordOptions = beginGetPasswordOptions, + ) + } + + @Throws(GetCredentialUnsupportedException::class) + private suspend fun getMatchingPasswordCredentialEntries( + requestCode: AtomicInteger, + userId: String, + callingAppInfo: CallingAppInfo?, + beginGetPasswordOptions: List, + ): List = + beginGetPasswordOptions.flatMap { option -> + if (option.allowedUserIds.isEmpty() || option.allowedUserIds.contains(userId)) { + buildCredentialEntries( + requestCode = requestCode, + userId = userId, + matchUri = callingAppInfo?.origin + ?: callingAppInfo?.packageName + ?.toAndroidAppUriString(), + option = option, + ) + } else { + //userid did not match any in allowedUserIds + emptySet() + } + } + + private suspend fun buildCredentialEntries( + requestCode: AtomicInteger, + userId: String, + matchUri: String?, + option: BeginGetPasswordOption, + ): List { + return autofillCipherProvider.getLoginAutofillCiphers( + uri = matchUri ?: return emptyList(), + ).toCredentialEntries( + requestCode = requestCode, + userId = userId, + option = option, + ) + } + + private fun List.toCredentialEntries( + requestCode: AtomicInteger, + userId: String, + option: BeginGetPasswordOption, + ): List = + this + .mapNotNull { + PasswordCredentialEntry + .Builder( + context = context, + username = it.username, + pendingIntent = intentManager + .createPasswordGetCredentialPendingIntent( + action = GET_PASSWORD_INTENT, + userId = userId, + cipherId = it.cipherId ?: return@mapNotNull null, + requestCode = requestCode.getAndIncrement(), + ), + beginGetPasswordOption = option, + ) + .build() + } + +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/util/PasswordIntentUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/util/PasswordIntentUtils.kt new file mode 100644 index 00000000000..87d76e9983f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/password/util/PasswordIntentUtils.kt @@ -0,0 +1,112 @@ +package com.x8bit.bitwarden.data.autofill.password.util + +import android.content.Intent +import android.os.Build +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.PendingIntentHandler +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsRequest +import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow +import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CIPHER_ID +import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_USER_ID + +/** + * Checks if this [Intent] contains a [PasswordCredentialRequest] related to an ongoing Password + * credential creation process. + */ +fun Intent.getPasswordCredentialRequestOrNull(): PasswordCredentialRequest? { + if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null + + val systemRequest = PendingIntentHandler + .retrieveProviderCreateCredentialRequest(this) + ?: return null + + val createPublicKeyRequest = systemRequest + .callingRequest + as? CreatePasswordRequest + ?: return null + + val userId = getStringExtra(EXTRA_KEY_USER_ID) + ?: return null + + return PasswordCredentialRequest( + userId = userId, + userName = createPublicKeyRequest.id, + password = createPublicKeyRequest.password, + packageName = systemRequest.callingAppInfo.packageName, + signingInfo = systemRequest.callingAppInfo.signingInfo, + origin = systemRequest.callingAppInfo.origin, + ) +} + +/** + * Checks if this [Intent] contains a [PasswordCredentialAssertionRequest] related to an ongoing Password + * credential authentication process. + */ +fun Intent.getPasswordAssertionRequestOrNull(): PasswordCredentialAssertionRequest? { + if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null + + val systemRequest = PendingIntentHandler + .retrieveProviderGetCredentialRequest(this) + ?: return null + + val option: GetPasswordOption = systemRequest + .credentialOptions + .firstNotNullOfOrNull { it as? GetPasswordOption } + ?: return null + + val callingAppInfo = systemRequest + .callingAppInfo + + val cipherId = getStringExtra(EXTRA_KEY_CIPHER_ID) + ?: return null + + val userId: String = getStringExtra(EXTRA_KEY_USER_ID) + ?: return null + + return PasswordCredentialAssertionRequest( + candidateQueryData = option.candidateQueryData, + userId = userId, + cipherId = cipherId, + allowedUserIds = option.allowedUserIds, + packageName = callingAppInfo.packageName, + signingInfo = callingAppInfo.signingInfo, + origin = callingAppInfo.origin, + ) +} + +/** + * Checks if this [Intent] contains a [PasswordGetCredentialsRequest] related to an ongoing Password + * credential lookup process. + */ +fun Intent.getPasswordGetCredentialsRequestOrNull(): PasswordGetCredentialsRequest? { + if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null + + val systemRequest = PendingIntentHandler + .retrieveBeginGetCredentialRequest(this) + ?: return null + + val option: BeginGetPasswordOption = systemRequest + .beginGetCredentialOptions + .firstNotNullOfOrNull { it as? BeginGetPasswordOption } + ?: return null + + val callingAppInfo = systemRequest + .callingAppInfo ?: return null + + val userId: String = getStringExtra(EXTRA_KEY_USER_ID) + ?: return null + + return PasswordGetCredentialsRequest( + candidateQueryData = option.candidateQueryData, + userId = userId, + id = option.id, + allowedUserIds = option.allowedUserIds, + packageName = callingAppInfo.packageName, + signingInfo = callingAppInfo.signingInfo, + origin = callingAppInfo.origin, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt index db5c0d197ca..41658cad45d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt @@ -45,6 +45,24 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider = } } +/** + * Returns true when the cipher is not deleted and contains a username. + */ +val CipherView.isActiveWithUsernameCredentials: Boolean + get() = deletedDate == null && !(login?.username.isNullOrEmpty()) + +/** + * Returns true when the cipher is not deleted and contains a password. + */ +val CipherView.isActiveWithPasswordCredentials: Boolean + get() = deletedDate == null && !(login?.password.isNullOrEmpty()) + +/** + * Returns true when the cipher is not deleted and contains a username or password. + */ +val CipherView.isActiveWithUsernameAndPasswordCredentials: Boolean + get() = deletedDate == null && !(login?.password.isNullOrEmpty()) && !(login?.username.isNullOrEmpty()) + /** * Returns true when the cipher is not deleted and contains at least one FIDO 2 credential. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt index 969115a43a4..57bdaa0476d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt @@ -6,6 +6,9 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsRequest import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.vault.model.TotpData import kotlinx.parcelize.Parcelize @@ -59,6 +62,16 @@ sealed class SpecialCircumstance : Parcelable { val shouldFinishWhenComplete: Boolean, ) : SpecialCircumstance() + /** + * The app was launched via the credential manager framework request to retrieve passkeys or passwords + * associated with the requesting entity. + */ + @Parcelize + data class GetCredentials( + val fido2GetCredentialsRequest: Fido2GetCredentialsRequest?, + val passwordGetCredentialsRequest: PasswordGetCredentialsRequest?, + ) : SpecialCircumstance() + /** * The app was launched via the credential manager framework in order to allow the user to * manually save a passkey to their vault. @@ -78,12 +91,21 @@ sealed class SpecialCircumstance : Parcelable { ) : SpecialCircumstance() /** - * The app was launched via the credential manager framework request to retrieve passkeys - * associated with the requesting entity. + * The app was launched via the credential manager framework in order to allow the user to + * manually save a password to their vault. + */ + @Parcelize + data class PasswordSave( + val passwordCredentialRequest: PasswordCredentialRequest, + ) : SpecialCircumstance() + + /** + * The app was launched via the credential manager framework in order to authenticate a Password + * credential saved to the user's vault. */ @Parcelize - data class Fido2GetCredentials( - val fido2GetCredentialsRequest: Fido2GetCredentialsRequest, + data class PasswordAssertion( + val passwordAssertionRequest: PasswordCredentialAssertionRequest, ) : SpecialCircumstance() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt index 0e17e2b3abd..29f69c00799 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt @@ -5,6 +5,9 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsRequest import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.ui.vault.model.TotpData @@ -49,7 +52,34 @@ fun SpecialCircumstance.toFido2AssertionRequestOrNull(): Fido2CredentialAssertio */ fun SpecialCircumstance.toFido2GetCredentialsRequestOrNull(): Fido2GetCredentialsRequest? = when (this) { - is SpecialCircumstance.Fido2GetCredentials -> this.fido2GetCredentialsRequest + is SpecialCircumstance.GetCredentials -> this.fido2GetCredentialsRequest + else -> null + } + +/** + * Returns [PasswordCredentialRequest] when contained in the given [SpecialCircumstance]. + */ +fun SpecialCircumstance.toPasswordCredentialsRequestOrNull(): PasswordCredentialRequest? = + when (this) { + is SpecialCircumstance.PasswordSave -> this.passwordCredentialRequest + else -> null + } + +/** + * Returns [PasswordCredentialAssertionRequest] when contained in the given [SpecialCircumstance]. + */ +fun SpecialCircumstance.toPasswordAssertionRequestOrNull(): PasswordCredentialAssertionRequest? = + when (this) { + is SpecialCircumstance.PasswordAssertion -> this.passwordAssertionRequest + else -> null + } + +/** + * Returns [PasswordGetCredentialsRequest] when contained in the given [SpecialCircumstance]. + */ +fun SpecialCircumstance.toPasswordGetCredentialsRequestOrNull(): PasswordGetCredentialsRequest? = + when (this) { + is SpecialCircumstance.GetCredentials -> this.passwordGetCredentialsRequest else -> null } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt index d2fea85baf7..0da3e07e796 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt @@ -38,13 +38,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsResult import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.inputFieldVisibilityToggleTestTag import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenInputLabel import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenInputTestTag import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenKeyboardType import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenMessage import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenTitle -import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager +import com.x8bit.bitwarden.ui.autofill.credential.manager.CredentialCompletionManager import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem @@ -62,7 +64,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager -import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager +import com.x8bit.bitwarden.ui.platform.composition.LocalCredentialCompletionManager import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import kotlinx.collections.immutable.persistentListOf @@ -79,7 +81,7 @@ fun VaultUnlockScreen( viewModel: VaultUnlockViewModel = hiltViewModel(), biometricsManager: BiometricsManager = LocalBiometricsManager.current, focusManager: FocusManager = LocalFocusManager.current, - fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current, + credentialCompletionManager: CredentialCompletionManager = LocalCredentialCompletionManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current @@ -113,14 +115,28 @@ fun VaultUnlockScreen( } VaultUnlockEvent.Fido2CredentialAssertionError -> { - fido2CompletionManager.completeFido2Assertion( + credentialCompletionManager.completeFido2Assertion( result = Fido2CredentialAssertionResult.Error, ) } VaultUnlockEvent.Fido2GetCredentialsError -> { - fido2CompletionManager.completeFido2GetCredentialRequest( - result = Fido2GetCredentialsResult.Error, + credentialCompletionManager.completeGetCredentialRequest( + fido2Result = Fido2GetCredentialsResult.Error, + passwordResult = null, + ) + } + + VaultUnlockEvent.PasswordCredentialAssertionError -> { + credentialCompletionManager.completePasswordAssertion( + result = PasswordCredentialAssertionResult.Error, + ) + } + + VaultUnlockEvent.PasswordGetCredentialsError -> { + credentialCompletionManager.completeGetCredentialRequest( + fido2Result = null, + passwordResult = PasswordGetCredentialsResult.Error, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index aefc42572fe..a76b7bbbdc6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -11,6 +11,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsRequest import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance @@ -74,7 +76,7 @@ class VaultUnlockViewModel @Inject constructor( val specialCircumstance = specialCircumstanceManager.specialCircumstance val showAccountMenu = VaultUnlockArgs(savedStateHandle).unlockType == UnlockType.STANDARD && - (specialCircumstance !is SpecialCircumstance.Fido2GetCredentials && + (specialCircumstance !is SpecialCircumstance.GetCredentials && specialCircumstance !is SpecialCircumstance.Fido2Assertion) VaultUnlockState( accountSummaries = accountSummaries, @@ -91,6 +93,10 @@ class VaultUnlockViewModel @Inject constructor( showBiometricInvalidatedMessage = false, vaultUnlockType = vaultUnlockType, userId = userState.activeUserId, + // TODO: [PM-13075] Handle PasswordGetCredentialsRequest special circumstance + passwordGetCredentialsRequest = null, + // TODO: [PM-13075] Handle PasswordCredentialAssertionRequest special circumstance + passwordCredentialAssertionRequest = null, // TODO: [PM-13075] Handle Fido2GetCredentialsRequest special circumstance fido2GetCredentialsRequest = null, // TODO: [PM-13076] Handle Fido2CredentialAssertionRequest special circumstance @@ -156,6 +162,14 @@ class VaultUnlockViewModel @Inject constructor( sendEvent(VaultUnlockEvent.Fido2CredentialAssertionError) } + state.passwordGetCredentialsRequest != null -> { + sendEvent(VaultUnlockEvent.PasswordGetCredentialsError) + } + + state.passwordCredentialAssertionRequest != null -> { + sendEvent(VaultUnlockEvent.PasswordCredentialAssertionError) + } + else -> Unit } } @@ -403,6 +417,8 @@ data class VaultUnlockState( val userId: String, val fido2GetCredentialsRequest: Fido2GetCredentialsRequest? = null, val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null, + val passwordGetCredentialsRequest: PasswordGetCredentialsRequest? = null, + val passwordCredentialAssertionRequest: PasswordCredentialAssertionRequest? = null, ) : Parcelable { /** @@ -420,13 +436,6 @@ data class VaultUnlockState( */ val showKeyboard: Boolean get() = !showBiometricLogin && !hideInput - /** - * Indicates if the vault is being unlocked as a result of receiving a FIDO 2 request. - */ - val isUnlockingForFido2Request: Boolean - get() = fido2GetCredentialsRequest != null || - fido2CredentialAssertionRequest != null - /** * Returns the user ID present in the current FIDO 2 request, or null when no FIDO 2 request is * present. @@ -480,6 +489,16 @@ sealed class VaultUnlockEvent { * Completes the FIDO2 credential assertion request with an error response. */ data object Fido2CredentialAssertionError : VaultUnlockEvent() + + /** + * Completes the Password get credentials request with an error response. + */ + data object PasswordGetCredentialsError : VaultUnlockEvent() + + /** + * Completes the Password credential assertion request with an error response. + */ + data object PasswordCredentialAssertionError : VaultUnlockEvent() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/credential/manager/CredentialCompletionManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/credential/manager/CredentialCompletionManager.kt new file mode 100644 index 00000000000..a09302b7b13 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/credential/manager/CredentialCompletionManager.kt @@ -0,0 +1,42 @@ +package com.x8bit.bitwarden.ui.autofill.credential.manager + +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordRegisterCredentialResult + +/** + * A manager for completing the FIDO 2 creation process. + */ +interface CredentialCompletionManager { + + /** + * Completes the FIDO 2 registration process with the provided [result]. + */ + fun completeFido2Registration(result: Fido2RegisterCredentialResult) + + /** + * Complete the FIDO 2 credential assertion process with the provided [result]. + */ + fun completeFido2Assertion(result: Fido2CredentialAssertionResult) + + /** + * Completes the Password registration process with the provided [result]. + */ + fun completePasswordRegistration(result: PasswordRegisterCredentialResult) + + /** + * Completes the Password registration process with the provided [result]. + */ + fun completePasswordAssertion(result: PasswordCredentialAssertionResult) + + /** + * Complete the FIDO 2 and/or Password "Get credentials" process with the provided [fido2Result] and or [passwordResult]. + */ + fun completeGetCredentialRequest( + fido2Result: Fido2GetCredentialsResult?, + passwordResult: PasswordGetCredentialsResult?, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/credential/manager/CredentialCompletionManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/credential/manager/CredentialCompletionManagerImpl.kt new file mode 100644 index 00000000000..59921402d6d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/credential/manager/CredentialCompletionManagerImpl.kt @@ -0,0 +1,198 @@ +package com.x8bit.bitwarden.ui.autofill.credential.manager + +import android.app.Activity +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.credentials.CreatePasswordResponse +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PasswordCredential +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.CredentialEntry +import androidx.credentials.provider.PendingIntentHandler +import com.x8bit.bitwarden.data.autofill.credential.model.getCredentialResponseAction +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordRegisterCredentialResult + +/** + * Primary implementation of [CredentialCompletionManager] when the build version is + * UPSIDE_DOWN_CAKE (34) or above. + */ +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class CredentialCompletionManagerImpl( + private val activity: Activity, +) : CredentialCompletionManager { + + override fun completeFido2Registration(result: Fido2RegisterCredentialResult) { + activity.also { + val intent = Intent() + when (result) { + is Fido2RegisterCredentialResult.Error -> { + PendingIntentHandler + .setCreateCredentialException( + intent = intent, + exception = CreateCredentialUnknownException(), + ) + } + + is Fido2RegisterCredentialResult.Success -> { + PendingIntentHandler + .setCreateCredentialResponse( + intent = intent, + response = CreatePublicKeyCredentialResponse( + registrationResponseJson = result.registrationResponse, + ), + ) + } + + is Fido2RegisterCredentialResult.Cancelled -> { + PendingIntentHandler + .setCreateCredentialException( + intent = intent, + exception = CreateCredentialCancellationException(), + ) + } + } + it.setResult(Activity.RESULT_OK, intent) + it.finish() + } + } + + override fun completeFido2Assertion(result: Fido2CredentialAssertionResult) { + activity.also { + val intent = Intent() + when (result) { + Fido2CredentialAssertionResult.Error -> { + PendingIntentHandler + .setGetCredentialException( + intent = intent, + exception = GetCredentialUnknownException(), + ) + } + + is Fido2CredentialAssertionResult.Success -> { + PendingIntentHandler + .setGetCredentialResponse( + intent = intent, + response = GetCredentialResponse( + credential = PublicKeyCredential(result.responseJson), + ), + ) + } + } + it.setResult(Activity.RESULT_OK, intent) + it.finish() + } + } + + override fun completePasswordRegistration(result: PasswordRegisterCredentialResult) { + activity.also { + val intent = Intent() + when (result) { + is PasswordRegisterCredentialResult.Error -> { + PendingIntentHandler + .setCreateCredentialException( + intent = intent, + exception = CreateCredentialUnknownException(), + ) + } + + is PasswordRegisterCredentialResult.Success -> { + PendingIntentHandler + .setCreateCredentialResponse( + intent = intent, + response = CreatePasswordResponse(), + ) + } + } + it.setResult(Activity.RESULT_OK, intent) + it.finish() + } + } + + override fun completePasswordAssertion(result: PasswordCredentialAssertionResult) { + activity.also { + val intent = Intent() + when (result) { + PasswordCredentialAssertionResult.Error -> { + PendingIntentHandler + .setGetCredentialException( + intent = intent, + exception = GetCredentialUnknownException(), + ) + } + + is PasswordCredentialAssertionResult.Success -> { + PendingIntentHandler + .setGetCredentialResponse( + intent = intent, + response = GetCredentialResponse( + credential = PasswordCredential( + id = result.credential.username ?: "", + password = result.credential.password ?: "", + ), + ), + ) + } + } + it.setResult(Activity.RESULT_OK, intent) + it.finish() + } + } + + override fun completeGetCredentialRequest( + fido2Result: Fido2GetCredentialsResult?, + passwordResult: PasswordGetCredentialsResult?, + ) { + val resultIntent = Intent() + val responseBuilder = BeginGetCredentialResponse.Builder() + val fido2Entries: List = when (fido2Result) { + is Fido2GetCredentialsResult.Success -> fido2Result.credentials + + Fido2GetCredentialsResult.Error, + null, + -> emptyList() + } + + val passwordEntries: List = when (passwordResult) { + is PasswordGetCredentialsResult.Success -> passwordResult.credentials + + PasswordGetCredentialsResult.Error, + null, + -> emptyList() + } + + val entries: List = fido2Entries.plus(passwordEntries) + + if (entries.isEmpty()) { + PendingIntentHandler.setGetCredentialException( + resultIntent, + GetCredentialUnknownException(), + ) + } else { + PendingIntentHandler + .setBeginGetCredentialResponse( + resultIntent, + responseBuilder + .setCredentialEntries(entries) + // Explicitly clear any pending authentication actions since we only + // display results from the active account. + .setAuthenticationActions(emptyList()) + .addAction(getCredentialResponseAction(activity)) + .build(), + ) + } + + activity.setResult(Activity.RESULT_OK, resultIntent) + activity.finish() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/credential/manager/CredentialCompletionManagerUnsupportedApiImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/credential/manager/CredentialCompletionManagerUnsupportedApiImpl.kt new file mode 100644 index 00000000000..8d0a80edfe0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/credential/manager/CredentialCompletionManagerUnsupportedApiImpl.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.ui.autofill.credential.manager + +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordRegisterCredentialResult + +/** + * A no-op implementation of [CredentialCompletionManager] provided when the build version is below + * UPSIDE_DOWN_CAKE (34). These versions do not support [androidx.credentials.CredentialProvider]. + */ +object CredentialCompletionManagerUnsupportedApiImpl : CredentialCompletionManager { + override fun completeFido2Registration(result: Fido2RegisterCredentialResult) = Unit + + override fun completeFido2Assertion(result: Fido2CredentialAssertionResult) = Unit + override fun completePasswordRegistration(result: PasswordRegisterCredentialResult) = Unit + + override fun completePasswordAssertion(result: PasswordCredentialAssertionResult) = Unit + + override fun completeGetCredentialRequest( + fido2Result: Fido2GetCredentialsResult?, + passwordResult: PasswordGetCredentialsResult?, + ) = Unit +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManager.kt deleted file mode 100644 index d477f93fcd8..00000000000 --- a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManager.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.x8bit.bitwarden.ui.autofill.fido2.manager - -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsResult -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult - -/** - * A manager for completing the FIDO 2 creation process. - */ -interface Fido2CompletionManager { - - /** - * Completes the FIDO 2 registration process with the provided [result]. - */ - fun completeFido2Registration(result: Fido2RegisterCredentialResult) - - /** - * Complete the FIDO 2 credential assertion process with the provided [result]. - */ - fun completeFido2Assertion(result: Fido2CredentialAssertionResult) - - /** - * Complete the FIDO 2 "Get credentials" process with the provided [result]. - */ - fun completeFido2GetCredentialRequest(result: Fido2GetCredentialsResult) -} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerImpl.kt deleted file mode 100644 index 85962dce098..00000000000 --- a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerImpl.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.x8bit.bitwarden.ui.autofill.fido2.manager - -import android.app.Activity -import android.content.Intent -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.credentials.CreatePublicKeyCredentialResponse -import androidx.credentials.GetCredentialResponse -import androidx.credentials.PublicKeyCredential -import androidx.credentials.exceptions.CreateCredentialCancellationException -import androidx.credentials.exceptions.CreateCredentialUnknownException -import androidx.credentials.exceptions.GetCredentialUnknownException -import androidx.credentials.provider.BeginGetCredentialResponse -import androidx.credentials.provider.PendingIntentHandler -import androidx.credentials.provider.PublicKeyCredentialEntry -import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsResult -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult -import com.x8bit.bitwarden.data.autofill.fido2.processor.GET_PASSKEY_INTENT -import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager -import kotlin.random.Random - -/** - * Primary implementation of [Fido2CompletionManager] when the build version is - * UPSIDE_DOWN_CAKE (34) or above. - */ -@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) -class Fido2CompletionManagerImpl( - private val activity: Activity, - private val intentManager: IntentManager, -) : Fido2CompletionManager { - - override fun completeFido2Registration(result: Fido2RegisterCredentialResult) { - activity.also { - val intent = Intent() - when (result) { - is Fido2RegisterCredentialResult.Error -> { - PendingIntentHandler - .setCreateCredentialException( - intent = intent, - exception = CreateCredentialUnknownException(), - ) - } - - is Fido2RegisterCredentialResult.Success -> { - PendingIntentHandler - .setCreateCredentialResponse( - intent = intent, - response = CreatePublicKeyCredentialResponse( - registrationResponseJson = result.registrationResponse, - ), - ) - } - - is Fido2RegisterCredentialResult.Cancelled -> { - PendingIntentHandler - .setCreateCredentialException( - intent = intent, - exception = CreateCredentialCancellationException(), - ) - } - } - it.setResult(Activity.RESULT_OK, intent) - it.finish() - } - } - - override fun completeFido2Assertion(result: Fido2CredentialAssertionResult) { - activity.also { - val intent = Intent() - when (result) { - Fido2CredentialAssertionResult.Error -> { - PendingIntentHandler - .setGetCredentialException( - intent = intent, - exception = GetCredentialUnknownException(), - ) - } - - is Fido2CredentialAssertionResult.Success -> { - PendingIntentHandler - .setGetCredentialResponse( - intent = intent, - response = GetCredentialResponse( - credential = PublicKeyCredential(result.responseJson), - ), - ) - } - } - it.setResult(Activity.RESULT_OK, intent) - it.finish() - } - } - - override fun completeFido2GetCredentialRequest(result: Fido2GetCredentialsResult) { - val resultIntent = Intent() - val responseBuilder = BeginGetCredentialResponse.Builder() - when (result) { - is Fido2GetCredentialsResult.Success -> { - val entries = result - .credentials - .map { - val pendingIntent = intentManager - .createFido2GetCredentialPendingIntent( - action = GET_PASSKEY_INTENT, - userId = result.userId, - credentialId = it.credentialId.toString(), - cipherId = it.cipherId, - requestCode = Random.nextInt(), - ) - PublicKeyCredentialEntry - .Builder( - context = activity, - username = it.userNameForUi - ?: activity.getString(R.string.no_username), - pendingIntent = pendingIntent, - beginGetPublicKeyCredentialOption = result.options, - ) - .build() - } - PendingIntentHandler - .setBeginGetCredentialResponse( - resultIntent, - responseBuilder - .setCredentialEntries(entries) - // Explicitly clear any pending authentication actions since we only - // display results from the active account. - .setAuthenticationActions(emptyList()) - .build(), - ) - } - - Fido2GetCredentialsResult.Error, - -> { - PendingIntentHandler.setGetCredentialException( - resultIntent, - GetCredentialUnknownException(), - ) - } - } - activity.setResult(Activity.RESULT_OK, resultIntent) - activity.finish() - } -} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerUnsupportedApiImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerUnsupportedApiImpl.kt deleted file mode 100644 index f24eddc4852..00000000000 --- a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerUnsupportedApiImpl.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.x8bit.bitwarden.ui.autofill.fido2.manager - -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsResult -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult - -/** - * A no-op implementation of [Fido2CompletionManagerImpl] provided when the build version is below - * UPSIDE_DOWN_CAKE (34). These versions do not support [androidx.credentials.CredentialProvider]. - */ -object Fido2CompletionManagerUnsupportedApiImpl : Fido2CompletionManager { - override fun completeFido2Registration(result: Fido2RegisterCredentialResult) = Unit - - override fun completeFido2Assertion(result: Fido2CredentialAssertionResult) = Unit - - override fun completeFido2GetCredentialRequest(result: Fido2GetCredentialsResult) = Unit -} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenOverwritePasswordConfirmationDialog.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenOverwritePasswordConfirmationDialog.kt new file mode 100644 index 00000000000..53d1f2876f1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenOverwritePasswordConfirmationDialog.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.ui.platform.components.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.model.OverwritePasswordConfirmationPromptReason + +/** + * A reusable dialog for confirming whether or not the user wants to overwrite an existing FIDO 2 + * credential. + * + * @param onConfirmClick A callback for when the overwrite confirmation button is clicked. + * @param onDismissRequest A callback for when the dialog is requesting dismissal. + */ +@Suppress("MaxLineLength") +@Composable +fun BitwardenOverwritePasswordConfirmationDialog( + reason: OverwritePasswordConfirmationPromptReason, + onConfirmClick: () -> Unit, + onDismissRequest: () -> Unit, +) { + BitwardenTwoButtonDialog( + title = stringResource( + id = when (reason) { + OverwritePasswordConfirmationPromptReason.UsernameAndPassword -> R.string.overwrite_username_and_password + OverwritePasswordConfirmationPromptReason.Username -> R.string.overwrite_username + OverwritePasswordConfirmationPromptReason.Password -> R.string.overwrite_password + } + ), + message = stringResource( + id = when (reason) { + OverwritePasswordConfirmationPromptReason.UsernameAndPassword -> R.string.this_item_already_contains_a_username_and_password_are_you_sure_you_want_to_overwrite_the_current_passkey + OverwritePasswordConfirmationPromptReason.Username -> R.string.this_item_already_contains_a_username_are_you_sure_you_want_to_overwrite_the_current_passkey + OverwritePasswordConfirmationPromptReason.Password -> R.string.this_item_already_contains_a_password_are_you_sure_you_want_to_overwrite_the_current_passkey + } + ), + confirmButtonText = stringResource(id = R.string.ok), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = onConfirmClick, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/OverwritePasswordConfirmationPromptReason.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/OverwritePasswordConfirmationPromptReason.kt new file mode 100644 index 00000000000..ee81a3b6121 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/OverwritePasswordConfirmationPromptReason.kt @@ -0,0 +1,7 @@ +package com.x8bit.bitwarden.ui.platform.components.model + +enum class OverwritePasswordConfirmationPromptReason { + UsernameAndPassword, + Username, + Password, +} \ No newline at end of file diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index f6d48f4a30a..2329b8e2f6c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -12,9 +12,9 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.platform.LocalContext import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow -import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager -import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManagerImpl -import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManagerUnsupportedApiImpl +import com.x8bit.bitwarden.ui.autofill.credential.manager.CredentialCompletionManager +import com.x8bit.bitwarden.ui.autofill.credential.manager.CredentialCompletionManagerImpl +import com.x8bit.bitwarden.ui.autofill.credential.manager.CredentialCompletionManagerUnsupportedApiImpl import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManagerImpl import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager @@ -34,20 +34,20 @@ fun LocalManagerProvider( content: @Composable () -> Unit, ) { val activity = LocalContext.current as Activity - val fido2IntentManager: IntentManager = IntentManagerImpl(activity) - val fido2CompletionManager = + val intentManager: IntentManager = IntentManagerImpl(activity) + val credentialCompletionManager = if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) { - Fido2CompletionManagerUnsupportedApiImpl + CredentialCompletionManagerUnsupportedApiImpl } else { - Fido2CompletionManagerImpl(activity, fido2IntentManager) + CredentialCompletionManagerImpl(activity) } CompositionLocalProvider( LocalPermissionsManager provides PermissionsManagerImpl(activity), - LocalIntentManager provides fido2IntentManager, + LocalIntentManager provides intentManager, LocalExitManager provides ExitManagerImpl(activity), LocalBiometricsManager provides BiometricsManagerImpl(activity), LocalNfcManager provides NfcManagerImpl(activity), - LocalFido2CompletionManager provides fido2CompletionManager, + LocalCredentialCompletionManager provides credentialCompletionManager, ) { content() } @@ -88,7 +88,7 @@ val LocalNfcManager: ProvidableCompositionLocal = compositionLocalOf error("CompositionLocal NfcManager not present") } -val LocalFido2CompletionManager: ProvidableCompositionLocal = +val LocalCredentialCompletionManager: ProvidableCompositionLocal = compositionLocalOf { - error("CompositionLocal Fido2CompletionManager not present") - } + error("CompositionLocal CredentialCompletionManager not present") + } \ No newline at end of file diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 4bb22e3fa86..1b1a9bb0d74 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -123,9 +123,11 @@ fun RootNavScreen( is RootNavState.VaultUnlockedForNewSend, is RootNavState.VaultUnlockedForNewTotp, is RootNavState.VaultUnlockedForAuthRequest, + is RootNavState.VaultUnlockedForGetCredentials, is RootNavState.VaultUnlockedForFido2Save, is RootNavState.VaultUnlockedForFido2Assertion, - is RootNavState.VaultUnlockedForFido2GetCredentials, + is RootNavState.VaultUnlockedForPasswordSave, + is RootNavState.VaultUnlockedForPasswordAssertion, -> VAULT_UNLOCKED_GRAPH_ROUTE RootNavState.OnboardingAccountLockSetup -> SETUP_UNLOCK_AS_ROOT_ROUTE @@ -232,9 +234,11 @@ fun RootNavScreen( ) } + is RootNavState.VaultUnlockedForGetCredentials, is RootNavState.VaultUnlockedForFido2Save, is RootNavState.VaultUnlockedForFido2Assertion, - is RootNavState.VaultUnlockedForFido2GetCredentials, + is RootNavState.VaultUnlockedForPasswordSave, + is RootNavState.VaultUnlockedForPasswordAssertion, -> { navController.navigateToVaultUnlockedGraph(rootNavOptions) navController.navigateToVaultItemListingAsRoot( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 74716bcafe1..6ed9bcfc0e6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -12,6 +12,9 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsRequest import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType @@ -130,6 +133,14 @@ class RootNavViewModel @Inject constructor( RootNavState.VaultUnlockedForAuthRequest } + is SpecialCircumstance.GetCredentials -> { + RootNavState.VaultUnlockedForGetCredentials( + activeUserId = userState.activeUserId, + fido2GetCredentialsRequest = specialCircumstance.fido2GetCredentialsRequest, + passwordGetCredentialsRequest = specialCircumstance.passwordGetCredentialsRequest, + ) + } + is SpecialCircumstance.Fido2Save -> { RootNavState.VaultUnlockedForFido2Save( activeUserId = userState.activeUserId, @@ -144,10 +155,17 @@ class RootNavViewModel @Inject constructor( ) } - is SpecialCircumstance.Fido2GetCredentials -> { - RootNavState.VaultUnlockedForFido2GetCredentials( + is SpecialCircumstance.PasswordSave -> { + RootNavState.VaultUnlockedForPasswordSave( activeUserId = userState.activeUserId, - fido2GetCredentialsRequest = specialCircumstance.fido2GetCredentialsRequest, + passwordCredentialRequest = specialCircumstance.passwordCredentialRequest, + ) + } + + is SpecialCircumstance.PasswordAssertion -> { + RootNavState.VaultUnlockedForPasswordAssertion( + activeUserId = userState.activeUserId, + passwordCredentialAssertionRequest = specialCircumstance.passwordAssertionRequest, ) } @@ -280,6 +298,17 @@ sealed class RootNavState : Parcelable { val type: AutofillSelectionData.Type, ) : RootNavState() + /** + * App should unlock the user's vault and retrieve Password credentials associated to the relying + * party. + */ + @Parcelize + data class VaultUnlockedForGetCredentials( + val activeUserId: String, + val fido2GetCredentialsRequest: Fido2GetCredentialsRequest?, + val passwordGetCredentialsRequest: PasswordGetCredentialsRequest?, + ) : RootNavState() + /** * App should show an add item screen for a user to complete the saving of data collected by * the fido2 credential manager framework @@ -304,13 +333,26 @@ sealed class RootNavState : Parcelable { ) : RootNavState() /** - * App should unlock the user's vault and retrieve FIDO 2 credentials associated to the relying - * party. + * App should show an add item screen for a user to complete the saving of data collected by + * the password credential manager framework + * + * @param activeUserId ID of the active user. Indirectly used to notify [RootNavViewModel] the + * active user has changed. + * @param passwordCredentialRequest System request containing Password credential data. + */ + @Parcelize + data class VaultUnlockedForPasswordSave( + val activeUserId: String, + val passwordCredentialRequest: PasswordCredentialRequest, + ) : RootNavState() + + /** + * App should perform Password credential assertion for the user. */ @Parcelize - data class VaultUnlockedForFido2GetCredentials( + data class VaultUnlockedForPasswordAssertion( val activeUserId: String, - val fido2GetCredentialsRequest: Fido2GetCredentialsRequest, + val passwordCredentialAssertionRequest: PasswordCredentialAssertionRequest, ) : RootNavState() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt index ff00823ca77..ba9c6d8f108 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt @@ -127,14 +127,35 @@ interface IntentManager { /** * Creates a pending intent to use when providing - * [androidx.credentials.provider.AuthenticationAction] instances for FIDO 2 credential filling. + * [androidx.credentials.provider.AuthenticationAction] instances for FIDO 2 or Password credential filling. */ - fun createFido2UnlockPendingIntent( + fun createCredentialUnlockPendingIntent( action: String, userId: String, requestCode: Int, ): PendingIntent + /** + * Creates a pending intent to use when providing [androidx.credentials.provider.CreateEntry] + * instances for Password credential creation. + */ + fun createPasswordCreationPendingIntent( + action: String, + userId: String, + requestCode: Int, + ): PendingIntent + + /** + * Creates a pending intent to use when providing + * [androidx.credentials.provider.CredentialEntry] instances for Password credential filling. + */ + fun createPasswordGetCredentialPendingIntent( + action: String, + userId: String, + cipherId: String, + requestCode: Int, + ): PendingIntent + /** * Open the default email app on device. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index 3902676e627..2b3ef9c1b3a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -295,7 +295,7 @@ class IntentManagerImpl( ) } - override fun createFido2UnlockPendingIntent( + override fun createCredentialUnlockPendingIntent( action: String, userId: String, requestCode: Int, @@ -312,6 +312,42 @@ class IntentManagerImpl( ) } + override fun createPasswordCreationPendingIntent( + action: String, + userId: String, + requestCode: Int, + ): PendingIntent { + val intent = Intent(action) + .setPackage(context.packageName) + .putExtra(EXTRA_KEY_USER_ID, userId) + + return PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ requestCode, + /* intent = */ intent, + /* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(), + ) + } + + override fun createPasswordGetCredentialPendingIntent( + action: String, + userId: String, + cipherId: String, + requestCode: Int, + ): PendingIntent { + val intent = Intent(action) + .setPackage(context.packageName) + .putExtra(EXTRA_KEY_USER_ID, userId) + .putExtra(EXTRA_KEY_CIPHER_ID, cipherId) + + return PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ requestCode, + /* intent = */ intent, + /* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(), + ) + } + override fun startDefaultEmailApplication() { val intent = Intent(Intent.ACTION_MAIN) intent.addCategory(Intent.CATEGORY_APP_EMAIL) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index f33e0b75188..1bb1b702a78 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -21,7 +21,7 @@ import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager +import com.x8bit.bitwarden.ui.autofill.credential.manager.CredentialCompletionManager import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar @@ -36,14 +36,15 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasswordConfirmationDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenPinDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager +import com.x8bit.bitwarden.ui.platform.composition.LocalCredentialCompletionManager import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager -import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.PinInputDialog @@ -73,7 +74,7 @@ fun VaultAddEditScreen( permissionsManager: PermissionsManager = LocalPermissionsManager.current, intentManager: IntentManager = LocalIntentManager.current, exitManager: ExitManager = LocalExitManager.current, - fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current, + credentialCompletionManager: CredentialCompletionManager = LocalCredentialCompletionManager.current, biometricsManager: BiometricsManager = LocalBiometricsManager.current, onNavigateToManualCodeEntryScreen: () -> Unit, onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit, @@ -124,7 +125,7 @@ fun VaultAddEditScreen( } is VaultAddEditEvent.CompleteFido2Registration -> { - fido2CompletionManager.completeFido2Registration(event.result) + credentialCompletionManager.completeFido2Registration(event.result) } is VaultAddEditEvent.Fido2UserVerification -> { @@ -136,6 +137,10 @@ fun VaultAddEditScreen( onNotSupported = userVerificationHandlers.onUserVerificationNotSupported, ) } + + is VaultAddEditEvent.CompletePasswordRegistration -> { + credentialCompletionManager.completePasswordRegistration(event.result) + } } } @@ -176,6 +181,9 @@ fun VaultAddEditScreen( onFido2ErrorDismiss = remember(viewModel) { { viewModel.trySendAction(VaultAddEditAction.Common.Fido2ErrorDialogDismissed) } }, + onPasswordErrorDismiss = remember(viewModel) { + { viewModel.trySendAction(VaultAddEditAction.Common.PasswordErrorDialogDismissed) } + }, onConfirmOverwriteExistingPasskey = remember(viewModel) { { viewModel.trySendAction( @@ -183,6 +191,13 @@ fun VaultAddEditScreen( ) } }, + onConfirmOverwriteExistingPassword = remember(viewModel) { + { + viewModel.trySendAction( + action = VaultAddEditAction.Common.ConfirmOverwriteExistingPasswordClick, + ) + } + }, onSubmitMasterPasswordFido2Verification = remember(viewModel) { { viewModel.trySendAction( @@ -368,7 +383,9 @@ private fun VaultAddEditItemDialogs( onDismissRequest: () -> Unit, onAutofillDismissRequest: () -> Unit, onFido2ErrorDismiss: () -> Unit, + onPasswordErrorDismiss: () -> Unit, onConfirmOverwriteExistingPasskey: () -> Unit, + onConfirmOverwriteExistingPassword: () -> Unit, onSubmitMasterPasswordFido2Verification: (password: String) -> Unit, onRetryFido2PasswordVerification: () -> Unit, onSubmitPinFido2Verification: (pin: String) -> Unit, @@ -414,6 +431,16 @@ private fun VaultAddEditItemDialogs( ) } + is VaultAddEditState.DialogState.PasswordError -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = dialogState.message, + ), + onDismissRequest = onPasswordErrorDismiss, + ) + } + is VaultAddEditState.DialogState.OverwritePasskeyConfirmationPrompt -> { BitwardenOverwritePasskeyConfirmationDialog( onConfirmClick = onConfirmOverwriteExistingPasskey, @@ -421,6 +448,14 @@ private fun VaultAddEditItemDialogs( ) } + is VaultAddEditState.DialogState.OverwritePasswordConfirmationPrompt -> { + BitwardenOverwritePasswordConfirmationDialog( + reason = dialogState.reason, + onConfirmClick = onConfirmOverwriteExistingPassword, + onDismissRequest = onDismissRequest, + ) + } + is VaultAddEditState.DialogState.Fido2MasterPasswordPrompt -> { BitwardenMasterPasswordDialog( onConfirmClick = onSubmitMasterPasswordFido2Verification, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 1a08cce105e..d8018e1db1c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -15,7 +15,12 @@ import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement +import com.x8bit.bitwarden.data.autofill.password.model.PasswordRegisterCredentialResult import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials +import com.x8bit.bitwarden.data.autofill.util.isActiveWithPasswordCredentials +import com.x8bit.bitwarden.data.autofill.util.isActiveWithUsernameAndPasswordCredentials +import com.x8bit.bitwarden.data.autofill.util.isActiveWithUsernameCredentials +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager @@ -24,6 +29,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSaveItemOrNull import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull import com.x8bit.bitwarden.data.platform.manager.util.toFido2RequestOrNull +import com.x8bit.bitwarden.data.platform.manager.util.toPasswordCredentialsRequestOrNull import com.x8bit.bitwarden.data.platform.manager.util.toTotpDataOrNull import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState @@ -42,6 +48,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat +import com.x8bit.bitwarden.ui.platform.components.model.OverwritePasswordConfirmationPromptReason import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldAction @@ -125,10 +132,13 @@ class VaultAddEditViewModel @Inject constructor( val fido2AttestationOptions = fido2CreationRequest?.let { request -> fido2CredentialManager.getPasskeyAttestationOptionsOrNull(request.requestJson) } + //Check for Password data to pre-populate + val passwordCreationRequest = specialCircumstance?.toPasswordCredentialsRequestOrNull() - // Exit on save if handling an autofill, Fido2 Attestation, or TOTP link + // Exit on save if handling an autofill, Fido2 Attestation, Password creation, or TOTP link val shouldExitOnSave = autofillSaveItem != null || - fido2AttestationOptions != null + fido2AttestationOptions != null || + passwordCreationRequest != null val dialogState = if (!settingsRepository.initialAutofillDialogShown && vaultAddEditType is VaultAddEditType.AddItem && @@ -150,6 +160,9 @@ class VaultAddEditViewModel @Inject constructor( attestationOptions = fido2AttestationOptions, isIndividualVaultDisabled = isIndividualVaultDisabled, ) + ?: passwordCreationRequest?.toDefaultAddTypeContent( + isIndividualVaultDisabled = isIndividualVaultDisabled, + ) ?: totpData?.toDefaultAddTypeContent(isIndividualVaultDisabled) ?: VaultAddEditState.ViewState.Content( common = VaultAddEditState.ViewState.Content.Common( @@ -274,6 +287,10 @@ class VaultAddEditViewModel @Inject constructor( handleConfirmOverwriteExistingPasskeyClick() } + is VaultAddEditAction.Common.ConfirmOverwriteExistingPasswordClick -> { + handleConfirmOverwriteExistingPasswordClick() + } + VaultAddEditAction.Common.UserVerificationSuccess -> { handleUserVerificationSuccess() } @@ -294,6 +311,10 @@ class VaultAddEditViewModel @Inject constructor( handleFido2ErrorDialogDismissed() } + VaultAddEditAction.Common.PasswordErrorDialogDismissed -> { + handlePasswordErrorDialogDismissed() + } + VaultAddEditAction.Common.UserVerificationNotSupported -> { handleUserVerificationNotSupported() } @@ -425,6 +446,13 @@ class VaultAddEditViewModel @Inject constructor( return@onContent } + specialCircumstanceManager.specialCircumstance + ?.toPasswordCredentialsRequestOrNull() + ?.let { request -> + handlePasswordRequestSpecialCircumstance(content.toCipherView()) + return@onContent + } + viewModelScope.launch { when (val vaultAddEditType = state.vaultAddEditType) { is VaultAddEditType.AddItem -> { @@ -461,6 +489,57 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun handlePasswordRequestSpecialCircumstance( + cipherView: CipherView, + ) { + when { + cipherView.isActiveWithUsernameAndPasswordCredentials -> { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.OverwritePasswordConfirmationPrompt( + reason = OverwritePasswordConfirmationPromptReason.UsernameAndPassword + ) + ) + } + } + + cipherView.isActiveWithUsernameCredentials -> { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.OverwritePasswordConfirmationPrompt( + reason = OverwritePasswordConfirmationPromptReason.Username + ) + ) + } + } + + cipherView.isActiveWithPasswordCredentials -> { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.OverwritePasswordConfirmationPrompt( + reason = OverwritePasswordConfirmationPromptReason.Password + ) + ) + } + } + + else -> { + registerPasswordCredential(cipherView) + } + } + } + + private fun registerPasswordCredential( + cipherView: CipherView, + ) { + viewModelScope.launch { + val result = vaultRepository.createCipher(cipherView = cipherView) + sendAction( + VaultAddEditAction.Internal.PasswordRegisterCredentialResultReceive(result), + ) + } + } + private fun registerFido2Credential( request: Fido2CredentialRequest, cipherView: CipherView, @@ -631,6 +710,15 @@ class VaultAddEditViewModel @Inject constructor( ) } + private fun handlePasswordErrorDialogDismissed() { + clearDialogState() + sendEvent( + VaultAddEditEvent.CompletePasswordRegistration( + result = PasswordRegisterCredentialResult.Error, + ), + ) + } + private fun handleUserVerificationCancelled() { fido2CredentialManager.isUserVerified = false clearDialogState() @@ -1437,6 +1525,10 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.Internal.ValidateFido2PinResultReceive -> { handleValidateFido2PinResultReceive(action) } + + is VaultAddEditAction.Internal.PasswordRegisterCredentialResultReceive -> { + handlePasswordRegisterCredentialResultReceive(action) + } } } @@ -1451,6 +1543,7 @@ class VaultAddEditViewModel @Inject constructor( } is CreateCipherResult.Success -> { + specialCircumstanceManager.specialCircumstance = null if (state.shouldExitOnSave) { sendEvent(event = VaultAddEditEvent.ExitApp) @@ -1464,6 +1557,36 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun handleConfirmOverwriteExistingPasswordClick() { + specialCircumstanceManager + .specialCircumstance + ?.toPasswordCredentialsRequestOrNull() + ?.let { + onContent { content -> + registerPasswordCredential(content.toCipherView()) + } + } + ?: showPasswordErrorDialog() + } + + private fun handlePasswordRegisterCredentialResultReceive( + action: VaultAddEditAction.Internal.PasswordRegisterCredentialResultReceive, + ) { + val result = when (action.result) { + is CreateCipherResult.Error -> { + sendEvent(VaultAddEditEvent.ShowToast(R.string.an_error_has_occurred.asText())) + PasswordRegisterCredentialResult.Error + } + + is CreateCipherResult.Success -> { + sendEvent(VaultAddEditEvent.ShowToast(R.string.item_updated.asText())) + PasswordRegisterCredentialResult.Success + } + } + + sendEvent(VaultAddEditEvent.CompletePasswordRegistration(result)) + } + private fun handleUpdateCipherResultReceive( action: VaultAddEditAction.Internal.UpdateCipherResultReceive, ) { @@ -1776,6 +1899,18 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun showPasswordErrorDialog() { + //TODO does not always fit the reason but matches showFido2ErrorDialog + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.PasswordError( + message = R.string.password_operation_failed_because_user_could_not_be_verified + .asText(), + ), + ) + } + } + private fun showGenericErrorDialog( message: Text = R.string.generic_error_message.asText(), ) { @@ -2396,12 +2531,26 @@ data class VaultAddEditState( @Parcelize data class Fido2Error(val message: Text) : DialogState() + /** + * Displays a Password operation error dialog to the user. + */ + @Parcelize + data class PasswordError(val message: Text) : DialogState() + /** * Displays the overwrite passkey confirmation prompt to the user. */ @Parcelize data object OverwritePasskeyConfirmationPrompt : DialogState() + /** + * Displays the overwrite password confirmation prompt to the user. + */ + @Parcelize + data class OverwritePasswordConfirmationPrompt( + val reason: OverwritePasswordConfirmationPromptReason, + ) : DialogState() + /** * Displays a dialog to prompt the user for their master password as part of the FIDO 2 * user verification flow. @@ -2525,6 +2674,15 @@ sealed class VaultAddEditEvent { data class Fido2UserVerification( val isRequired: Boolean, ) : BackgroundEvent, VaultAddEditEvent() + + /** + * Complete the current FIDO 2 credential registration process. + * + * @property result the result of FIDO 2 credential registration. + */ + data class CompletePasswordRegistration( + val result: PasswordRegisterCredentialResult, + ) : BackgroundEvent, VaultAddEditEvent() } /** @@ -2582,6 +2740,11 @@ sealed class VaultAddEditAction { */ data object ConfirmOverwriteExistingPasskeyClick : Common() + /** + * The user has confirmed overwriting the existing passkey. + */ + data object ConfirmOverwriteExistingPasswordClick : Common() + /** * Represents the action when a type option is selected. * @@ -2702,6 +2865,11 @@ sealed class VaultAddEditAction { */ data object Fido2ErrorDialogDismissed : Common() + /** + * The user has dismissed the Password credential error dialog. + */ + data object PasswordErrorDialogDismissed : Common() + /** * User verification cannot be performed with device biometrics or credentials. */ @@ -3123,6 +3291,14 @@ sealed class VaultAddEditAction { data class ValidateFido2PinResultReceive( val result: ValidatePinResult, ) : Internal() + + /** + * Indicates a result for creating a cipher has been received. + */ + data class PasswordRegisterCredentialResultReceive( + val result: CreateCipherResult, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasswordCredentialRequestExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasswordCredentialRequestExtensions.kt new file mode 100644 index 00000000000..126e04c546e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasswordCredentialRequestExtensions.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.ui.vault.feature.addedit.util + +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialRequest +import com.x8bit.bitwarden.data.platform.util.toUriOrNull +import com.x8bit.bitwarden.ui.platform.base.util.toAndroidAppUriString +import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState +import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem +import java.util.UUID + +/** + * Returns pre-filled content that may be used for an "add" type + * [VaultAddEditState.ViewState.Content] during Password credential creation. + */ +fun PasswordCredentialRequest.toDefaultAddTypeContent( + isIndividualVaultDisabled: Boolean, +): VaultAddEditState.ViewState.Content { + + val rpUri = origin + ?.toUriOrNull() + ?.toString() + ?: packageName + .toAndroidAppUriString() + + val rpName = origin + ?.toUriOrNull() + ?.toString() + ?: packageName + + return VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common( + name = rpName, + ), + isIndividualVaultDisabled = isIndividualVaultDisabled, + type = VaultAddEditState.ViewState.Content.ItemType.Login( + username = this.userName, + password = this.password, + uriList = listOf( + UriItem( + id = UUID.randomUUID().toString(), + uri = rpUri, + match = null, + checksum = null, + ), + ), + ), + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index 5a8360bf1c2..f4fe2c3613d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -21,7 +21,7 @@ import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager +import com.x8bit.bitwarden.ui.autofill.credential.manager.CredentialCompletionManager import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountSwitcher @@ -37,6 +37,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasswordConfirmationDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenPinDialog import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.fab.BitwardenFloatingActionButton @@ -45,8 +46,8 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.rememberBitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager +import com.x8bit.bitwarden.ui.platform.composition.LocalCredentialCompletionManager import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager -import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.PinInputDialog @@ -81,7 +82,7 @@ fun VaultItemListingScreen( onNavigateToSearch: (searchType: SearchType) -> Unit, intentManager: IntentManager = LocalIntentManager.current, exitManager: ExitManager = LocalExitManager.current, - fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current, + credentialCompletionManager: CredentialCompletionManager = LocalCredentialCompletionManager.current, biometricsManager: BiometricsManager = LocalBiometricsManager.current, viewModel: VaultItemListingViewModel = hiltViewModel(), ) { @@ -152,7 +153,7 @@ fun VaultItemListingScreen( } is VaultItemListingEvent.CompleteFido2Registration -> { - fido2CompletionManager.completeFido2Registration(event.result) + credentialCompletionManager.completeFido2Registration(event.result) } is VaultItemListingEvent.Fido2UserVerification -> { @@ -173,11 +174,22 @@ fun VaultItemListingScreen( } is VaultItemListingEvent.CompleteFido2Assertion -> { - fido2CompletionManager.completeFido2Assertion(event.result) + credentialCompletionManager.completeFido2Assertion(event.result) } - is VaultItemListingEvent.CompleteFido2GetCredentialsRequest -> { - fido2CompletionManager.completeFido2GetCredentialRequest(event.result) + is VaultItemListingEvent.CompletePasswordRegistration -> { + credentialCompletionManager.completePasswordRegistration(event.result) + } + + is VaultItemListingEvent.CompletePasswordAssertion -> { + credentialCompletionManager.completePasswordAssertion(event.result) + } + + is VaultItemListingEvent.CompleteGetCredentialsRequest -> { + credentialCompletionManager.completeGetCredentialRequest( + event.fido2Result, + event.passwordResult, + ) } VaultItemListingEvent.ExitApp -> exitManager.exitApplication() @@ -196,6 +208,13 @@ fun VaultItemListingScreen( ) } }, + onDismissPasswordErrorDialog = remember(viewModel) { + { + viewModel.trySendAction( + VaultItemListingsAction.DismissPasswordErrorDialogClick, + ) + } + }, onConfirmOverwriteExistingPasskey = remember(viewModel) { { cipherId -> viewModel.trySendAction( @@ -205,6 +224,15 @@ fun VaultItemListingScreen( ) } }, + onConfirmOverwriteExistingPassword = remember(viewModel) { + { cipherId -> + viewModel.trySendAction( + VaultItemListingsAction.ConfirmOverwriteExistingPasswordClick( + cipherViewId = cipherId, + ), + ) + } + }, onSubmitMasterPasswordFido2Verification = remember(viewModel) { { password, cipherId -> viewModel.trySendAction( @@ -283,7 +311,9 @@ private fun VaultItemListingDialogs( dialogState: VaultItemListingState.DialogState?, onDismissRequest: () -> Unit, onDismissFido2ErrorDialog: () -> Unit, + onDismissPasswordErrorDialog: () -> Unit, onConfirmOverwriteExistingPasskey: (cipherViewId: String) -> Unit, + onConfirmOverwriteExistingPassword: (cipherViewId: String) -> Unit, onSubmitMasterPasswordFido2Verification: (password: String, cipherId: String) -> Unit, onRetryFido2PasswordVerification: (cipherId: String) -> Unit, onSubmitPinFido2Verification: (pin: String, cipherId: String) -> Unit, @@ -313,6 +343,14 @@ private fun VaultItemListingDialogs( onDismissRequest = onDismissFido2ErrorDialog, ) + is VaultItemListingState.DialogState.PasswordOperationFail -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialogState.title, + message = dialogState.message, + ), + onDismissRequest = onDismissPasswordErrorDialog, + ) + is VaultItemListingState.DialogState.OverwritePasskeyConfirmationPrompt -> { BitwardenOverwritePasskeyConfirmationDialog( onConfirmClick = { onConfirmOverwriteExistingPasskey(dialogState.cipherViewId) }, @@ -320,6 +358,14 @@ private fun VaultItemListingDialogs( ) } + is VaultItemListingState.DialogState.OverwritePasswordConfirmationPrompt -> { + BitwardenOverwritePasswordConfirmationDialog( + reason = dialogState.reason, + onConfirmClick = { onConfirmOverwriteExistingPassword(dialogState.cipherViewId) }, + onDismissRequest = onDismissRequest, + ) + } + is VaultItemListingState.DialogState.Fido2MasterPasswordPrompt -> { BitwardenMasterPasswordDialog( onConfirmClick = { password -> diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 57d28909354..fb31d74c58c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -21,9 +21,20 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement +import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialAssertionResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordCredentialRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsRequest +import com.x8bit.bitwarden.data.autofill.password.model.PasswordGetCredentialsResult +import com.x8bit.bitwarden.data.autofill.password.model.PasswordRegisterCredentialResult +import com.x8bit.bitwarden.data.autofill.password.processor.PasswordProviderProcessor import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials +import com.x8bit.bitwarden.data.autofill.util.isActiveWithPasswordCredentials +import com.x8bit.bitwarden.data.autofill.util.isActiveWithUsernameAndPasswordCredentials +import com.x8bit.bitwarden.data.autofill.util.isActiveWithUsernameCredentials import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager @@ -34,6 +45,9 @@ import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrN import com.x8bit.bitwarden.data.platform.manager.util.toFido2AssertionRequestOrNull import com.x8bit.bitwarden.data.platform.manager.util.toFido2GetCredentialsRequestOrNull import com.x8bit.bitwarden.data.platform.manager.util.toFido2RequestOrNull +import com.x8bit.bitwarden.data.platform.manager.util.toPasswordAssertionRequestOrNull +import com.x8bit.bitwarden.data.platform.manager.util.toPasswordCredentialsRequestOrNull +import com.x8bit.bitwarden.data.platform.manager.util.toPasswordGetCredentialsRequestOrNull import com.x8bit.bitwarden.data.platform.manager.util.toTotpDataOrNull import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -44,6 +58,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.map import com.x8bit.bitwarden.data.platform.util.getFido2RpIdOrNull import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult @@ -59,6 +74,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.components.model.IconRes +import com.x8bit.bitwarden.ui.platform.components.model.OverwritePasswordConfirmationPromptReason import com.x8bit.bitwarden.ui.platform.feature.search.SearchTypeData import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.feature.search.util.filterAndOrganize @@ -83,6 +99,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import java.time.Clock +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject /** @@ -106,6 +123,8 @@ class VaultItemListingViewModel @Inject constructor( private val policyManager: PolicyManager, private val fido2CredentialManager: Fido2CredentialManager, private val organizationEventManager: OrganizationEventManager, + private val fido2ProviderProcessor: Fido2ProviderProcessor, + private val passwordProviderProcessor: PasswordProviderProcessor, ) : BaseViewModel( initialState = run { val userState = requireNotNull(authRepository.userStateFlow.value) @@ -136,6 +155,9 @@ class VaultItemListingViewModel @Inject constructor( fido2CredentialRequest = fido2CredentialRequest, fido2CredentialAssertionRequest = specialCircumstance?.toFido2AssertionRequestOrNull(), fido2GetCredentialsRequest = specialCircumstance?.toFido2GetCredentialsRequestOrNull(), + passwordCredentialRequest = specialCircumstance?.toPasswordCredentialsRequestOrNull(), + passwordCredentialAssertionRequest = specialCircumstance?.toPasswordAssertionRequestOrNull(), + passwordGetCredentialRequest = specialCircumstance?.toPasswordGetCredentialsRequestOrNull(), isPremium = userState.activeAccount.isPremium, isRefreshing = false, ) @@ -155,8 +177,7 @@ class VaultItemListingViewModel @Inject constructor( .launchIn(viewModelScope) viewModelScope.launch { - state - .fido2CredentialRequest + state.fido2CredentialRequest ?.let { request -> sendAction( VaultItemListingsAction.Internal.Fido2RegisterCredentialRequestReceive( @@ -172,6 +193,14 @@ class VaultItemListingViewModel @Inject constructor( ), ) } + ?: state.passwordCredentialAssertionRequest + ?.let { request -> + sendAction( + VaultItemListingsAction.Internal.PasswordAssertionDataReceive( + data = request, + ), + ) + } ?: observeVaultData() } @@ -191,6 +220,8 @@ class VaultItemListingViewModel @Inject constructor( .filterForAutofillIfNecessary() .filterForFido2CreationIfNecessary() .filterForFidoGetCredentialsIfNecessary() + .filterForPasswordCreationIfNecessary() + .filterForPasswordGetCredentialsIfNecessary() .filterForTotpIfNecessary(), ) } @@ -209,6 +240,10 @@ class VaultItemListingViewModel @Inject constructor( handleDismissFido2ErrorDialogClick() } + is VaultItemListingsAction.DismissPasswordErrorDialogClick -> { + handleDismissPasswordErrorDialogClick() + } + is VaultItemListingsAction.MasterPasswordFido2VerificationSubmit -> { handleMasterPasswordFido2VerificationSubmit(action) } @@ -254,6 +289,10 @@ class VaultItemListingViewModel @Inject constructor( handleConfirmOverwriteExistingPasskeyClick(action) } + is VaultItemListingsAction.ConfirmOverwriteExistingPasswordClick -> { + handleConfirmOverwriteExistingPasswordClick(action) + } + VaultItemListingsAction.UserVerificationLockOut -> { handleUserVerificationLockOut() } @@ -322,6 +361,18 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun handleConfirmOverwriteExistingPasswordClick( + action: VaultItemListingsAction.ConfirmOverwriteExistingPasswordClick, + ) { + clearDialogState() + getCipherViewOrNull(action.cipherViewId) + ?.let { registerPasswordCredential(it) } + ?: run { + showPasswordErrorDialog() + return + } + } + private fun handleUserVerificationLockOut() { fido2CredentialManager.isUserVerified = false showFido2ErrorDialog() @@ -591,6 +642,11 @@ class VaultItemListingViewModel @Inject constructor( return } + if (state.isPasswordCreation) { + handlePasswordRegistrationRequestReceive(action) + return + } + val event = when (state.itemListingType) { is VaultItemListingState.ItemListingType.Vault -> { VaultItemListingEvent.NavigateToVaultItem(id = action.id) @@ -623,6 +679,81 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun handlePasswordRegistrationRequestReceive(action: VaultItemListingsAction.ItemClick) { + val cipherView = getCipherViewOrNull(action.id) + ?: run { + showPasswordErrorDialog() + return + } + + when { + cipherView.isActiveWithUsernameAndPasswordCredentials -> { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.OverwritePasswordConfirmationPrompt( + cipherViewId = action.id, + reason = OverwritePasswordConfirmationPromptReason.UsernameAndPassword, + ) + ) + } + } + + cipherView.isActiveWithUsernameCredentials -> { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.OverwritePasswordConfirmationPrompt( + cipherViewId = action.id, + reason = OverwritePasswordConfirmationPromptReason.Username, + ) + ) + } + } + + cipherView.isActiveWithPasswordCredentials -> { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.OverwritePasswordConfirmationPrompt( + cipherViewId = action.id, + reason = OverwritePasswordConfirmationPromptReason.Password, + ) + ) + } + } + + else -> { + registerPasswordCredential(cipherView) + } + } + } + + private fun registerPasswordCredential( + cipherView: CipherView, + ) { + val credentialRequest = state + .passwordCredentialRequest + ?: run { + // This scenario should not occur because `isFido2Creation` is false when + // `fido2CredentialRequest` is null. We show the FIDO 2 error dialog to inform + // the user and terminate the flow just in case it does occur. + showPasswordErrorDialog() + return + } + + viewModelScope.launch { + val result = vaultRepository.createCipher( + cipherView = cipherView.copy( + login = cipherView.login?.copy( + username = credentialRequest.userName, + password = credentialRequest.password + ) + ) + ) + sendAction( + VaultItemListingsAction.Internal.PasswordRegisterCredentialResultReceive(result), + ) + } + } + private fun registerFido2Credential(cipherView: CipherView) { val credentialRequest = state .fido2CredentialRequest @@ -855,6 +986,38 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun handleDismissPasswordErrorDialogClick() { + clearDialogState() + when { + state.passwordCredentialRequest != null -> { + sendEvent( + VaultItemListingEvent.CompletePasswordRegistration( + result = PasswordRegisterCredentialResult.Error, + ), + ) + } + + state.passwordCredentialAssertionRequest != null -> { + sendEvent( + VaultItemListingEvent.CompletePasswordAssertion( + result = PasswordCredentialAssertionResult.Error, + ), + ) + } + + else -> { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ) + } + } + } + } + private fun handleBackClick() { sendEvent( event = if (state.isTotp || state.isAutofill) { @@ -1002,6 +1165,19 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingsAction.Internal.Fido2AssertionResultReceive -> { handleFido2AssertionResultReceive(action) } + + is VaultItemListingsAction.Internal.PasswordAssertionDataReceive -> { + handlePasswordAssertionDataReceive(action) + } + + is VaultItemListingsAction.Internal.PasswordAssertionResultReceive -> { + handlePasswordAssertionResultReceive(action) + } + + is VaultItemListingsAction.Internal.PasswordRegisterCredentialResultReceive -> { + handlePasswordRegisterCredentialResultReceive(action) + } + } } @@ -1303,32 +1479,53 @@ class VaultItemListingViewModel @Inject constructor( private fun vaultLoadedReceive(vaultData: DataState.Loaded) { updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true) - state.fido2GetCredentialsRequest - ?.let { fido2GetCredentialsRequest -> - val relyingPartyId = fido2CredentialManager - .getPasskeyAssertionOptionsOrNull( - requestJson = fido2GetCredentialsRequest.option.requestJson, - ) - ?.relyingPartyId - ?: run { - showFido2ErrorDialog() - return - } - sendEvent( - VaultItemListingEvent.CompleteFido2GetCredentialsRequest( - Fido2GetCredentialsResult.Success( - userId = fido2GetCredentialsRequest.userId, - options = fido2GetCredentialsRequest.option, - credentials = vaultData - .data - .fido2CredentialAutofillViewList - ?.filter { it.rpId == relyingPartyId } - ?: emptyList(), - ), - ), + + if (state.fido2GetCredentialsRequest != null || state.passwordGetCredentialRequest != null) { + viewModelScope.launch { + handleGetCredentialsRequest( + state.fido2GetCredentialsRequest, + state.passwordGetCredentialRequest, ) } - ?: mutableStateFlow.update { it.copy(isRefreshing = false) } + } else { + mutableStateFlow.update { it.copy(isRefreshing = false) } + } + } + + private suspend fun handleGetCredentialsRequest( + fido2Request: Fido2GetCredentialsRequest?, + passwordRequest: PasswordGetCredentialsRequest?, + ) { + val requestCode = AtomicInteger() + val userState = authRepository.userStateFlow.value + + val fido2Result = if (fido2Request != null) { + Fido2GetCredentialsResult.Success( + credentials = fido2ProviderProcessor.processGetCredentialRequest( + requestCode, + userState!!.activeUserId, + listOf(fido2Request.option), + ) ?: emptyList() + ) + } else null + + val passwordResult = if (passwordRequest != null) { + PasswordGetCredentialsResult.Success( + passwordProviderProcessor.processGetCredentialRequest( + requestCode, + userState!!.activeUserId, + passwordRequest.callingAppInfo, + listOf(passwordRequest.option), + ) ?: emptyList() + ) + } else null + + sendEvent( + VaultItemListingEvent.CompleteGetCredentialsRequest( + fido2Result = fido2Result, + passwordResult = passwordResult + ) + ) } private fun vaultLoadingReceive() { @@ -1417,6 +1614,25 @@ class VaultItemListingViewModel @Inject constructor( sendEvent(VaultItemListingEvent.CompleteFido2Registration(action.result)) } + private fun handlePasswordRegisterCredentialResultReceive( + action: VaultItemListingsAction.Internal.PasswordRegisterCredentialResultReceive, + ) { + + val result = when (action.result) { + is CreateCipherResult.Error -> { + sendEvent(VaultItemListingEvent.ShowToast(R.string.an_error_has_occurred.asText())) + PasswordRegisterCredentialResult.Error + } + + is CreateCipherResult.Success -> { + sendEvent(VaultItemListingEvent.ShowToast(R.string.item_updated.asText())) + PasswordRegisterCredentialResult.Success + } + } + + sendEvent(VaultItemListingEvent.CompletePasswordRegistration(result)) + } + private fun handleFido2OriginValidationFail(error: Fido2ValidateOriginResult.Error) { val messageResId = when (error) { Fido2ValidateOriginResult.Error.ApplicationNotFound -> { @@ -1555,6 +1771,64 @@ class VaultItemListingViewModel @Inject constructor( ) } + private fun handlePasswordAssertionDataReceive( + action: VaultItemListingsAction.Internal.PasswordAssertionDataReceive, + ) { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Loading( + message = R.string.loading.asText(), + ), + ) + } + val request = action.data + val ciphers = vaultRepository + .ciphersStateFlow + .value + .data + .orEmpty() + + if (request.cipherId.isEmpty()) { + showPasswordErrorDialog() + } else { + val selectedCipher = ciphers + .find { it.id == request.cipherId } + ?: run { + showPasswordErrorDialog() + return + } + + if (state.hasMasterPassword && + selectedCipher.reprompt == CipherRepromptType.PASSWORD + ) { + repromptMasterPasswordForFido2Assertion(request.cipherId) + } else { + val loginView = selectedCipher.login + if (loginView == null) { + showPasswordErrorDialog() + return + } + + viewModelScope.launch { + sendAction( + VaultItemListingsAction.Internal.PasswordAssertionResultReceive( + result = PasswordCredentialAssertionResult.Success(loginView), + ), + ) + } + } + } + } + + private fun handlePasswordAssertionResultReceive( + action: VaultItemListingsAction.Internal.PasswordAssertionResultReceive, + ) { + clearDialogState() + sendEvent( + VaultItemListingEvent.CompletePasswordAssertion(action.result), + ) + } + private fun updateStateWithVaultData(vaultData: VaultData, clearDialogState: Boolean) { mutableStateFlow.update { currentState -> currentState.copy( @@ -1673,6 +1947,45 @@ class VaultItemListingViewModel @Inject constructor( } } + /** + * Takes the given vault data and filters it for Password credential creation if necessary. + */ + private suspend fun DataState.filterForPasswordCreationIfNecessary(): DataState { + val request = state.passwordCredentialRequest ?: return this + val matchUri = request.origin + ?: request.packageName + .toAndroidAppUriString() + + return this.map { vaultData -> + vaultData.copy( + cipherViewList = cipherMatchingManager.filterCiphersForMatches( + ciphers = vaultData.cipherViewList, + matchUri = matchUri, + ), + ) + } + } + + /** + * Takes the given vault data and filters it for Password credential selection. + */ + @Suppress("MaxLineLength") + private suspend fun DataState.filterForPasswordGetCredentialsIfNecessary(): DataState { + val request = state.passwordGetCredentialRequest ?: return this + val matchUri = request.origin + ?: request.packageName + .toAndroidAppUriString() + + return this.map { vaultData -> + vaultData.copy( + cipherViewList = cipherMatchingManager.filterCiphersForMatches( + ciphers = vaultData.cipherViewList, + matchUri = matchUri, + ), + ) + } + } + /** * Takes the given vault data and filters it for totp data. */ @@ -1716,6 +2029,19 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun showPasswordErrorDialog() { + fido2CredentialManager.authenticationAttempts = 0 + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.PasswordOperationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.password_operation_failed_because_user_could_not_be_verified + .asText(), + ), + ) + } + } + private fun clearDialogState() { mutableStateFlow.update { it.copy(dialogState = null) } } @@ -1742,6 +2068,9 @@ data class VaultItemListingState( val fido2CredentialRequest: Fido2CredentialRequest? = null, val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null, val fido2GetCredentialsRequest: Fido2GetCredentialsRequest? = null, + val passwordCredentialRequest: PasswordCredentialRequest? = null, + val passwordCredentialAssertionRequest: PasswordCredentialAssertionRequest? = null, + val passwordGetCredentialRequest: PasswordGetCredentialsRequest? = null, val hasMasterPassword: Boolean, val isPremium: Boolean, val isRefreshing: Boolean, @@ -1765,6 +2094,12 @@ data class VaultItemListingState( val isFido2Creation: Boolean get() = fido2CredentialRequest != null + /** + * Whether or not this represents a listing screen for FIDO2 creation. + */ + val isPasswordCreation: Boolean + get() = passwordCredentialRequest != null + /** * Whether or not this represents a listing screen for totp. */ @@ -1829,6 +2164,15 @@ data class VaultItemListingState( val message: Text, ) : DialogState() + /** + * Represents a dialog indicating that a Password credential operation encountered an error. + */ + @Parcelize + data class PasswordOperationFail( + val title: Text, + val message: Text, + ) : DialogState() + /** * Represents a loading dialog with the given [message]. */ @@ -1843,6 +2187,15 @@ data class VaultItemListingState( @Parcelize data class OverwritePasskeyConfirmationPrompt(val cipherViewId: String) : DialogState() + /** + * Displays the overwrite username and password confirmation prompt to the user. + */ + @Parcelize + data class OverwritePasswordConfirmationPrompt( + val cipherViewId: String, + val reason: OverwritePasswordConfirmationPromptReason, + ) : DialogState() + /** * Represents a dialog to prompt the user for their master password as part of the FIDO 2 * user verification flow. @@ -2237,6 +2590,17 @@ sealed class VaultItemListingEvent { */ data class ShowToast(val text: Text) : VaultItemListingEvent() + /** + * FIDO 2 or Password credential lookup result has been received and the process is ready to be completed. + * + * @property fido2Result The result of querying for matching FIDO 2 credentials. + * @property passwordResult The result of querying for matching Password credentials. + */ + data class CompleteGetCredentialsRequest( + val fido2Result: Fido2GetCredentialsResult?, + val passwordResult: PasswordGetCredentialsResult?, + ) : BackgroundEvent, VaultItemListingEvent() + /** * Complete the current FIDO 2 credential registration process. * @@ -2265,13 +2629,24 @@ sealed class VaultItemListingEvent { ) : BackgroundEvent, VaultItemListingEvent() /** - * FIDO 2 credential lookup result has been received and the process is ready to be completed. + * Complete the current FIDO 2 credential registration process. * - * @property result The result of querying for matching FIDO 2 credentials. + * @property result The result of FIDO 2 credential registration. */ - data class CompleteFido2GetCredentialsRequest( - val result: Fido2GetCredentialsResult, + data class CompletePasswordRegistration( + val result: PasswordRegisterCredentialResult, ) : BackgroundEvent, VaultItemListingEvent() + + /** + * FIDO 2 credential assertion result has been received and the process is ready to be + * completed. + * + * @property result The result of the FIDO 2 credential assertion. + */ + data class CompletePasswordAssertion( + val result: PasswordCredentialAssertionResult, + ) : BackgroundEvent, VaultItemListingEvent() + } /** @@ -2311,6 +2686,11 @@ sealed class VaultItemListingsAction { */ data object DismissFido2ErrorDialogClick : VaultItemListingsAction() + /** + * Click to dismiss the Password creation error dialog. + */ + data object DismissPasswordErrorDialogClick : VaultItemListingsAction() + /** * Click to submit the master password for FIDO 2 verification. */ @@ -2470,6 +2850,13 @@ sealed class VaultItemListingsAction { val cipherViewId: String, ) : VaultItemListingsAction() + /** + * The user has confirmed overwriting the existing cipher's passkey. + */ + data class ConfirmOverwriteExistingPasswordClick( + val cipherViewId: String, + ) : VaultItemListingsAction() + /** * Models actions that the [VaultItemListingViewModel] itself might send. */ @@ -2572,6 +2959,27 @@ sealed class VaultItemListingsAction { data class Fido2AssertionResultReceive( val result: Fido2CredentialAssertionResult, ) : Internal() + + /** + * Indicates a result for creating a cipher has been received. + */ + data class PasswordRegisterCredentialResultReceive( + val result: CreateCipherResult, + ) : Internal() + + /** + * Indicates that Password assertion request data has been received. + */ + data class PasswordAssertionDataReceive( + val data: PasswordCredentialAssertionRequest, + ) : Internal() + + /** + * Indicates that a result of a Password credential assertion has been received. + */ + data class PasswordAssertionResultReceive( + val result: PasswordCredentialAssertionResult, + ) : Internal() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 426bcbcce80..d8cd049c8c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ An error has occurred. Back Bitwarden + open Bitwarden Cancel Copy Copy password @@ -892,7 +893,13 @@ Do you want to switch to this account? Passkeys for %1$s Passwords for %1$s Overwrite passkey? + Overwrite password? + Overwrite username? + Overwrite username and ? This item already contains a passkey. Are you sure you want to overwrite the current passkey? + This item already contains a password. Are you sure you want to overwrite the current password? + This item already contains a username. Are you sure you want to overwrite the current username? + This item already contains a username and a password. Are you sure you want to overwrite the current username and password? Duo two-step login is required for your account. Follow the steps from Duo to finish logging in. Launch Duo @@ -913,6 +920,7 @@ Do you want to switch to this account? 3. Select \"Bitwarden\" to use for passwords and passkeys Your passkey will be saved to your Bitwarden vault Your passkey will be saved to your Bitwarden vault for %1$s + Your username and password will be saved to your Bitwarden vault for %1$s Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible. On May 16, 2024, unassigned organization items will no longer be visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible. Remind me later @@ -936,6 +944,7 @@ Do you want to switch to this account? There was an error starting WebAuthn two factor authentication Self-hosted server URL Passkey operation failed because user could not be verified. + Password operation failed because user could not be verified. User verification Creating on: Follow the instructions in the email sent to %1$s to continue creating your account. diff --git a/app/src/main/res/xml/provider.xml b/app/src/main/res/xml/provider.xml index 395d04812ba..aeecec60e70 100644 --- a/app/src/main/res/xml/provider.xml +++ b/app/src/main/res/xml/provider.xml @@ -2,5 +2,6 @@ +