diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 85b6258231..d820e0ed92 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -309,10 +309,10 @@ class MainViewModel @Inject constructor( val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut val hasVaultShortcut = intent.isMyVaultShortcut val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut - val fido2CreateCredentialRequestData = intent.getFido2CreateCredentialRequestOrNull() val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull() - val fido2CredentialAssertionRequest = intent.getFido2AssertionRequestOrNull() + val fido2CreateCredentialRequest = intent.getFido2CreateCredentialRequestOrNull() val fido2GetCredentialsRequest = intent.getFido2GetCredentialsRequestOrNull() + val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull() when { passwordlessRequestData != null -> { authRepository.activeUserId?.let { @@ -370,34 +370,39 @@ class MainViewModel @Inject constructor( ) } - fido2CreateCredentialRequestData != null -> { + fido2CreateCredentialRequest != 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. - fido2CreateCredentialRequestData.isUserVerified + fido2CreateCredentialRequest.providerRequest + .biometricPromptResult + ?.isSuccessful ?.let { isVerified -> fido2CredentialManager.isUserVerified = isVerified } + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( - fido2CreateCredentialRequest = fido2CreateCredentialRequestData, + fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) // Switch accounts if the selected user is not the active user. if (authRepository.activeUserId != null && - authRepository.activeUserId != fido2CreateCredentialRequestData.userId + authRepository.activeUserId != fido2CreateCredentialRequest.userId ) { - authRepository.switchAccount(fido2CreateCredentialRequestData.userId) + authRepository.switchAccount(fido2CreateCredentialRequest.userId) } } - fido2CredentialAssertionRequest != null -> { + fido2AssertCredentialRequest != null -> { // If device biometric verification was performed as part of single-tap // authentication, set the user's verification state to the device result. // Otherwise, retain the verification state as-is. - fido2CredentialAssertionRequest.isUserVerified + fido2AssertCredentialRequest.providerRequest.biometricPromptResult + ?.isSuccessful ?.let { isVerified -> fido2CredentialManager.isUserVerified = isVerified } + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( - fido2AssertionRequest = fido2CredentialAssertionRequest, + fido2AssertionRequest = fido2AssertCredentialRequest, ) } 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 d92aa98957..be05fae114 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 @@ -68,13 +68,11 @@ object Fido2ProviderModule { fun provideFido2CredentialManager( vaultSdkSource: VaultSdkSource, fido2CredentialStore: Fido2CredentialStore, - fido2OriginManager: Fido2OriginManager, json: Json, ): Fido2CredentialManager = Fido2CredentialManagerImpl( vaultSdkSource = vaultSdkSource, fido2CredentialStore = fido2CredentialStore, - fido2OriginManager = fido2OriginManager, json = json, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt index 530ab9cce7..c16ef69b70 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt @@ -1,12 +1,15 @@ package com.x8bit.bitwarden.data.autofill.fido2.manager +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.ProviderGetCredentialRequest import com.bitwarden.vault.CipherView -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions +import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement /** * Responsible for managing FIDO 2 credential registration and authentication. @@ -43,7 +46,8 @@ interface Fido2CredentialManager { */ suspend fun registerFido2Credential( userId: String, - fido2CreateCredentialRequest: Fido2CreateCredentialRequest, + callingAppInfo: CallingAppInfo, + createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest, selectedCipherView: CipherView, ): Fido2RegisterCredentialResult @@ -52,12 +56,42 @@ interface Fido2CredentialManager { */ suspend fun authenticateFido2Credential( userId: String, - request: Fido2CredentialAssertionRequest, + callingAppInfo: CallingAppInfo, + request: GetPublicKeyCredentialOption, selectedCipherView: CipherView, + origin: String?, ): Fido2CredentialAssertionResult /** * Whether or not the user has authentication attempts remaining. */ fun hasAuthenticationAttemptsRemaining(): Boolean + + /** + * Determines the user verification requirement for a given FIDO2 assertion request. + * + * @param request The FIDO2 credential assertion request. + * @param fallbackRequirement The fallback requirement to use if the request doesn't specify + * one. + * Defaults to [UserVerificationRequirement.REQUIRED]. + * @return The user verification requirement for the request. + */ + fun getUserVerificationRequirement( + request: ProviderGetCredentialRequest, + fallbackRequirement: UserVerificationRequirement = UserVerificationRequirement.REQUIRED, + ): UserVerificationRequirement + + /** + * Determines the user verification requirement for a given FIDO2 registration request. + * + * @param request The FIDO2 credential request. + * @param fallbackRequirement The fallback requirement to use if the request doesn't specify + * one. + * Defaults to [UserVerificationRequirement.REQUIRED]. + * @return The user verification requirement for the request. + */ + fun getUserVerificationRequirement( + request: CreatePublicKeyCredentialRequest, + fallbackRequirement: UserVerificationRequirement = UserVerificationRequirement.REQUIRED, + ): UserVerificationRequirement } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt index 4906e6f7c0..85a16f9d4c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt @@ -1,21 +1,19 @@ package com.x8bit.bitwarden.data.autofill.fido2.manager +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.provider.CallingAppInfo -import com.bitwarden.core.data.util.decodeFromStringOrNull +import androidx.credentials.provider.ProviderGetCredentialRequest import com.bitwarden.fido.ClientData import com.bitwarden.fido.Origin import com.bitwarden.fido.UnverifiedAssetLink import com.bitwarden.sdk.Fido2CredentialStore -import com.bitwarden.ui.util.asText import com.bitwarden.vault.CipherView -import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult 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.PasskeyAssertionOptions import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions +import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement import com.x8bit.bitwarden.data.platform.util.getAppOrigin import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString @@ -36,7 +34,6 @@ import timber.log.Timber class Fido2CredentialManagerImpl( private val vaultSdkSource: VaultSdkSource, private val fido2CredentialStore: Fido2CredentialStore, - private val fido2OriginManager: Fido2OriginManager, private val json: Json, ) : Fido2CredentialManager, Fido2CredentialStore by fido2CredentialStore { @@ -47,76 +44,111 @@ class Fido2CredentialManagerImpl( override suspend fun registerFido2Credential( userId: String, - fido2CreateCredentialRequest: Fido2CreateCredentialRequest, + callingAppInfo: CallingAppInfo, + createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest, selectedCipherView: CipherView, ): Fido2RegisterCredentialResult { - val callingAppInfo = fido2CreateCredentialRequest.callingAppInfo - val clientData = if (fido2CreateCredentialRequest.origin.isNullOrEmpty()) { - ClientData.DefaultWithExtraData(androidPackageName = callingAppInfo.packageName) - } else { - callingAppInfo - .getAppSigningSignatureFingerprint() - ?.let { ClientData.DefaultWithCustomHash(hash = it) } - ?: return Fido2RegisterCredentialResult.Error( - R.string.passkey_operation_failed_because_app_is_signed_incorrectly.asText(), - ) - } - val sdkOrigin = if (fido2CreateCredentialRequest.origin.isNullOrEmpty()) { - val host = getOriginUrlFromAttestationOptionsOrNull( - requestJson = fido2CreateCredentialRequest.requestJson, - ) - ?: return Fido2RegisterCredentialResult.Error( - R.string.passkey_operation_failed_because_host_url_is_not_present_in_request - .asText(), - ) - Origin.Android( - UnverifiedAssetLink( - packageName = callingAppInfo.packageName, - sha256CertFingerprint = callingAppInfo.getSignatureFingerprintAsHexString() - ?: return Fido2RegisterCredentialResult.Error( - R.string.passkey_operation_failed_because_app_signature_is_invalid - .asText(), - ), - host = host, - assetLinkUrl = host, - ), + return if (callingAppInfo.isOriginPopulated()) { + registerFido2CredentialForPrivilegedApp( + userId = userId, + callingAppInfo = callingAppInfo, + createPublicKeyCredentialRequest = createPublicKeyCredentialRequest, + selectedCipherView = selectedCipherView, ) } else { - Origin.Web(fido2CreateCredentialRequest.origin) + registerFido2CredentialForUnprivilegedApp( + userId = userId, + callingAppInfo = callingAppInfo, + createPublicKeyCredentialRequest = createPublicKeyCredentialRequest, + selectedCipherView = selectedCipherView, + ) } - return vaultSdkSource - .registerFido2Credential( - request = RegisterFido2CredentialRequest( - userId = userId, - origin = sdkOrigin, - requestJson = """{"publicKey": ${fido2CreateCredentialRequest.requestJson}}""", - clientData = clientData, - selectedCipherView = selectedCipherView, - // User verification is handled prior to engaging the SDK. We always respond - // `true` so that the SDK does not fail if the relying party requests UV. - isUserVerificationSupported = true, - ), - fido2CredentialStore = this, - ) - .map { it.toAndroidAttestationResponse() } - .mapCatching { json.encodeToString(it) } - .fold( - onSuccess = { Fido2RegisterCredentialResult.Success(it) }, - onFailure = { - Fido2RegisterCredentialResult.Error( - R.string.passkey_registration_failed_due_to_an_internal_error.asText(), - ) - }, - ) } - private suspend fun validateOrigin( + private suspend fun registerFido2CredentialForUnprivilegedApp( + userId: String, callingAppInfo: CallingAppInfo, - relyingPartyId: String, - ): Fido2ValidateOriginResult = fido2OriginManager - .validateOrigin( - callingAppInfo = callingAppInfo, - relyingPartyId = relyingPartyId, + createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest, + selectedCipherView: CipherView, + ): Fido2RegisterCredentialResult { + val clientData = ClientData.DefaultWithExtraData(callingAppInfo.packageName) + + val host = getOriginUrlFromAttestationOptionsOrNull( + requestJson = createPublicKeyCredentialRequest.requestJson, + ) + ?: return Fido2RegisterCredentialResult.Error.MissingHostUrl + + val signatureFingerprint = callingAppInfo + .getSignatureFingerprintAsHexString() + ?: return Fido2RegisterCredentialResult.Error.InvalidAppSignature + + val sdkOrigin = Origin.Android( + UnverifiedAssetLink( + packageName = callingAppInfo.packageName, + sha256CertFingerprint = signatureFingerprint, + host = host, + assetLinkUrl = host, + ), + ) + + return registerFido2CredentialInternal( + userId = userId, + sdkOrigin = sdkOrigin, + createPublicKeyCredentialRequest = createPublicKeyCredentialRequest, + selectedCipherView = selectedCipherView, + clientData = clientData, + ) + } + + private suspend fun registerFido2CredentialForPrivilegedApp( + userId: String, + callingAppInfo: CallingAppInfo, + createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest, + selectedCipherView: CipherView, + ): Fido2RegisterCredentialResult { + val clientData = callingAppInfo + .getAppSigningSignatureFingerprint() + ?.let { ClientData.DefaultWithCustomHash(hash = it) } + ?: return Fido2RegisterCredentialResult.Error.InvalidAppSignature + + val sdkOrigin = createPublicKeyCredentialRequest.origin + ?.let { Origin.Web(it) } + ?: return Fido2RegisterCredentialResult.Error.MissingHostUrl + + return registerFido2CredentialInternal( + userId = userId, + sdkOrigin = sdkOrigin, + createPublicKeyCredentialRequest = createPublicKeyCredentialRequest, + selectedCipherView = selectedCipherView, + clientData = clientData, + ) + } + + private suspend fun registerFido2CredentialInternal( + userId: String, + sdkOrigin: Origin, + createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest, + selectedCipherView: CipherView, + clientData: ClientData, + ): Fido2RegisterCredentialResult = vaultSdkSource + .registerFido2Credential( + request = RegisterFido2CredentialRequest( + userId = userId, + origin = sdkOrigin, + requestJson = """{"publicKey": ${createPublicKeyCredentialRequest.requestJson}}""", + clientData = clientData, + selectedCipherView = selectedCipherView, + // User verification is handled prior to engaging the SDK. We always respond + // `true` so that the SDK does not fail if the relying party requests UV. + isUserVerificationSupported = true, + ), + fido2CredentialStore = this, + ) + .map { it.toAndroidAttestationResponse() } + .mapCatching { json.encodeToString(it) } + .fold( + onSuccess = { Fido2RegisterCredentialResult.Success(it) }, + onFailure = { Fido2RegisterCredentialResult.Error.InternalError }, ) override fun getPasskeyAttestationOptionsOrNull( @@ -148,85 +180,80 @@ class Fido2CredentialManagerImpl( @Suppress("LongMethod") override suspend fun authenticateFido2Credential( userId: String, - request: Fido2CredentialAssertionRequest, + callingAppInfo: CallingAppInfo, + request: GetPublicKeyCredentialOption, selectedCipherView: CipherView, + origin: String?, ): Fido2CredentialAssertionResult { - val callingAppInfo = request.callingAppInfo val clientData = request.clientDataHash ?.let { ClientData.DefaultWithCustomHash(hash = it) } ?: ClientData.DefaultWithExtraData(androidPackageName = callingAppInfo.getAppOrigin()) - val relyingPartyId = json - .decodeFromStringOrNull(request.requestJson) - ?.relyingPartyId - ?: return Fido2CredentialAssertionResult.Error( - R.string.passkey_operation_failed_because_relying_party_cannot_be_identified - .asText(), - ) - val validateOriginResult = validateOrigin( - callingAppInfo = callingAppInfo, - relyingPartyId = relyingPartyId, - ) - - val sdkOrigin = if (!request.origin.isNullOrEmpty()) { - Origin.Web(request.origin) + val sdkOrigin = if (!origin.isNullOrEmpty()) { + Origin.Web(origin) } else { val hostUrl = getOriginUrlFromAssertionOptionsOrNull(request.requestJson) - ?: return Fido2CredentialAssertionResult.Error( - R.string.passkey_operation_failed_because_host_url_is_not_present_in_request - .asText(), - ) + ?: return Fido2CredentialAssertionResult.Error.MissingHostUrl Origin.Android( UnverifiedAssetLink( packageName = callingAppInfo.packageName, - sha256CertFingerprint = callingAppInfo.getSignatureFingerprintAsHexString() - ?: return Fido2CredentialAssertionResult.Error( - R.string.passkey_operation_failed_because_app_signature_is_invalid - .asText(), - ), + sha256CertFingerprint = callingAppInfo + .getSignatureFingerprintAsHexString() + ?: return Fido2CredentialAssertionResult + .Error + .InvalidAppSignature, host = hostUrl, assetLinkUrl = hostUrl, ), ) } - - return when (validateOriginResult) { - is Fido2ValidateOriginResult.Error -> { - Fido2CredentialAssertionResult.Error(validateOriginResult.messageResId.asText()) - } - - is Fido2ValidateOriginResult.Success -> { - vaultSdkSource - .authenticateFido2Credential( - request = AuthenticateFido2CredentialRequest( - userId = userId, - origin = sdkOrigin, - requestJson = """{"publicKey": ${request.requestJson}}""", - clientData = clientData, - selectedCipherView = selectedCipherView, - isUserVerificationSupported = true, - ), - fido2CredentialStore = this, - ) - .map { it.toAndroidFido2PublicKeyCredential() } - .mapCatching { json.encodeToString(it) } - .fold( - onSuccess = { Fido2CredentialAssertionResult.Success(it) }, - onFailure = { - Timber.e(it, "Failed to authenticate FIDO2 credential.") - Fido2CredentialAssertionResult.Error( - R.string.passkey_authentication_failed_due_to_an_internal_error - .asText(), - ) - }, - ) - } - } + return vaultSdkSource + .authenticateFido2Credential( + request = AuthenticateFido2CredentialRequest( + userId = userId, + origin = sdkOrigin, + requestJson = """{"publicKey": ${request.requestJson}}""", + clientData = clientData, + selectedCipherView = selectedCipherView, + isUserVerificationSupported = true, + ), + fido2CredentialStore = this, + ) + .map { it.toAndroidFido2PublicKeyCredential() } + .mapCatching { json.encodeToString(it) } + .fold( + onSuccess = { Fido2CredentialAssertionResult.Success(it) }, + onFailure = { + Timber.e(it, "Failed to authenticate FIDO2 credential.") + Fido2CredentialAssertionResult.Error.InternalError + }, + ) } override fun hasAuthenticationAttemptsRemaining(): Boolean = authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS + override fun getUserVerificationRequirement( + request: ProviderGetCredentialRequest, + fallbackRequirement: UserVerificationRequirement, + ): UserVerificationRequirement = request + .credentialOptions + .filterIsInstance() + .firstOrNull() + ?.let { option -> + getPasskeyAssertionOptionsOrNull(option.requestJson) + ?.userVerification + } + ?: fallbackRequirement + + override fun getUserVerificationRequirement( + request: CreatePublicKeyCredentialRequest, + fallbackRequirement: UserVerificationRequirement, + ): UserVerificationRequirement = getPasskeyAttestationOptionsOrNull(request.requestJson) + ?.authenticatorSelection + ?.userVerification + ?: fallbackRequirement + private fun getOriginUrlFromAssertionOptionsOrNull(requestJson: String) = getPasskeyAssertionOptionsOrNull(requestJson) ?.relyingPartyId @@ -240,4 +267,3 @@ class Fido2CredentialManagerImpl( } private const val MAX_AUTHENTICATION_ATTEMPTS = 5 -private const val HTTPS = "https://" diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CreateCredentialRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CreateCredentialRequest.kt index 14586c3b88..cbf5dff79b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CreateCredentialRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CreateCredentialRequest.kt @@ -1,31 +1,66 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import android.content.pm.SigningInfo +import android.os.Bundle import android.os.Parcelable +import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.ProviderCreateCredentialRequest +import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize /** * Represents raw data from the a user deciding to create a passkey in their vault via the * credential manager framework. * - * @property userId The user under which the passkey should be saved. - * @property requestJson JSON payload containing the RP request. - * @property callingAppInfo Information about the application that initiated the request. + * @property userId The ID of the user creating the passkey. + * @property requestData Provider request data in the form of a [Bundle]. */ @Parcelize data class Fido2CreateCredentialRequest( val userId: String, - val requestJson: String, - val packageName: String, - val signingInfo: SigningInfo, - val origin: String?, - val isUserVerified: Boolean?, + val requestData: Bundle, ) : Parcelable { - val callingAppInfo: CallingAppInfo - get() = CallingAppInfo( - packageName = packageName, - signingInfo = signingInfo, - origin = origin, - ) + + /** + * The [ProviderCreateCredentialRequest] from the [requestData]. + */ + @IgnoredOnParcel + val providerRequest: ProviderCreateCredentialRequest by lazy { + ProviderCreateCredentialRequest.fromBundle(requestData) + } + + /** + * The [CallingAppInfo] of the [providerRequest]. + */ + @IgnoredOnParcel + val callingAppInfo: CallingAppInfo by lazy { providerRequest.callingAppInfo } + + /** + * The [CreatePublicKeyCredentialRequest] of the [providerRequest], or null if the calling + * request is not a [CreatePublicKeyCredentialRequest]. + */ + @IgnoredOnParcel + val createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest? by lazy { + providerRequest.callingRequest as? CreatePublicKeyCredentialRequest + } + + /** + * The [requestJson] of the [createPublicKeyCredentialRequest], or null if the calling request + * is not a [CreatePublicKeyCredentialRequest]. + */ + @IgnoredOnParcel + val requestJson: String? by lazy { createPublicKeyCredentialRequest?.requestJson } + + /** + * Returns the ID of the relying party, or null if the relying party cannot be identified. + */ + @IgnoredOnParcel + val relyingPartyIdOrNull: String? by lazy { + if (callingAppInfo.isOriginPopulated()) { + providerRequest.callingRequest.origin?.toHostOrPathOrNull() + } else { + callingAppInfo.packageName + } + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionRequest.kt index 293e41759c..5d0acc8e5d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionRequest.kt @@ -1,38 +1,50 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import android.content.pm.SigningInfo +import android.os.Bundle import android.os.Parcelable +import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.ProviderGetCredentialRequest +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize /** * Models a FIDO 2 credential authentication request parsed from the launching intent. * - * @param userId The ID of the Bitwarden user to authenticate. - * @param cipherId The ID of the cipher that contains the passkey to authenticate. - * @param credentialId The ID of the credential to authenticate. - * @param requestJson The JSON representation of the FIDO 2 request. - * @param clientDataHash The hash of the client data. - * @param packageName The package name of the calling app. - * @param signingInfo The signing info of the calling app. - * @param origin The origin of the calling app. Only populated if the calling application is a - * privileged application. I.e., a web browser. - * @param isUserVerified Whether the user has been verified prior to receiving this request. Only - * populated if device biometric verification was performed. If null, the application is responsible - * for prompting user verification when it is deemed necessary. + * @param userId ID of the user requesting credential authentication. + * @param cipherId ID of the cipher to be authenticated against. + * @param credentialId ID of the credential to authenticate. + * @param requestData Provider request data in the form of a [Bundle]. */ @Parcelize data class Fido2CredentialAssertionRequest( val userId: String, - val cipherId: String?, - val credentialId: String?, - val requestJson: String, - val clientDataHash: ByteArray?, - val packageName: String, - val signingInfo: SigningInfo, - val origin: String?, - val isUserVerified: Boolean?, + val cipherId: String, + val credentialId: String, + private val requestData: Bundle, ) : Parcelable { - val callingAppInfo: CallingAppInfo - get() = CallingAppInfo(packageName, signingInfo, origin) + + /** + * The [ProviderGetCredentialRequest] from the [requestData]. + */ + @IgnoredOnParcel + val providerRequest: ProviderGetCredentialRequest by lazy { + ProviderGetCredentialRequest.fromBundle(requestData) + } + + /** + * The [CallingAppInfo] from the [providerRequest]. + */ + @IgnoredOnParcel + val callingAppInfo: CallingAppInfo by lazy { providerRequest.callingAppInfo } + + /** + * The [GetPublicKeyCredentialOption] from the [providerRequest], or null if one is not found + * in the request options list. + */ + @IgnoredOnParcel + val option: GetPublicKeyCredentialOption? by lazy { + providerRequest.credentialOptions + .firstNotNullOfOrNull { it as? GetPublicKeyCredentialOption } + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionResult.kt index fe1268436f..266445a96f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionResult.kt @@ -1,7 +1,5 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import com.bitwarden.ui.util.Text - /** * Represents possible outcomes of a FIDO 2 credential assertion request. */ @@ -15,5 +13,35 @@ sealed class Fido2CredentialAssertionResult { /** * Indicates there was an error and the assertion was not successful. */ - data class Error(val message: Text) : Fido2CredentialAssertionResult() + sealed class Error : Fido2CredentialAssertionResult() { + /** + * Indicates the relying party ID was missing from the request. + */ + data object MissingRpId : Error() + + /** + * Indicates the host URL was missing from the request. + */ + data object MissingHostUrl : Error() + + /** + * Indicates the calling application signature was invalid. + */ + data object InvalidAppSignature : Error() + + /** + * Indicates origin validation failed. + * + * @property originValidationError The specific error that caused the origin validation to + * fail. + */ + data class OriginValidationFailed( + val originValidationError: Fido2ValidateOriginResult.Error, + ) : Error() + + /** + * Indicates an internal error occurred. + */ + data object InternalError : Error() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsRequest.kt index b32e6fb6bf..46699e1dec 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsRequest.kt @@ -1,34 +1,62 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import android.content.pm.SigningInfo import android.os.Bundle import android.os.Parcelable +import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetPublicKeyCredentialOption import androidx.credentials.provider.CallingAppInfo +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize /** * Models a FIDO 2 request to retrieve FIDO credentials parsed from the launching intent. + * + * @param userId The ID of the user's vault to search. + * @param requestData Provider request data in the form of a [Bundle]. */ @Parcelize data class Fido2GetCredentialsRequest( - val candidateQueryData: Bundle, - val id: String, val userId: String, - val requestJson: String, - val clientDataHash: ByteArray? = null, - val packageName: String, - val signingInfo: SigningInfo, - val origin: String?, + private val requestData: Bundle, ) : Parcelable { - val callingAppInfo: CallingAppInfo - get() = CallingAppInfo(packageName, signingInfo, origin) + /** + * The [BeginGetCredentialRequest] from the [requestData], or null if the [requestData] does not + * contain a [BeginGetCredentialRequest]. + */ + @IgnoredOnParcel + val providerRequest: BeginGetCredentialRequest? by lazy { + BeginGetCredentialRequest.fromBundle(requestData) + } - val option: BeginGetPublicKeyCredentialOption - get() = BeginGetPublicKeyCredentialOption( - candidateQueryData, - id, - requestJson, - clientDataHash, - ) + /** + * The first [BeginGetPublicKeyCredentialOption] of the [providerRequest], or null if the + * [providerRequest] is not a [BeginGetCredentialRequest] or does not contain a + * [BeginGetPublicKeyCredentialOption]. + */ + @IgnoredOnParcel + val beginGetPublicKeyCredentialOption: BeginGetPublicKeyCredentialOption? by lazy { + providerRequest + ?.beginGetCredentialOptions + ?.filterIsInstance() + ?.firstOrNull() + } + + /** + * The [CallingAppInfo] of the [providerRequest], or null if the [providerRequest] is not a + * [BeginGetCredentialRequest]. + */ + @IgnoredOnParcel + val callingAppInfo: CallingAppInfo? by lazy { providerRequest?.callingAppInfo } + + /** + * The first [BeginGetPublicKeyCredentialOption] of the [providerRequest], or null if the + * [providerRequest] does not contain a [BeginGetPublicKeyCredentialOption]. + */ + @IgnoredOnParcel + val option: BeginGetPublicKeyCredentialOption? by lazy { + providerRequest?.beginGetCredentialOptions + ?.firstNotNullOfOrNull { + it as? BeginGetPublicKeyCredentialOption + } + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2RegisterCredentialResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2RegisterCredentialResult.kt index cfcabd5e22..696011dc9e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2RegisterCredentialResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2RegisterCredentialResult.kt @@ -1,7 +1,5 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import com.bitwarden.ui.util.Text - /** * Models the data returned from creating a FIDO 2 credential. */ @@ -17,10 +15,21 @@ sealed class Fido2RegisterCredentialResult { /** * Indicates there was an error and the credential was not registered. */ - data class Error(val message: Text) : Fido2RegisterCredentialResult() + sealed class Error : Fido2RegisterCredentialResult() { - /** - * Indicates the user cancelled the request. - */ - data object Cancelled : Fido2RegisterCredentialResult() + /** + * Indicates the host URL was missing from the request. + */ + data object MissingHostUrl : Error() + + /** + * Indicates the app signature was invalid. + */ + data object InvalidAppSignature : Error() + + /** + * Indicates an internal error occurred. + */ + data object InternalError : Error() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt index 34ade4824f..68fd5a6e11 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt @@ -1,8 +1,5 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import androidx.annotation.StringRes -import com.x8bit.bitwarden.R - /** * Models the result of validating the origin of a FIDO2 request. */ @@ -19,66 +16,42 @@ sealed class Fido2ValidateOriginResult { * Represents a validation error. */ sealed class Error : Fido2ValidateOriginResult() { - /** - * The string resource ID of the error message. - */ - @get:StringRes - abstract val messageResId: Int /** * Indicates the digital asset links file could not be located. */ - data object AssetLinkNotFound : Error() { - override val messageResId = - R.string.passkey_operation_failed_because_of_missing_asset_links - } + data object AssetLinkNotFound : Error() /** * Indicates the application package name was not found in the digital asset links file. */ - data object ApplicationNotFound : Error() { - override val messageResId = - R.string.passkey_operation_failed_because_app_not_found_in_asset_links - } + data object ApplicationNotFound : Error() /** * Indicates the application fingerprint was not found the digital asset links file. */ - data object ApplicationFingerprintNotVerified : Error() { - override val messageResId = - R.string.passkey_operation_failed_because_app_could_not_be_verified - } + data object ApplicationFingerprintNotVerified : Error() /** * Indicates the calling application is privileged but its package name is not found within * the privileged app allow list. */ - data object PrivilegedAppNotAllowed : Error() { - override val messageResId = - R.string.passkey_operation_failed_because_browser_is_not_privileged - } + data object PrivilegedAppNotAllowed : Error() /** * Indicates the calling app is privileged but but no matching signing certificate signature * is present in the allow list. */ - data object PrivilegedAppSignatureNotFound : Error() { - override val messageResId = - R.string.passkey_operation_failed_because_browser_signature_does_not_match - } + data object PrivilegedAppSignatureNotFound : Error() /** * Indicates passkeys are not supported for the requesting application. */ - data object PasskeyNotSupportedForApp : Error() { - override val messageResId = R.string.passkeys_not_supported_for_this_app - } + data object PasskeyNotSupportedForApp : Error() /** * Indicates an unknown error was encountered while validating the origin. */ - data object Unknown : Error() { - override val messageResId = R.string.generic_error_message - } + data object Unknown : Error() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtils.kt index 1ec6d30bcc..573d12e4c4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtils.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtils.kt @@ -2,10 +2,10 @@ package com.x8bit.bitwarden.data.autofill.fido2.util import android.content.Intent import android.os.Build -import androidx.credentials.CreatePublicKeyCredentialRequest -import androidx.credentials.GetPublicKeyCredentialOption -import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.PendingIntentHandler +import androidx.credentials.provider.ProviderCreateCredentialRequest +import androidx.credentials.provider.ProviderGetCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest @@ -21,13 +21,7 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_USER_ID fun Intent.getFido2CreateCredentialRequestOrNull(): Fido2CreateCredentialRequest? { if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null - val systemRequest = PendingIntentHandler - .retrieveProviderCreateCredentialRequest(this) - ?: return null - - val createPublicKeyRequest = systemRequest - .callingRequest - as? CreatePublicKeyCredentialRequest + val systemRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(this) ?: return null val userId = getStringExtra(EXTRA_KEY_USER_ID) @@ -35,11 +29,7 @@ fun Intent.getFido2CreateCredentialRequestOrNull(): Fido2CreateCredentialRequest return Fido2CreateCredentialRequest( userId = userId, - requestJson = createPublicKeyRequest.requestJson, - packageName = systemRequest.callingAppInfo.packageName, - signingInfo = systemRequest.callingAppInfo.signingInfo, - origin = systemRequest.callingAppInfo.origin, - isUserVerified = systemRequest.biometricPromptResult?.isSuccessful, + requestData = ProviderCreateCredentialRequest.asBundle(systemRequest), ) } @@ -54,11 +44,6 @@ fun Intent.getFido2AssertionRequestOrNull(): Fido2CredentialAssertionRequest? { .retrieveProviderGetCredentialRequest(this) ?: return null - val option: GetPublicKeyCredentialOption = systemRequest - .credentialOptions - .firstNotNullOfOrNull { it as? GetPublicKeyCredentialOption } - ?: return null - val credentialId = getStringExtra(EXTRA_KEY_CREDENTIAL_ID) ?: return null @@ -68,18 +53,11 @@ fun Intent.getFido2AssertionRequestOrNull(): Fido2CredentialAssertionRequest? { val userId: String = getStringExtra(EXTRA_KEY_USER_ID) ?: return null - val isUserVerified = systemRequest.biometricPromptResult?.isSuccessful - return Fido2CredentialAssertionRequest( userId = userId, cipherId = cipherId, credentialId = credentialId, - requestJson = option.requestJson, - clientDataHash = option.clientDataHash, - packageName = systemRequest.callingAppInfo.packageName, - signingInfo = systemRequest.callingAppInfo.signingInfo, - origin = systemRequest.callingAppInfo.origin, - isUserVerified = isUserVerified, + requestData = ProviderGetCredentialRequest.asBundle(systemRequest), ) } @@ -94,26 +72,11 @@ fun Intent.getFido2GetCredentialsRequestOrNull(): Fido2GetCredentialsRequest? { .retrieveBeginGetCredentialRequest(this) ?: return null - val option: BeginGetPublicKeyCredentialOption = systemRequest - .beginGetCredentialOptions - .firstNotNullOfOrNull { it as? BeginGetPublicKeyCredentialOption } - ?: return null - - val callingAppInfo = systemRequest - .callingAppInfo - ?: return null - val userId: String = getStringExtra(EXTRA_KEY_USER_ID) ?: return null return Fido2GetCredentialsRequest( - candidateQueryData = option.candidateQueryData, - id = option.id, userId = userId, - requestJson = option.requestJson, - clientDataHash = option.clientDataHash, - packageName = callingAppInfo.packageName, - signingInfo = callingAppInfo.signingInfo, - origin = callingAppInfo.origin, + requestData = BeginGetCredentialRequest.asBundle(systemRequest), ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/ProviderCreateCredentialRequestExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/ProviderCreateCredentialRequestExtensions.kt new file mode 100644 index 0000000000..b50a6e3707 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/ProviderCreateCredentialRequestExtensions.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.data.autofill.fido2.util + +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.provider.ProviderCreateCredentialRequest + +/** + * Retrieves the [CreatePublicKeyCredentialRequest] from a [ProviderCreateCredentialRequest], + * otherwise null. + */ +@Suppress("MaxLineLength") +fun ProviderCreateCredentialRequest.getCreatePasskeyCredentialRequestOrNull(): CreatePublicKeyCredentialRequest? = + callingRequest as? CreatePublicKeyCredentialRequest diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt index 912816cbf7..6941241473 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt @@ -3,21 +3,8 @@ package com.x8bit.bitwarden.data.platform.util import android.util.Base64 import androidx.credentials.provider.CallingAppInfo import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult -import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull import java.security.MessageDigest -/** - * Returns the name of the RP. If this [CallingAppInfo] is a privileged app the RP host name will be - * returned. If this [CallingAppInfo] is a native RP application the package name will be returned. - * Otherwise, `null` is returned. - */ -fun CallingAppInfo.getFido2RpIdOrNull(): String? = - if (isOriginPopulated()) { - origin?.toHostOrPathOrNull() - } else { - packageName - } - /** * Returns the application's signing certificate hash formatted as a hex string if it has a single * signing certificate. Otherwise `null` is returned. 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 799134a104..34fba3e63e 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 @@ -36,8 +36,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel 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.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 @@ -45,6 +43,8 @@ import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenKeyboard 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.fido2.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.GetFido2CredentialsResult import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.cardStyle import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin @@ -130,13 +130,13 @@ fun VaultUnlockScreen( is VaultUnlockEvent.Fido2CredentialAssertionError -> { fido2CompletionManager.completeFido2Assertion( - result = Fido2CredentialAssertionResult.Error(event.message), + result = AssertFido2CredentialResult.Error(message = event.message), ) } is VaultUnlockEvent.Fido2GetCredentialsError -> { - fido2CompletionManager.completeFido2GetCredentialRequest( - result = Fido2GetCredentialsResult.Error(message = event.message), + fido2CompletionManager.completeFido2GetCredentialsRequest( + result = GetFido2CredentialsResult.Error(message = event.message), ) } } 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 index d477f93fcd..028b11902f 100644 --- 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 @@ -1,8 +1,8 @@ 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 +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.GetFido2CredentialsResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult /** * A manager for completing the FIDO 2 creation process. @@ -12,15 +12,15 @@ interface Fido2CompletionManager { /** * Completes the FIDO 2 registration process with the provided [result]. */ - fun completeFido2Registration(result: Fido2RegisterCredentialResult) + fun completeFido2Registration(result: RegisterFido2CredentialResult) /** * Complete the FIDO 2 credential assertion process with the provided [result]. */ - fun completeFido2Assertion(result: Fido2CredentialAssertionResult) + fun completeFido2Assertion(result: AssertFido2CredentialResult) /** * Complete the FIDO 2 "Get credentials" process with the provided [result]. */ - fun completeFido2GetCredentialRequest(result: Fido2GetCredentialsResult) + fun completeFido2GetCredentialsRequest(result: GetFido2CredentialsResult) } 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 index ace38acab2..8b357e5e4e 100644 --- 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 @@ -10,15 +10,16 @@ import androidx.credentials.GetCredentialResponse import androidx.credentials.PublicKeyCredential import androidx.credentials.exceptions.CreateCredentialCancellationException import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialCancellationException 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.autofill.fido2.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.GetFido2CredentialsResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import kotlin.random.Random @@ -32,19 +33,21 @@ class Fido2CompletionManagerImpl( private val intentManager: IntentManager, ) : Fido2CompletionManager { - override fun completeFido2Registration(result: Fido2RegisterCredentialResult) { + override fun completeFido2Registration(result: RegisterFido2CredentialResult) { activity.also { val intent = Intent() when (result) { - is Fido2RegisterCredentialResult.Error -> { + is RegisterFido2CredentialResult.Error -> { PendingIntentHandler .setCreateCredentialException( intent = intent, - exception = CreateCredentialUnknownException(), + exception = CreateCredentialUnknownException( + errorMessage = result.message.invoke(it.resources), + ), ) } - is Fido2RegisterCredentialResult.Success -> { + is RegisterFido2CredentialResult.Success -> { PendingIntentHandler .setCreateCredentialResponse( intent = intent, @@ -54,7 +57,7 @@ class Fido2CompletionManagerImpl( ) } - is Fido2RegisterCredentialResult.Cancelled -> { + is RegisterFido2CredentialResult.Cancelled -> { PendingIntentHandler .setCreateCredentialException( intent = intent, @@ -67,19 +70,21 @@ class Fido2CompletionManagerImpl( } } - override fun completeFido2Assertion(result: Fido2CredentialAssertionResult) { + override fun completeFido2Assertion(result: AssertFido2CredentialResult) { activity.also { val intent = Intent() when (result) { - is Fido2CredentialAssertionResult.Error -> { + is AssertFido2CredentialResult.Error -> { PendingIntentHandler .setGetCredentialException( intent = intent, - exception = GetCredentialUnknownException(), + exception = GetCredentialUnknownException( + errorMessage = result.message.invoke(it.resources), + ), ) } - is Fido2CredentialAssertionResult.Success -> { + is AssertFido2CredentialResult.Success -> { PendingIntentHandler .setGetCredentialResponse( intent = intent, @@ -88,17 +93,25 @@ class Fido2CompletionManagerImpl( ), ) } + + is AssertFido2CredentialResult.Cancelled -> { + PendingIntentHandler + .setGetCredentialException( + intent = intent, + exception = GetCredentialCancellationException(), + ) + } } it.setResult(Activity.RESULT_OK, intent) it.finish() } } - override fun completeFido2GetCredentialRequest(result: Fido2GetCredentialsResult) { + override fun completeFido2GetCredentialsRequest(result: GetFido2CredentialsResult) { val resultIntent = Intent() val responseBuilder = BeginGetCredentialResponse.Builder() when (result) { - is Fido2GetCredentialsResult.Success -> { + is GetFido2CredentialsResult.Success -> { val entries = result .credentials .map { @@ -116,7 +129,7 @@ class Fido2CompletionManagerImpl( username = it.userNameForUi ?: activity.getString(R.string.no_username), pendingIntent = pendingIntent, - beginGetPublicKeyCredentialOption = result.options, + beginGetPublicKeyCredentialOption = result.option, ) .setIcon( Icon @@ -139,11 +152,11 @@ class Fido2CompletionManagerImpl( ) } - is Fido2GetCredentialsResult.Error -> { + is GetFido2CredentialsResult.Error -> { PendingIntentHandler.setGetCredentialException( resultIntent, GetCredentialUnknownException( - errorMessage = result.message.toString(activity.resources), + errorMessage = result.message.invoke(activity.resources), ), ) } 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 index f24eddc485..b2580964e5 100644 --- 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 @@ -1,17 +1,17 @@ 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 +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.GetFido2CredentialsResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult /** * 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 completeFido2Registration(result: RegisterFido2CredentialResult) = Unit - override fun completeFido2Assertion(result: Fido2CredentialAssertionResult) = Unit + override fun completeFido2Assertion(result: AssertFido2CredentialResult) = Unit - override fun completeFido2GetCredentialRequest(result: Fido2GetCredentialsResult) = Unit + override fun completeFido2GetCredentialsRequest(result: GetFido2CredentialsResult) = Unit } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/model/AssertFido2CredentialResult.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/model/AssertFido2CredentialResult.kt new file mode 100644 index 0000000000..6c7f2715d9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/model/AssertFido2CredentialResult.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.ui.autofill.fido2.manager.model + +import com.bitwarden.ui.util.Text + +/** + * Represents the result of FIDO2 authentication. + */ +sealed class AssertFido2CredentialResult { + /** + * Represents a successful authentication of FIDO2 credentials. + */ + data class Success(val responseJson: String) : AssertFido2CredentialResult() + + /** + * Indicates the user cancelled authentication. + */ + data object Cancelled : AssertFido2CredentialResult() + + /** + * Represents an error during FIDO2 credential assertion. + */ + data class Error(val message: Text) : AssertFido2CredentialResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/model/GetFido2CredentialsResult.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/model/GetFido2CredentialsResult.kt new file mode 100644 index 0000000000..27d1d19828 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/model/GetFido2CredentialsResult.kt @@ -0,0 +1,28 @@ +package com.x8bit.bitwarden.ui.autofill.fido2.manager.model + +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import com.bitwarden.fido.Fido2CredentialAutofillView +import com.bitwarden.ui.util.Text + +/** + * Represents the result of fetching FIDO2 credentials. + */ +sealed class GetFido2CredentialsResult { + + /** + * Represents a successful result with a list of matching credentials, the original request + * option, and associated user ID. + */ + data class Success( + val credentials: List, + val option: BeginGetPublicKeyCredentialOption, + val userId: String, + ) : GetFido2CredentialsResult() + + /** + * Represents an error result with a message. + * + * @property message The error message. + */ + data class Error(val message: Text) : GetFido2CredentialsResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/model/RegisterFido2CredentialResult.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/model/RegisterFido2CredentialResult.kt new file mode 100644 index 0000000000..7b6dadc23a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/model/RegisterFido2CredentialResult.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.ui.autofill.fido2.manager.model + +import com.bitwarden.ui.util.Text + +/** + * Represents the result of a FIDO2 credential registration attempt. + */ +sealed class RegisterFido2CredentialResult { + /** + * Indicates that the registration was successful. + */ + data class Success(val responseJson: String) : RegisterFido2CredentialResult() + + /** + * Indicates that an error occurred during registration. + */ + data class Error(val message: Text) : RegisterFido2CredentialResult() + + /** + * Indicates that the registration was cancelled by the user. + */ + data object Cancelled : RegisterFido2CredentialResult() +} 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 92a60f4750..1c99264b8b 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 @@ -133,21 +133,24 @@ class RootNavViewModel @Inject constructor( is SpecialCircumstance.Fido2Save -> { RootNavState.VaultUnlockedForFido2Save( activeUserId = userState.activeUserId, - fido2CreateCredentialRequest = specialCircumstance.fido2CreateCredentialRequest, + fido2CreateCredentialRequest = + specialCircumstance.fido2CreateCredentialRequest, ) } is SpecialCircumstance.Fido2Assertion -> { RootNavState.VaultUnlockedForFido2Assertion( activeUserId = userState.activeUserId, - fido2CredentialAssertionRequest = specialCircumstance.fido2AssertionRequest, + fido2CredentialAssertionRequest = + specialCircumstance.fido2AssertionRequest, ) } is SpecialCircumstance.Fido2GetCredentials -> { RootNavState.VaultUnlockedForFido2GetCredentials( activeUserId = userState.activeUserId, - fido2GetCredentialsRequest = specialCircumstance.fido2GetCredentialsRequest, + fido2GetCredentialsRequest = + specialCircumstance.fido2GetCredentialsRequest, ) } 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 6e3d5c27f6..3c471fa1d8 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 @@ -1,6 +1,9 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit import android.os.Parcelable +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.DateTime @@ -20,6 +23,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement +import com.x8bit.bitwarden.data.autofill.fido2.util.getCreatePasskeyCredentialRequestOrNull import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager @@ -44,6 +48,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager @@ -58,6 +63,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toDefaultAddTypeContent import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toItemType import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState import com.x8bit.bitwarden.ui.vault.feature.addedit.util.validateCipherOrReturnErrorState +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.messageResourceId import com.x8bit.bitwarden.ui.vault.feature.util.canAssignToCollections import com.x8bit.bitwarden.ui.vault.feature.util.hasDeletePermissionInAtLeastOneCollection import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView @@ -134,13 +140,14 @@ class VaultAddEditViewModel @Inject constructor( val totpData = specialCircumstance?.toTotpDataOrNull() // Check for Fido2 data to pre-populate val fido2CreationRequest = specialCircumstance?.toFido2CreateRequestOrNull() - val fido2AttestationOptions = fido2CreationRequest?.let { request -> - fido2CredentialManager.getPasskeyAttestationOptionsOrNull(request.requestJson) - } + val fido2AttestationOptions = fido2CreationRequest?.requestJson + ?.let { request -> + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(request) + } // Exit on save if handling an autofill, Fido2 Attestation, or TOTP link val shouldExitOnSave = autofillSaveItem != null || - fido2AttestationOptions != null + fido2CreationRequest != null val dialogState = if (!settingsRepository.initialAutofillDialogShown && vaultAddEditType is VaultAddEditType.AddItem && @@ -180,8 +187,9 @@ class VaultAddEditViewModel @Inject constructor( dialog = dialogState, bottomSheetState = null, totpData = totpData, + fido2CreateCredentialRequest = fido2CreationRequest, // Set special conditions for autofill and fido2 save - shouldShowCloseButton = autofillSaveItem == null && fido2AttestationOptions == null, + shouldShowCloseButton = autofillSaveItem == null && fido2CreationRequest == null, shouldExitOnSave = shouldExitOnSave, shouldShowCoachMarkTour = false, shouldClearSpecialCircumstance = autofillSelectionData == null, @@ -405,10 +413,12 @@ class VaultAddEditViewModel @Inject constructor( ) } - specialCircumstanceManager.specialCircumstance - ?.toFido2CreateRequestOrNull() + state.fido2CreateCredentialRequest ?.let { request -> - handleFido2RequestSpecialCircumstance(request, content.toCipherView()) + handleProviderCreateCredentialRequest( + request.providerRequest, + content.toCipherView(), + ) return@onContent } @@ -435,65 +445,67 @@ class VaultAddEditViewModel @Inject constructor( } } - private fun handleFido2RequestSpecialCircumstance( - request: Fido2CreateCredentialRequest, + private fun handleProviderCreateCredentialRequest( + request: ProviderCreateCredentialRequest, + cipherView: CipherView, + ) { + request + .getCreatePasskeyCredentialRequestOrNull() + ?.let { handleCreatePublicKeyCredentialRequest(request.callingAppInfo, it, cipherView) } + ?: run { handleUnsupportedProviderCreateCredentialRequest() } + } + + private fun handleCreatePublicKeyCredentialRequest( + callingAppInfo: CallingAppInfo, + request: CreatePublicKeyCredentialRequest, cipherView: CipherView, ) { if (cipherView.isActiveWithFido2Credentials) { mutableStateFlow.update { it.copy(dialog = VaultAddEditState.DialogState.OverwritePasskeyConfirmationPrompt) } - } else { - registerFido2Credential(request, cipherView) - } - } - - private fun registerFido2Credential( - request: Fido2CreateCredentialRequest, - cipherView: CipherView, - ) { - - if (fido2CredentialManager.isUserVerified) { - registerFido2CredentialToCipher(request, cipherView) return } - val createOptions = fido2CredentialManager - .getPasskeyAttestationOptionsOrNull(request.requestJson) - ?: run { - showFido2ErrorDialog() - return - } + if (fido2CredentialManager.isUserVerified) { + registerFido2CredentialToCipher(callingAppInfo, request, cipherView) + return + } - when (createOptions.authenticatorSelection.userVerification) { + when (val requirement = fido2CredentialManager.getUserVerificationRequirement(request)) { UserVerificationRequirement.DISCOURAGED -> { - registerFido2CredentialToCipher(request, cipherView) + registerFido2CredentialToCipher(callingAppInfo, request, cipherView) } - UserVerificationRequirement.PREFERRED -> { - sendEvent(VaultAddEditEvent.Fido2UserVerification(isRequired = false)) - } - - UserVerificationRequirement.REQUIRED -> { - sendEvent(VaultAddEditEvent.Fido2UserVerification(isRequired = true)) + else -> { + sendEvent( + VaultAddEditEvent.Fido2UserVerification( + isRequired = requirement == UserVerificationRequirement.REQUIRED, + ), + ) } } } private fun registerFido2CredentialToCipher( - request: Fido2CreateCredentialRequest, + callingAppInfo: CallingAppInfo, + request: CreatePublicKeyCredentialRequest, cipherView: CipherView, ) { viewModelScope.launch { val userId = authRepository.activeUserId ?: run { - showFido2ErrorDialog() + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ) return@launch } val result: Fido2RegisterCredentialResult = fido2CredentialManager.registerFido2Credential( userId = userId, - fido2CreateCredentialRequest = request, + callingAppInfo = callingAppInfo, + createPublicKeyCredentialRequest = request, selectedCipherView = cipherView, ) sendAction( @@ -502,6 +514,12 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun handleUnsupportedProviderCreateCredentialRequest() { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_unsupported.asText(), + ) + } + private fun handleAttachmentsClick() { onEdit { sendEvent(VaultAddEditEvent.NavigateToAttachments(it.vaultItemId)) } } @@ -568,20 +586,27 @@ class VaultAddEditViewModel @Inject constructor( } private fun handleConfirmOverwriteExistingPasskeyClick() { - specialCircumstanceManager - .specialCircumstance - ?.toFido2CreateRequestOrNull() + state + .fido2CreateCredentialRequest + ?.providerRequest ?.let { request -> onContent { content -> - registerFido2Credential(request, content.toCipherView()) + handleProviderCreateCredentialRequest( + request, + content.toCipherView(), + ) } } - ?: showFido2ErrorDialog() + ?: showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), + ) } private fun handleUserVerificationLockOut() { fido2CredentialManager.isUserVerified = false - showFido2ErrorDialog() + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ) } private fun handleUserVerificationSuccess() { @@ -590,22 +615,25 @@ class VaultAddEditViewModel @Inject constructor( } private fun getRequestAndRegisterCredential() = - specialCircumstanceManager - .specialCircumstance - ?.toFido2CreateRequestOrNull() + state.fido2CreateCredentialRequest + ?.providerRequest ?.let { request -> onContent { content -> - registerFido2CredentialToCipher( + handleProviderCreateCredentialRequest( request = request, cipherView = content.toCipherView(), ) } } - ?: showFido2ErrorDialog() + ?: showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_unsupported.asText(), + ) private fun handleUserVerificationFail() { fido2CredentialManager.isUserVerified = false - showFido2ErrorDialog() + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ) } private fun handleFido2ErrorDialogDismissed( @@ -615,9 +643,7 @@ class VaultAddEditViewModel @Inject constructor( clearDialogState() sendEvent( VaultAddEditEvent.CompleteFido2Registration( - result = Fido2RegisterCredentialResult.Error( - message = action.message, - ), + result = RegisterFido2CredentialResult.Error(action.message), ), ) } @@ -627,7 +653,7 @@ class VaultAddEditViewModel @Inject constructor( clearDialogState() sendEvent( VaultAddEditEvent.CompleteFido2Registration( - result = Fido2RegisterCredentialResult.Cancelled, + result = RegisterFido2CredentialResult.Cancelled, ), ) } @@ -640,7 +666,9 @@ class VaultAddEditViewModel @Inject constructor( .value ?.activeAccount ?: run { - showFido2ErrorDialog() + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ) return } @@ -725,7 +753,10 @@ class VaultAddEditViewModel @Inject constructor( } private fun handleDismissFido2VerificationDialogClick() { - showFido2ErrorDialog() + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ) } private fun handleSelectOrAddFolderForItem() { @@ -1821,17 +1852,24 @@ class VaultAddEditViewModel @Inject constructor( when (action.result) { is Fido2RegisterCredentialResult.Error -> { sendEvent(VaultAddEditEvent.ShowToast(R.string.an_error_has_occurred.asText())) + sendEvent( + VaultAddEditEvent.CompleteFido2Registration( + RegisterFido2CredentialResult.Error( + action.result.messageResourceId.asText(), + ), + ), + ) } is Fido2RegisterCredentialResult.Success -> { sendEvent(VaultAddEditEvent.ShowToast(R.string.item_updated.asText())) - } - - Fido2RegisterCredentialResult.Cancelled -> { - // no-op: The OS will handle re-displaying the system prompt. + sendEvent( + VaultAddEditEvent.CompleteFido2Registration( + RegisterFido2CredentialResult.Success(action.result.responseJson), + ), + ) } } - sendEvent(VaultAddEditEvent.CompleteFido2Registration(action.result)) } private fun handleValidateFido2PasswordResultReceive( @@ -1841,7 +1879,10 @@ class VaultAddEditViewModel @Inject constructor( when (action.result) { is ValidatePasswordResult.Error -> { - showFido2ErrorDialog() + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ) } is ValidatePasswordResult.Success -> { @@ -1863,7 +1904,9 @@ class VaultAddEditViewModel @Inject constructor( when (action.result) { is ValidatePinResult.Error -> { - showFido2ErrorDialog() + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ) } is ValidatePinResult.Success -> { @@ -1885,7 +1928,9 @@ class VaultAddEditViewModel @Inject constructor( it.copy(dialog = errorDialogState) } } else { - showFido2ErrorDialog() + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ) } } @@ -1907,13 +1952,10 @@ class VaultAddEditViewModel @Inject constructor( mutableStateFlow.update { it.copy(dialog = null) } } - private fun showFido2ErrorDialog() { + private fun showFido2ErrorDialog(message: Text) { mutableStateFlow.update { it.copy( - dialog = VaultAddEditState.DialogState.Fido2Error( - message = R.string.passkey_operation_failed_because_user_could_not_be_verified - .asText(), - ), + dialog = VaultAddEditState.DialogState.Fido2Error(message), ) } } @@ -2080,6 +2122,7 @@ data class VaultAddEditState( val shouldExitOnSave: Boolean = false, val shouldClearSpecialCircumstance: Boolean = true, val totpData: TotpData? = null, + val fido2CreateCredentialRequest: Fido2CreateCredentialRequest? = null, private val shouldShowCoachMarkTour: Boolean, ) : Parcelable { @@ -2697,7 +2740,7 @@ sealed class VaultAddEditEvent { * @property result the result of FIDO 2 credential registration. */ data class CompleteFido2Registration( - val result: Fido2RegisterCredentialResult, + val result: RegisterFido2CredentialResult, ) : BackgroundEvent, VaultAddEditEvent() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/Fido2CreateCredentialRequestExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/Fido2CreateCredentialRequestExtensions.kt index 85da447a2e..7876e422a5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/Fido2CreateCredentialRequestExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/Fido2CreateCredentialRequestExtensions.kt @@ -17,10 +17,10 @@ fun Fido2CreateCredentialRequest.toDefaultAddTypeContent( isIndividualVaultDisabled: Boolean, ): VaultAddEditState.ViewState.Content { - val rpUri = origin + val rpUri = providerRequest.callingRequest.origin ?.toUriOrNull() ?.toString() - ?: packageName + ?: callingAppInfo.packageName .toAndroidAppUriString() val rpName = attestationOptions 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 c2a510cd77..e6a45b2b7f 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 @@ -20,10 +20,10 @@ import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.ui.util.Text import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect -import com.bitwarden.ui.util.Text import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountSwitcher import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar @@ -185,7 +185,7 @@ fun VaultItemListingScreen( } is VaultItemListingEvent.CompleteFido2GetCredentialsRequest -> { - fido2CompletionManager.completeFido2GetCredentialRequest(event.result) + fido2CompletionManager.completeFido2GetCredentialsRequest(event.result) } VaultItemListingEvent.ExitApp -> exitManager.exitApplication() 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 9a387cf1b8..bfe993f959 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 @@ -2,11 +2,17 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting import android.os.Parcelable import androidx.annotation.DrawableRes +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.ProviderCreateCredentialRequest +import androidx.credentials.provider.ProviderGetCredentialRequest import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.map import com.bitwarden.fido.Fido2CredentialAutofillView +import com.bitwarden.ui.util.Text +import com.bitwarden.ui.util.asText +import com.bitwarden.ui.util.concat import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType import com.bitwarden.vault.CipherView @@ -22,10 +28,10 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialReques import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest -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.util.getCreatePasskeyCredentialRequestOrNull import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials @@ -45,7 +51,6 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl -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.DecryptFido2CredentialAutofillViewResult @@ -53,11 +58,11 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.GetFido2CredentialsResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent -import com.bitwarden.ui.util.Text -import com.bitwarden.ui.util.asText -import com.bitwarden.ui.util.concat import com.x8bit.bitwarden.ui.platform.base.util.toAndroidAppUriString import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary @@ -70,6 +75,7 @@ import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType import com.x8bit.bitwarden.ui.vault.components.util.toVaultItemCipherTypeOrNull import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.determineListingPredicate +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.messageResourceId import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toItemListingType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toSearchType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toVaultItemCipherType @@ -123,7 +129,9 @@ class VaultItemListingViewModel @Inject constructor( val activeAccountSummary = userState.toActiveAccountSummary() val accountSummaries = userState.toAccountSummaries() val specialCircumstance = specialCircumstanceManager.specialCircumstance - val fido2CredentialRequest = specialCircumstance?.toFido2CreateRequestOrNull() + val fido2CreateCredentialRequest = specialCircumstance?.toFido2CreateRequestOrNull() + val fido2GetCredentialsRequest = specialCircumstance?.toFido2GetCredentialsRequestOrNull() + val fido2AssertCredentialRequest = specialCircumstance?.toFido2AssertionRequestOrNull() VaultItemListingState( itemListingType = VaultItemListingArgs(savedStateHandle = savedStateHandle) .vaultItemListingType @@ -136,7 +144,7 @@ class VaultItemListingViewModel @Inject constructor( baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl, isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, - dialogState = fido2CredentialRequest + dialogState = fido2CreateCredentialRequest ?.let { VaultItemListingState.DialogState.Loading(R.string.loading.asText()) }, policyDisablesSend = policyManager .getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) @@ -144,9 +152,9 @@ class VaultItemListingViewModel @Inject constructor( autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull(), hasMasterPassword = userState.activeAccount.hasMasterPassword, totpData = specialCircumstance?.toTotpDataOrNull(), - fido2CreateCredentialRequest = fido2CredentialRequest, - fido2CredentialAssertionRequest = specialCircumstance?.toFido2AssertionRequestOrNull(), - fido2GetCredentialsRequest = specialCircumstance?.toFido2GetCredentialsRequestOrNull(), + fido2CreateCredentialRequest = fido2CreateCredentialRequest, + fido2CredentialAssertionRequest = fido2AssertCredentialRequest, + fido2GetCredentialsRequest = fido2GetCredentialsRequest, isPremium = userState.activeAccount.isPremium, isRefreshing = false, ) @@ -165,24 +173,22 @@ class VaultItemListingViewModel @Inject constructor( .onEach { sendAction(VaultItemListingsAction.Internal.IconLoadingSettingReceive(it)) } .launchIn(viewModelScope) - viewModelScope.launch { - state - .fido2CreateCredentialRequest - ?.let { request -> - sendAction( - VaultItemListingsAction.Internal.Fido2RegisterCredentialRequestReceive( - request = request, - ), - ) - } - ?: observeVaultData() - } - policyManager .getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND) .map { VaultItemListingsAction.Internal.PolicyUpdateReceive(it.any()) } .onEach(::sendAction) .launchIn(viewModelScope) + + specialCircumstanceManager.specialCircumstance + ?.toFido2CreateRequestOrNull() + ?.let { request -> + trySendAction( + VaultItemListingsAction.Internal.Fido2RegisterCredentialRequestReceive( + request = request, + ), + ) + } + ?: observeVaultData() } private fun observeVaultData() { @@ -193,7 +199,6 @@ class VaultItemListingViewModel @Inject constructor( it .filterForAutofillIfNecessary() .filterForFido2CreationIfNecessary() - .filterForFidoGetCredentialsIfNecessary() .filterForTotpIfNecessary(), ) } @@ -364,11 +369,22 @@ class VaultItemListingViewModel @Inject constructor( private fun handleUserVerificationCancelled() { fido2CredentialManager.isUserVerified = false clearDialogState() - sendEvent( - VaultItemListingEvent.CompleteFido2Registration( - result = Fido2RegisterCredentialResult.Cancelled, - ), - ) + state.fido2CreateCredentialRequest + ?.let { + sendEvent( + VaultItemListingEvent.CompleteFido2Registration( + result = RegisterFido2CredentialResult.Cancelled, + ), + ) + } + ?: state.fido2CredentialAssertionRequest + ?.let { + sendEvent( + VaultItemListingEvent.CompleteFido2Assertion( + result = AssertFido2CredentialResult.Cancelled, + ), + ) + } } private fun handleUserVerificationNotSupported( @@ -679,10 +695,11 @@ class VaultItemListingViewModel @Inject constructor( return } - if (state.isFido2Creation) { - handleFido2RegistrationRequestReceive(action) - return - } + state.fido2CreateCredentialRequest + ?.let { + handleItemClickForProviderCreateCredentialRequest(action, it) + return + } val event = when (state.itemListingType) { is VaultItemListingState.ItemListingType.Vault -> { @@ -699,7 +716,10 @@ class VaultItemListingViewModel @Inject constructor( sendEvent(event) } - private fun handleFido2RegistrationRequestReceive(action: VaultItemListingsAction.ItemClick) { + private fun handleItemClickForProviderCreateCredentialRequest( + action: VaultItemListingsAction.ItemClick, + fido2CreateCredentialRequest: Fido2CreateCredentialRequest, + ) { val cipherView = getCipherViewOrNull(action.id) ?: run { showFido2ErrorDialog( @@ -709,12 +729,34 @@ class VaultItemListingViewModel @Inject constructor( return } + fido2CreateCredentialRequest + .providerRequest + .getCreatePasskeyCredentialRequestOrNull() + ?.let { + handleItemClickForCreatePublicKeyCredentialRequest( + cipherId = action.id, + cipherView = cipherView, + ) + } + ?: run { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_unsupported.asText(), + ) + } + } + + private fun handleItemClickForCreatePublicKeyCredentialRequest( + cipherId: String, + cipherView: CipherView, + ) { if (cipherView.isActiveWithFido2Credentials) { mutableStateFlow.update { it.copy( dialogState = VaultItemListingState .DialogState - .OverwritePasskeyConfirmationPrompt(cipherViewId = action.id), + .OverwritePasskeyConfirmationPrompt( + cipherViewId = cipherId, + ), ) } } else { @@ -723,17 +765,6 @@ class VaultItemListingViewModel @Inject constructor( } private fun registerFido2Credential(cipherView: CipherView) { - val credentialRequest = state - .fido2CreateCredentialRequest - ?: 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. - showFido2ErrorDialog( - R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), - ) - return - } mutableStateFlow.update { it.copy( dialogState = VaultItemListingState.DialogState.Loading( @@ -741,35 +772,45 @@ class VaultItemListingViewModel @Inject constructor( ), ) } - if (fido2CredentialManager.isUserVerified) { - // The user has performed verification implicitly so we continue FIDO 2 registration - // without checking the request's user verification settings. - registerFido2CredentialToCipher( - request = credentialRequest, - cipherView = cipherView, - ) - } else { - performUserVerificationIfRequired(credentialRequest, cipherView) - } - } - private fun performUserVerificationIfRequired( - credentialRequest: Fido2CreateCredentialRequest, - cipherView: CipherView, - ) { - val attestationOptions = fido2CredentialManager - .getPasskeyAttestationOptionsOrNull(credentialRequest.requestJson) + val providerRequest = state + .fido2CreateCredentialRequest + ?.providerRequest ?: run { showFido2ErrorDialog( R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), ) return } - when (attestationOptions.authenticatorSelection.userVerification) { + + if (fido2CredentialManager.isUserVerified) { + // The user has performed verification implicitly so we continue FIDO 2 registration + // without checking the request's user verification settings. + registerFido2CredentialToCipher(cipherView, providerRequest) + } else { + performUserVerificationIfRequired(cipherView, providerRequest) + } + } + + private fun performUserVerificationIfRequired( + cipherView: CipherView, + providerRequest: ProviderCreateCredentialRequest, + ) { + val userVerificationRequirement = providerRequest + .getCreatePasskeyCredentialRequestOrNull() + ?.let { fido2CredentialManager.getUserVerificationRequirement(it) } + ?: run { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_unsupported.asText(), + ) + return + } + + when (userVerificationRequirement) { UserVerificationRequirement.DISCOURAGED -> { registerFido2CredentialToCipher( - request = credentialRequest, cipherView = cipherView, + providerRequest = providerRequest, ) } @@ -794,8 +835,8 @@ class VaultItemListingViewModel @Inject constructor( } private fun registerFido2CredentialToCipher( - request: Fido2CreateCredentialRequest, cipherView: CipherView, + providerRequest: ProviderCreateCredentialRequest, ) { val activeUserId = authRepository.activeUserId ?: run { @@ -804,11 +845,22 @@ class VaultItemListingViewModel @Inject constructor( ) return } + + val createRequest = providerRequest + .getCreatePasskeyCredentialRequestOrNull() + ?: run { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), + ) + return + } + viewModelScope.launch { val result: Fido2RegisterCredentialResult = fido2CredentialManager.registerFido2Credential( userId = activeUserId, - fido2CreateCredentialRequest = request, + callingAppInfo = providerRequest.callingAppInfo, + createPublicKeyCredentialRequest = createRequest, selectedCipherView = cipherView, ) sendAction( @@ -818,8 +870,7 @@ class VaultItemListingViewModel @Inject constructor( } private fun authenticateFido2Credential( - relyingPartyId: String, - request: Fido2CredentialAssertionRequest, + request: ProviderGetCredentialRequest, cipherView: CipherView, ) { val activeUserId = authRepository.activeUserId @@ -829,12 +880,32 @@ class VaultItemListingViewModel @Inject constructor( ) return } + val option = request.credentialOptions + .filterIsInstance() + .firstOrNull() + ?: run { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), + ) + return + } + val relyingPartyId = fido2CredentialManager + .getPasskeyAssertionOptionsOrNull(option.requestJson) + ?.relyingPartyId + ?: run { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), + ) + return + } viewModelScope.launch { + val validateOriginResult = fido2OriginManager .validateOrigin( callingAppInfo = request.callingAppInfo, relyingPartyId = relyingPartyId, ) + when (validateOriginResult) { is Fido2ValidateOriginResult.Error -> { handleFido2OriginValidationFail(validateOriginResult) @@ -846,7 +917,9 @@ class VaultItemListingViewModel @Inject constructor( result = fido2CredentialManager.authenticateFido2Credential( userId = activeUserId, selectedCipherView = cipherView, - request = request, + request = option, + callingAppInfo = request.callingAppInfo, + origin = validateOriginResult.origin, ), ), ) @@ -975,9 +1048,7 @@ class VaultItemListingViewModel @Inject constructor( state.fido2CreateCredentialRequest != null -> { sendEvent( VaultItemListingEvent.CompleteFido2Registration( - result = Fido2RegisterCredentialResult.Error( - action.message, - ), + result = RegisterFido2CredentialResult.Error(action.message), ), ) } @@ -985,7 +1056,7 @@ class VaultItemListingViewModel @Inject constructor( state.fido2CredentialAssertionRequest != null -> { sendEvent( VaultItemListingEvent.CompleteFido2Assertion( - result = Fido2CredentialAssertionResult.Error( + result = AssertFido2CredentialResult.Error( message = action.message, ), ), @@ -995,7 +1066,7 @@ class VaultItemListingViewModel @Inject constructor( state.fido2GetCredentialsRequest != null -> { sendEvent( VaultItemListingEvent.CompleteFido2GetCredentialsRequest( - result = Fido2GetCredentialsResult.Error( + result = GetFido2CredentialsResult.Error( message = action.message, ), ), @@ -1467,29 +1538,21 @@ class VaultItemListingViewModel @Inject constructor( } private fun continueFido2Operation(cipherView: CipherView) { - specialCircumstanceManager - .specialCircumstance - ?.toFido2CreateRequestOrNull() + state.fido2CreateCredentialRequest + ?.providerRequest ?.let { request -> registerFido2CredentialToCipher( - request = request, cipherView = cipherView, + providerRequest = request, ) } - ?: specialCircumstanceManager - .specialCircumstance - ?.toFido2AssertionRequestOrNull() + ?: state.fido2CredentialAssertionRequest + ?.providerRequest ?.let { request -> - fido2CredentialManager - .getPasskeyAssertionOptionsOrNull(request.requestJson) - ?.relyingPartyId - ?.let { relyingPartyId -> - authenticateFido2Credential( - relyingPartyId = relyingPartyId, - request = request, - cipherView = cipherView, - ) - } + authenticateFido2Credential( + request = request, + cipherView = cipherView, + ) } ?: run { showFido2ErrorDialog( @@ -1518,34 +1581,9 @@ 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( - R.string.passkey_operation_failed_because_the_request_is_invalid - .asText(), - ) - return - } - sendEvent( - VaultItemListingEvent.CompleteFido2GetCredentialsRequest( - Fido2GetCredentialsResult.Success( - userId = fido2GetCredentialsRequest.userId, - options = fido2GetCredentialsRequest.option, - credentials = vaultData - .data - .fido2CredentialAutofillViewList - ?.filter { it.rpId == relyingPartyId } - ?: emptyList(), - ), - ), - ) - } + ?.let { handleFido2GetCredentialsRequest(it, vaultData.data) } ?: state.fido2CredentialAssertionRequest ?.let { request -> trySendAction( @@ -1600,19 +1638,23 @@ class VaultItemListingViewModel @Inject constructor( private fun handleFido2RegisterCredentialRequestReceive( action: VaultItemListingsAction.Internal.Fido2RegisterCredentialRequestReceive, ) { + val relyingPartyId = action.request + .providerRequest + .getCreatePasskeyCredentialRequestOrNull() + ?.let { fido2CredentialManager.getPasskeyAttestationOptionsOrNull(it.requestJson) } + ?.relyingParty + ?.id + ?: run { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), + ) + return + } viewModelScope.launch { - val options = fido2CredentialManager - .getPasskeyAttestationOptionsOrNull(requestJson = action.request.requestJson) - ?: run { - showFido2ErrorDialog( - R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), - ) - return@launch - } val validateOriginResult = fido2OriginManager .validateOrigin( callingAppInfo = action.request.callingAppInfo, - relyingPartyId = options.relyingParty.id, + relyingPartyId = relyingPartyId, ) when (validateOriginResult) { is Fido2ValidateOriginResult.Error -> { @@ -1632,18 +1674,90 @@ class VaultItemListingViewModel @Inject constructor( clearDialogState() when (action.result) { is Fido2RegisterCredentialResult.Error -> { - sendEvent(VaultItemListingEvent.ShowToast(R.string.an_error_has_occurred.asText())) + handleFido2RegisterCredentialResultErrorReceive(action.result) } is Fido2RegisterCredentialResult.Success -> { sendEvent(VaultItemListingEvent.ShowToast(R.string.item_updated.asText())) + sendEvent( + VaultItemListingEvent.CompleteFido2Registration( + RegisterFido2CredentialResult.Success(action.result.responseJson), + ), + ) } + } + } - Fido2RegisterCredentialResult.Cancelled -> { - // no-op: The OS will handle re-displaying the system prompt. + private fun handleFido2RegisterCredentialResultErrorReceive( + error: Fido2RegisterCredentialResult.Error, + ) { + sendEvent(VaultItemListingEvent.ShowToast(R.string.an_error_has_occurred.asText())) + sendEvent( + VaultItemListingEvent.CompleteFido2Registration( + RegisterFido2CredentialResult.Error( + message = error.messageResourceId.asText(), + ), + ), + ) + } + + private fun handleFido2GetCredentialsRequest( + request: Fido2GetCredentialsRequest, + vaultData: VaultData, + ) { + val beginGetCredentialOption = request + .beginGetPublicKeyCredentialOption + ?: run { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), + ) + return + } + val relyingPartyId = fido2CredentialManager + .getPasskeyAssertionOptionsOrNull(requestJson = beginGetCredentialOption.requestJson) + ?.relyingPartyId + ?: run { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_relying_party_cannot_be_identified + .asText(), + ) + return + } + val callingAppInfo = request.callingAppInfo + ?: run { + showFido2ErrorDialog( + R.string.passkey_operation_failed_because_app_could_not_be_verified.asText(), + ) + return + } + + viewModelScope.launch { + val validateOriginResult = fido2OriginManager.validateOrigin( + callingAppInfo = callingAppInfo, + relyingPartyId = relyingPartyId, + ) + when (validateOriginResult) { + is Fido2ValidateOriginResult.Success -> { + sendEvent( + VaultItemListingEvent.CompleteFido2GetCredentialsRequest( + GetFido2CredentialsResult.Success( + userId = request.userId, + option = beginGetCredentialOption, + credentials = vaultData + .toFido2CredentialAutofillViews() + .orEmpty() + .filter { it.rpId == relyingPartyId }, + ), + ), + ) + } + + is Fido2ValidateOriginResult.Error -> { + handleFido2OriginValidationFail(validateOriginResult) + return@launch + } } } - sendEvent(VaultItemListingEvent.CompleteFido2Registration(action.result)) } private fun handleFido2OriginValidationFail(error: Fido2ValidateOriginResult.Error) { @@ -1651,7 +1765,7 @@ class VaultItemListingViewModel @Inject constructor( it.copy( dialogState = VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), - message = error.messageResId.asText(), + message = error.messageResourceId.asText(), ), ) } @@ -1674,13 +1788,15 @@ class VaultItemListingViewModel @Inject constructor( .data .orEmpty() .filter { it.isActiveWithFido2Credentials } - if (request.cipherId.isNullOrEmpty()) { + val selectedCipherId = request.cipherId + + if (selectedCipherId.isEmpty()) { showFido2ErrorDialog( R.string.passkey_operation_failed_because_no_item_was_selected.asText(), ) } else { val selectedCipher = ciphers - .find { it.id == request.cipherId } + .find { it.id == selectedCipherId } ?: run { showFido2ErrorDialog( R.string.passkey_operation_failed_because_the_selected_item_does_not_exist @@ -1692,9 +1808,9 @@ class VaultItemListingViewModel @Inject constructor( if (state.hasMasterPassword && selectedCipher.reprompt == CipherRepromptType.PASSWORD ) { - repromptMasterPasswordForFido2Assertion(request.cipherId) + repromptMasterPasswordForFido2Assertion(selectedCipherId) } else { - verifyUserAndAuthenticateCredential(request, selectedCipher) + verifyUserAndAuthenticateCredential(request.providerRequest, selectedCipher) } } } @@ -1710,37 +1826,22 @@ class VaultItemListingViewModel @Inject constructor( } private fun verifyUserAndAuthenticateCredential( - request: Fido2CredentialAssertionRequest, + request: ProviderGetCredentialRequest, selectedCipher: CipherView, ) { - val assertionOptions = fido2CredentialManager - .getPasskeyAssertionOptionsOrNull(request.requestJson) - ?: run { - showFido2ErrorDialog( - R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), - ) - return - } - - val relyingPartyId = assertionOptions.relyingPartyId - ?: run { - showFido2ErrorDialog( - R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), - ) - return - } if (fido2CredentialManager.isUserVerified) { - authenticateFido2Credential(relyingPartyId, request, selectedCipher) + authenticateFido2Credential(request, selectedCipher) return } - when (assertionOptions.userVerification) { + val userVerificationRequirement = + fido2CredentialManager.getUserVerificationRequirement(request) + when (userVerificationRequirement) { UserVerificationRequirement.DISCOURAGED -> { authenticateFido2Credential( - relyingPartyId, - request, - selectedCipher, + request = request, + cipherView = selectedCipher, ) } @@ -1759,9 +1860,27 @@ class VaultItemListingViewModel @Inject constructor( ) { fido2CredentialManager.isUserVerified = false clearDialogState() - sendEvent( - VaultItemListingEvent.CompleteFido2Assertion(action.result), - ) + when (action.result) { + is Fido2CredentialAssertionResult.Error -> { + sendEvent( + VaultItemListingEvent.CompleteFido2Assertion( + AssertFido2CredentialResult.Error( + message = action.result.messageResourceId.asText(), + ), + ), + ) + } + + is Fido2CredentialAssertionResult.Success -> { + sendEvent( + VaultItemListingEvent.CompleteFido2Assertion( + AssertFido2CredentialResult.Success( + responseJson = action.result.responseJson, + ), + ), + ) + } + } } private fun updateStateWithVaultData(vaultData: VaultData, clearDialogState: Boolean) { @@ -1848,30 +1967,8 @@ class VaultItemListingViewModel @Inject constructor( private suspend fun DataState.filterForFido2CreationIfNecessary(): DataState { val request = state.fido2CreateCredentialRequest ?: return this return this.map { vaultData -> - val matchUri = request.origin - ?: request.packageName - .toAndroidAppUriString() - - vaultData.copy( - cipherViewList = cipherMatchingManager.filterCiphersForMatches( - ciphers = vaultData.cipherViewList, - matchUri = matchUri, - ), - fido2CredentialAutofillViewList = vaultData.toFido2CredentialAutofillViews(), - ) - } - } - - /** - * Takes the given vault data and filters it for FIDO 2 credential selection. - */ - @Suppress("MaxLineLength") - private suspend fun DataState.filterForFidoGetCredentialsIfNecessary(): DataState { - val request = state.fido2GetCredentialsRequest ?: return this - return this.map { vaultData -> - val matchUri = request.origin - ?: request.packageName - .toAndroidAppUriString() + val matchUri = request.providerRequest.callingRequest.origin + ?: request.callingAppInfo.packageName.toAndroidAppUriString() vaultData.copy( cipherViewList = cipherMatchingManager.filterCiphersForMatches( @@ -1994,8 +2091,7 @@ data class VaultItemListingState( ?.toHostOrPathOrNull() ?.let { R.string.items_for_uri.asText(it) } ?: fido2CreateCredentialRequest - ?.callingAppInfo - ?.getFido2RpIdOrNull() + ?.relyingPartyIdOrNull ?.let { R.string.items_for_uri.asText(it) } ?: totpData?.let { R.string.items_for_uri.asText(it.issuer ?: it.accountName ?: "--") } ?: itemListingType.titleText @@ -2478,7 +2574,7 @@ sealed class VaultItemListingEvent { * @property result The result of FIDO 2 credential registration. */ data class CompleteFido2Registration( - val result: Fido2RegisterCredentialResult, + val result: RegisterFido2CredentialResult, ) : BackgroundEvent, VaultItemListingEvent() /** @@ -2496,7 +2592,7 @@ sealed class VaultItemListingEvent { * @property result The result of the FIDO 2 credential assertion. */ data class CompleteFido2Assertion( - val result: Fido2CredentialAssertionResult, + val result: AssertFido2CredentialResult, ) : BackgroundEvent, VaultItemListingEvent() /** @@ -2505,7 +2601,7 @@ sealed class VaultItemListingEvent { * @property result The result of querying for matching FIDO 2 credentials. */ data class CompleteFido2GetCredentialsRequest( - val result: Fido2GetCredentialsResult, + val result: GetFido2CredentialsResult, ) : BackgroundEvent, VaultItemListingEvent() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/Fido2CredentialAssertionResultExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/Fido2CredentialAssertionResultExtensions.kt new file mode 100644 index 0000000000..1f1021002c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/Fido2CredentialAssertionResultExtensions.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util + +import androidx.annotation.StringRes +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult + +/** + * Returns the string resource ID corresponding to the error message for the given + * [Fido2CredentialAssertionResult.Error]. + */ +val Fido2CredentialAssertionResult.Error.messageResourceId: Int + @StringRes + get() = when (this) { + Fido2CredentialAssertionResult.Error.InternalError -> { + R.string.passkey_registration_failed_due_to_an_internal_error + } + + Fido2CredentialAssertionResult.Error.InvalidAppSignature -> { + R.string.passkey_operation_failed_because_app_signature_is_invalid + } + + Fido2CredentialAssertionResult.Error.MissingHostUrl -> { + R.string.passkey_operation_failed_because_host_url_is_not_present_in_request + } + + Fido2CredentialAssertionResult.Error.MissingRpId -> { + R.string.passkey_operation_failed_because_relying_party_cannot_be_identified + } + + is Fido2CredentialAssertionResult.Error.OriginValidationFailed -> { + originValidationError.messageResourceId + } + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/Fido2RegisterCredentialResultExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/Fido2RegisterCredentialResultExtensions.kt new file mode 100644 index 0000000000..34f975542e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/Fido2RegisterCredentialResultExtensions.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util + +import androidx.annotation.StringRes +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult + +/** + * Returns the string resource ID corresponding to the error message for the given + * [Fido2RegisterCredentialResult.Error]. + */ +val Fido2RegisterCredentialResult.Error.messageResourceId: Int + @StringRes + get() = when (this) { + Fido2RegisterCredentialResult.Error.InternalError -> { + R.string.passkey_registration_failed_due_to_an_internal_error + } + + Fido2RegisterCredentialResult.Error.InvalidAppSignature -> { + R.string.passkey_operation_failed_because_app_signature_is_invalid + } + + Fido2RegisterCredentialResult.Error.MissingHostUrl -> { + R.string.passkey_operation_failed_because_host_url_is_not_present_in_request + } + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/Fido2ValidateOriginResultExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/Fido2ValidateOriginResultExtensions.kt new file mode 100644 index 0000000000..fd2fb22a06 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/Fido2ValidateOriginResultExtensions.kt @@ -0,0 +1,41 @@ +package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util + +import androidx.annotation.StringRes +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult + +/** + * Returns the string resource ID corresponding to the error message for the given + * [Fido2ValidateOriginResult.Error]. + */ +val Fido2ValidateOriginResult.Error.messageResourceId: Int + @StringRes + get() = when (this) { + Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified -> { + R.string.passkey_operation_failed_because_app_could_not_be_verified + } + + Fido2ValidateOriginResult.Error.ApplicationNotFound -> { + R.string.passkey_operation_failed_because_app_not_found_in_asset_links + } + + Fido2ValidateOriginResult.Error.AssetLinkNotFound -> { + R.string.passkey_operation_failed_because_of_missing_asset_links + } + + Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp -> { + R.string.passkeys_not_supported_for_this_app + } + + Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed -> { + R.string.passkey_operation_failed_because_browser_is_not_privileged + } + + Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound -> { + R.string.passkey_operation_failed_because_browser_signature_does_not_match + } + + Fido2ValidateOriginResult.Error.Unknown -> { + R.string.generic_error_message + } + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 0dc6dee6e7..a09227096a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -6,6 +6,7 @@ import androidx.annotation.DrawableRes import com.bitwarden.fido.Fido2CredentialAutofillView import com.bitwarden.send.SendType import com.bitwarden.send.SendView +import com.bitwarden.ui.util.asText import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType import com.bitwarden.vault.CipherView @@ -17,7 +18,6 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials import com.x8bit.bitwarden.data.platform.util.subtitle import com.x8bit.bitwarden.data.vault.repository.model.VaultData -import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern @@ -175,7 +175,7 @@ fun VaultData.toViewState( ?.toHostOrPathOrNull() ?.let { R.string.no_items_for_uri.asText(it) } ?: fido2CreationData - ?.origin + ?.relyingPartyIdOrNull ?.toHostOrPathOrNull() ?.let { R.string.no_items_for_uri.asText(it) } ?: totpData?.let { R.string.search_for_a_login_or_add_a_new_login.asText() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52e66d8368..1ce701c1be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1198,18 +1198,17 @@ Do you want to switch to this account? Using a system certificate is less secure than storing the certificate with Bitwarden. Continuing will display a list of available system certificates if one is already installed. Certificate alias already exists Link - Passkey operation failed because app is signed incorrectly. Passkey operation failed because host URL is not present in request. Passkey operation failed because app signature is invalid. Passkey registration failed due to an internal error. Passkey operation failed because relying party cannot be identified. - Passkey authentication failed due to an internal error. Passkey operation failed because the selected item does not exist. Passkey operation failed because user is locked out. Passkey operation failed because user verification was cancelled. Passkey operation failed because the request is invalid. Passkey operation failed because user verification attempts exceeded. Passkey operation failed because no item was selected. + Passkey operation failed because the request is unsupported. Self-host server URL Use Chrome autofill integration Use Chrome autofill integration (Beta) diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index 4fde074a02..54edabf82d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -1,11 +1,16 @@ package com.x8bit.bitwarden import android.content.Intent -import android.content.pm.SigningInfo +import androidx.core.os.bundleOf +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.BiometricPromptResult +import androidx.credentials.provider.ProviderCreateCredentialRequest +import androidx.credentials.provider.ProviderGetCredentialRequest import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager +import com.bitwarden.ui.util.asText import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManagerImpl @@ -53,7 +58,6 @@ import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticato import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest -import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -66,8 +70,10 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkObject import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow @@ -147,6 +153,20 @@ class MainViewModelTest : BaseViewModelTest() { getFeatureFlagFlow(key = FlagKey.MobileErrorReporting) } returns mutableMobileErrorReportingFeatureFlow } + private val mockBiometricsPromptResult = mockk(relaxed = true) { + every { isSuccessful } returns true + } + private val mockProviderCreateCredentialRequest = + mockk(relaxed = true) { + every { biometricPromptResult } returns mockBiometricsPromptResult + } + private val mockProviderGetCredentialRequest = + mockk(relaxed = true) { + every { biometricPromptResult } returns mockBiometricsPromptResult + every { credentialOptions } returns listOf( + mockk(relaxed = true), + ) + } @BeforeEach fun setup() { @@ -166,6 +186,16 @@ class MainViewModelTest : BaseViewModelTest() { Intent::isPasswordGeneratorShortcut, Intent::isAccountSecurityShortcut, ) + mockkObject( + ProviderCreateCredentialRequest.Companion, + ProviderGetCredentialRequest.Companion, + ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockProviderCreateCredentialRequest + every { + ProviderGetCredentialRequest.fromBundle(any()) + } returns mockProviderGetCredentialRequest } @AfterEach @@ -186,6 +216,10 @@ class MainViewModelTest : BaseViewModelTest() { Intent::isPasswordGeneratorShortcut, Intent::isAccountSecurityShortcut, ) + unmockkObject( + ProviderCreateCredentialRequest.Companion, + ProviderGetCredentialRequest.Companion, + ) } @Suppress("MaxLineLength") @@ -514,33 +548,35 @@ class MainViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to ExpiredRegistration if token is not valid`() { - val viewModel = createViewModel() - val intentEmail = "email" - val token = "token" - val completeRegistrationData = mockk { - every { email } returns intentEmail - every { verificationToken } returns token - } - val mockIntent = createMockIntent(mockCompleteRegistrationData = completeRegistrationData) - every { authRepository.activeUserId } returns null - coEvery { - authRepository.validateEmailToken( - email = intentEmail, - token = token, - ) - } returns EmailTokenResult.Expired + fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to ExpiredRegistration if token is not valid`() = + runTest { + val viewModel = createViewModel() + val intentEmail = "email" + val token = "token" + val completeRegistrationData = mockk { + every { email } returns intentEmail + every { verificationToken } returns token + } + val mockIntent = + createMockIntent(mockCompleteRegistrationData = completeRegistrationData) + every { authRepository.activeUserId } returns null + coEvery { + authRepository.validateEmailToken( + email = intentEmail, + token = token, + ) + } returns EmailTokenResult.Expired - viewModel.trySendAction( - MainAction.ReceiveFirstIntent( - intent = mockIntent, - ), - ) - assertEquals( - SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink, - specialCircumstanceManager.specialCircumstance, - ) - } + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + assertEquals( + SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink, + specialCircumstanceManager.specialCircumstance, + ) + } @Suppress("MaxLineLength") @Test @@ -651,27 +687,16 @@ class MainViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on ReceiveFirstIntent with fido2 request data should set the special circumstance to Fido2Save`() { + fun `on ReceiveFirstIntent with fido2 create intent data should set the special circumstance to Fido2Save`() { val viewModel = createViewModel() val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = DEFAULT_USER_STATE.activeUserId, - requestJson = """{"mockRequestJson":1}""", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = "mockOrigin", - isUserVerified = true, + requestData = bundleOf(), ) val fido2Intent = createMockIntent( mockFido2CreateCredentialRequest = fido2CreateCredentialRequest, ) - coEvery { - fido2OriginManager.validateOrigin( - fido2CreateCredentialRequest.callingAppInfo, - fido2CreateCredentialRequest.requestJson, - ) - } returns Fido2ValidateOriginResult.Success(null) - viewModel.trySendAction( MainAction.ReceiveFirstIntent( intent = fido2Intent, @@ -702,72 +727,68 @@ class MainViewModelTest : BaseViewModelTest() { ) verify { - fido2CredentialManager.isUserVerified = createCredentialRequest.isUserVerified ?: false + fido2CredentialManager.isUserVerified = true } } @Suppress("MaxLineLength") @Test - fun `on ReceiveFirstIntent with fido2 request data should switch users if active user is not selected`() { - mutableUserStateFlow.value = DEFAULT_USER_STATE - val viewModel = createViewModel() - val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( - userId = "selectedUserId", - requestJson = """{"mockRequestJson":1}""", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = "mockOrigin", - isUserVerified = true, - ) - val mockIntent = createMockIntent( - mockFido2CreateCredentialRequest = fido2CreateCredentialRequest, - ) - coEvery { - fido2OriginManager.validateOrigin( - fido2CreateCredentialRequest.callingAppInfo, - fido2CreateCredentialRequest.requestJson, + fun `on ReceiveFirstIntent with fido2 create intent data should switch users if active user is not selected`() = + runTest { + mutableUserStateFlow.value = DEFAULT_USER_STATE + val viewModel = createViewModel() + val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( + userId = "selectedUserId", + requestData = bundleOf(), ) - } returns Fido2ValidateOriginResult.Success(null) + val mockIntent = createMockIntent( + mockFido2CreateCredentialRequest = fido2CreateCredentialRequest, + ) + coEvery { + fido2OriginManager.validateOrigin( + callingAppInfo = any(), + relyingPartyId = any(), + ) + } returns Fido2ValidateOriginResult.Success(null) - viewModel.trySendAction( - MainAction.ReceiveFirstIntent( - intent = mockIntent, - ), - ) + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) - verify(exactly = 1) { authRepository.switchAccount(fido2CreateCredentialRequest.userId) } - } + verify(exactly = 1) { + authRepository.switchAccount(fido2CreateCredentialRequest.userId) + } + } @Suppress("MaxLineLength") @Test - fun `on ReceiveFirstIntent with fido2 request data should not switch users if active user is selected`() { - val viewModel = createViewModel() - val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( - userId = DEFAULT_USER_STATE.activeUserId, - requestJson = """{"mockRequestJson":1}""", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = "mockOrigin", - isUserVerified = true, - ) - val mockIntent = createMockIntent( - mockFido2CreateCredentialRequest = fido2CreateCredentialRequest, - ) - coEvery { - fido2OriginManager.validateOrigin( - fido2CreateCredentialRequest.callingAppInfo, - fido2CreateCredentialRequest.requestJson, + fun `on ReceiveFirstIntent with fido2 request data should not switch users if active user is selected`() = + runTest { + val viewModel = createViewModel() + val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( + userId = DEFAULT_USER_STATE.activeUserId, + requestData = bundleOf(), ) - } returns Fido2ValidateOriginResult.Success(null) + val mockIntent = createMockIntent( + mockFido2CreateCredentialRequest = fido2CreateCredentialRequest, + ) + coEvery { + fido2OriginManager.validateOrigin( + callingAppInfo = any(), + relyingPartyId = any(), + ) + } returns Fido2ValidateOriginResult.Success(null) - viewModel.trySendAction( - MainAction.ReceiveFirstIntent( - intent = mockIntent, - ), - ) + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) - verify(exactly = 0) { authRepository.switchAccount(fido2CreateCredentialRequest.userId) } - } + verify(exactly = 0) { authRepository.switchAccount(fido2CreateCredentialRequest.userId) } + } @Suppress("MaxLineLength") @Test diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt index 0a28f668d8..80c0b5e007 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt @@ -3,6 +3,10 @@ package com.x8bit.bitwarden.data.autofill.fido2.manager import android.content.pm.Signature import android.content.pm.SigningInfo import android.util.Base64 +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.ProviderGetCredentialRequest import com.bitwarden.core.data.util.asSuccess import com.bitwarden.core.data.util.decodeFromStringOrNull import com.bitwarden.fido.ClientData @@ -10,17 +14,14 @@ import com.bitwarden.fido.Origin import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse import com.bitwarden.fido.UnverifiedAssetLink import com.bitwarden.sdk.Fido2CredentialStore -import com.bitwarden.ui.util.asText -import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2AttestationResponse -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2PublicKeyCredential 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.PasskeyAssertionOptions import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions -import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CreateCredentialRequest +import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement +import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest @@ -37,13 +38,13 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkStatic +import io.mockk.verify import kotlinx.coroutines.test.runTest import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -55,13 +56,13 @@ class Fido2CredentialManagerTest { private lateinit var fido2CredentialManager: Fido2CredentialManager - private val fido2OriginManager = mockk { - coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null) - } private val json = mockk { every { decodeFromString(any()) - } returns createMockPasskeyAttestationOptions(number = 1) + } returns createMockPasskeyAttestationOptions( + number = 1, + relyingPartyId = DEFAULT_HOST, + ) every { decodeFromString(any()) } returns createMockPasskeyAssertionOptions(number = 1) @@ -70,33 +71,57 @@ class Fido2CredentialManagerTest { } returns createMockPasskeyAssertionOptions(number = 1) } private val mockSigningInfo = mockk { - every { apkContentsSigners } returns arrayOf(Signature("0987654321ABCDEF")) + every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE)) every { hasMultipleSigners() } returns false } private val mockMessageDigest = mockk { every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray() } + val mockCallingAppInfo = mockk { + every { isOriginPopulated() } returns true + every { packageName } returns "com.x8bit.bitwarden" + every { signingInfo } returns mockSigningInfo + } + val mockCreatePublicKeyCredentialRequest = mockk { + every { requestJson } returns DEFAULT_FIDO2_CREATE_REQUEST_JSON + every { clientDataHash } returns byteArrayOf() + } + val mockGetPublicKeyCredentialOption = mockk { + every { requestJson } returns DEFAULT_FIDO2_AUTH_REQUEST_JSON + every { clientDataHash } returns byteArrayOf() + } + val mockProviderGetCredentialRequest = mockk { + every { callingAppInfo } returns mockCallingAppInfo + every { credentialOptions } returns listOf(mockGetPublicKeyCredentialOption) + } private val mockVaultSdkSource = mockk() private val mockFido2CredentialStore = mockk() @BeforeEach fun setUp() { - mockkStatic(MessageDigest::class, Base64::class) + mockkStatic( + MessageDigest::class, + Base64::class, + ) every { MessageDigest.getInstance(any()) } returns mockMessageDigest + every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE fido2CredentialManager = Fido2CredentialManagerImpl( vaultSdkSource = mockVaultSdkSource, fido2CredentialStore = mockFido2CredentialStore, - fido2OriginManager = fido2OriginManager, json = json, ) } @AfterEach fun tearDown() { - unmockkStatic(MessageDigest::class, Base64::class) + unmockkStatic( + MessageDigest::class, + Base64::class, + ) unmockkStatic( PublicKeyCredentialAuthenticatorAssertionResponse::toAndroidFido2PublicKeyCredential, + CallingAppInfo::getAppSigningSignatureFingerprint, ) } @@ -104,9 +129,9 @@ class Fido2CredentialManagerTest { fun `getPasskeyAttestationOptionsOrNull should return passkey options when deserialized`() = runTest { assertEquals( - createMockPasskeyAttestationOptions(number = 1), + createMockPasskeyAttestationOptions(number = 1, relyingPartyId = DEFAULT_HOST), fido2CredentialManager.getPasskeyAttestationOptionsOrNull( - requestJson = "", + requestJson = DEFAULT_FIDO2_AUTH_REQUEST_JSON, ), ) } @@ -169,110 +194,125 @@ class Fido2CredentialManagerTest { @Suppress("MaxLineLength") @Test - fun `registerFido2Credential should construct ClientData DefaultWithCustomHash when callingAppInfo origin is populated`() = + fun `registerFido2Credential should construct correct RegisterFido2CredentialRequest when callingAppInfo origin is populated`() = runTest { - val mockFido2CreateCredentialRequest = createMockFido2CreateCredentialRequest( - number = 1, - origin = "origin", - signingInfo = mockSigningInfo, - ) - val mockCipherView = createMockCipherView(1) - val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1) + mockkStatic(CallingAppInfo::getAppSigningSignatureFingerprint) - every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE - every { json.encodeToString(any(), any()) } returns "" - val requestCaptureSlot = slot() + val selectedCipherView = createMockCipherView(number = 1) + val slot = slot() + + every { + mockCallingAppInfo.getAppSigningSignatureFingerprint() + } returns DEFAULT_APP_SIGNATURE.toByteArray() + every { mockCreatePublicKeyCredentialRequest.origin } returns DEFAULT_WEB_ORIGIN.v1 coEvery { mockVaultSdkSource.registerFido2Credential( - request = capture(requestCaptureSlot), + request = capture(slot), fido2CredentialStore = any(), ) - } coAnswers { - mockRegistrationResponse - .asSuccess() - } + } returns createMockPublicKeyAttestationResponse(number = 1).asSuccess() fido2CredentialManager.registerFido2Credential( userId = "mockUserId", - fido2CreateCredentialRequest = mockFido2CreateCredentialRequest, - selectedCipherView = mockCipherView, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + selectedCipherView = selectedCipherView, + callingAppInfo = mockCallingAppInfo, ) - assertTrue( - requestCaptureSlot.captured.clientData is ClientData.DefaultWithCustomHash, + verify { mockCallingAppInfo.getAppSigningSignatureFingerprint() } + assertEquals( + "mockUserId", + slot.captured.userId, ) + assertEquals( + DEFAULT_WEB_ORIGIN, + slot.captured.origin, + ) + assertEquals( + """{"publicKey": $DEFAULT_FIDO2_CREATE_REQUEST_JSON}""", + slot.captured.requestJson, + ) + assertEquals( + DEFAULT_APP_SIGNATURE, + (slot.captured.clientData as ClientData.DefaultWithCustomHash) + .hash + .decodeToString(), + ) + assertEquals(selectedCipherView, slot.captured.selectedCipherView) + assertTrue(slot.captured.isUserVerificationSupported) } @Suppress("MaxLineLength") @Test - fun `registerFido2Credential should construct ClientData DefaultWithExtraData when callingAppInfo origin is null`() = + fun `registerFido2Credential should construct correct RegisterFido2CredentialRequest when callingAppInfo origin is null`() = runTest { - val mockSigningInfo = mockk { - every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE)) - every { hasMultipleSigners() } returns false - } - val mockFido2Request = createMockFido2CreateCredentialRequest( - number = 1, - signingInfo = mockSigningInfo, - ) + val mockCipherView = createMockCipherView(number = 1) val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1) + val slot = slot() - every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE every { json.encodeToString(any(), any()) } returns "" - val requestCaptureSlot = slot() + every { mockCallingAppInfo.isOriginPopulated() } returns false + every { mockCreatePublicKeyCredentialRequest.origin } returns null coEvery { mockVaultSdkSource.registerFido2Credential( - request = capture(requestCaptureSlot), + request = capture(slot), fido2CredentialStore = any(), ) - } coAnswers { - mockRegistrationResponse - .asSuccess() - } + } returns mockRegistrationResponse.asSuccess() fido2CredentialManager.registerFido2Credential( userId = "mockUserId", - fido2CreateCredentialRequest = mockFido2Request, - selectedCipherView = createMockCipherView(1), + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + selectedCipherView = mockCipherView, + callingAppInfo = mockCallingAppInfo, ) + + assertEquals( + "mockUserId", + slot.captured.userId, + ) + assertEquals( + DEFAULT_ANDROID_ORIGIN, + slot.captured.origin, + ) + assertEquals( + """{"publicKey": ${DEFAULT_FIDO2_CREATE_REQUEST_JSON}}""", + slot.captured.requestJson, + ) + assertEquals( + DEFAULT_PACKAGE_NAME, + (slot.captured.clientData as ClientData.DefaultWithExtraData) + .androidPackageName, + ) + assertEquals(mockCipherView, slot.captured.selectedCipherView) + assertTrue(slot.captured.isUserVerificationSupported) } @Test fun `registerFido2Credential should wrap request in webauthn json object`() = runTest { - val mockSigningInfo = mockk { - every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE)) - every { hasMultipleSigners() } returns false - } - val mockFido2CreateCredentialRequest = createMockFido2CreateCredentialRequest( - number = 1, - origin = "origin", - signingInfo = mockSigningInfo, - ) - val mockCipherView = createMockCipherView(1) val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1) every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE every { json.encodeToString(any(), any()) } returns "" + every { mockCreatePublicKeyCredentialRequest.origin } returns DEFAULT_WEB_ORIGIN.v1 val requestCaptureSlot = slot() coEvery { mockVaultSdkSource.registerFido2Credential( request = capture(requestCaptureSlot), fido2CredentialStore = any(), ) - } coAnswers { - mockRegistrationResponse - .asSuccess() - } + } returns mockRegistrationResponse.asSuccess() fido2CredentialManager.registerFido2Credential( userId = "mockUserId", - fido2CreateCredentialRequest = mockFido2CreateCredentialRequest, - selectedCipherView = mockCipherView, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + selectedCipherView = createMockCipherView(number = 1), + callingAppInfo = mockCallingAppInfo, ) assertEquals( - """{"publicKey": ${mockFido2CreateCredentialRequest.requestJson}}""", + """{"publicKey": ${mockCreatePublicKeyCredentialRequest.requestJson}}""", requestCaptureSlot.captured.requestJson, ) } @@ -280,46 +320,32 @@ class Fido2CredentialManagerTest { @Test fun `registerFido2Credential should register FIDO 2 credential to active user ID`() = runTest { - val mockSigningInfo = mockk { - every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE)) - every { hasMultipleSigners() } returns false - } - val mockFido2CreateCredentialRequest = createMockFido2CreateCredentialRequest( - number = 1, - origin = "origin", - signingInfo = mockSigningInfo, - ) + val mockCipherView = createMockCipherView(1) val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1) every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE every { json.encodeToString(any(), any()) } returns "" + every { mockCreatePublicKeyCredentialRequest.origin } returns DEFAULT_WEB_ORIGIN.v1 val requestCaptureSlot = slot() coEvery { mockVaultSdkSource.registerFido2Credential( request = capture(requestCaptureSlot), fido2CredentialStore = any(), ) - } coAnswers { - mockRegistrationResponse - .asSuccess() - } + } returns mockRegistrationResponse.asSuccess() fido2CredentialManager.registerFido2Credential( userId = "mockUserId", - fido2CreateCredentialRequest = mockFido2CreateCredentialRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, selectedCipherView = mockCipherView, + callingAppInfo = mockCallingAppInfo, ) assertEquals( "mockUserId", requestCaptureSlot.captured.userId, ) - - assertNotEquals( - "mockUserId", - mockFido2CreateCredentialRequest.userId, - ) } @Suppress("MaxLineLength") @@ -329,23 +355,16 @@ class Fido2CredentialManagerTest { val mockSigningInfo = mockk { every { hasMultipleSigners() } returns true } - val mockFido2CredentialRequest = createMockFido2CreateCredentialRequest( - number = 1, - origin = "origin", - signingInfo = mockSigningInfo, - ) - + every { mockCallingAppInfo.signingInfo } returns mockSigningInfo val result = fido2CredentialManager.registerFido2Credential( userId = "mockUserId", - fido2CreateCredentialRequest = mockFido2CredentialRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, selectedCipherView = createMockCipherView(number = 1), + callingAppInfo = mockCallingAppInfo, ) assertEquals( - Fido2RegisterCredentialResult.Error( - R.string.passkey_operation_failed_because_app_is_signed_incorrectly - .asText(), - ), + Fido2RegisterCredentialResult.Error.InvalidAppSignature, result, ) } @@ -357,22 +376,17 @@ class Fido2CredentialManagerTest { val mockSigningInfo = mockk { every { hasMultipleSigners() } returns true } - val mockFido2CredentialRequest = createMockFido2CreateCredentialRequest( - number = 1, - signingInfo = mockSigningInfo, - ) + every { mockCallingAppInfo.signingInfo } returns mockSigningInfo val result = fido2CredentialManager.registerFido2Credential( userId = "mockUserId", - fido2CreateCredentialRequest = mockFido2CredentialRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, selectedCipherView = createMockCipherView(number = 1), + callingAppInfo = mockCallingAppInfo, ) assertEquals( - Fido2RegisterCredentialResult.Error( - R.string.passkey_operation_failed_because_app_signature_is_invalid - .asText(), - ), + Fido2RegisterCredentialResult.Error.InvalidAppSignature, result, ) } @@ -380,15 +394,7 @@ class Fido2CredentialManagerTest { @Test fun `registerFido2Credential should return Error when deserialization fails`() = runTest { - val mockSigningInfo = mockk { - every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE)) - every { hasMultipleSigners() } returns false - } - val mockFido2CredentialRequest = createMockFido2CreateCredentialRequest( - number = 1, - origin = "origin", - signingInfo = mockSigningInfo, - ) + every { mockCallingAppInfo.signingInfo } returns mockSigningInfo val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1) every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE @@ -398,49 +404,41 @@ class Fido2CredentialManagerTest { any(), ) } throws IllegalArgumentException() + every { mockCreatePublicKeyCredentialRequest.origin } returns DEFAULT_WEB_ORIGIN.v1 coEvery { mockVaultSdkSource.registerFido2Credential( request = any(), fido2CredentialStore = any(), ) - } coAnswers { - mockRegistrationResponse - .asSuccess() - } + } returns mockRegistrationResponse.asSuccess() val result = fido2CredentialManager.registerFido2Credential( userId = "mockUserId", - fido2CreateCredentialRequest = mockFido2CredentialRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, selectedCipherView = createMockCipherView(number = 1), + callingAppInfo = mockCallingAppInfo, ) assertEquals( - Fido2RegisterCredentialResult.Error( - R.string.passkey_registration_failed_due_to_an_internal_error - .asText(), - ), + Fido2RegisterCredentialResult.Error.InternalError, result, ) } @Test fun `registerFido2Credential should return Error when origin is null`() = runTest { - val mockAssertionRequest = createMockFido2CreateCredentialRequest( - number = 1, - origin = null, - signingInfo = mockSigningInfo, - ) - val mockSelectedCipher = createMockCipherView(number = 1) - every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE every { json.decodeFromString(any()) } throws SerializationException() + every { mockCallingAppInfo.isOriginPopulated() } returns false + every { mockCreatePublicKeyCredentialRequest.origin } returns null val result = fido2CredentialManager.registerFido2Credential( - userId = "activeUserId", - fido2CreateCredentialRequest = mockAssertionRequest, - selectedCipherView = mockSelectedCipher, + userId = "mockUserId", + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + selectedCipherView = createMockCipherView(number = 1), + callingAppInfo = mockCallingAppInfo, ) coVerify(exactly = 0) { @@ -451,14 +449,49 @@ class Fido2CredentialManagerTest { } assertEquals( - Fido2RegisterCredentialResult.Error( - R.string.passkey_operation_failed_because_host_url_is_not_present_in_request - .asText(), - ), + Fido2RegisterCredentialResult.Error.MissingHostUrl, result, ) } + @Suppress("MaxLineLength") + @Test + fun `registerFido2Credential should return InvalidAppSignature when calling app is not privileged and signature is invalid`() = + runTest { + every { mockCallingAppInfo.isOriginPopulated() } returns false + every { mockSigningInfo.hasMultipleSigners() } returns true + val result = fido2CredentialManager.registerFido2Credential( + userId = "mockUserId", + callingAppInfo = mockCallingAppInfo, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + selectedCipherView = createMockCipherView(number = 1), + ) + + assertEquals( + Fido2RegisterCredentialResult.Error.InvalidAppSignature, + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `registerFido2Credential should return MissingHostUrl when calling app if privileged and origin is missing`() = + runTest { + every { mockCreatePublicKeyCredentialRequest.origin } returns null + + val result = fido2CredentialManager.registerFido2Credential( + userId = "mockUserId", + callingAppInfo = mockCallingAppInfo, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + selectedCipherView = createMockCipherView(number = 1), + ) + + assertEquals( + Fido2RegisterCredentialResult.Error.MissingHostUrl, + result, + ) + } + @Suppress("MaxLineLength") @Test fun `hasAuthenticationAttemptsRemaining returns true when authenticationAttempts is less than 5`() { @@ -478,13 +511,11 @@ class Fido2CredentialManagerTest { runTest { every { Base64.encodeToString(any(), any()) } returns "" val mockCipherView = createMockCipherView(number = 1) - val mockRequest = createMockFido2AssertionRequest( - mockClientDataHash = byteArrayOf(), - mockSigningInfo = mockSigningInfo, - ) val requestCaptureSlot = slot() val mockSdkResponse = mockk(relaxed = true) + every { mockCallingAppInfo.isOriginPopulated() } returns true + every { mockGetPublicKeyCredentialOption.clientDataHash } returns byteArrayOf(1, 2, 3) coEvery { mockVaultSdkSource.authenticateFido2Credential( request = any(), @@ -494,8 +525,10 @@ class Fido2CredentialManagerTest { fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", - request = mockRequest, - selectedCipherView = createMockCipherView(number = 1), + request = mockGetPublicKeyCredentialOption, + selectedCipherView = mockCipherView, + callingAppInfo = mockCallingAppInfo, + origin = DEFAULT_WEB_ORIGIN.v1, ) coVerify { @@ -508,8 +541,10 @@ class Fido2CredentialManagerTest { AuthenticateFido2CredentialRequest( userId = "activeUserId", origin = DEFAULT_WEB_ORIGIN, - requestJson = """{"publicKey": ${mockRequest.requestJson}}""", - clientData = ClientData.DefaultWithCustomHash(mockRequest.clientDataHash!!), + requestJson = """{"publicKey": ${mockGetPublicKeyCredentialOption.requestJson}}""", + clientData = ClientData.DefaultWithCustomHash( + hash = mockGetPublicKeyCredentialOption.clientDataHash!!, + ), selectedCipherView = mockCipherView, isUserVerificationSupported = true, ), @@ -527,10 +562,10 @@ class Fido2CredentialManagerTest { every { mockSigningInfo.hasMultipleSigners() } returns false + every { mockCallingAppInfo.isOriginPopulated() } returns true + + every { mockGetPublicKeyCredentialOption.clientDataHash } returns null val mockCipherView = createMockCipherView(number = 1) - val mockRequest = createMockFido2AssertionRequest( - mockSigningInfo = mockSigningInfo, - ) val requestCaptureSlot = slot() val mockSdkResponse = mockk(relaxed = true) @@ -544,8 +579,10 @@ class Fido2CredentialManagerTest { fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", - request = mockRequest, - selectedCipherView = createMockCipherView(number = 1), + request = mockGetPublicKeyCredentialOption, + selectedCipherView = mockCipherView, + callingAppInfo = mockCallingAppInfo, + origin = DEFAULT_WEB_ORIGIN.v1, ) coVerify { @@ -558,7 +595,8 @@ class Fido2CredentialManagerTest { AuthenticateFido2CredentialRequest( userId = "activeUserId", origin = DEFAULT_WEB_ORIGIN, - requestJson = """{"publicKey": ${mockRequest.requestJson}}""", + requestJson = + """{"publicKey": ${mockGetPublicKeyCredentialOption.requestJson}}""", clientData = ClientData.DefaultWithExtraData( androidPackageName = "android:apk-key-hash:$DEFAULT_APP_SIGNATURE", ), @@ -573,11 +611,6 @@ class Fido2CredentialManagerTest { @Test fun `authenticateFido2Credential should construct correct assertion request when calling app is unprivileged`() = runTest { - val mockAssertionRequest = createMockFido2AssertionRequest( - mockOrigin = null, - mockClientDataHash = null, - mockSigningInfo = mockSigningInfo, - ) val mockAssertionOptions = createMockPasskeyAssertionOptions(number = 1) val mockSelectedCipher = createMockCipherView(number = 1) val requestCaptureSlot = slot() @@ -592,8 +625,10 @@ class Fido2CredentialManagerTest { fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", - request = mockAssertionRequest, + request = mockGetPublicKeyCredentialOption, selectedCipherView = mockSelectedCipher, + callingAppInfo = mockCallingAppInfo, + origin = null, ) coVerify { @@ -619,12 +654,6 @@ class Fido2CredentialManagerTest { @Suppress("MaxLineLength") @Test fun `authenticateFido2Credential should return Error when origin is null`() = runTest { - val mockAssertionRequest = createMockFido2AssertionRequest( - mockOrigin = null, - mockClientDataHash = null, - mockSigningInfo = mockSigningInfo, - ) - val mockSelectedCipher = createMockCipherView(number = 1) every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE @@ -634,8 +663,10 @@ class Fido2CredentialManagerTest { val result = fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", - request = mockAssertionRequest, + request = mockGetPublicKeyCredentialOption, selectedCipherView = mockSelectedCipher, + callingAppInfo = mockCallingAppInfo, + origin = null, ) coVerify(exactly = 0) { @@ -646,10 +677,7 @@ class Fido2CredentialManagerTest { } assertEquals( - Fido2CredentialAssertionResult.Error( - R.string.passkey_operation_failed_because_host_url_is_not_present_in_request - .asText(), - ), + Fido2CredentialAssertionResult.Error.MissingHostUrl, result, ) } @@ -662,7 +690,6 @@ class Fido2CredentialManagerTest { PublicKeyCredentialAuthenticatorAssertionResponse::toAndroidFido2PublicKeyCredential, ) every { Base64.encodeToString(any(), any()) } returns "" - val mockRequest = createMockFido2AssertionRequest(mockSigningInfo = mockSigningInfo) val mockPublicKeyCredential = mockk() val mockSdkResponse = mockk(relaxed = true) { @@ -678,8 +705,10 @@ class Fido2CredentialManagerTest { val authResult = fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", - request = mockRequest, + request = mockGetPublicKeyCredentialOption, selectedCipherView = createMockCipherView(number = 1), + callingAppInfo = mockCallingAppInfo, + origin = null, ) coVerify { @@ -703,7 +732,6 @@ class Fido2CredentialManagerTest { runTest { mockkStatic(PublicKeyCredentialAuthenticatorAssertionResponse::toAndroidFido2PublicKeyCredential) every { Base64.encodeToString(any(), any()) } returns "" - val mockRequest = createMockFido2AssertionRequest(mockSigningInfo = mockSigningInfo) val requestCaptureSlot = slot() val mockPublicKeyCredential = mockk() val mockSdkResponse = @@ -720,8 +748,10 @@ class Fido2CredentialManagerTest { val authResult = fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", - request = mockRequest, + request = mockGetPublicKeyCredentialOption, selectedCipherView = createMockCipherView(number = 1), + callingAppInfo = mockCallingAppInfo, + origin = null, ) coVerify { @@ -734,74 +764,143 @@ class Fido2CredentialManagerTest { } assertEquals( - Fido2CredentialAssertionResult.Error( - R.string.passkey_authentication_failed_due_to_an_internal_error - .asText(), - ), + Fido2CredentialAssertionResult.Error.InternalError, authResult, ) } + @Suppress("MaxLineLength") @Test - fun `authenticateFido2Credential should return Error when relyingPartyId is null`() = runTest { - val mockAssertionRequest = createMockFido2AssertionRequest( - mockSigningInfo = mockSigningInfo, - ) - val mockSelectedCipherView = createMockCipherView(number = 1) - + fun `getUserVerificationRequirement should return correct verification required based on attestation options`() { + // Verify REQUIRED is mapped correctly every { - json.decodeFromStringOrNull(DEFAULT_FIDO2_AUTH_REQUEST_JSON) - } returns createMockPasskeyAssertionOptions(number = 1, relyingPartyId = null) - - val result = fido2CredentialManager.authenticateFido2Credential( - userId = "activeUserId", - request = mockAssertionRequest, - selectedCipherView = mockSelectedCipherView, + json.decodeFromString(any()) + } returns createMockPasskeyAttestationOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.REQUIRED, ) - coVerify(exactly = 0) { - mockVaultSdkSource.authenticateFido2Credential( - any(), - any(), + assertEquals( + UserVerificationRequirement.REQUIRED, + fido2CredentialManager.getUserVerificationRequirement( + request = mockCreatePublicKeyCredentialRequest, + fallbackRequirement = UserVerificationRequirement.PREFERRED, + ), + ) + + // Verify PREFERRED is mapped correctly + every { + json.decodeFromString(any()) + } returns createMockPasskeyAttestationOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.PREFERRED, + ) + + assertEquals( + UserVerificationRequirement.PREFERRED, + fido2CredentialManager.getUserVerificationRequirement( + request = mockCreatePublicKeyCredentialRequest, + fallbackRequirement = UserVerificationRequirement.REQUIRED, + ), + ) + + // Verify DISCOURAGED is mapped correctly + every { + json.decodeFromString(any()) + } returns createMockPasskeyAttestationOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.DISCOURAGED, + ) + + assertEquals( + UserVerificationRequirement.DISCOURAGED, + fido2CredentialManager.getUserVerificationRequirement( + request = mockCreatePublicKeyCredentialRequest, + fallbackRequirement = UserVerificationRequirement.PREFERRED, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `getUserVerificationRequirement should return fallback requirement when attestation options are invalid`() { + every { + json.decodeFromString(any()) + } throws SerializationException() + assertEquals( + UserVerificationRequirement.PREFERRED, + fido2CredentialManager.getUserVerificationRequirement( + request = mockCreatePublicKeyCredentialRequest, + fallbackRequirement = UserVerificationRequirement.PREFERRED, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `getUserVerificationRequirement should return fallback requirement based on assertion options`() = + runTest { + // Verify REQUIRED is mapped correctly + every { + json.decodeFromString(any()) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.REQUIRED, + ) + assertEquals( + UserVerificationRequirement.REQUIRED, + fido2CredentialManager.getUserVerificationRequirement( + request = mockProviderGetCredentialRequest, + fallbackRequirement = UserVerificationRequirement.PREFERRED, + ), + ) + + // Verify PREFERRED is mapped correctly + every { + json.decodeFromString(any()) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.REQUIRED, + ) + assertEquals( + UserVerificationRequirement.REQUIRED, + fido2CredentialManager.getUserVerificationRequirement( + request = mockProviderGetCredentialRequest, + fallbackRequirement = UserVerificationRequirement.PREFERRED, + ), + ) + + // Verify DISCOURAGED is mapped correctly + every { + json.decodeFromString(any()) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.DISCOURAGED, + ) + assertEquals( + UserVerificationRequirement.DISCOURAGED, + fido2CredentialManager.getUserVerificationRequirement( + request = mockProviderGetCredentialRequest, + fallbackRequirement = UserVerificationRequirement.PREFERRED, + ), ) } - assertEquals( - Fido2CredentialAssertionResult.Error( - R.string.passkey_operation_failed_because_relying_party_cannot_be_identified - .asText(), - ), - result, - ) - } - + @Suppress("MaxLineLength") @Test - fun `authenticateFido2Credential should return Error when validateOrigin is Error`() = runTest { - val mockAssertionRequest = createMockFido2AssertionRequest( - mockOrigin = null, - mockSigningInfo = mockSigningInfo, - ) - val mockSelectedCipherView = createMockCipherView(number = 1) - - coEvery { - fido2OriginManager.validateOrigin(any(), any()) - } returns Fido2ValidateOriginResult.Error.Unknown - - val result = fido2CredentialManager.authenticateFido2Credential( - userId = "activeUserId", - request = mockAssertionRequest, - selectedCipherView = mockSelectedCipherView, - ) - - assertEquals( - Fido2CredentialAssertionResult.Error(R.string.generic_error_message.asText()), - result, - ) - - coVerify(exactly = 0) { - mockVaultSdkSource.authenticateFido2Credential(any(), any()) + fun `getUserVerificationRequirement should return fallback requirement when assertion options are invalid`() = + runTest { + every { + mockProviderGetCredentialRequest.credentialOptions + } returns emptyList() + assertEquals( + UserVerificationRequirement.REQUIRED, + fido2CredentialManager.getUserVerificationRequirement( + request = mockProviderGetCredentialRequest, + fallbackRequirement = UserVerificationRequirement.REQUIRED, + ), + ) } - } } private const val DEFAULT_PACKAGE_NAME = "com.x8bit.bitwarden" @@ -812,33 +911,11 @@ private val DEFAULT_ANDROID_ORIGIN = Origin.Android( UnverifiedAssetLink( packageName = DEFAULT_PACKAGE_NAME, sha256CertFingerprint = DEFAULT_CERT_FINGERPRINT, - host = DEFAULT_HOST, - assetLinkUrl = "bitwarden.com", + host = "https://$DEFAULT_HOST", + assetLinkUrl = "https://$DEFAULT_HOST", ), ) private val DEFAULT_WEB_ORIGIN = Origin.Web("bitwarden.com") -private const val DEFAULT_ALLOW_LIST = """ -{ - "apps": [ - { - "type": "android", - "info": { - "package_name": "com.x8bit.bitwarden", - "signatures": [ - { - "build": "release", - "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" - }, - { - "build": "userdebug", - "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" - } - ] - } - } - ] -} -""" private const val DEFAULT_FIDO2_AUTH_REQUEST_JSON = """ { "allowCredentials": [ @@ -862,20 +939,21 @@ private const val DEFAULT_FIDO2_AUTH_REQUEST_JSON = """ "userVerification": "preferred" } """ - -private fun createMockFido2AssertionRequest( - mockOrigin: String? = "bitwarden.com", - mockClientDataHash: ByteArray? = null, - mockSigningInfo: SigningInfo, -) = mockk { - every { origin } returns mockOrigin - every { requestJson } returns DEFAULT_FIDO2_AUTH_REQUEST_JSON - every { clientDataHash } returns mockClientDataHash - every { callingAppInfo } returns mockk { - every { origin } returns mockOrigin - every { packageName } returns DEFAULT_PACKAGE_NAME - every { getOrigin(DEFAULT_ALLOW_LIST) } returns mockOrigin - every { signingInfo } returns mockSigningInfo - every { isOriginPopulated() } returns mockOrigin.isNullOrEmpty().not() - } +private const val DEFAULT_FIDO2_CREATE_REQUEST_JSON = """ +{ + "authenticatorSelection": { "residentKey":"required", "userVerification":"preferred" }, + "challenge":"TrvS2AJTFN877BNxAisWaMRvi_2wlT83ZD8IMry2QQs", + "excludeCredentials":[], + "extensions": { "credProps":true }, + "pubKeyCredParams":[ + { "alg":-7, "type":"public-key" }, + { "alg":-257, "type":"public-key" } + ], + "rp": { "id":"learnpasskeys.io", "name":"Learn Passkeys" }, + "user": { + "displayName":"Clyde O'Hara", + "id":"TE8tc3VWR184emowN3liOVpwbDN6azA4SnA3cTUtUmo", + "name":"overjoyed.clyde.0553" + } } +""" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionRequestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionRequestUtil.kt index 10c955ed97..8e8e92b397 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionRequestUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialAssertionRequestUtil.kt @@ -1,20 +1,14 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import android.content.pm.SigningInfo +import androidx.core.os.bundleOf fun createMockFido2CredentialAssertionRequest( number: Int = 1, userId: String = "mockUserId-$number", - isUserVerified: Boolean = false, ): Fido2CredentialAssertionRequest = Fido2CredentialAssertionRequest( userId = userId, cipherId = "mockCipherId-$number", credentialId = "mockCredentialId-$number", - requestJson = "mockRequestJson-$number", - clientDataHash = byteArrayOf(0), - packageName = "mockPackageName-$number", - signingInfo = SigningInfo(), - origin = "mockOrigin-$number", - isUserVerified = isUserVerified, + requestData = bundleOf(), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialRequestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialRequestUtil.kt index 62a7f0c6a6..4c7144571f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialRequestUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialRequestUtil.kt @@ -1,20 +1,15 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import android.content.pm.SigningInfo +import android.os.Bundle +import androidx.core.os.bundleOf /** * Creates a mock [Fido2CreateCredentialRequest] with a given [number]. */ fun createMockFido2CreateCredentialRequest( number: Int, - origin: String? = null, - signingInfo: SigningInfo = SigningInfo(), -): Fido2CreateCredentialRequest = - Fido2CreateCredentialRequest( - userId = "mockUserId-$number", - requestJson = """{"request": {"number": $number}}""", - packageName = "com.x8bit.bitwarden", - signingInfo = signingInfo, - origin = origin, - isUserVerified = true, - ) + requestData: Bundle = bundleOf(), +): Fido2CreateCredentialRequest = Fido2CreateCredentialRequest( + userId = "mockUserId-$number", + requestData = requestData, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsRequestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsRequestUtil.kt index eb1a251cba..eb89889f4f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsRequestUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2GetCredentialsRequestUtil.kt @@ -1,20 +1,11 @@ package com.x8bit.bitwarden.data.autofill.fido2.model -import android.content.pm.SigningInfo -import android.os.Bundle +import androidx.core.os.bundleOf fun createMockFido2GetCredentialsRequest( number: Int, userId: String = "mockUserId-$number", - signingInfo: SigningInfo = SigningInfo(), - origin: String? = null, ): Fido2GetCredentialsRequest = Fido2GetCredentialsRequest( - candidateQueryData = Bundle(), - id = "mockId-$number", userId = userId, - requestJson = "requestJson-$number", - clientDataHash = null, - packageName = "mockPackageName-$number", - signingInfo = signingInfo, - origin = origin, + requestData = bundleOf(), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt index 9e2e568fec..b6884cab99 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt @@ -22,7 +22,6 @@ import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetCredentialResponse import androidx.credentials.provider.BeginGetPasswordOption import androidx.credentials.provider.BeginGetPublicKeyCredentialOption -import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.PublicKeyCredentialEntry import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.util.asFailure @@ -134,11 +133,7 @@ class Fido2ProviderProcessorTest { @Test fun `processCreateCredentialRequest should invoke callback with error when user id is null`() { val request: BeginCreateCredentialRequest = mockk { - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) every { candidateQueryData } returns Bundle() } val callback: OutcomeReceiver = @@ -162,11 +157,7 @@ class Fido2ProviderProcessorTest { @Test fun `processCreateCredentialRequest should invoke callback with error on password create request`() { val request: BeginCreatePasswordCredentialRequest = mockk { - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) every { candidateQueryData } returns Bundle() } val callback: OutcomeReceiver = @@ -187,11 +178,7 @@ class Fido2ProviderProcessorTest { @Test fun `processCreateCredentialRequest should invoke callback with error when json is null or empty`() { val request: BeginCreatePublicKeyCredentialRequest = mockk { - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) } val candidateQueryData: Bundle = mockk() val callback: OutcomeReceiver = @@ -215,11 +202,7 @@ class Fido2ProviderProcessorTest { @Test fun `processCreateCredentialRequest should invoke callback with error when user state null`() { val request: BeginCreatePublicKeyCredentialRequest = mockk { - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) } val candidateQueryData: Bundle = mockk() val callback: OutcomeReceiver = @@ -244,11 +227,7 @@ class Fido2ProviderProcessorTest { @Test fun `processCreateCredentialRequest should invoke callback with result when user state is valid`() { val request: BeginCreatePublicKeyCredentialRequest = mockk { - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) } val candidateQueryData: Bundle = mockk() val callback: OutcomeReceiver = @@ -286,11 +265,7 @@ class Fido2ProviderProcessorTest { @Test fun `processCreateCredentialRequest should generate correct entries based on state`() { val request: BeginCreatePublicKeyCredentialRequest = mockk { - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) } val candidateQueryData: Bundle = mockk() val callback: OutcomeReceiver = @@ -375,11 +350,7 @@ class Fido2ProviderProcessorTest { @Test fun `processGetCredentialRequest should invoke callback with error when user state is null`() { val request: BeginGetCredentialRequest = mockk { - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) every { beginGetCredentialOptions } returns listOf( @@ -412,11 +383,7 @@ class Fido2ProviderProcessorTest { every { candidateQueryData } returns Bundle() }, ) - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) } val callback: OutcomeReceiver = mockk() val captureSlot = slot() @@ -477,11 +444,7 @@ class Fido2ProviderProcessorTest { every { candidateQueryData } returns Bundle() }, ) - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) } val callback: OutcomeReceiver = mockk() val captureSlot = slot() @@ -508,11 +471,7 @@ class Fido2ProviderProcessorTest { ) val request: BeginGetCredentialRequest = mockk { every { beginGetCredentialOptions } returns listOf(mockOption) - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) } every { fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any()) @@ -542,11 +501,7 @@ class Fido2ProviderProcessorTest { ) val request: BeginGetCredentialRequest = mockk { every { beginGetCredentialOptions } returns listOf(mockOption) - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) } val callback: OutcomeReceiver = mockk() val captureSlot = slot() @@ -593,11 +548,7 @@ class Fido2ProviderProcessorTest { ) val request: BeginGetCredentialRequest = mockk { every { beginGetCredentialOptions } returns listOf(mockOption) - every { callingAppInfo } returns CallingAppInfo( - packageName = "com.x8bit.bitwarden.dev", - signingInfo = mockk(), - origin = null, - ) + every { callingAppInfo } returns mockk(relaxed = true) } val callback: OutcomeReceiver = mockk() val captureSlot = slot() diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtilsTest.kt index 2ece56aa47..30ee9d8c32 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtilsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtilsTest.kt @@ -1,22 +1,11 @@ package com.x8bit.bitwarden.data.autofill.fido2.util import android.content.Intent -import android.content.pm.SigningInfo -import android.service.credentials.BeginGetCredentialRequest import androidx.core.os.bundleOf -import androidx.credentials.CreatePasswordRequest -import androidx.credentials.CreatePublicKeyCredentialRequest -import androidx.credentials.GetPasswordOption -import androidx.credentials.GetPublicKeyCredentialOption -import androidx.credentials.provider.BeginGetPasswordOption -import androidx.credentials.provider.BeginGetPublicKeyCredentialOption -import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.PendingIntentHandler import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.credentials.provider.ProviderGetCredentialRequest -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest 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_CREDENTIAL_ID @@ -39,14 +28,24 @@ class Fido2IntentUtilsTest { @BeforeEach fun setUp() { mockkStatic(::isBuildVersionBelow) - mockkObject(PendingIntentHandler.Companion) + mockkObject( + PendingIntentHandler.Companion, + BeginGetCredentialRequest.Companion, + ProviderCreateCredentialRequest.Companion, + ProviderGetCredentialRequest.Companion, + ) every { isBuildVersionBelow(any()) } returns false } @AfterEach fun tearDown() { unmockkStatic(::isBuildVersionBelow) - unmockkObject(PendingIntentHandler.Companion) + unmockkObject( + BeginGetCredentialRequest.Companion, + PendingIntentHandler.Companion, + ProviderCreateCredentialRequest.Companion, + ProviderGetCredentialRequest.Companion, + ) } @Suppress("MaxLineLength") @@ -55,82 +54,16 @@ class Fido2IntentUtilsTest { val intent = mockk { every { getStringExtra(EXTRA_KEY_USER_ID) } returns "mockUserId" } - val mockCallingRequest = mockk { - every { requestJson } returns "requestJson" - every { clientDataHash } returns byteArrayOf(0) - every { preferImmediatelyAvailableCredentials } returns false - every { origin } returns "mockOrigin" - every { isAutoSelectAllowed } returns true - } - val mockCallingAppInfo = CallingAppInfo( - packageName = "mockPackageName", - signingInfo = SigningInfo(), - origin = "mockOrigin", - ) - val mockProviderRequest = ProviderCreateCredentialRequest( - callingRequest = mockCallingRequest, - callingAppInfo = mockCallingAppInfo, - ) + every { ProviderCreateCredentialRequest.asBundle(any()) } returns bundleOf() every { PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) - } returns mockProviderRequest + } returns mockk() val createRequest = intent.getFido2CreateCredentialRequestOrNull() assertEquals( - Fido2CreateCredentialRequest( - userId = "mockUserId", - requestJson = mockCallingRequest.requestJson, - packageName = mockCallingAppInfo.packageName, - signingInfo = mockCallingAppInfo.signingInfo, - origin = mockCallingAppInfo.origin, - isUserVerified = null, - ), - createRequest, - ) - } - - @Suppress("MaxLineLength") - @Test - fun `getFido2CreateCredentialRequestOrNull should set isUserVerified when biometric prompt result is present`() { - val intent = mockk { - every { getStringExtra(EXTRA_KEY_USER_ID) } returns "mockUserId" - } - val mockCallingRequest = mockk { - every { requestJson } returns "requestJson" - every { clientDataHash } returns byteArrayOf(0) - every { preferImmediatelyAvailableCredentials } returns false - every { origin } returns "mockOrigin" - every { isAutoSelectAllowed } returns true - } - val mockCallingAppInfo = CallingAppInfo( - packageName = "mockPackageName", - signingInfo = SigningInfo(), - origin = "mockOrigin", - ) - val mockProviderRequest = ProviderCreateCredentialRequest( - callingRequest = mockCallingRequest, - callingAppInfo = mockCallingAppInfo, - biometricPromptResult = mockk { - every { isSuccessful } returns true - }, - ) - - every { - PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) - } returns mockProviderRequest - - val createRequest = intent.getFido2CreateCredentialRequestOrNull() - assertEquals( - Fido2CreateCredentialRequest( - userId = "mockUserId", - requestJson = mockCallingRequest.requestJson, - packageName = mockCallingAppInfo.packageName, - signingInfo = mockCallingAppInfo.signingInfo, - origin = mockCallingAppInfo.origin, - isUserVerified = true, - ), - createRequest, + "mockUserId", + createRequest?.userId, ) } @@ -146,35 +79,9 @@ class Fido2IntentUtilsTest { @Suppress("MaxLineLength") @Test - fun `getFido2CreateCredentialRequestOrNull should return null when intent is not a provider create credential request`() { - val intent = mockk() - - every { - PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) - } returns null - - assertNull(intent.getFido2CreateCredentialRequestOrNull()) - } - - @Suppress("MaxLineLength") - @Test - fun `getFido2CreateCredentialRequestOrNull should return null when calling request is not a public key credential create request`() { - val intent = mockk() - val mockCallingRequest = mockk() - val mockCallingAppInfo = CallingAppInfo( - packageName = "mockPackageName", - signingInfo = SigningInfo(), - origin = "mockOrigin", - ) - val mockProviderRequest = ProviderCreateCredentialRequest( - callingRequest = mockCallingRequest, - callingAppInfo = mockCallingAppInfo, - ) - every { - PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) - } returns mockProviderRequest - - assertNull(intent.getFido2CreateCredentialRequestOrNull()) + fun `getFido2CreateCredentialRequestOrNull should return null when retrieveProviderCreateCredentialRequest is null`() { + every { PendingIntentHandler.retrieveProviderCreateCredentialRequest(any()) } returns null + assertNull(mockk().getFido2CreateCredentialRequestOrNull()) } @Suppress("MaxLineLength") @@ -183,26 +90,9 @@ class Fido2IntentUtilsTest { val intent = mockk { every { getStringExtra(EXTRA_KEY_USER_ID) } returns null } - val mockCallingRequest = mockk { - every { requestJson } returns "requestJson" - every { clientDataHash } returns byteArrayOf(0) - every { preferImmediatelyAvailableCredentials } returns false - every { origin } returns "mockOrigin" - every { isAutoSelectAllowed } returns true - } - val mockCallingAppInfo = CallingAppInfo( - packageName = "mockPackageName", - signingInfo = SigningInfo(), - origin = "mockOrigin", - ) - val mockProviderRequest = ProviderCreateCredentialRequest( - callingRequest = mockCallingRequest, - callingAppInfo = mockCallingAppInfo, - ) - every { PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) - } returns mockProviderRequest + } returns mockk() assertNull(intent.getFido2CreateCredentialRequestOrNull()) } @@ -214,96 +104,24 @@ class Fido2IntentUtilsTest { every { getStringExtra(EXTRA_KEY_CIPHER_ID) } returns "mockCipherId" every { getStringExtra(EXTRA_KEY_CREDENTIAL_ID) } returns "mockCredentialId" } - val mockOption = GetPublicKeyCredentialOption( - requestJson = "requestJson", - clientDataHash = byteArrayOf(0), - allowedProviders = emptySet(), - ) - val mockCallingAppInfo = CallingAppInfo( - packageName = "mockPackageName", - signingInfo = SigningInfo(), - origin = "mockOrigin", - ) - val mockProviderGetCredentialRequest = ProviderGetCredentialRequest( - credentialOptions = listOf(mockOption), - callingAppInfo = mockCallingAppInfo, - ) + every { ProviderGetCredentialRequest.asBundle(any()) } returns bundleOf() every { PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) - } returns mockProviderGetCredentialRequest + } returns mockk() val assertionRequest = intent.getFido2AssertionRequestOrNull() assertNotNull(assertionRequest) - assertEquals( - Fido2CredentialAssertionRequest( - userId = "mockUserId", - cipherId = "mockCipherId", - credentialId = "mockCredentialId", - requestJson = mockOption.requestJson, - clientDataHash = mockOption.clientDataHash, - packageName = mockCallingAppInfo.packageName, - signingInfo = mockCallingAppInfo.signingInfo, - origin = mockCallingAppInfo.origin, - isUserVerified = null, - ), - assertionRequest, - ) - } - - @Suppress("MaxLineLength") - @Test - fun `getFido2AssertionRequestOrNull should set isUserVerified when biometric prompt result is present`() { - val intent = mockk { - every { getStringExtra(EXTRA_KEY_USER_ID) } returns "mockUserId" - every { getStringExtra(EXTRA_KEY_CIPHER_ID) } returns "mockCipherId" - every { getStringExtra(EXTRA_KEY_CREDENTIAL_ID) } returns "mockCredentialId" - } - val mockOption = GetPublicKeyCredentialOption( - requestJson = "requestJson", - clientDataHash = byteArrayOf(0), - allowedProviders = emptySet(), - ) - val mockCallingAppInfo = CallingAppInfo( - packageName = "mockPackageName", - signingInfo = SigningInfo(), - origin = "mockOrigin", - ) - val mockProviderGetCredentialRequest = ProviderGetCredentialRequest( - credentialOptions = listOf(mockOption), - callingAppInfo = mockCallingAppInfo, - biometricPromptResult = mockk { - every { isSuccessful } returns true - }, - ) - - every { - PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) - } returns mockProviderGetCredentialRequest - - val assertionRequest = intent.getFido2AssertionRequestOrNull() - - assertNotNull(assertionRequest) - assertEquals( - Fido2CredentialAssertionRequest( - userId = "mockUserId", - cipherId = "mockCipherId", - credentialId = "mockCredentialId", - requestJson = mockOption.requestJson, - clientDataHash = mockOption.clientDataHash, - packageName = mockCallingAppInfo.packageName, - signingInfo = mockCallingAppInfo.signingInfo, - origin = mockCallingAppInfo.origin, - isUserVerified = true, - ), - assertionRequest, - ) + assertEquals("mockUserId", assertionRequest?.userId) + assertEquals("mockCipherId", assertionRequest?.cipherId) + assertEquals("mockCredentialId", assertionRequest?.credentialId) } @Test fun `getFido2AssertionRequestOrNull should return null when build version is below 34`() { val intent = mockk() + every { isBuildVersionBelow(34) } returns true val assertionRequest = intent.getFido2AssertionRequestOrNull() @@ -325,91 +143,41 @@ class Fido2IntentUtilsTest { assertNull(assertionRequest) } - @Suppress("MaxLineLength") @Test - fun `getFido2AssertionRequestOrNull should return null when no passkey credential options are present in request`() { - val intent = mockk() - - val mockProviderGetCredentialRequest = ProviderGetCredentialRequest( - credentialOptions = listOf(GetPasswordOption()), - callingAppInfo = mockk(), - ) + fun `getFido2AssertionRequestOrNull should return null when extras are not correctly set`() { every { - PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) - } returns mockProviderGetCredentialRequest + PendingIntentHandler.retrieveProviderGetCredentialRequest(any()) + } returns mockk() - val assertionRequest = intent.getFido2AssertionRequestOrNull() - - assertNull(assertionRequest) - } - - @Test - fun `getFido2AssertionRequestOrNull should return null when credential id is not in extras`() { - val intent = mockk { - every { getStringExtra(EXTRA_KEY_CREDENTIAL_ID) } returns null - } - val mockOption = GetPublicKeyCredentialOption( - requestJson = "requestJson", - clientDataHash = byteArrayOf(0), - allowedProviders = emptySet(), + // Verify credential ID is required + assertNull( + mockk { + every { getStringExtra(EXTRA_KEY_CREDENTIAL_ID) } returns null + every { getStringExtra(EXTRA_KEY_CIPHER_ID) } returns "mockCipherId" + every { getStringExtra(EXTRA_KEY_USER_ID) } returns "mockUserId" + } + .getFido2AssertionRequestOrNull(), ) - val mockProviderGetCredentialRequest = ProviderGetCredentialRequest( - credentialOptions = listOf(mockOption), - callingAppInfo = mockk(), - ) - every { - PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) - } returns mockProviderGetCredentialRequest - val assertionRequest = intent.getFido2AssertionRequestOrNull() - - assertNull(assertionRequest) - } - - @Test - fun `getFido2AssertionRequestOrNull should return null when cipher id is not in extras`() { - val intent = mockk { - every { getStringExtra(EXTRA_KEY_CREDENTIAL_ID) } returns "mockCredentialId" - every { getStringExtra(EXTRA_KEY_CIPHER_ID) } returns null - } - val mockOption = GetPublicKeyCredentialOption( - requestJson = "requestJson", - clientDataHash = byteArrayOf(0), - allowedProviders = emptySet(), + // Verify cipher ID is required + assertNull( + mockk { + every { getStringExtra(EXTRA_KEY_CREDENTIAL_ID) } returns "mockCredentialId" + every { getStringExtra(EXTRA_KEY_CIPHER_ID) } returns null + every { getStringExtra(EXTRA_KEY_USER_ID) } returns "mockUserId" + } + .getFido2AssertionRequestOrNull(), ) - val mockProviderGetCredentialRequest = ProviderGetCredentialRequest( - credentialOptions = listOf(mockOption), - callingAppInfo = mockk(), - ) - every { - PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) - } returns mockProviderGetCredentialRequest - val assertionRequest = intent.getFido2AssertionRequestOrNull() - assertNull(assertionRequest) - } - - @Test - fun `getFido2AssertionRequestOrNull should return null when user id is not in extras`() { - val intent = mockk { - every { getStringExtra(EXTRA_KEY_CREDENTIAL_ID) } returns "mockCredentialId" - every { getStringExtra(EXTRA_KEY_CIPHER_ID) } returns "mockCipherId" - every { getStringExtra(EXTRA_KEY_USER_ID) } returns null - } - val mockOption = GetPublicKeyCredentialOption( - requestJson = "requestJson", - clientDataHash = byteArrayOf(0), - allowedProviders = emptySet(), + // Verify user ID is required + assertNull( + mockk { + every { getStringExtra(EXTRA_KEY_CREDENTIAL_ID) } returns "mockCredentialId" + every { getStringExtra(EXTRA_KEY_CIPHER_ID) } returns "mockCipherId" + every { getStringExtra(EXTRA_KEY_USER_ID) } returns null + } + .getFido2AssertionRequestOrNull(), ) - val mockProviderGetCredentialRequest = ProviderGetCredentialRequest( - credentialOptions = listOf(mockOption), - callingAppInfo = mockk(), - ) - every { - PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) - } returns mockProviderGetCredentialRequest - val assertionRequest = intent.getFido2AssertionRequestOrNull() - assertNull(assertionRequest) } @Suppress("MaxLineLength") @@ -418,87 +186,28 @@ class Fido2IntentUtilsTest { val intent = mockk { every { getStringExtra("user_id") } returns "mockUserId" } - val mockOption = BeginGetPublicKeyCredentialOption( - candidateQueryData = bundleOf(), - id = "mockId", - requestJson = "mockRequestJson", - clientDataHash = byteArrayOf(0), - ) - val mockCallingAppInfo = CallingAppInfo( - packageName = "mockPackageName", - signingInfo = SigningInfo(), - origin = "mockOrigin", - ) + every { BeginGetCredentialRequest.asBundle(any()) } returns bundleOf() every { PendingIntentHandler.retrieveBeginGetCredentialRequest(intent) - } returns mockk { - every { beginGetCredentialOptions } returns listOf(mockOption) - every { callingAppInfo } returns mockCallingAppInfo - } + } returns mockk() - val result = intent.getFido2GetCredentialsRequestOrNull() - - assertEquals( - Fido2GetCredentialsRequest( - candidateQueryData = mockOption.candidateQueryData, - id = mockOption.id, - userId = "mockUserId", - requestJson = mockOption.requestJson, - clientDataHash = mockOption.clientDataHash, - packageName = mockCallingAppInfo.packageName, - signingInfo = mockCallingAppInfo.signingInfo, - origin = mockCallingAppInfo.origin, - ), - result, - ) + assertNotNull(intent.getFido2GetCredentialsRequestOrNull()) } @Test fun `getGido2GetCredentialsRequestOrNull should return null when build version is below 34`() { val intent = mockk() every { isBuildVersionBelow(34) } returns true - val result = intent.getFido2GetCredentialsRequestOrNull() - assertNull(result) } @Suppress("MaxLineLength") @Test fun `getFido2GetCredentialsRequestOrNull should return null when retrieveBeginGetCredentialRequest is null`() { - val intent = mockk { - every { - getParcelableExtra( - "android.service.credentials.extra.BEGIN_GET_CREDENTIAL_REQUEST", - BeginGetCredentialRequest::class.java, - ) - } returns null - } - every { PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) } returns null - val result = intent.getFido2GetCredentialsRequestOrNull() - assertNull(result) - } - - @Suppress("MaxLineLength") - @Test - fun `getFido2GetCredentialRequestOrNull should return null when no passkey credential options are present`() { val intent = mockk() - every { PendingIntentHandler.retrieveBeginGetCredentialRequest(intent) } returns mockk { - every { beginGetCredentialOptions } returns listOf(mockk()) - } - val result = intent.getFido2GetCredentialsRequestOrNull() - assertNull(result) - } - - @Test - fun `getFido2GetCredentialRequestOrNull should return null when calling app info is null`() { - val intent = mockk() - val mockOption = createMockBeginGetPublicKeyCredentialOption(number = 1) - every { PendingIntentHandler.retrieveBeginGetCredentialRequest(intent) } returns mockk { - every { beginGetCredentialOptions } returns listOf(mockOption) - every { callingAppInfo } returns null - } + every { PendingIntentHandler.retrieveBeginGetCredentialRequest(intent) } returns null val result = intent.getFido2GetCredentialsRequestOrNull() assertNull(result) } @@ -508,22 +217,8 @@ class Fido2IntentUtilsTest { val intent = mockk { every { getStringExtra(EXTRA_KEY_USER_ID) } returns null } - val mockOption = createMockBeginGetPublicKeyCredentialOption(number = 1) - every { PendingIntentHandler.retrieveBeginGetCredentialRequest(intent) } returns mockk { - every { beginGetCredentialOptions } returns listOf(mockOption) - every { callingAppInfo } returns mockk() - } + every { PendingIntentHandler.retrieveBeginGetCredentialRequest(intent) } returns mockk() val result = intent.getFido2GetCredentialsRequestOrNull() assertNull(result) } } - -private fun createMockBeginGetPublicKeyCredentialOption( - number: Int, -): BeginGetPublicKeyCredentialOption = - BeginGetPublicKeyCredentialOption( - candidateQueryData = bundleOf(), - id = "mockId-$number", - requestJson = "mockRequestJson-$number", - clientDataHash = byteArrayOf(0), - ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt index a5290f6bfb..1a3e990747 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt @@ -1,6 +1,6 @@ package com.x8bit.bitwarden.data.platform.manager.util -import android.content.pm.SigningInfo +import androidx.core.os.bundleOf import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentialsRequest @@ -148,11 +148,7 @@ class SpecialCircumstanceExtensionsTest { fun `toFido2RequestOrNull should return a non-null value for Fido2Save`() { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "mockUserId", - requestJson = "mockRequestJson", - packageName = "mockPackageName", - signingInfo = SigningInfo(), - origin = "mockOrigin", - isUserVerified = true, + requestData = bundleOf(), ) assertEquals( fido2CreateCredentialRequest, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt index d61c4c0485..58f3261ff2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt @@ -30,46 +30,6 @@ class CallingAppInfoExtensionsTest { unmockkStatic(Base64::class) } - @Test - fun `getFido2RpOrNull should return null when origin is populated with invalid URI`() { - val mockCallingAppInfo = mockk { - every { isOriginPopulated() } returns true - every { origin } returns "invalidUri9685%^$^&(*" - } - - assertNull(mockCallingAppInfo.getFido2RpIdOrNull()) - } - - @Test - fun `getFido2RpOrNull should return origin when origin is populated`() { - val mockCallingAppInfo = mockk { - every { isOriginPopulated() } returns true - every { origin } returns "mockUri" - } - - assertEquals("mockUri", mockCallingAppInfo.getFido2RpIdOrNull()) - } - - @Test - fun `getFido2RpOrNull should return null when origin is null`() { - val mockCallingAppInfo = mockk { - every { isOriginPopulated() } returns true - every { origin } returns null - } - - assertNull(mockCallingAppInfo.getFido2RpIdOrNull()) - } - - @Test - fun `getFido2RpOrNull should return package name when origin is not populated`() { - val mockCallingAppInfo = mockk { - every { isOriginPopulated() } returns false - every { packageName } returns "mockPackageName" - } - - assertEquals("mockPackageName", mockCallingAppInfo.getFido2RpIdOrNull()) - } - @Test fun `getCallingAppApkFingerprint should return key hash`() { val mockMessageDigest = mockk { @@ -85,7 +45,7 @@ class CallingAppInfoExtensionsTest { val appInfo = mockk { every { packageName } returns "packageName" every { signingInfo } returns mockSigningInfo - every { origin } returns null + every { getOrigin(any()) } returns null } assertEquals( DEFAULT_SIGNATURE_HASH, @@ -107,7 +67,7 @@ class CallingAppInfoExtensionsTest { val appInfo = mockk { every { packageName } returns "packageName" every { signingInfo } returns mockSigningInfo - every { origin } returns null + every { getOrigin(any()) } returns null } assertNull(appInfo.getSignatureFingerprintAsHexString()) } @@ -164,7 +124,7 @@ class CallingAppInfoExtensionsTest { fun `validatePrivilegedApp should return PrivilegedAppNotAllowed when calling app is not present in allow list`() { val appInfo = mockk { every { packageName } returns "packageName" - every { origin } returns "origin" + every { getOrigin(any()) } returns "origin" } assertEquals( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt index 59c34dde9c..041f55cb0e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt @@ -22,10 +22,10 @@ import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsResult import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.GetFido2CredentialsResult import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager @@ -80,7 +80,7 @@ class VaultUnlockScreenTest : BaseComposeTest() { } private val fido2CompletionManager: Fido2CompletionManager = mockk { every { completeFido2Assertion(any()) } just runs - every { completeFido2GetCredentialRequest(any()) } just runs + every { completeFido2GetCredentialsRequest(any()) } just runs } @Before @@ -136,8 +136,8 @@ class VaultUnlockScreenTest : BaseComposeTest() { ), ) verify(exactly = 1) { - fido2CompletionManager.completeFido2GetCredentialRequest( - result = Fido2GetCredentialsResult.Error( + fido2CompletionManager.completeFido2GetCredentialsRequest( + result = GetFido2CredentialsResult.Error( R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), ) @@ -150,7 +150,7 @@ class VaultUnlockScreenTest : BaseComposeTest() { mutableEventFlow.tryEmit(VaultUnlockEvent.Fido2CredentialAssertionError("".asText())) verify(exactly = 1) { fido2CompletionManager.completeFido2Assertion( - result = Fido2CredentialAssertionResult.Error("".asText()), + result = AssertFido2CredentialResult.Error("".asText()), ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerTest.kt index d3024570ad..2c9063742b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerTest.kt @@ -4,16 +4,18 @@ import android.app.Activity import android.app.PendingIntent import android.content.Intent import android.graphics.drawable.Icon +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.BeginGetCredentialResponse import androidx.credentials.provider.PendingIntentHandler import androidx.credentials.provider.PublicKeyCredentialEntry +import com.bitwarden.ui.util.asText 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.data.vault.datasource.sdk.model.createMockFido2CredentialAutofillView -import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.GetFido2CredentialsResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import io.mockk.Called import io.mockk.MockKVerificationScope @@ -56,7 +58,7 @@ class Fido2CompletionManagerTest { @Test fun `completeFido2Registration should perform no operations`() { - val mockRegistrationResult = mockk() + val mockRegistrationResult = mockk() fido2CompletionManager.completeFido2Registration(mockRegistrationResult) verify { mockRegistrationResult wasNot Called @@ -66,7 +68,7 @@ class Fido2CompletionManagerTest { @Test fun `completeFido2Assertion should perform no operations`() { - val mockAssertionResult = mockk() + val mockAssertionResult = mockk() fido2CompletionManager.completeFido2Assertion(mockAssertionResult) verify { mockAssertionResult wasNot Called @@ -76,8 +78,8 @@ class Fido2CompletionManagerTest { @Test fun `completeFido2GetCredentials should perform no operations`() { - val mockGetCredentialResult = mockk() - fido2CompletionManager.completeFido2GetCredentialRequest(mockGetCredentialResult) + val mockGetCredentialResult = mockk() + fido2CompletionManager.completeFido2GetCredentialsRequest(mockGetCredentialResult) verify { mockGetCredentialResult wasNot Called mockActivity wasNot Called @@ -117,7 +119,7 @@ class Fido2CompletionManagerTest { fun `completeFido2Registration should set CreateCredentialResponse, set activity result, then finish activity when result is Success`() { fido2CompletionManager .completeFido2Registration( - Fido2RegisterCredentialResult.Success( + RegisterFido2CredentialResult.Success( responseJson = "registrationResponse", ), ) @@ -131,9 +133,10 @@ class Fido2CompletionManagerTest { @Test fun `completeFido2Registration should set CreateCredentialException, set activity result, then finish activity when result is Error`() { fido2CompletionManager - .completeFido2Registration(Fido2RegisterCredentialResult.Error("".asText())) + .completeFido2Registration(RegisterFido2CredentialResult.Error("".asText())) verifyActivityResultIsSetAndFinishedAfter { + mockActivity.resources PendingIntentHandler.setCreateCredentialException(any(), any()) } } @@ -142,7 +145,7 @@ class Fido2CompletionManagerTest { @Test fun `completeFido2Registration should set CreateCredentialException, set activity result, then finish activity when result is Cancelled`() { fido2CompletionManager - .completeFido2Registration(Fido2RegisterCredentialResult.Cancelled) + .completeFido2Registration(RegisterFido2CredentialResult.Cancelled) verifyActivityResultIsSetAndFinishedAfter { PendingIntentHandler.setCreateCredentialException(any(), any()) @@ -153,7 +156,7 @@ class Fido2CompletionManagerTest { @Test fun `completeFido2Assertion should set GetCredentialResponse, set activity result, then finish activity when result is Success`() { fido2CompletionManager - .completeFido2Assertion(Fido2CredentialAssertionResult.Success("responseJson")) + .completeFido2Assertion(AssertFido2CredentialResult.Success("responseJson")) verifyActivityResultIsSetAndFinishedAfter { PendingIntentHandler.setGetCredentialResponse(any(), any()) @@ -164,10 +167,27 @@ class Fido2CompletionManagerTest { @Test fun `completeFido2Assertion should set GetCredentialException, set activity result, then finish activity when result is Error`() { fido2CompletionManager - .completeFido2Assertion(Fido2CredentialAssertionResult.Error("".asText())) + .completeFido2Assertion(AssertFido2CredentialResult.Error("".asText())) verifyActivityResultIsSetAndFinishedAfter { - PendingIntentHandler.setGetCredentialException(any(), any()) + mockActivity.resources + PendingIntentHandler.setGetCredentialException( + any(), + any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `completeFido2Assertion should set GetCredentialException, set activity result, then finish activity when result is Cancelled`() { + fido2CompletionManager + .completeFido2Assertion(AssertFido2CredentialResult.Cancelled) + verifyActivityResultIsSetAndFinishedAfter { + PendingIntentHandler.setGetCredentialException( + any(), + any(), + ) } } @@ -175,11 +195,11 @@ class Fido2CompletionManagerTest { @Test fun `completeFido2GetCredentials should set BeginGetCredentialResponse, set activity result, then finish activity when result is Success`() { fido2CompletionManager - .completeFido2GetCredentialRequest( - Fido2GetCredentialsResult.Success( + .completeFido2GetCredentialsRequest( + GetFido2CredentialsResult.Success( userId = "mockUserId", - options = mockk(), credentials = emptyList(), + option = mockk(), ), ) @@ -196,7 +216,6 @@ class Fido2CompletionManagerTest { val mockCredentialEntry = mockk() val mockFido2AutofillView = createMockFido2CredentialAutofillView(number = 1) - val mockFido2AutofillViewList = listOf(mockFido2AutofillView) every { anyConstructed().build() @@ -214,11 +233,11 @@ class Fido2CompletionManagerTest { every { Icon.createWithResource(mockActivity, any()) } returns mockk() fido2CompletionManager - .completeFido2GetCredentialRequest( - Fido2GetCredentialsResult.Success( + .completeFido2GetCredentialsRequest( + GetFido2CredentialsResult.Success( userId = "mockUserId", - options = mockk(), - credentials = mockFido2AutofillViewList, + credentials = listOf(mockFido2AutofillView), + option = mockk(), ), ) @@ -265,11 +284,11 @@ class Fido2CompletionManagerTest { every { Icon.createWithResource(mockActivity, any()) } returns mockk() fido2CompletionManager - .completeFido2GetCredentialRequest( - Fido2GetCredentialsResult.Success( + .completeFido2GetCredentialsRequest( + GetFido2CredentialsResult.Success( userId = "mockUserId", - options = mockk(), credentials = mockFido2AutofillViewList, + option = mockk(), ), ) @@ -293,10 +312,8 @@ class Fido2CompletionManagerTest { @Test fun `completeFido2GetCredentials should set GetCredentialException, set activity result, then finish activity when result is Error`() { fido2CompletionManager - .completeFido2GetCredentialRequest( - Fido2GetCredentialsResult.Error( - "".asText(), - ), + .completeFido2GetCredentialsRequest( + GetFido2CredentialsResult.Error("".asText()), ) verifyActivityResultIsSetAndFinishedAfter { mockActivity.resources diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index e71c0f9442..b9fefd9fa9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -1,6 +1,6 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav -import android.content.pm.SigningInfo +import androidx.core.os.bundleOf import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -664,11 +664,7 @@ class RootNavViewModelTest : BaseViewModelTest() { fun `when the active user has an unlocked vault but there is a Fido2Save special circumstance the nav state should be VaultUnlockedForFido2Save`() { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "activeUserId", - requestJson = "{}", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = "mockOrigin", - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(fido2CreateCredentialRequest) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index 8e4eec07c6..5fc832a4fb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -40,13 +40,13 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import androidx.core.net.toUri import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.util.asText import com.bitwarden.vault.UriMatchType -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest -import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -222,7 +222,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { @Test fun `on CompleteFido2Create event should invoke Fido2CompletionManager`() { - val result = Fido2RegisterCredentialResult.Success( + val result = RegisterFido2CredentialResult.Success( responseJson = "mockRegistrationResponse", ) mutableEventFlow.tryEmit(VaultAddEditEvent.CompleteFido2Registration(result = result)) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 5bad934375..faf89b5d1c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -1,6 +1,10 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit -import android.content.pm.SigningInfo +import androidx.core.os.bundleOf +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.DateTime @@ -8,6 +12,8 @@ import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager import com.bitwarden.send.SendView +import com.bitwarden.ui.util.Text +import com.bitwarden.ui.util.asText import com.bitwarden.vault.CipherView import com.bitwarden.vault.CollectionView import com.bitwarden.vault.FolderView @@ -60,9 +66,8 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest -import com.bitwarden.ui.util.Text -import com.bitwarden.ui.util.asText 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 @@ -84,8 +89,10 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkObject import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.MutableSharedFlow @@ -187,6 +194,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { fun setup() { mockkStatic(CipherView::toViewState) mockkStatic(UUID::randomUUID) + mockkObject(ProviderCreateCredentialRequest.Companion) every { UUID.randomUUID().toString() } returns TEST_ID } @@ -194,6 +202,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { fun tearDown() { unmockkStatic(CipherView::toViewState) unmockkStatic(UUID::randomUUID) + unmockkObject(ProviderCreateCredentialRequest.Companion) } @Test @@ -382,13 +391,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Test fun `initial add state should be correct when fido2 save`() = runTest { + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "mockUserId-1", - requestJson = "mockRequestJson-1", - packageName = "mockPackageName-1", - signingInfo = SigningInfo(), - origin = null, - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = fido2CreateCredentialRequest, @@ -403,7 +411,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { vaultAddEditType = vaultAddEditType, commonContentViewState = fido2ContentState.common, typeContentViewState = fido2ContentState.type, + fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) + val viewModel = createAddVaultItemViewModel( savedStateHandle = createSavedStateHandleWithState( state = initState, @@ -870,11 +880,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { runTest { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "mockUserId", - requestJson = "mockRequestJson", - packageName = "mockPackageName", - signingInfo = mockk(), - origin = null, - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( @@ -887,6 +893,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { commonContentViewState = createCommonContentViewState( name = "mockName-1", ), + fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) .copy(shouldExitOnSave = true) @@ -894,6 +901,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { commonContentViewState = createCommonContentViewState( name = "mockName-1", ), + fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) .copy(shouldExitOnSave = true) @@ -908,27 +916,29 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { vaultItemCipherType = VaultItemCipherType.LOGIN, ), ) - val mockCreateResult = Fido2RegisterCredentialResult.Success( - responseJson = "mockRegistrationResponse", - ) - val mockAttestationOptions = createMockPasskeyAttestationOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.REQUIRED, - ) + val mockCreatePublicKeyCredentialRequest = + mockk(relaxed = true) + setupFido2CreateRequest( + mockCreatePublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + ) coEvery { fido2CredentialManager.registerFido2Credential( userId = "mockUserId", selectedCipherView = any(), - fido2CreateCredentialRequest = fido2CreateCredentialRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + callingAppInfo = any(), ) - } returns mockCreateResult + } returns Fido2RegisterCredentialResult.Success("mockRegistrationResponse") every { - fido2CredentialManager.getPasskeyAttestationOptionsOrNull( - requestJson = fido2CreateCredentialRequest.requestJson, + fido2CredentialManager.getUserVerificationRequirement( + request = mockCreatePublicKeyCredentialRequest, ) - } returns mockAttestationOptions + } returns UserVerificationRequirement.REQUIRED every { authRepository.activeUserId } returns "mockUserId" + coEvery { + vaultRepository.createCipherInOrganization(any(), any()) + } returns CreateCipherResult.Success viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) @@ -949,11 +959,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { val mockUserId = "mockUserId" val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = mockUserId, - requestJson = "mockRequestJson", - packageName = "mockPackageName", - signingInfo = mockk(), - origin = null, - isUserVerified = false, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( @@ -966,6 +972,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { commonContentViewState = createCommonContentViewState( name = "mockName-1", ), + fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) .copy(shouldExitOnSave = true) @@ -973,9 +980,34 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { commonContentViewState = createCommonContentViewState( name = "mockName-1", ), + fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) .copy(shouldExitOnSave = true) + val mockCreatePublicKeyCredentialRequest = + mockk(relaxed = true) + setupFido2CreateRequest( + mockCreatePublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + ) + + every { + fido2CredentialManager.getUserVerificationRequirement( + mockCreatePublicKeyCredentialRequest, + ) + } returns UserVerificationRequirement.DISCOURAGED + coEvery { + fido2CredentialManager.registerFido2Credential( + userId = "mockUserId", + selectedCipherView = any(), + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + callingAppInfo = any(), + ) + } returns Fido2RegisterCredentialResult.Success(responseJson = "mockResponse") + every { authRepository.activeUserId } returns mockUserId + coEvery { + vaultRepository.createCipherInOrganization(any(), any()) + } returns CreateCipherResult.Success + mutableVaultDataFlow.value = DataState.Loaded( createVaultData(), ) @@ -986,24 +1018,6 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { vaultItemCipherType = VaultItemCipherType.LOGIN, ), ) - val mockCreateResult = Fido2RegisterCredentialResult.Success("mockResponse") - val mockAttestationOptions = createMockPasskeyAttestationOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.DISCOURAGED, - ) - coEvery { - fido2CredentialManager.registerFido2Credential( - userId = mockUserId, - selectedCipherView = any(), - fido2CreateCredentialRequest = fido2CreateCredentialRequest, - ) - } returns mockCreateResult - every { - fido2CredentialManager.getPasskeyAttestationOptionsOrNull( - requestJson = fido2CreateCredentialRequest.requestJson, - ) - } returns mockAttestationOptions - every { authRepository.activeUserId } returns mockUserId viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) @@ -1016,14 +1030,19 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) assertEquals(stateWithName, stateFlow.awaitItem()) assertEquals( - VaultAddEditEvent.CompleteFido2Registration(mockCreateResult), + VaultAddEditEvent.CompleteFido2Registration( + RegisterFido2CredentialResult.Success( + responseJson = "mockResponse", + ), + ), eventFlow.awaitItem(), ) coVerify(exactly = 1) { fido2CredentialManager.registerFido2Credential( userId = mockUserId, selectedCipherView = any(), - fido2CreateCredentialRequest = fido2CreateCredentialRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + callingAppInfo = any(), ) } } @@ -1042,6 +1061,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { commonContentViewState = createCommonContentViewState( name = "mockName-1", ), + fido2CreateCredentialRequest = fido2CredentialRequest, ) .copy(shouldExitOnSave = true) @@ -1056,23 +1076,47 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ), ) + val mockCreatePublicKeyCredentialRequest = + mockk(relaxed = true) + setupFido2CreateRequest( + mockCreatePublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + ) + + every { + fido2CredentialManager.getUserVerificationRequirement( + mockCreatePublicKeyCredentialRequest, + ) + } returns UserVerificationRequirement.DISCOURAGED + coEvery { + fido2CredentialManager.registerFido2Credential( + userId = "mockUserId", + selectedCipherView = any(), + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + callingAppInfo = any(), + ) + } returns Fido2RegisterCredentialResult.Success(responseJson = "mockResponse") every { authRepository.activeUserId } returns fido2CredentialRequest.userId every { fido2CredentialManager.isUserVerified } returns true coEvery { fido2CredentialManager.registerFido2Credential( userId = fido2CredentialRequest.userId, - fido2CreateCredentialRequest = fido2CredentialRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, selectedCipherView = any(), + callingAppInfo = any(), ) } returns Fido2RegisterCredentialResult.Success(responseJson = "mockResponse") + coEvery { + vaultRepository.createCipherInOrganization(any(), any()) + } returns CreateCipherResult.Success viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) coVerify { fido2CredentialManager.registerFido2Credential( userId = fido2CredentialRequest.userId, - fido2CreateCredentialRequest = fido2CredentialRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, selectedCipherView = any(), + callingAppInfo = any(), ) } @@ -1083,7 +1127,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `in add mode during fido2, SaveClick should show fido2 error dialog when create options are null`() = + fun `in add mode during fido2, SaveClick should show fido2 error dialog when request type is not supported`() = runTest { val fido2CredentialRequest = createMockFido2CreateCredentialRequest(number = 1) specialCircumstanceManager.specialCircumstance = @@ -1094,14 +1138,19 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { commonContentViewState = createCommonContentViewState( name = "mockName-1", ), + fido2CreateCredentialRequest = fido2CredentialRequest, ) .copy(shouldExitOnSave = true) + val mockProviderCreateCredentialRequest: ProviderCreateCredentialRequest = + mockk(relaxed = true) { + every { callingAppInfo } returns mockk(relaxed = true) + every { callingRequest } returns mockk(relaxed = true) + } + every { - fido2CredentialManager.getPasskeyAttestationOptionsOrNull( - requestJson = fido2CredentialRequest.requestJson, - ) - } returns null + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockProviderCreateCredentialRequest mutableVaultDataFlow.value = DataState.Loaded( createVaultData(), @@ -1118,7 +1167,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { assertEquals( VaultAddEditState.DialogState.Fido2Error( - message = R.string.passkey_operation_failed_because_user_could_not_be_verified + message = R.string.passkey_operation_failed_because_the_request_is_unsupported .asText(), ), viewModel.stateFlow.value.dialog, @@ -1138,20 +1187,21 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { commonContentViewState = createCommonContentViewState( name = "mockName-1", ), + fido2CreateCredentialRequest = fido2CredentialRequest, ) .copy(shouldExitOnSave = true) + setupFido2CreateRequest() every { - fido2CredentialManager.getPasskeyAttestationOptionsOrNull( - requestJson = fido2CredentialRequest.requestJson, + fido2CredentialManager.getUserVerificationRequirement( + request = any(), ) - } returns createMockPasskeyAttestationOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.PREFERRED, - ) + } returns UserVerificationRequirement.PREFERRED + mutableVaultDataFlow.value = DataState.Loaded( createVaultData(), ) + val viewModel = createAddVaultItemViewModel( createSavedStateHandleWithState( state = stateWithName, @@ -1183,20 +1233,22 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { commonContentViewState = createCommonContentViewState( name = "mockName-1", ), + fido2CreateCredentialRequest = fido2CredentialRequest, ) .copy(shouldExitOnSave = true) + setupFido2CreateRequest() + every { - fido2CredentialManager.getPasskeyAttestationOptionsOrNull( - requestJson = fido2CredentialRequest.requestJson, + fido2CredentialManager.getUserVerificationRequirement( + request = any(), ) - } returns createMockPasskeyAttestationOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.REQUIRED, - ) + } returns UserVerificationRequirement.REQUIRED + mutableVaultDataFlow.value = DataState.Loaded( createVaultData(), ) + val viewModel = createAddVaultItemViewModel( createSavedStateHandleWithState( state = stateWithName, @@ -1880,6 +1932,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { number = 1, fido2Credentials = createMockSdkFido2CredentialList(number = 1), ) + val mockFido2CredentialRequest = createMockFido2CreateCredentialRequest(number = 1) val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) val stateWithName = createVaultAddItemState( commonContentViewState = createCommonContentViewState( @@ -1892,12 +1945,15 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { "14:30 PM", ), ), + fido2CreateCredentialRequest = mockFido2CredentialRequest, ) - val mockFido2CredentialRequest = createMockFido2CreateCredentialRequest(number = 1) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = mockFido2CredentialRequest, ) + + setupFido2CreateRequest() + every { cipherView.toViewState( isClone = false, @@ -1937,6 +1993,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { fido2Credentials = createMockSdkFido2CredentialList(number = 1), ) val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val mockFidoRequest = createMockFido2CreateCredentialRequest(number = 1) val stateWithName = createVaultAddItemState( vaultAddEditType = vaultAddEditType, commonContentViewState = createCommonContentViewState( @@ -1944,15 +2001,23 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { originalCipher = cipherView, notes = "mockNotes-1", ), + fido2CreateCredentialRequest = mockFidoRequest, ) - val mockFidoRequest = createMockFido2CreateCredentialRequest(number = 1) + val mockCallingAppInfo = mockk(relaxed = true) + val mockCreatePublicKeyCredentialRequest = + mockk(relaxed = true) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = mockFidoRequest, ) + setupFido2CreateRequest( + mockCallingAppInfo = mockCallingAppInfo, + mockCreatePublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + ) coEvery { fido2CredentialManager.registerFido2Credential( userId = mockFidoRequest.userId, - fido2CreateCredentialRequest = mockFidoRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + callingAppInfo = mockCallingAppInfo, selectedCipherView = any(), ) } returns Fido2RegisterCredentialResult.Success("mockResponse") @@ -1987,7 +2052,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { fido2CredentialManager.isUserVerified fido2CredentialManager.registerFido2Credential( userId = mockFidoRequest.userId, - fido2CreateCredentialRequest = mockFidoRequest, + createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest, + callingAppInfo = mockCallingAppInfo, selectedCipherView = any(), ) } @@ -2001,6 +2067,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { number = 1, fido2Credentials = createMockSdkFido2CredentialList(number = 1), ) + val mockFidoRequest = createMockFido2CreateCredentialRequest(number = 1) val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) val stateWithName = createVaultAddItemState( vaultAddEditType = vaultAddEditType, @@ -2016,11 +2083,14 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ), notes = "mockNotes-1", ), + fido2CreateCredentialRequest = mockFidoRequest, ) - val mockFidoRequest = createMockFido2CreateCredentialRequest(number = 1) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = mockFidoRequest, ) + + setupFido2CreateRequest() + every { cipherView.toViewState( isClone = false, @@ -2034,11 +2104,10 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } returns stateWithName.viewState every { fido2CredentialManager.isUserVerified } returns false every { - fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns createMockPasskeyAttestationOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.REQUIRED, - ) + fido2CredentialManager.getUserVerificationRequirement( + any(), + ) + } returns UserVerificationRequirement.REQUIRED mutableVaultDataFlow.value = DataState.Loaded( createVaultData(cipherView = cipherView), @@ -2053,9 +2122,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) viewModel.trySendAction(VaultAddEditAction.Common.ConfirmOverwriteExistingPasskeyClick) - coVerify { + verify { fido2CredentialManager.isUserVerified - fido2CredentialManager.getPasskeyAttestationOptionsOrNull(mockFidoRequest.requestJson) } } @@ -2144,7 +2212,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { assertNull(viewModel.stateFlow.value.dialog) assertEquals( VaultAddEditEvent.CompleteFido2Registration( - result = Fido2RegisterCredentialResult.Error( + result = RegisterFido2CredentialResult.Error( R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), ), @@ -3845,7 +3913,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { assertEquals( VaultAddEditEvent.CompleteFido2Registration( - result = Fido2RegisterCredentialResult.Cancelled, + result = RegisterFido2CredentialResult.Cancelled, ), awaitItem(), ) @@ -4234,64 +4302,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationSuccess should display Fido2ErrorDialog when SpecialCircumstance is null`() = - runTest { - specialCircumstanceManager.specialCircumstance = null - coEvery { - fido2CredentialManager.registerFido2Credential( - any(), - any(), - any(), - ) - } returns Fido2RegisterCredentialResult.Success( - responseJson = "mockResponse", - ) - - viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationSuccess) - - assertEquals( - VaultAddEditState.DialogState.Fido2Error( - message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), - ), - viewModel.stateFlow.value.dialog, - ) - } - - @Suppress("MaxLineLength") - @Test - fun `UserVerificationSuccess should display Fido2ErrorDialog when Fido2Request is null`() = - runTest { - specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.AutofillSave( - AutofillSaveItem.Login( - username = "mockUsername", - password = "mockPassword", - uri = "mockUri", - ), - ) - coEvery { - fido2CredentialManager.registerFido2Credential( - any(), - any(), - any(), - ) - } returns Fido2RegisterCredentialResult.Success( - responseJson = "mockResponse", - ) - - viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationSuccess) - - assertEquals( - VaultAddEditState.DialogState.Fido2Error( - message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), - ), - viewModel.stateFlow.value.dialog, - ) - } - - @Suppress("MaxLineLength") - @Test - fun `UserVerificationSuccess should display Fido2ErrorDialog when activeUserId is null`() { + fun `UserVerificationSuccess should display Fido2ErrorDialog when request is invalid`() { every { authRepository.activeUserId } returns null specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(createMockFido2CreateCredentialRequest(number = 1)) @@ -4300,7 +4311,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { assertEquals( VaultAddEditState.DialogState.Fido2Error( - message = R.string.passkey_operation_failed_because_user_could_not_be_verified + message = R.string.passkey_operation_failed_because_the_request_is_unsupported .asText(), ), viewModel.stateFlow.value.dialog, @@ -4309,7 +4320,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationSuccess should set isUserVerified to true, and register FIDO 2 credential`() = + fun `UserVerificationSuccess should set isUserVerified to true`() = runTest { val mockRequest = createMockFido2CreateCredentialRequest(number = 1) val mockResult = Fido2RegisterCredentialResult.Success( @@ -4318,36 +4329,21 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = mockRequest, ) + setupFido2CreateRequest() every { authRepository.activeUserId } returns "activeUserId" coEvery { fido2CredentialManager.registerFido2Credential( any(), any(), any(), + any(), ) } returns mockResult - every { fido2CredentialManager.isUserVerified } returns true - + every { fido2CredentialManager.isUserVerified } returns false viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationSuccess) - coVerify { + verify { fido2CredentialManager.isUserVerified = true - fido2CredentialManager.registerFido2Credential( - userId = any(), - fido2CreateCredentialRequest = mockRequest, - selectedCipherView = any(), - ) - } - - viewModel.eventFlow.test { - assertEquals( - VaultAddEditEvent.ShowToast(R.string.item_updated.asText()), - awaitItem(), - ) - assertEquals( - VaultAddEditEvent.CompleteFido2Registration(mockResult), - awaitItem(), - ) } } @@ -4356,7 +4352,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { fun `Fido2RegisterCredentialResult Error should show toast and emit CompleteFido2Registration result`() = runTest { val mockRequest = createMockFido2CreateCredentialRequest(number = 1) - val mockResult = Fido2RegisterCredentialResult.Error("".asText()) + val mockResult = Fido2RegisterCredentialResult.Error.InternalError specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = mockRequest, ) @@ -4366,9 +4362,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { any(), any(), any(), + any(), ) } returns mockResult + setupFido2CreateRequest() + viewModel.trySendAction( VaultAddEditAction.Internal.Fido2RegisterCredentialResultReceive( mockResult, @@ -4382,7 +4381,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultAddEditEvent.CompleteFido2Registration(mockResult), + VaultAddEditEvent.CompleteFido2Registration( + RegisterFido2CredentialResult.Error( + R.string.passkey_registration_failed_due_to_an_internal_error + .asText(), + ), + ), awaitItem(), ) } @@ -4399,12 +4403,14 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = mockRequest, ) + setupFido2CreateRequest() every { authRepository.activeUserId } returns "activeUserId" coEvery { fido2CredentialManager.registerFido2Credential( any(), any(), any(), + any(), ) } returns mockResult @@ -4421,39 +4427,11 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultAddEditEvent.CompleteFido2Registration(mockResult), - awaitItem(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `Fido2RegisterCredentialResult Cancelled should emit CompleteFido2Registration result`() = - runTest { - val mockRequest = createMockFido2CreateCredentialRequest(number = 1) - val mockResult = Fido2RegisterCredentialResult.Cancelled - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( - fido2CreateCredentialRequest = mockRequest, - ) - every { authRepository.activeUserId } returns "activeUserId" - coEvery { - fido2CredentialManager.registerFido2Credential( - any(), - any(), - any(), - ) - } returns mockResult - - viewModel.trySendAction( - VaultAddEditAction.Internal.Fido2RegisterCredentialResultReceive( - mockResult, - ), - ) - - viewModel.eventFlow.test { - assertEquals( - VaultAddEditEvent.CompleteFido2Registration(mockResult), + VaultAddEditEvent.CompleteFido2Registration( + RegisterFido2CredentialResult.Success( + responseJson = "mockResponse", + ), + ), awaitItem(), ) } @@ -4474,6 +4452,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { bottomSheetState: VaultAddEditState.BottomSheetState? = null, totpData: TotpData? = null, shouldClearSpecialCircumstance: Boolean = true, + fido2CreateCredentialRequest: Fido2CreateCredentialRequest? = null, ): VaultAddEditState = VaultAddEditState( vaultAddEditType = vaultAddEditType, @@ -4489,6 +4468,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { totpData = totpData, shouldShowCoachMarkTour = false, shouldClearSpecialCircumstance = shouldClearSpecialCircumstance, + fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) @Suppress("LongParameterList") @@ -4828,6 +4808,21 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { viewModel.trySendAction(action) assertEquals(expectedState, viewModel.stateFlow.value.viewState) } + + private fun setupFido2CreateRequest( + mockCallingAppInfo: CallingAppInfo = mockk(relaxed = true), + mockCreatePublicKeyCredentialRequest: CreatePublicKeyCredentialRequest = + mockk(relaxed = true), + mockProviderCreateCredentialRequest: ProviderCreateCredentialRequest = + mockk(relaxed = true) { + every { callingAppInfo } returns mockCallingAppInfo + every { callingRequest } returns mockCreatePublicKeyCredentialRequest + }, + ) { + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockProviderCreateCredentialRequest + } //endregion Helper functions } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/Fido2CredentialRequestExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/Fido2CredentialRequestExtensionsTest.kt index b8728ed561..03fdbe6392 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/Fido2CredentialRequestExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/Fido2CredentialRequestExtensionsTest.kt @@ -1,11 +1,15 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit.util -import android.content.pm.SigningInfo +import androidx.core.os.bundleOf +import androidx.credentials.provider.ProviderCreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic +import io.mockk.unmockkObject import io.mockk.unmockkStatic import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -18,17 +22,23 @@ class Fido2CredentialRequestExtensionsTest { @BeforeEach fun setUp() { mockkStatic(UUID::class) + mockkObject(ProviderCreateCredentialRequest.Companion) } @AfterEach fun tearDown() { unmockkStatic(UUID::class) + unmockkObject(ProviderCreateCredentialRequest.Companion) } @Suppress("MaxLineLength") @Test fun `toDefaultAddTypeContent should return the correct content when calling app is not privileged`() { every { UUID.randomUUID().toString() } returns "uuid" + every { ProviderCreateCredentialRequest.fromBundle(any()) } returns mockk(relaxed = true) { + every { callingRequest.origin } returns null + every { callingAppInfo.packageName } returns "mockPackageName-1" + } assertEquals( VaultAddEditState.ViewState.Content( common = VaultAddEditState.ViewState.Content.Common( @@ -49,11 +59,7 @@ class Fido2CredentialRequestExtensionsTest { ), Fido2CreateCredentialRequest( userId = "mockUserId-1", - requestJson = "mockRequestJson-1", - packageName = "mockPackageName-1", - signingInfo = SigningInfo(), - origin = null, - isUserVerified = true, + requestData = bundleOf(), ) .toDefaultAddTypeContent( attestationOptions = createMockPasskeyAttestationOptions(1), @@ -65,6 +71,10 @@ class Fido2CredentialRequestExtensionsTest { @Suppress("MaxLineLength") @Test fun `toDefaultAddTypeContent should return the correct content when calling app is privileged`() { + every { ProviderCreateCredentialRequest.fromBundle(any()) } returns mockk(relaxed = true) { + every { callingRequest.origin } returns "www.test.com" + every { callingAppInfo.packageName } returns "mockPackageName-1" + } every { UUID.randomUUID().toString() } returns "uuid" assertEquals( VaultAddEditState.ViewState.Content( @@ -86,11 +96,7 @@ class Fido2CredentialRequestExtensionsTest { ), Fido2CreateCredentialRequest( userId = "mockUserId-1", - requestJson = "mockRequestJson-1", - packageName = "mockPackageName-1", - signingInfo = SigningInfo(), - origin = "www.test.com", - isUserVerified = true, + requestData = bundleOf(), ) .toDefaultAddTypeContent( attestationOptions = createMockPasskeyAttestationOptions(number = 1), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasskeyAttestationOptionsTestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasskeyAttestationOptionsTestHelpers.kt index 207bc8a4d8..0406c00faf 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasskeyAttestationOptionsTestHelpers.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasskeyAttestationOptionsTestHelpers.kt @@ -13,6 +13,7 @@ fun createMockPasskeyAttestationOptions( number: Int, userVerificationRequirement: UserVerificationRequirement = UserVerificationRequirement.PREFERRED, + relyingPartyId: String = "mockPublicKeyCredentialRpEntity-$number", ) = PasskeyAttestationOptions( authenticatorSelection = PasskeyAttestationOptions .AuthenticatorSelectionCriteria(userVerification = userVerificationRequirement), @@ -32,7 +33,7 @@ fun createMockPasskeyAttestationOptions( ), relyingParty = PasskeyAttestationOptions.PublicKeyCredentialRpEntity( name = "mockPublicKeyCredentialRpEntityName-$number", - id = "mockPublicKeyCredentialRpEntity-$number", + id = relyingPartyId, ), user = PasskeyAttestationOptions.PublicKeyCredentialUserEntity( name = "mockPublicKeyCredentialUserEntityName-$number", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 01ab4c38e4..078a3e84df 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -16,19 +16,19 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.core.net.toUri import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.util.asText import com.bitwarden.vault.CipherType 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.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.GetFido2CredentialsResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest -import com.bitwarden.ui.util.asText 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 @@ -102,7 +102,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { private val fido2CompletionManager: Fido2CompletionManager = mockk { every { completeFido2Registration(any()) } just runs every { completeFido2Assertion(any()) } just runs - every { completeFido2GetCredentialRequest(any()) } just runs + every { completeFido2GetCredentialsRequest(any()) } just runs } private val biometricsManager: BiometricsManager = mockk() private val mutableEventFlow = bufferedMutableSharedFlow() @@ -1905,7 +1905,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { @Test fun `CompleteFido2Registration event should call Fido2CompletionManager with result`() { - val result = Fido2RegisterCredentialResult.Success("mockResponse") + val result = RegisterFido2CredentialResult.Success("mockResponse") mutableEventFlow.tryEmit(VaultItemListingEvent.CompleteFido2Registration(result)) verify { fido2CompletionManager.completeFido2Registration(result) @@ -1914,7 +1914,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { @Test fun `CompleteFido2Assertion event should call Fido2CompletionManager with result`() { - val result = Fido2CredentialAssertionResult.Success("mockResponse") + val result = AssertFido2CredentialResult.Success("mockResponse") mutableEventFlow.tryEmit(VaultItemListingEvent.CompleteFido2Assertion(result)) verify { fido2CompletionManager.completeFido2Assertion(result) @@ -1923,14 +1923,14 @@ class VaultItemListingScreenTest : BaseComposeTest() { @Test fun `CompleteFido2GetCredentials event should call Fido2CompletionManager with result`() { - val result = Fido2GetCredentialsResult.Success( + val result = GetFido2CredentialsResult.Success( userId = "mockUserId", - options = mockk(), + option = mockk(), credentials = mockk(), ) mutableEventFlow.tryEmit(VaultItemListingEvent.CompleteFido2GetCredentialsRequest(result)) verify { - fido2CompletionManager.completeFido2GetCredentialRequest(result) + fido2CompletionManager.completeFido2GetCredentialsRequest(result) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index e70f87e8db..e9fc93ae25 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -1,12 +1,18 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting -import android.content.pm.SigningInfo import android.net.Uri -import androidx.credentials.provider.CallingAppInfo +import androidx.core.os.bundleOf +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.ProviderCreateCredentialRequest +import androidx.credentials.provider.ProviderGetCredentialRequest import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager +import com.bitwarden.ui.util.Text +import com.bitwarden.ui.util.asText +import com.bitwarden.ui.util.concat import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType import com.bitwarden.vault.CipherView @@ -25,7 +31,6 @@ import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest 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 @@ -62,10 +67,9 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.autofill.fido2.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest -import com.bitwarden.ui.util.Text -import com.bitwarden.ui.util.asText -import com.bitwarden.ui.util.concat import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType @@ -86,8 +90,10 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkObject import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.collections.immutable.persistentListOf @@ -96,10 +102,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.Clock import java.time.Instant @@ -181,6 +189,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { authenticationAttempts = any() } just runs every { hasAuthenticationAttemptsRemaining() } returns true every { getPasskeyAttestationOptionsOrNull(any()) } returns mockk(relaxed = true) + every { + getUserVerificationRequirement(any()) + } returns UserVerificationRequirement.PREFERRED } private val fido2OriginManager: Fido2OriginManager = mockk { coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null) @@ -198,6 +209,34 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType( vaultItemListingType = VaultItemListingType.Login, ) + private val mockProviderGetCredentialRequest = + mockk(relaxed = true) { + every { + credentialOptions + } returns listOf( + mockk(relaxed = true), + ) + } + + @BeforeEach + fun setUp() { + mockkObject( + ProviderCreateCredentialRequest.Companion, + ProviderGetCredentialRequest.Companion, + ) + every { ProviderCreateCredentialRequest.fromBundle(any()) } returns mockk(relaxed = true) + every { + ProviderGetCredentialRequest.fromBundle(any()) + } returns mockProviderGetCredentialRequest + } + + @AfterEach + fun tearDown() { + unmockkObject( + ProviderCreateCredentialRequest.Companion, + ProviderGetCredentialRequest.Companion, + ) + } @Test fun `initial state should be correct`() = runTest { @@ -210,32 +249,42 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `initial dialog state should be correct when fido2Request is present`() = runTest { - val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( - "mockUserId", - "{}", - "com.x8bit.bitwarden", - SigningInfo(), - origin = null, - isUserVerified = true, - ) - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( - fido2CreateCredentialRequest = fido2CreateCredentialRequest, - ) - - val viewModel = createVaultItemListingViewModel() - - viewModel.stateFlow.test { - assertEquals( - initialState.copy( - fido2CreateCredentialRequest = fido2CreateCredentialRequest, - dialogState = VaultItemListingState.DialogState.Loading( - message = R.string.loading.asText(), - ), - ), - awaitItem(), + fun `initial dialog state should be correct when Fido2CreateCredentialRequest is present`() = + runTest { + val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( + "mockUserId", + requestData = bundleOf(), ) - } + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CreateCredentialRequest = fido2CreateCredentialRequest, + ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + every { callingAppInfo } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns createMockPasskeyAttestationOptions(number = 1) + coEvery { + fido2OriginManager.validateOrigin(any(), any()) + } returns Fido2ValidateOriginResult.Success(null) + val viewModel = createVaultItemListingViewModel() + + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + fido2CreateCredentialRequest = fido2CreateCredentialRequest, + dialogState = VaultItemListingState.DialogState.Loading( + message = R.string.loading.asText(), + ), + ), + awaitItem(), + ) + } } @Test @@ -548,41 +597,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } - @Suppress("MaxLineLength") - @Test - fun `ItemClick for vault item during FIDO 2 registration should show FIDO 2 error dialog when PasskeyCreateOptions is null`() { - setupMockUri() - val cipherView = createMockCipherView(number = 1) - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( - fido2CreateCredentialRequest = createMockFido2CreateCredentialRequest(number = 1), - ) - mutableVaultDataStateFlow.value = DataState.Loaded( - data = VaultData( - cipherViewList = listOf(cipherView), - folderViewList = emptyList(), - collectionViewList = emptyList(), - sendViewList = emptyList(), - ), - ) - every { fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) } returns null - - val viewModel = createVaultItemListingViewModel() - viewModel.trySendAction( - VaultItemListingsAction.ItemClick( - id = cipherView.id.orEmpty(), - cipherType = CipherType.LOGIN, - ), - ) - - assertEquals( - VaultItemListingState.DialogState.Fido2OperationFail( - title = R.string.an_error_has_occurred.asText(), - message = R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), - ), - viewModel.stateFlow.value.dialogState, - ) - } - @Suppress("MaxLineLength") @Test fun `ItemClick for vault item during FIDO 2 registration should show overwrite passkey confirmation when selected cipher has existing passkey`() { @@ -603,6 +617,13 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { sendViewList = emptyList(), ), ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } every { fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) } returns createMockPasskeyAttestationOptions( @@ -614,6 +635,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { any(), any(), any(), + any(), ) } returns Fido2RegisterCredentialResult.Success("mockResponse") @@ -651,6 +673,18 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { sendViewList = emptyList(), ), ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getUserVerificationRequirement( + request = any(), + ) + } returns UserVerificationRequirement.REQUIRED every { fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) } returns createMockPasskeyAttestationOptions( @@ -662,6 +696,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { any(), any(), any(), + any(), ) } returns Fido2RegisterCredentialResult.Success("mockResponse") @@ -706,6 +741,18 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { sendViewList = emptyList(), ), ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getUserVerificationRequirement( + request = any(), + ) + } returns UserVerificationRequirement.DISCOURAGED every { fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) } returns createMockPasskeyAttestationOptions( @@ -714,9 +761,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) coEvery { fido2CredentialManager.registerFido2Credential( - any(), - any(), - any(), + userId = any(), + callingAppInfo = any(), + createPublicKeyCredentialRequest = any(), + selectedCipherView = any(), ) } returns Fido2RegisterCredentialResult.Success("mockResponse") @@ -731,8 +779,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { coVerify { fido2CredentialManager.registerFido2Credential( userId = DEFAULT_USER_STATE.activeUserId, - fido2CreateCredentialRequest = mockFido2CredentialRequest, + createPublicKeyCredentialRequest = any(), selectedCipherView = cipherView, + callingAppInfo = any(), ) } } @@ -754,6 +803,14 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { sendViewList = emptyList(), ), ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { fido2CredentialManager.isUserVerified } returns true every { fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) } returns createMockPasskeyAttestationOptions( @@ -762,12 +819,12 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) coEvery { fido2CredentialManager.registerFido2Credential( - any(), - any(), - any(), + userId = any(), + callingAppInfo = any(), + createPublicKeyCredentialRequest = any(), + selectedCipherView = any(), ) } returns Fido2RegisterCredentialResult.Success("mockResponse") - every { fido2CredentialManager.isUserVerified } returns true val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( @@ -781,8 +838,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { coVerify(exactly = 1) { fido2CredentialManager.registerFido2Credential( userId = DEFAULT_USER_STATE.activeUserId, - fido2CreateCredentialRequest = mockFido2CredentialRequest, + createPublicKeyCredentialRequest = any(), selectedCipherView = cipherView, + callingAppInfo = any(), ) } } @@ -1775,6 +1833,19 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { fido2Credentials = createMockSdkFido2CredentialList(number = 1), ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) { + every { origin } returns "mockOrigin" + } + every { callingAppInfo } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns createMockPasskeyAttestationOptions(number = 1) coEvery { vaultRepository.getDecryptedFido2CredentialAutofillViews( cipherViewList = listOf(cipherView1, cipherView2), @@ -1788,11 +1859,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "activeUserId", - requestJson = "{}", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = "mockOrigin", - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = @@ -1842,85 +1909,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") - @Test - fun `vaultDataStateFlow Loaded with Fido2GetCredentials special circumstance should update ViewState to Content with filtered data`() = - runTest { - setupMockUri() - - val cipherView1 = createMockCipherView( - number = 1, - fido2Credentials = createMockSdkFido2CredentialList(number = 1), - ) - val cipherView2 = createMockCipherView( - number = 2, - fido2Credentials = createMockSdkFido2CredentialList(number = 1), - ) - - every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any()) - } returns createMockPasskeyAssertionOptions( - number = 1, - ) - coEvery { - vaultRepository.getDecryptedFido2CredentialAutofillViews( - cipherViewList = listOf(cipherView1, cipherView2), - ) - } returns DecryptFido2CredentialAutofillViewResult.Success(emptyList()) - - mockFilteredCiphers = listOf(cipherView1) - - val fido2GetCredentialRequest = Fido2GetCredentialsRequest( - requestJson = "{}", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = "mockOrigin", - candidateQueryData = mockk(), - clientDataHash = byteArrayOf(0), - id = "mockId", - userId = "mockUserId", - ) - - specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.Fido2GetCredentials( - fido2GetCredentialsRequest = fido2GetCredentialRequest, - ) - val dataState = DataState.Loaded( - data = VaultData( - cipherViewList = listOf(cipherView1, cipherView2), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), - ) - - val viewModel = createVaultItemListingViewModel() - - mutableVaultDataStateFlow.value = dataState - - assertEquals( - createVaultItemListingState( - viewState = VaultItemListingState.ViewState.Content( - displayCollectionList = emptyList(), - displayItemList = listOf( - createMockDisplayItemForCipher(number = 1) - .copy( - secondSubtitleTestTag = "PasskeySite", - ), - ), - displayFolderList = emptyList(), - ), - ) - .copy(fido2GetCredentialsRequest = fido2GetCredentialRequest), - viewModel.stateFlow.value, - ) - coVerify { - vaultRepository.getDecryptedFido2CredentialAutofillViews( - cipherViewList = listOf(cipherView1, cipherView2), - ) - } - } - @Suppress("MaxLineLength") @Test fun `vaultDataStateFlow Loaded with empty items should update ViewState to NoItems content for Login ItemListingType`() = @@ -2566,19 +2554,30 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `Fido2Request should be evaluated before observing vault data`() { + fun `Fido2CreateCredentialRequest should be evaluated before observing vault data`() { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( - "mockUserId", - "{}", - "com.x8bit.bitwarden", - SigningInfo(), - origin = "com.x8bit.bitwarden", - isUserVerified = true, + userId = "mockUserId", + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest, ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { callingRequest } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns mockk(relaxed = true) + coEvery { + fido2OriginManager.validateOrigin( + callingAppInfo = any(), + relyingPartyId = any(), + ) + } returns Fido2ValidateOriginResult.Success("mockOrigin") + createVaultItemListingViewModel() coVerify(ordering = Ordering.ORDERED) { @@ -2589,16 +2588,25 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Test fun `Fido2ValidateOriginResult should update dialog state on Unknown error`() = runTest { - val mockCallingAppInfo = mockk(relaxed = true) - val mock = mockk { - every { callingAppInfo } returns mockCallingAppInfo - every { requestJson } returns "{}" - } - - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( - fido2CreateCredentialRequest = mock, + val mockFido2CreateRequest = Fido2CreateCredentialRequest( + userId = "mockUserId", + requestData = bundleOf(), ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CreateCredentialRequest = mockFido2CreateRequest, + ) + + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns mockk(relaxed = true) coEvery { fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.Unknown @@ -2620,17 +2628,23 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { runTest { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "mockUserId", - requestJson = "{}", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = null, - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns mockk(relaxed = true) coEvery { fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed @@ -2652,17 +2666,23 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { runTest { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "mockUserId", - requestJson = "{}", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = null, - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns mockk(relaxed = true) coEvery { fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound @@ -2684,17 +2704,23 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { runTest { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "mockUserId", - requestJson = "{}", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = null, - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns mockk(relaxed = true) coEvery { fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp @@ -2716,17 +2742,23 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { runTest { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "mockUserId", - requestJson = "{}", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = null, - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns mockk(relaxed = true) coEvery { fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.ApplicationNotFound @@ -2748,17 +2780,23 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { runTest { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "mockUserId", - requestJson = "{}", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = null, - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns mockk(relaxed = true) coEvery { fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.AssetLinkNotFound @@ -2780,17 +2818,23 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { runTest { val fido2CreateCredentialRequest = Fido2CreateCredentialRequest( userId = "mockUserId", - requestJson = "{}", - packageName = "com.x8bit.bitwarden", - signingInfo = SigningInfo(), - origin = null, - isUserVerified = true, + requestData = bundleOf(), ) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = fido2CreateCredentialRequest, ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } + every { + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any()) + } returns mockk(relaxed = true) coEvery { fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified @@ -2810,7 +2854,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Test fun `Fido2RegisterCredentialResult Error should show toast and emit CompleteFido2Registration result`() = runTest { - val mockResult = Fido2RegisterCredentialResult.Error("".asText()) + val mockResult = Fido2RegisterCredentialResult.Error.InternalError val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( @@ -2826,7 +2870,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingEvent.CompleteFido2Registration(mockResult), + VaultItemListingEvent.CompleteFido2Registration( + RegisterFido2CredentialResult.Error( + R.string.passkey_registration_failed_due_to_an_internal_error.asText(), + ), + ), awaitItem(), ) } @@ -2854,28 +2902,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingEvent.CompleteFido2Registration(mockResult), - awaitItem(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `Fido2RegisterCredentialResult Cancelled should emit CompleteFido2Registration result`() = - runTest { - val mockResult = Fido2RegisterCredentialResult.Cancelled - val viewModel = createVaultItemListingViewModel() - - viewModel.trySendAction( - VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive( - mockResult, - ), - ) - - viewModel.eventFlow.test { - assertEquals( - VaultItemListingEvent.CompleteFido2Registration(mockResult), + VaultItemListingEvent.CompleteFido2Registration( + RegisterFido2CredentialResult.Success( + responseJson = "mockResponse", + ), + ), awaitItem(), ) } @@ -2898,7 +2929,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertNull(viewModel.stateFlow.value.dialogState) assertEquals( VaultItemListingEvent.CompleteFido2Registration( - result = Fido2RegisterCredentialResult.Error( + result = RegisterFido2CredentialResult.Error( "".asText(), ), ), @@ -2933,7 +2964,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { assertEquals( VaultItemListingEvent.CompleteFido2Assertion( - result = Fido2CredentialAssertionResult.Error("".asText()), + result = AssertFido2CredentialResult.Error("".asText()), ), awaitItem(), ) @@ -2975,13 +3006,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { mockAssertionRequest, ) every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, + fido2CredentialManager.getUserVerificationRequirement( + any(), ) - } returns createMockPasskeyAssertionOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.REQUIRED, - ) + } returns UserVerificationRequirement.REQUIRED every { vaultRepository .ciphersStateFlow @@ -3037,13 +3065,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { mockAssertionRequest, ) every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, + fido2CredentialManager.getUserVerificationRequirement( + any(), ) - } returns createMockPasskeyAssertionOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.PREFERRED, - ) + } returns UserVerificationRequirement.PREFERRED every { vaultRepository .ciphersStateFlow @@ -3100,9 +3125,12 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) every { authRepository.activeUserId } returns "activeUserId" every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, + fido2CredentialManager.getUserVerificationRequirement( + any(), ) + } returns UserVerificationRequirement.DISCOURAGED + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any()) } returns createMockPasskeyAssertionOptions( number = 1, userVerificationRequirement = UserVerificationRequirement.DISCOURAGED, @@ -3121,8 +3149,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { coEvery { fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", - request = mockAssertionRequest, + request = any(), selectedCipherView = mockCipherView, + callingAppInfo = any(), + origin = null, ) } returns Fido2CredentialAssertionResult.Success(responseJson = "responseJson") @@ -3141,61 +3171,14 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { fido2CredentialManager.isUserVerified fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", - request = mockAssertionRequest, + request = any(), selectedCipherView = mockCipherView, + callingAppInfo = any(), + origin = null, ) } } - @Suppress("MaxLineLength") - @Test - fun `Fido2AssertionRequest should show error dialog when assertion options are null`() = - runTest { - setupMockUri() - val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) - .copy(cipherId = "mockId-1") - val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) - val mockCipherView = createMockCipherView( - number = 1, - fido2Credentials = mockFido2CredentialList, - ) - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( - mockAssertionRequest, - ) - every { - vaultRepository - .ciphersStateFlow - .value - .data - } returns listOf(mockCipherView) - every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) - } returns null - - val dataState = DataState.Loaded( - data = VaultData( - cipherViewList = listOf(mockCipherView), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), - ) - val viewModel = createVaultItemListingViewModel() - mutableVaultDataStateFlow.value = dataState - - assertEquals( - VaultItemListingState.DialogState.Fido2OperationFail( - title = R.string.an_error_has_occurred.asText(), - message = R.string.passkey_operation_failed_because_the_request_is_invalid - .asText(), - ), - viewModel.stateFlow.value.dialogState, - ) - verify(exactly = 0) { fido2CredentialManager.isUserVerified } - } - @Test fun `Fido2AssertionRequest should show error dialog when relyingPartyId is null`() = runTest { setupMockUri() @@ -3215,10 +3198,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { .value .data } returns listOf(mockCipherView) + every { fido2CredentialManager.isUserVerified } returns true every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) + fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any()) } returns createMockPasskeyAssertionOptions( number = 1, userVerificationRequirement = UserVerificationRequirement.DISCOURAGED, @@ -3243,7 +3225,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ), viewModel.stateFlow.value.dialogState, ) - verify(exactly = 0) { fido2CredentialManager.isUserVerified } } @Test @@ -3264,6 +3245,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( mockAssertionRequest, ) + every { fido2CredentialManager.isUserVerified } returns true every { vaultRepository .ciphersStateFlow @@ -3271,9 +3253,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { .data } returns listOf(mockCipherView) every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - requestJson = mockAssertionRequest.requestJson, - ) + fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any()) } returns mockAssertionOptions coEvery { fido2OriginManager.validateOrigin(any(), any()) @@ -3301,38 +3281,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") - @Test - fun `Fido2AssertionRequest should observe vault data when request does not contain a cipherId`() = - runTest { - val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) - .copy(cipherId = null) - val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( - mockAssertionRequest, - ) - every { - vaultRepository - .ciphersStateFlow - .value - .data - } returns listOf( - createMockCipherView( - number = 1, - fido2Credentials = mockFido2CredentialList, - ), - ) - - createVaultItemListingViewModel() - - verify { vaultRepository.vaultDataStateFlow } - verify(exactly = 0) { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - requestJson = mockAssertionRequest.requestJson, - ) - } - } - @Test fun `Fido2AssertionRequest should show error dialog when cipher state flow data is null`() = runTest { @@ -3427,9 +3375,12 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) every { fido2CredentialManager.isUserVerified } returns true every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, + fido2CredentialManager.getUserVerificationRequirement( + any(), ) + } returns UserVerificationRequirement.PREFERRED + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any()) } returns createMockPasskeyAssertionOptions( number = 1, userVerificationRequirement = UserVerificationRequirement.PREFERRED, @@ -3437,8 +3388,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { coEvery { fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", - request = mockAssertionRequest, + request = any(), selectedCipherView = mockCipherView, + callingAppInfo = any(), + origin = null, ) } returns Fido2CredentialAssertionResult.Success(responseJson = "responseJson") @@ -3448,11 +3401,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { .value .data } returns listOf(mockCipherView) - every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) - } returns createMockPasskeyAssertionOptions(number = 1) every { authRepository.activeUserId } returns "activeUserId" val dataState = DataState.Loaded( @@ -3472,6 +3420,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { userId = any(), request = any(), selectedCipherView = any(), + callingAppInfo = any(), + origin = null, ) } } @@ -3491,9 +3441,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) every { fido2CredentialManager.isUserVerified } returns true every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) + fido2CredentialManager.getPasskeyAssertionOptionsOrNull("mockRequestJson") } returns createMockPasskeyAssertionOptions( number = 1, userVerificationRequirement = UserVerificationRequirement.PREFERRED, @@ -3505,9 +3453,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { .data } returns listOf(mockCipherView) every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) + fido2CredentialManager.getPasskeyAssertionOptionsOrNull("mockRequestJson") } returns createMockPasskeyAssertionOptions(number = 1) every { authRepository.activeUserId } returns null @@ -3527,6 +3473,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { userId = any(), request = any(), selectedCipherView = any(), + callingAppInfo = any(), + origin = null, ) } @@ -3557,9 +3505,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) every { fido2CredentialManager.isUserVerified } returns true every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) + fido2CredentialManager.getPasskeyAssertionOptionsOrNull("mockRequestJson") } returns createMockPasskeyAssertionOptions( number = 1, userVerificationRequirement = UserVerificationRequirement.PREFERRED, @@ -3571,9 +3517,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { .data } returns listOf(mockCipherView) every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) + fido2CredentialManager.getPasskeyAssertionOptionsOrNull("mockRequestJson") } returns createMockPasskeyAssertionOptions(number = 1) every { authRepository.activeUserId } returns null @@ -3590,7 +3534,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertEquals( VaultItemListingState.DialogState.Fido2MasterPasswordPrompt( - selectedCipherId = mockAssertionRequest.cipherId!!, + selectedCipherId = mockAssertionRequest.cipherId, ), viewModel.stateFlow.value.dialogState, ) @@ -3628,14 +3572,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { mockAssertionRequest, ) every { fido2CredentialManager.isUserVerified } returns true - every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) - } returns createMockPasskeyAssertionOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.PREFERRED, - ) every { vaultRepository .ciphersStateFlow @@ -3643,15 +3579,15 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { .data } returns listOf(mockCipherView) every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) + fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any()) } returns createMockPasskeyAssertionOptions(number = 1) coEvery { fido2CredentialManager.authenticateFido2Credential( DEFAULT_USER_STATE.activeUserId, - mockAssertionRequest, - mockCipherView, + request = any(), + selectedCipherView = mockCipherView, + callingAppInfo = any(), + origin = null, ) } returns Fido2CredentialAssertionResult.Success("responseJson") @@ -3671,6 +3607,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { userId = any(), request = any(), selectedCipherView = any(), + callingAppInfo = any(), + origin = null, ) } } @@ -3695,6 +3633,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Test fun `UserVerificationCancelled should clear dialog state, set isUserVerified to false, and emit CompleteFido2Create with cancelled result`() = runTest { + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + createMockFido2CreateCredentialRequest(number = 1), + ) val viewModel = createVaultItemListingViewModel() viewModel.trySendAction(VaultItemListingsAction.UserVerificationCancelled) @@ -3703,7 +3644,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { assertEquals( VaultItemListingEvent.CompleteFido2Registration( - result = Fido2RegisterCredentialResult.Cancelled, + result = RegisterFido2CredentialResult.Cancelled, ), awaitItem(), ) @@ -3736,6 +3677,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { any(), any(), any(), + any(), ) } returns Fido2RegisterCredentialResult.Success( responseJson = "mockResponse", @@ -3775,6 +3717,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { any(), any(), any(), + any(), ) } returns Fido2RegisterCredentialResult.Success( responseJson = "mockResponse", @@ -3829,11 +3772,19 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( fido2CreateCredentialRequest = mockRequest, ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + callingRequest + } returns mockk(relaxed = true) + } coEvery { fido2CredentialManager.registerFido2Credential( - any(), - any(), - any(), + userId = any(), + callingAppInfo = any(), + createPublicKeyCredentialRequest = any(), + selectedCipherView = any(), ) } returns Fido2RegisterCredentialResult.Success( responseJson = "mockResponse", @@ -3850,8 +3801,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { fido2CredentialManager.isUserVerified = true fido2CredentialManager.registerFido2Credential( userId = DEFAULT_ACCOUNT.userId, - fido2CreateCredentialRequest = mockRequest, + createPublicKeyCredentialRequest = any(), selectedCipherView = any(), + callingAppInfo = any(), ) } } @@ -3867,6 +3819,15 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { fido2AssertionRequest = mockAssertionRequest, ) + every { + ProviderGetCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { + credentialOptions + } returns listOf( + mockk(relaxed = true), + ) + } every { vaultRepository .ciphersStateFlow @@ -3879,18 +3840,18 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ), ) every { - fido2CredentialManager.getPasskeyAssertionOptionsOrNull( - mockAssertionRequest.requestJson, - ) + fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any()) } returns createMockPasskeyAssertionOptions( number = 1, userVerificationRequirement = UserVerificationRequirement.PREFERRED, ) coEvery { fido2CredentialManager.authenticateFido2Credential( - any(), - any(), - any(), + userId = any(), + callingAppInfo = any(), + request = any(), + selectedCipherView = any(), + origin = any(), ) } returns Fido2CredentialAssertionResult.Success( responseJson = "mockResponse", @@ -3907,8 +3868,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { fido2CredentialManager.isUserVerified = true fido2CredentialManager.authenticateFido2Credential( userId = DEFAULT_ACCOUNT.userId, - request = mockAssertionRequest, + request = any(), selectedCipherView = any(), + callingAppInfo = any(), + origin = null, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 05c51d3b17..9c671fc376 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -1,9 +1,11 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util -import android.content.pm.SigningInfo import android.net.Uri +import androidx.core.os.bundleOf +import androidx.credentials.provider.ProviderCreateCredentialRequest import com.bitwarden.send.SendType import com.bitwarden.send.SendView +import com.bitwarden.ui.util.asText import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType import com.bitwarden.vault.CipherView @@ -22,14 +24,15 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView import com.x8bit.bitwarden.data.vault.repository.model.VaultData -import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.model.TotpData import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic +import io.mockk.unmockkObject import io.mockk.unmockkStatic import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -50,6 +53,7 @@ class VaultItemListingDataExtensionsTest { fun tearDown() { unmockkStatic(Uri::class) unmockkStatic(CipherView::subtitle) + unmockkObject(ProviderCreateCredentialRequest.Companion) } @Test @@ -677,6 +681,7 @@ class VaultItemListingDataExtensionsTest { @Suppress("MaxLineLength") @Test fun `toViewState should transform an empty list of CipherViews into a NoItems ViewState with the appropriate data`() { + mockkObject(ProviderCreateCredentialRequest.Companion) val vaultData = VaultData( cipherViewList = listOf(), collectionViewList = listOf(), @@ -684,6 +689,13 @@ class VaultItemListingDataExtensionsTest { sendViewList = listOf(), ) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockk(relaxed = true) { + every { callingAppInfo.isOriginPopulated() } returns true + every { callingRequest.origin } returns "www.test.com" + } + // Trash assertEquals( VaultItemListingState.ViewState.NoItems( @@ -874,12 +886,8 @@ class VaultItemListingDataExtensionsTest { isIconLoadingDisabled = false, autofillSelectionData = null, fido2CreationData = Fido2CreateCredentialRequest( - userId = "", - requestJson = "", - packageName = "", - signingInfo = SigningInfo(), - origin = "https://www.test.com", - isUserVerified = true, + userId = "userId", + requestData = bundleOf(), ), fido2CredentialAutofillViews = null, totpData = null, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9d86b7e76..e5c5f96786 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ androidxComposeBom = "2025.03.01" androidxCore = "1.15.0" # Versions >= 1.5.0-alpha05 introduce breaking changes and bugs that are not suitable for # production. Do not update until https://issuetracker.google.com/issues/355141766 is resolved. -androidxCredentials = "1.5.0-alpha04" +androidxCredentials = "1.5.0" androidxHiltNavigationCompose = "1.2.0" androidxLifecycle = "2.8.7" androidxNavigation = "2.8.0"