Update androidx.credentials (#4919)

This commit is contained in:
Patrick Honkonen authored and GitHub committed 2025-04-09 15:35:06 +00:00
1 parent bf1ed690a4
commit 8767f2dd24
50 files changed
+2234 -2130

No files matched your search

@@ -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,
)
}
@@ -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,
)
@@ -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
}
@@ -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<PasskeyAssertionOptions>(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<GetPublicKeyCredentialOption>()
.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://"
@@ -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
}
}
}
@@ -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 }
}
}
@@ -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()
}
}
@@ -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<BeginGetPublicKeyCredentialOption>()
?.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
}
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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),
)
}
@@ -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
@@ -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.
@@ -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),
)
}
}
@@ -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)
}
@@ -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),
),
)
}
@@ -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
}
@@ -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()
}
@@ -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<Fido2CredentialAutofillView>,
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()
}
@@ -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()
}
@@ -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,
)
}
@@ -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()
/**
@@ -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
@@ -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()
@@ -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<GetPublicKeyCredentialOption>()
.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<VaultData>) {
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<VaultData>.filterForFido2CreationIfNecessary(): DataState<VaultData> {
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<VaultData>.filterForFidoGetCredentialsIfNecessary(): DataState<VaultData> {
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()
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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() }
+1 -2
View File
@@ -1198,18 +1198,17 @@ Do you want to switch to this account?</string>
<string name="system_certificates_are_not_as_secure_as_importing_certificates_to_bitwarden">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.</string>
<string name="certificate_alias_already_exists">Certificate alias already exists</string>
<string name="link">Link</string>
<string name="passkey_operation_failed_because_app_is_signed_incorrectly">Passkey operation failed because app is signed incorrectly.</string>
<string name="passkey_operation_failed_because_host_url_is_not_present_in_request">Passkey operation failed because host URL is not present in request.</string>
<string name="passkey_operation_failed_because_app_signature_is_invalid">Passkey operation failed because app signature is invalid.</string>
<string name="passkey_registration_failed_due_to_an_internal_error">Passkey registration failed due to an internal error.</string>
<string name="passkey_operation_failed_because_relying_party_cannot_be_identified">Passkey operation failed because relying party cannot be identified.</string>
<string name="passkey_authentication_failed_due_to_an_internal_error">Passkey authentication failed due to an internal error.</string>
<string name="passkey_operation_failed_because_the_selected_item_does_not_exist">Passkey operation failed because the selected item does not exist.</string>
<string name="passkey_operation_failed_because_user_is_locked_out">Passkey operation failed because user is locked out.</string>
<string name="passkey_operation_failed_because_user_verification_was_cancelled">Passkey operation failed because user verification was cancelled.</string>
<string name="passkey_operation_failed_because_the_request_is_invalid">Passkey operation failed because the request is invalid.</string>
<string name="passkey_operation_failed_because_user_verification_attempts_exceeded">Passkey operation failed because user verification attempts exceeded.</string>
<string name="passkey_operation_failed_because_no_item_was_selected">Passkey operation failed because no item was selected.</string>
<string name="passkey_operation_failed_because_the_request_is_unsupported">Passkey operation failed because the request is unsupported.</string>
<string name="self_host_server_url">Self-host server URL</string>
<string name="use_chrome_autofill_integration">Use Chrome autofill integration</string>
<string name="use_chrome_beta_autofill_integration">Use Chrome autofill integration (Beta)</string>
@@ -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<BiometricPromptResult>(relaxed = true) {
every { isSuccessful } returns true
}
private val mockProviderCreateCredentialRequest =
mockk<ProviderCreateCredentialRequest>(relaxed = true) {
every { biometricPromptResult } returns mockBiometricsPromptResult
}
private val mockProviderGetCredentialRequest =
mockk<ProviderGetCredentialRequest>(relaxed = true) {
every { biometricPromptResult } returns mockBiometricsPromptResult
every { credentialOptions } returns listOf(
mockk<GetPublicKeyCredentialOption>(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<CompleteRegistrationData> {
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<CompleteRegistrationData> {
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
@@ -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<Fido2OriginManager> {
coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null)
}
private val json = mockk<Json> {
every {
decodeFromString<PasskeyAttestationOptions>(any())
} returns createMockPasskeyAttestationOptions(number = 1)
} returns createMockPasskeyAttestationOptions(
number = 1,
relyingPartyId = DEFAULT_HOST,
)
every {
decodeFromString<PasskeyAssertionOptions>(any())
} returns createMockPasskeyAssertionOptions(number = 1)
@@ -70,33 +71,57 @@ class Fido2CredentialManagerTest {
} returns createMockPasskeyAssertionOptions(number = 1)
}
private val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature("0987654321ABCDEF"))
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE))
every { hasMultipleSigners() } returns false
}
private val mockMessageDigest = mockk<MessageDigest> {
every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray()
}
val mockCallingAppInfo = mockk<CallingAppInfo> {
every { isOriginPopulated() } returns true
every { packageName } returns "com.x8bit.bitwarden"
every { signingInfo } returns mockSigningInfo
}
val mockCreatePublicKeyCredentialRequest = mockk<CreatePublicKeyCredentialRequest> {
every { requestJson } returns DEFAULT_FIDO2_CREATE_REQUEST_JSON
every { clientDataHash } returns byteArrayOf()
}
val mockGetPublicKeyCredentialOption = mockk<GetPublicKeyCredentialOption> {
every { requestJson } returns DEFAULT_FIDO2_AUTH_REQUEST_JSON
every { clientDataHash } returns byteArrayOf()
}
val mockProviderGetCredentialRequest = mockk<ProviderGetCredentialRequest> {
every { callingAppInfo } returns mockCallingAppInfo
every { credentialOptions } returns listOf(mockGetPublicKeyCredentialOption)
}
private val mockVaultSdkSource = mockk<VaultSdkSource>()
private val mockFido2CredentialStore = mockk<Fido2CredentialStore>()
@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<Fido2AttestationResponse>(any(), any()) } returns ""
val requestCaptureSlot = slot<RegisterFido2CredentialRequest>()
val selectedCipherView = createMockCipherView(number = 1)
val slot = slot<RegisterFido2CredentialRequest>()
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<SigningInfo> {
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<RegisterFido2CredentialRequest>()
every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
every { json.encodeToString<Fido2AttestationResponse>(any(), any()) } returns ""
val requestCaptureSlot = slot<RegisterFido2CredentialRequest>()
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<SigningInfo> {
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<Fido2AttestationResponse>(any(), any()) } returns ""
every { mockCreatePublicKeyCredentialRequest.origin } returns DEFAULT_WEB_ORIGIN.v1
val requestCaptureSlot = slot<RegisterFido2CredentialRequest>()
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<SigningInfo> {
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<Fido2AttestationResponse>(any(), any()) } returns ""
every { mockCreatePublicKeyCredentialRequest.origin } returns DEFAULT_WEB_ORIGIN.v1
val requestCaptureSlot = slot<RegisterFido2CredentialRequest>()
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<SigningInfo> {
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<SigningInfo> {
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<SigningInfo> {
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<PasskeyAttestationOptions>(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<AuthenticateFido2CredentialRequest>()
val mockSdkResponse =
mockk<PublicKeyCredentialAuthenticatorAssertionResponse>(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<AuthenticateFido2CredentialRequest>()
val mockSdkResponse =
mockk<PublicKeyCredentialAuthenticatorAssertionResponse>(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<AuthenticateFido2CredentialRequest>()
@@ -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<Fido2PublicKeyCredential>()
val mockSdkResponse =
mockk<PublicKeyCredentialAuthenticatorAssertionResponse>(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<AuthenticateFido2CredentialRequest>()
val mockPublicKeyCredential = mockk<Fido2PublicKeyCredential>()
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<PasskeyAssertionOptions>(DEFAULT_FIDO2_AUTH_REQUEST_JSON)
} returns createMockPasskeyAssertionOptions(number = 1, relyingPartyId = null)
val result = fido2CredentialManager.authenticateFido2Credential(
userId = "activeUserId",
request = mockAssertionRequest,
selectedCipherView = mockSelectedCipherView,
json.decodeFromString<PasskeyAttestationOptions>(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<PasskeyAttestationOptions>(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<PasskeyAttestationOptions>(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<PasskeyAttestationOptions>(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<PasskeyAssertionOptions>(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<PasskeyAssertionOptions>(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<PasskeyAssertionOptions>(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<Fido2CredentialAssertionRequest> {
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"
}
}
"""
@@ -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(),
)
@@ -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,
)
@@ -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(),
)
@@ -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<BeginCreateCredentialResponse, CreateCredentialException> =
@@ -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<BeginCreateCredentialResponse, CreateCredentialException> =
@@ -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<BeginCreateCredentialResponse, CreateCredentialException> =
@@ -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<BeginCreateCredentialResponse, CreateCredentialException> =
@@ -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<BeginCreateCredentialResponse, CreateCredentialException> =
@@ -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<BeginCreateCredentialResponse, CreateCredentialException> =
@@ -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<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<BeginGetCredentialResponse>()
@@ -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<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<GetCredentialException>()
@@ -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<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<GetCredentialException>()
@@ -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<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<BeginGetCredentialResponse>()
@@ -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<Intent> {
every { getStringExtra(EXTRA_KEY_USER_ID) } returns "mockUserId"
}
val mockCallingRequest = mockk<CreatePublicKeyCredentialRequest> {
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<Intent> {
every { getStringExtra(EXTRA_KEY_USER_ID) } returns "mockUserId"
}
val mockCallingRequest = mockk<CreatePublicKeyCredentialRequest> {
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<Intent>()
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<Intent>()
val mockCallingRequest = mockk<CreatePasswordRequest>()
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<Intent>().getFido2CreateCredentialRequestOrNull())
}
@Suppress("MaxLineLength")
@@ -183,26 +90,9 @@ class Fido2IntentUtilsTest {
val intent = mockk<Intent> {
every { getStringExtra(EXTRA_KEY_USER_ID) } returns null
}
val mockCallingRequest = mockk<CreatePublicKeyCredentialRequest> {
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<Intent> {
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<Intent>()
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<Intent>()
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<Intent> {
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<Intent> {
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<Intent> {
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<Intent> {
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<Intent> {
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<Intent> {
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<Intent> {
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<Intent>()
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<Intent> {
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<Intent>()
every { PendingIntentHandler.retrieveBeginGetCredentialRequest(intent) } returns mockk {
every { beginGetCredentialOptions } returns listOf(mockk<BeginGetPasswordOption>())
}
val result = intent.getFido2GetCredentialsRequestOrNull()
assertNull(result)
}
@Test
fun `getFido2GetCredentialRequestOrNull should return null when calling app info is null`() {
val intent = mockk<Intent>()
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<Intent> {
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),
)
@@ -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,
@@ -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<CallingAppInfo> {
every { isOriginPopulated() } returns true
every { origin } returns "invalidUri9685%^$^&(*"
}
assertNull(mockCallingAppInfo.getFido2RpIdOrNull())
}
@Test
fun `getFido2RpOrNull should return origin when origin is populated`() {
val mockCallingAppInfo = mockk<CallingAppInfo> {
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<CallingAppInfo> {
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<CallingAppInfo> {
every { isOriginPopulated() } returns false
every { packageName } returns "mockPackageName"
}
assertEquals("mockPackageName", mockCallingAppInfo.getFido2RpIdOrNull())
}
@Test
fun `getCallingAppApkFingerprint should return key hash`() {
val mockMessageDigest = mockk<MessageDigest> {
@@ -85,7 +45,7 @@ class CallingAppInfoExtensionsTest {
val appInfo = mockk<CallingAppInfo> {
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<CallingAppInfo> {
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<CallingAppInfo> {
every { packageName } returns "packageName"
every { origin } returns "origin"
every { getOrigin(any()) } returns "origin"
}
assertEquals(
@@ -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()),
)
}
}
@@ -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<Fido2RegisterCredentialResult>()
val mockRegistrationResult = mockk<RegisterFido2CredentialResult>()
fido2CompletionManager.completeFido2Registration(mockRegistrationResult)
verify {
mockRegistrationResult wasNot Called
@@ -66,7 +68,7 @@ class Fido2CompletionManagerTest {
@Test
fun `completeFido2Assertion should perform no operations`() {
val mockAssertionResult = mockk<Fido2CredentialAssertionResult>()
val mockAssertionResult = mockk<AssertFido2CredentialResult>()
fido2CompletionManager.completeFido2Assertion(mockAssertionResult)
verify {
mockAssertionResult wasNot Called
@@ -76,8 +78,8 @@ class Fido2CompletionManagerTest {
@Test
fun `completeFido2GetCredentials should perform no operations`() {
val mockGetCredentialResult = mockk<Fido2GetCredentialsResult>()
fido2CompletionManager.completeFido2GetCredentialRequest(mockGetCredentialResult)
val mockGetCredentialResult = mockk<GetFido2CredentialsResult>()
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<GetCredentialUnknownException>(),
)
}
}
@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<GetCredentialCancellationException>(),
)
}
}
@@ -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<PublicKeyCredentialEntry>()
val mockFido2AutofillView = createMockFido2CredentialAutofillView(number = 1)
val mockFido2AutofillViewList = listOf(mockFido2AutofillView)
every {
anyConstructed<PublicKeyCredentialEntry.Builder>().build()
@@ -214,11 +233,11 @@ class Fido2CompletionManagerTest {
every { Icon.createWithResource(mockActivity, any()) } returns mockk<Icon>()
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<Icon>()
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
@@ -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)
@@ -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))
@@ -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<SigningInfo>(),
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<CreatePublicKeyCredentialRequest>(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<SigningInfo>(),
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<CreatePublicKeyCredentialRequest>(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<CreatePublicKeyCredentialRequest>(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<ProviderCreateCredentialRequest>(relaxed = true) {
every { callingAppInfo } returns mockk(relaxed = true)
every { callingRequest } returns mockk<CreatePasswordRequest>(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<CreatePublicKeyCredentialRequest>(),
)
} 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<CreatePublicKeyCredentialRequest>(),
)
} 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<CallingAppInfo>(relaxed = true)
val mockCreatePublicKeyCredentialRequest =
mockk<CreatePublicKeyCredentialRequest>(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<CreatePublicKeyCredentialRequest>(),
)
} 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<CreatePublicKeyCredentialRequest>(relaxed = true),
mockProviderCreateCredentialRequest: ProviderCreateCredentialRequest =
mockk<ProviderCreateCredentialRequest>(relaxed = true) {
every { callingAppInfo } returns mockCallingAppInfo
every { callingRequest } returns mockCreatePublicKeyCredentialRequest
},
) {
every {
ProviderCreateCredentialRequest.fromBundle(any())
} returns mockProviderCreateCredentialRequest
}
//endregion Helper functions
}
@@ -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),
@@ -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",
@@ -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<VaultItemListingEvent>()
@@ -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)
}
}
@@ -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,
+1 -1
View File
@@ -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"