mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
Merge branch 'main' into pm-6702/registration-flows
# Conflicts: # app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt
This commit is contained in:
commit
3251d776a2
32 changed files with 1290 additions and 30 deletions
|
@ -9,6 +9,8 @@
|
|||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
|
|
@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorI
|
|||
import com.x8bit.bitwarden.data.platform.manager.AssetManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
@ -35,12 +36,18 @@ object Fido2ProviderModule {
|
|||
fun provideCredentialProviderProcessor(
|
||||
@ApplicationContext context: Context,
|
||||
authRepository: AuthRepository,
|
||||
vaultRepository: VaultRepository,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
fido2CredentialManager: Fido2CredentialManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
intentManager: IntentManager,
|
||||
): Fido2ProviderProcessor =
|
||||
Fido2ProviderProcessorImpl(
|
||||
context,
|
||||
authRepository,
|
||||
vaultRepository,
|
||||
fido2CredentialStore,
|
||||
fido2CredentialManager,
|
||||
intentManager,
|
||||
dispatcherManager,
|
||||
)
|
||||
|
|
|
@ -4,6 +4,7 @@ import com.bitwarden.vault.CipherView
|
|||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
|
||||
|
||||
/**
|
||||
|
@ -36,6 +37,13 @@ interface Fido2CredentialManager {
|
|||
requestJson: String,
|
||||
): PasskeyAttestationOptions?
|
||||
|
||||
/**
|
||||
* Attempt to extract FIDO 2 passkey assertion options from the system [requestJson], or null.
|
||||
*/
|
||||
fun getPasskeyAssertionOptionsOrNull(
|
||||
requestJson: String,
|
||||
): PasskeyAssertionOptions?
|
||||
|
||||
/**
|
||||
* Register a new FIDO 2 credential to a users vault.
|
||||
*/
|
||||
|
@ -44,4 +52,9 @@ interface Fido2CredentialManager {
|
|||
fido2CredentialRequest: Fido2CredentialRequest,
|
||||
selectedCipherView: CipherView,
|
||||
): Fido2RegisterCredentialResult
|
||||
|
||||
/**
|
||||
* Whether or not the user has authentication attempts remaining.
|
||||
*/
|
||||
fun hasAuthenticationAttemptsRemaining(): Boolean
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.Digita
|
|||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
|
||||
import com.x8bit.bitwarden.data.platform.manager.AssetManager
|
||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||
|
@ -106,6 +107,17 @@ class Fido2CredentialManagerImpl(
|
|||
null
|
||||
}
|
||||
|
||||
override fun getPasskeyAssertionOptionsOrNull(
|
||||
requestJson: String,
|
||||
): PasskeyAssertionOptions? =
|
||||
try {
|
||||
json.decodeFromString<PasskeyAssertionOptions>(requestJson)
|
||||
} catch (e: SerializationException) {
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun validateCallingApplicationAssetLinks(
|
||||
fido2CredentialRequest: Fido2CredentialRequest,
|
||||
): Fido2ValidateOriginResult {
|
||||
|
@ -206,4 +218,9 @@ class Fido2CredentialManagerImpl(
|
|||
e.asFailure()
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasAuthenticationAttemptsRemaining(): Boolean =
|
||||
authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS
|
||||
}
|
||||
|
||||
private const val MAX_AUTHENTICATION_ATTEMPTS = 5
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
/**
|
||||
* Models the request options for a passkey request, based off the spec found at:
|
||||
* https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options
|
||||
*/
|
||||
@Serializable
|
||||
data class PasskeyAssertionOptions(
|
||||
@SerialName("challenge") val challenge: String,
|
||||
@SerialName("allowCredentials") val allowCredentials: List<PublicKeyCredentialDescriptor>?,
|
||||
@SerialName("rpId") val relyingPartyId: String?,
|
||||
@SerialName("userVerification") val userVerification: String?,
|
||||
)
|
|
@ -90,19 +90,6 @@ data class PasskeyAttestationOptions(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents details about a credential provided in the creation options.
|
||||
*/
|
||||
@Serializable
|
||||
data class PublicKeyCredentialDescriptor(
|
||||
@SerialName("type")
|
||||
val type: String,
|
||||
@SerialName("id")
|
||||
val id: String,
|
||||
@SerialName("transports")
|
||||
val transports: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents parameters for a credential in the creation options.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package com.x8bit.bitwarden.data.autofill.fido2.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents details about a credential provided in the creation options.
|
||||
*/
|
||||
@Serializable
|
||||
data class PublicKeyCredentialDescriptor(
|
||||
@SerialName("type")
|
||||
val type: String,
|
||||
@SerialName("id")
|
||||
val id: String,
|
||||
@SerialName("transports")
|
||||
val transports: List<String>,
|
||||
)
|
|
@ -10,34 +10,50 @@ import androidx.credentials.exceptions.ClearCredentialUnsupportedException
|
|||
import androidx.credentials.exceptions.CreateCredentialCancellationException
|
||||
import androidx.credentials.exceptions.CreateCredentialException
|
||||
import androidx.credentials.exceptions.CreateCredentialUnknownException
|
||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||
import androidx.credentials.exceptions.GetCredentialException
|
||||
import androidx.credentials.exceptions.GetCredentialUnknownException
|
||||
import androidx.credentials.exceptions.GetCredentialUnsupportedException
|
||||
import androidx.credentials.provider.AuthenticationAction
|
||||
import androidx.credentials.provider.BeginCreateCredentialRequest
|
||||
import androidx.credentials.provider.BeginCreateCredentialResponse
|
||||
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
|
||||
import androidx.credentials.provider.BeginGetCredentialRequest
|
||||
import androidx.credentials.provider.BeginGetCredentialResponse
|
||||
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
|
||||
import androidx.credentials.provider.CreateEntry
|
||||
import androidx.credentials.provider.CredentialEntry
|
||||
import androidx.credentials.provider.ProviderClearCredentialStateRequest
|
||||
import androidx.credentials.provider.PublicKeyCredentialEntry
|
||||
import com.bitwarden.fido.Fido2CredentialAutofillView
|
||||
import com.bitwarden.sdk.Fido2CredentialStore
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY"
|
||||
private const val GET_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY"
|
||||
const val UNLOCK_ACCOUNT_INTENT = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT"
|
||||
|
||||
/**
|
||||
* The default implementation of [Fido2ProviderProcessor]. Its purpose is to handle FIDO2 related
|
||||
* processing.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
class Fido2ProviderProcessorImpl(
|
||||
private val context: Context,
|
||||
private val authRepository: AuthRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val fido2CredentialStore: Fido2CredentialStore,
|
||||
private val fido2CredentialManager: Fido2CredentialManager,
|
||||
private val intentManager: IntentManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : Fido2ProviderProcessor {
|
||||
|
@ -124,10 +140,106 @@ class Fido2ProviderProcessorImpl(
|
|||
cancellationSignal: CancellationSignal,
|
||||
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
|
||||
) {
|
||||
// no-op: RFU
|
||||
callback.onError(GetCredentialUnsupportedException())
|
||||
// If the user is not logged in, return an error.
|
||||
val userState = authRepository.userStateFlow.value
|
||||
if (userState == null) {
|
||||
callback.onError(GetCredentialUnknownException("Active user is required."))
|
||||
return
|
||||
}
|
||||
|
||||
// Return an unlock action if the current account is locked.
|
||||
if (!userState.activeAccount.isVaultUnlocked) {
|
||||
val authenticationAction = AuthenticationAction(
|
||||
title = context.getString(R.string.unlock),
|
||||
pendingIntent = intentManager.createFido2UnlockPendingIntent(
|
||||
action = UNLOCK_ACCOUNT_INTENT,
|
||||
requestCode = requestCode.getAndIncrement(),
|
||||
),
|
||||
)
|
||||
|
||||
callback.onResult(
|
||||
BeginGetCredentialResponse(
|
||||
authenticationActions = listOf(authenticationAction),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, find all matching credentials from the current vault.
|
||||
val getCredentialJob = scope.launch {
|
||||
try {
|
||||
val credentialEntries = getMatchingFido2CredentialEntries(
|
||||
userId = userState.activeUserId,
|
||||
request = request,
|
||||
)
|
||||
|
||||
callback.onResult(
|
||||
BeginGetCredentialResponse(
|
||||
credentialEntries = credentialEntries,
|
||||
),
|
||||
)
|
||||
} catch (e: GetCredentialException) {
|
||||
callback.onError(e)
|
||||
}
|
||||
}
|
||||
cancellationSignal.setOnCancelListener {
|
||||
callback.onError(GetCredentialCancellationException())
|
||||
getCredentialJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws
|
||||
private suspend fun getMatchingFido2CredentialEntries(
|
||||
userId: String,
|
||||
request: BeginGetCredentialRequest,
|
||||
): List<CredentialEntry> =
|
||||
request
|
||||
.beginGetCredentialOptions
|
||||
.flatMap { option ->
|
||||
if (option is BeginGetPublicKeyCredentialOption) {
|
||||
val relyingPartyId = fido2CredentialManager
|
||||
.getPasskeyAssertionOptionsOrNull(requestJson = option.requestJson)
|
||||
?.relyingPartyId
|
||||
?: throw GetCredentialUnknownException("Invalid data.")
|
||||
|
||||
vaultRepository
|
||||
.silentlyDiscoverCredentials(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
)
|
||||
.fold(
|
||||
onSuccess = { it.toCredentialEntries(option) },
|
||||
onFailure = {
|
||||
throw GetCredentialUnknownException("Error decrypting credentials.")
|
||||
},
|
||||
)
|
||||
} else {
|
||||
throw GetCredentialUnsupportedException("Unsupported option.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Fido2CredentialAutofillView>.toCredentialEntries(
|
||||
option: BeginGetPublicKeyCredentialOption,
|
||||
): List<CredentialEntry> =
|
||||
this
|
||||
.map {
|
||||
PublicKeyCredentialEntry
|
||||
.Builder(
|
||||
context = context,
|
||||
username = it.userNameForUi ?: context.getString(R.string.no_username),
|
||||
pendingIntent = intentManager
|
||||
.createFido2GetCredentialPendingIntent(
|
||||
action = GET_PASSKEY_INTENT,
|
||||
credentialId = it.credentialId.toString(),
|
||||
cipherId = it.cipherId,
|
||||
requestCode = requestCode.getAndIncrement(),
|
||||
),
|
||||
beginGetPublicKeyCredentialOption = option,
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun processClearCredentialStateRequest(
|
||||
request: ProviderClearCredentialStateRequest,
|
||||
cancellationSignal: CancellationSignal,
|
||||
|
|
|
@ -64,6 +64,7 @@ class AutofillParserImpl(
|
|||
/**
|
||||
* Parse the [AssistStructure] into an [AutofillRequest].
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
private fun parseInternal(
|
||||
assistStructure: AssistStructure,
|
||||
autofillAppInfo: AutofillAppInfo,
|
||||
|
@ -71,13 +72,24 @@ class AutofillParserImpl(
|
|||
): AutofillRequest {
|
||||
// Parse the `assistStructure` into internal models.
|
||||
val traversalDataList = assistStructure.traverse()
|
||||
// Flatten the autofill views for processing.
|
||||
val autofillViews = traversalDataList
|
||||
.map { it.autofillViews }
|
||||
// Take only the autofill views from the node that currently has focus.
|
||||
// Then remove all the fields that cannot be filled with data.
|
||||
// We fallback to taking all the fillable views if nothing has focus.
|
||||
val autofillViewsList = traversalDataList.map { it.autofillViews }
|
||||
val autofillViews = autofillViewsList
|
||||
.filter { views -> views.any { it.data.isFocused } }
|
||||
.flatten()
|
||||
.filter { it !is AutofillView.Unused }
|
||||
.takeUnless { it.isEmpty() }
|
||||
?: autofillViewsList
|
||||
.flatten()
|
||||
.filter { it !is AutofillView.Unused }
|
||||
|
||||
// Find the focused view.
|
||||
val focusedView = autofillViews.firstOrNull { it.data.isFocused }
|
||||
// Find the focused view, or fallback to the first fillable item on the screen (so
|
||||
// we at least have something to hook into)
|
||||
val focusedView = autofillViews
|
||||
.firstOrNull { it.data.isFocused }
|
||||
?: autofillViews.firstOrNull()
|
||||
|
||||
val packageName = traversalDataList.buildPackageNameOrNull(
|
||||
assistStructure = assistStructure,
|
||||
|
@ -108,6 +120,7 @@ class AutofillParserImpl(
|
|||
|
||||
is AutofillView.Unused -> {
|
||||
// The view is unfillable since the field is not meant to be used for autofill.
|
||||
// This will never happen since we filter out all unused views above.
|
||||
return AutofillRequest.Unfillable
|
||||
}
|
||||
}
|
||||
|
|
|
@ -446,4 +446,15 @@ interface VaultSdkSource {
|
|||
userId: String,
|
||||
vararg cipherViews: CipherView,
|
||||
): Result<List<Fido2CredentialAutofillView>>
|
||||
|
||||
/**
|
||||
* Silently discovers FIDO 2 credentials for a given [userId] and [relyingPartyId].
|
||||
*
|
||||
* @return A list of FIDO 2 credentials.
|
||||
*/
|
||||
suspend fun silentlyDiscoverCredentials(
|
||||
userId: String,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
relyingPartyId: String,
|
||||
): Result<List<Fido2CredentialAutofillView>>
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
|||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialAuthenticationUserInterfaceImpl
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialRegistrationUserInterfaceImpl
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialSearchUserInterfaceImpl
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
|
@ -524,6 +525,21 @@ class VaultSdkSourceImpl(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun silentlyDiscoverCredentials(
|
||||
userId: String,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
relyingPartyId: String,
|
||||
): Result<List<Fido2CredentialAutofillView>> = runCatching {
|
||||
getClient(userId)
|
||||
.platform()
|
||||
.fido2()
|
||||
.authenticator(
|
||||
userInterface = Fido2CredentialSearchUserInterfaceImpl(),
|
||||
credentialStore = fido2CredentialStore,
|
||||
)
|
||||
.silentlyDiscoverCredentials(relyingPartyId)
|
||||
}
|
||||
|
||||
private suspend fun getClient(
|
||||
userId: String,
|
||||
): Client = sdkClientManager.getOrCreateClient(userId = userId)
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
|
||||
|
||||
import com.bitwarden.fido.CheckUserOptions
|
||||
import com.bitwarden.sdk.CheckUserAndPickCredentialForCreationResult
|
||||
import com.bitwarden.sdk.CheckUserResult
|
||||
import com.bitwarden.sdk.CipherViewWrapper
|
||||
import com.bitwarden.sdk.Fido2UserInterface
|
||||
import com.bitwarden.sdk.UiHint
|
||||
import com.bitwarden.vault.CipherView
|
||||
import com.bitwarden.vault.Fido2CredentialNewView
|
||||
|
||||
/**
|
||||
* Implementation of [Fido2UserInterface] for searching for matching FIDO 2 credentials.
|
||||
*/
|
||||
class Fido2CredentialSearchUserInterfaceImpl : Fido2UserInterface {
|
||||
override suspend fun checkUser(
|
||||
options: CheckUserOptions,
|
||||
hint: UiHint,
|
||||
): CheckUserResult =
|
||||
CheckUserResult(
|
||||
userPresent = true,
|
||||
userVerified = true,
|
||||
)
|
||||
|
||||
override suspend fun checkUserAndPickCredentialForCreation(
|
||||
options: CheckUserOptions,
|
||||
newCredential: Fido2CredentialNewView,
|
||||
): CheckUserAndPickCredentialForCreationResult = throw IllegalStateException()
|
||||
|
||||
// Always return true for this property because any problems with verification should
|
||||
// be handled downstream where the app can actually offer verification methods.
|
||||
override suspend fun isVerificationEnabled(): Boolean = true
|
||||
|
||||
override suspend fun pickCredentialForAuthentication(
|
||||
availableCredentials: List<CipherView>,
|
||||
): CipherViewWrapper = throw IllegalStateException()
|
||||
}
|
|
@ -4,6 +4,8 @@ import android.net.Uri
|
|||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.exporters.ExportFormat
|
||||
import com.bitwarden.fido.Fido2CredentialAutofillView
|
||||
import com.bitwarden.sdk.Fido2CredentialStore
|
||||
import com.bitwarden.send.SendType
|
||||
import com.bitwarden.send.SendView
|
||||
import com.bitwarden.vault.CipherView
|
||||
|
@ -152,6 +154,15 @@ interface VaultRepository : CipherManager, VaultLockManager {
|
|||
cipherViewList: List<CipherView>,
|
||||
): DecryptFido2CredentialAutofillViewResult
|
||||
|
||||
/**
|
||||
* Silently discovers FIDO 2 credentials for a given [userId] and [relyingPartyId].
|
||||
*/
|
||||
suspend fun silentlyDiscoverCredentials(
|
||||
userId: String,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
relyingPartyId: String,
|
||||
): Result<List<Fido2CredentialAutofillView>>
|
||||
|
||||
/**
|
||||
* Emits the totp code result flow to listeners.
|
||||
*/
|
||||
|
|
|
@ -6,6 +6,8 @@ import com.bitwarden.core.InitOrgCryptoRequest
|
|||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.exporters.ExportFormat
|
||||
import com.bitwarden.fido.Fido2CredentialAutofillView
|
||||
import com.bitwarden.sdk.Fido2CredentialStore
|
||||
import com.bitwarden.send.Send
|
||||
import com.bitwarden.send.SendType
|
||||
import com.bitwarden.send.SendView
|
||||
|
@ -538,6 +540,18 @@ class VaultRepositoryImpl(
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun silentlyDiscoverCredentials(
|
||||
userId: String,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
relyingPartyId: String,
|
||||
): Result<List<Fido2CredentialAutofillView>> =
|
||||
vaultSdkSource
|
||||
.silentlyDiscoverCredentials(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
)
|
||||
|
||||
override fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) {
|
||||
mutableTotpCodeResultFlow.tryEmit(totpCodeResult)
|
||||
}
|
||||
|
|
|
@ -96,6 +96,26 @@ interface IntentManager {
|
|||
*/
|
||||
fun openEmailApp()
|
||||
|
||||
/**
|
||||
* Creates a pending intent to use when providing
|
||||
* [androidx.credentials.provider.CredentialEntry] instances for FIDO 2 credential filling.
|
||||
*/
|
||||
fun createFido2GetCredentialPendingIntent(
|
||||
action: String,
|
||||
credentialId: String,
|
||||
cipherId: String,
|
||||
requestCode: Int,
|
||||
): PendingIntent
|
||||
|
||||
/**
|
||||
* Creates a pending intent to use when providing
|
||||
* [androidx.credentials.provider.AuthenticationAction] instances for FIDO 2 credential filling.
|
||||
*/
|
||||
fun createFido2UnlockPendingIntent(
|
||||
action: String,
|
||||
requestCode: Int,
|
||||
): PendingIntent
|
||||
|
||||
/**
|
||||
* Represents file information.
|
||||
*/
|
||||
|
|
|
@ -51,6 +51,20 @@ private const val TEMP_CAMERA_IMAGE_DIR: String = "camera_temp"
|
|||
*/
|
||||
const val EXTRA_KEY_USER_ID: String = "user_id"
|
||||
|
||||
/**
|
||||
* Key for the credential id included in FIDO 2 provider "get entries".
|
||||
*
|
||||
* @see IntentManager.createFido2GetCredentialPendingIntent
|
||||
*/
|
||||
const val EXTRA_KEY_CREDENTIAL_ID: String = "credential_id"
|
||||
|
||||
/**
|
||||
* Key for the cipher id included in FIDO 2 provider "get entries".
|
||||
*
|
||||
* @see IntentManager.createFido2GetCredentialPendingIntent
|
||||
*/
|
||||
const val EXTRA_KEY_CIPHER_ID: String = "cipher_id"
|
||||
|
||||
/**
|
||||
* The default implementation of the [IntentManager] for simplifying the handling of Android
|
||||
* Intents within a given context.
|
||||
|
@ -217,6 +231,39 @@ class IntentManagerImpl(
|
|||
)
|
||||
}
|
||||
|
||||
override fun createFido2GetCredentialPendingIntent(
|
||||
action: String,
|
||||
credentialId: String,
|
||||
cipherId: String,
|
||||
requestCode: Int,
|
||||
): PendingIntent {
|
||||
val intent = Intent(action)
|
||||
.setPackage(context.packageName)
|
||||
.putExtra(EXTRA_KEY_CREDENTIAL_ID, credentialId)
|
||||
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
/* context = */ context,
|
||||
/* requestCode = */ requestCode,
|
||||
/* intent = */ intent,
|
||||
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun createFido2UnlockPendingIntent(
|
||||
action: String,
|
||||
requestCode: Int,
|
||||
): PendingIntent {
|
||||
val intent = Intent(action).setPackage(context.packageName)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
/* context = */ context,
|
||||
/* requestCode = */ requestCode,
|
||||
/* intent = */ intent,
|
||||
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCameraFileData(): IntentManager.FileData {
|
||||
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
|
||||
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)
|
||||
|
|
|
@ -35,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingConten
|
|||
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
|
||||
|
@ -176,6 +177,27 @@ fun VaultAddEditScreen(
|
|||
)
|
||||
}
|
||||
},
|
||||
onSubmitMasterPasswordFido2Verification = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
action = VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(it),
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismissFido2PasswordVerification = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
action = VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
onRetryFido2PasswordVerification = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
action = VaultAddEditAction.Common.RetryFido2PasswordVerificationClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (pendingDeleteCipher) {
|
||||
|
@ -311,6 +333,9 @@ private fun VaultAddEditItemDialogs(
|
|||
onAutofillDismissRequest: () -> Unit,
|
||||
onFido2ErrorDismiss: () -> Unit,
|
||||
onConfirmOverwriteExistingPasskey: () -> Unit,
|
||||
onSubmitMasterPasswordFido2Verification: (password: String) -> Unit,
|
||||
onDismissFido2PasswordVerification: () -> Unit,
|
||||
onRetryFido2PasswordVerification: () -> Unit,
|
||||
) {
|
||||
when (dialogState) {
|
||||
is VaultAddEditState.DialogState.Loading -> {
|
||||
|
@ -356,6 +381,23 @@ private fun VaultAddEditItemDialogs(
|
|||
)
|
||||
}
|
||||
|
||||
is VaultAddEditState.DialogState.Fido2MasterPasswordPrompt -> {
|
||||
BitwardenMasterPasswordDialog(
|
||||
onConfirmClick = { onSubmitMasterPasswordFido2Verification(it) },
|
||||
onDismissRequest = onDismissFido2PasswordVerification,
|
||||
)
|
||||
}
|
||||
|
||||
is VaultAddEditState.DialogState.Fido2MasterPasswordError -> {
|
||||
BitwardenBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
title = null,
|
||||
message = R.string.invalid_master_password.asText(),
|
||||
),
|
||||
onDismissRequest = onRetryFido2PasswordVerification,
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ import com.x8bit.bitwarden.R
|
|||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
|
||||
|
@ -216,6 +218,7 @@ class VaultAddEditViewModel @Inject constructor(
|
|||
|
||||
//region Common Handlers
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun handleCommonActions(action: VaultAddEditAction.Common) {
|
||||
when (action) {
|
||||
is VaultAddEditAction.Common.CustomFieldValueChange -> {
|
||||
|
@ -284,6 +287,18 @@ class VaultAddEditViewModel @Inject constructor(
|
|||
VaultAddEditAction.Common.UserVerificationNotSupported -> {
|
||||
handleUserVerificationNotSupported()
|
||||
}
|
||||
|
||||
is VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit -> {
|
||||
handleMasterPasswordFido2VerificationSubmit(action)
|
||||
}
|
||||
|
||||
VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick -> {
|
||||
handleDismissFido2PasswordVerificationDialogClick()
|
||||
}
|
||||
|
||||
VaultAddEditAction.Common.RetryFido2PasswordVerificationClick -> {
|
||||
handleRetryFido2PasswordVerificationClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -556,6 +571,10 @@ class VaultAddEditViewModel @Inject constructor(
|
|||
|
||||
private fun handleUserVerificationSuccess() {
|
||||
fido2CredentialManager.isUserVerified = true
|
||||
getRequestAndRegisterCredential()
|
||||
}
|
||||
|
||||
private fun getRequestAndRegisterCredential() =
|
||||
specialCircumstanceManager
|
||||
.specialCircumstance
|
||||
?.toFido2RequestOrNull()
|
||||
|
@ -568,7 +587,6 @@ class VaultAddEditViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
?: showFido2ErrorDialog()
|
||||
}
|
||||
|
||||
private fun handleUserVerificationFail() {
|
||||
fido2CredentialManager.isUserVerified = false
|
||||
|
@ -597,9 +615,51 @@ class VaultAddEditViewModel @Inject constructor(
|
|||
|
||||
private fun handleUserVerificationNotSupported() {
|
||||
fido2CredentialManager.isUserVerified = false
|
||||
|
||||
val activeAccount = authRepository
|
||||
.userStateFlow
|
||||
.value
|
||||
?.activeAccount
|
||||
?: run {
|
||||
showFido2ErrorDialog()
|
||||
return
|
||||
}
|
||||
|
||||
if (activeAccount.vaultUnlockType == VaultUnlockType.PIN) {
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-9682
|
||||
} else if (activeAccount.hasMasterPassword) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultAddEditState.DialogState.Fido2MasterPasswordPrompt)
|
||||
}
|
||||
} else {
|
||||
// Prompt the user to set up a PIN for their account.
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-9681
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMasterPasswordFido2VerificationSubmit(
|
||||
action: VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.validatePassword(action.password)
|
||||
sendAction(
|
||||
VaultAddEditAction.Internal.ValidateFido2PasswordResultReceive(
|
||||
result = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDismissFido2PasswordVerificationDialogClick() {
|
||||
showFido2ErrorDialog()
|
||||
}
|
||||
|
||||
private fun handleRetryFido2PasswordVerificationClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultAddEditState.DialogState.Fido2MasterPasswordPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAddNewCustomFieldClick(
|
||||
action: VaultAddEditAction.Common.AddNewCustomFieldClick,
|
||||
) {
|
||||
|
@ -1263,6 +1323,10 @@ class VaultAddEditViewModel @Inject constructor(
|
|||
is VaultAddEditAction.Internal.Fido2RegisterCredentialResultReceive -> {
|
||||
handleFido2RegisterCredentialResultReceive(action)
|
||||
}
|
||||
|
||||
is VaultAddEditAction.Internal.ValidateFido2PasswordResultReceive -> {
|
||||
handleValidateFido2PasswordResultReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1508,6 +1572,39 @@ class VaultAddEditViewModel @Inject constructor(
|
|||
sendEvent(VaultAddEditEvent.CompleteFido2Registration(action.result))
|
||||
}
|
||||
|
||||
private fun handleValidateFido2PasswordResultReceive(
|
||||
action: VaultAddEditAction.Internal.ValidateFido2PasswordResultReceive,
|
||||
) {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
|
||||
when (action.result) {
|
||||
ValidatePasswordResult.Error -> {
|
||||
showFido2ErrorDialog()
|
||||
}
|
||||
|
||||
is ValidatePasswordResult.Success -> {
|
||||
if (!action.result.isValid) {
|
||||
fido2CredentialManager.authenticationAttempts += 1
|
||||
if (fido2CredentialManager.hasAuthenticationAttemptsRemaining()) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultAddEditState.DialogState.Fido2MasterPasswordError,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showFido2ErrorDialog()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fido2CredentialManager.isUserVerified = true
|
||||
fido2CredentialManager.authenticationAttempts = 0
|
||||
|
||||
getRequestAndRegisterCredential()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//endregion Internal Type Handlers
|
||||
|
||||
//region Utility Functions
|
||||
|
@ -2085,6 +2182,20 @@ data class VaultAddEditState(
|
|||
*/
|
||||
@Parcelize
|
||||
data object OverwritePasskeyConfirmationPrompt : DialogState()
|
||||
|
||||
/**
|
||||
* Displays a dialog to prompt the user for their master password as part of the FIDO 2
|
||||
* user verification flow.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Fido2MasterPasswordPrompt : DialogState()
|
||||
|
||||
/**
|
||||
* Displays a dialog to alert the user that their password for the FIDO 2 user
|
||||
* verification flow was incorrect and to retry.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Fido2MasterPasswordError : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2351,6 +2462,23 @@ sealed class VaultAddEditAction {
|
|||
* User verification cannot be performed with device biometrics or credentials.
|
||||
*/
|
||||
data object UserVerificationNotSupported : Common()
|
||||
|
||||
/**
|
||||
* The user has clicked to submit the master password for FIDO 2 verification.
|
||||
*/
|
||||
data class MasterPasswordFido2VerificationSubmit(
|
||||
val password: String,
|
||||
) : Common()
|
||||
|
||||
/**
|
||||
* The user has clicked to dismiss the FIDO 2 password verification dialog.
|
||||
*/
|
||||
data object DismissFido2PasswordVerificationDialogClick : Common()
|
||||
|
||||
/**
|
||||
* The user has clicked to retry the FIDO 2 password verification.
|
||||
*/
|
||||
data object RetryFido2PasswordVerificationClick : Common()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2695,5 +2823,13 @@ sealed class VaultAddEditAction {
|
|||
data class Fido2RegisterCredentialResultReceive(
|
||||
val result: Fido2RegisterCredentialResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that a result for verifying the user's master password as part of the FIDO 2
|
||||
* user verification flow has been received.
|
||||
*/
|
||||
data class ValidateFido2PasswordResultReceive(
|
||||
val result: ValidatePasswordResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -972,7 +972,7 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
is ValidatePasswordResult.Success -> {
|
||||
if (!action.result.isValid) {
|
||||
fido2CredentialManager.authenticationAttempts += 1
|
||||
if (fido2CredentialManager.authenticationAttempts < 5) {
|
||||
if (fido2CredentialManager.hasAuthenticationAttemptsRemaining()) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = VaultItemListingState
|
||||
|
|
|
@ -148,6 +148,7 @@
|
|||
<string name="no_favorites">There are no favorites in your vault.</string>
|
||||
<string name="no_items">There are no items in your vault.</string>
|
||||
<string name="no_items_tap">There are no items in your vault for this website/app. Tap to add one.</string>
|
||||
<string name="no_username">No Username</string>
|
||||
<string name="no_username_password_configured">This login does not have a username or password configured.</string>
|
||||
<string name="ok_got_it">Ok, got it!</string>
|
||||
<string name="option_defaults">Option defaults are set from the main Bitwarden app\'s password generator tool.</string>
|
||||
|
|
|
@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
|||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.platform.manager.AssetManager
|
||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||
|
@ -21,6 +22,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
|||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPublicKeyAttestationResponse
|
||||
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAssertionOptions
|
||||
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAttestationOptions
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
|
@ -34,6 +36,7 @@ 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
|
||||
|
@ -57,6 +60,9 @@ class Fido2CredentialManagerTest {
|
|||
every {
|
||||
decodeFromString<PasskeyAttestationOptions>(any())
|
||||
} returns createMockPasskeyAttestationOptions(number = 1)
|
||||
every {
|
||||
decodeFromString<PasskeyAssertionOptions>(any())
|
||||
} returns createMockPasskeyAssertionOptions(number = 1)
|
||||
}
|
||||
private val mockPrivilegedCallingAppInfo = mockk<CallingAppInfo> {
|
||||
every { packageName } returns "com.x8bit.bitwarden"
|
||||
|
@ -277,7 +283,7 @@ class Fido2CredentialManagerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `getPasskeyCreateOptionsOrNull should return null when deserialization fails`() =
|
||||
fun `getPasskeyAttestationOptionsOrNull should return null when deserialization fails`() =
|
||||
runTest {
|
||||
every {
|
||||
json.decodeFromString<PasskeyAttestationOptions>(any())
|
||||
|
@ -291,7 +297,7 @@ class Fido2CredentialManagerTest {
|
|||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getPasskeyCreateOptionsOrNull should return null when IllegalArgumentException is thrown`() {
|
||||
fun `getPasskeyAttestationOptionsOrNull should return null when IllegalArgumentException is thrown`() {
|
||||
every {
|
||||
json.decodeFromString<PasskeyAttestationOptions>(any())
|
||||
} throws IllegalArgumentException()
|
||||
|
@ -299,6 +305,39 @@ class Fido2CredentialManagerTest {
|
|||
assertNull(fido2CredentialManager.getPasskeyAttestationOptionsOrNull(requestJson = ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPasskeyAssertionOptionsOrNull should return options when deserialized`() = runTest {
|
||||
assertEquals(
|
||||
createMockPasskeyAssertionOptions(number = 1),
|
||||
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(
|
||||
requestJson = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPasskeyAssertionOptionsOrNull should return null when deserialization fails`() =
|
||||
runTest {
|
||||
every {
|
||||
json.decodeFromString<PasskeyAssertionOptions>(any())
|
||||
} throws SerializationException()
|
||||
assertNull(
|
||||
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(
|
||||
requestJson = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getPasskeyAssertionOptionsOrNull should return null when IllegalArgumentException is thrown`() {
|
||||
every {
|
||||
json.decodeFromString<PasskeyAssertionOptions>(any())
|
||||
} throws IllegalArgumentException()
|
||||
|
||||
assertNull(fido2CredentialManager.getPasskeyAssertionOptionsOrNull(requestJson = ""))
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `registerFido2Credential should construct ClientData DefaultWithCustomHash when callingAppInfo origin is populated`() =
|
||||
|
@ -523,6 +562,19 @@ class Fido2CredentialManagerTest {
|
|||
result is Fido2RegisterCredentialResult.Error,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `hasAuthenticationAttemptsRemaining returns true when authenticationAttempts is less than 5`() {
|
||||
assertTrue(fido2CredentialManager.hasAuthenticationAttemptsRemaining())
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `hasAuthenticationAttemptsRemaining returns false when authenticationAttempts is greater than 5`() {
|
||||
fido2CredentialManager.authenticationAttempts = 6
|
||||
assertFalse(fido2CredentialManager.hasAuthenticationAttemptsRemaining())
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_APP_SIGNATURE = "0987654321ABCDEF"
|
||||
|
|
|
@ -7,24 +7,49 @@ import android.os.CancellationSignal
|
|||
import android.os.OutcomeReceiver
|
||||
import androidx.credentials.exceptions.CreateCredentialException
|
||||
import androidx.credentials.exceptions.CreateCredentialUnknownException
|
||||
import androidx.credentials.exceptions.GetCredentialException
|
||||
import androidx.credentials.exceptions.GetCredentialUnknownException
|
||||
import androidx.credentials.exceptions.GetCredentialUnsupportedException
|
||||
import androidx.credentials.provider.AuthenticationAction
|
||||
import androidx.credentials.provider.BeginCreateCredentialRequest
|
||||
import androidx.credentials.provider.BeginCreateCredentialResponse
|
||||
import androidx.credentials.provider.BeginCreatePasswordCredentialRequest
|
||||
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
|
||||
import androidx.credentials.provider.BeginGetCredentialRequest
|
||||
import androidx.credentials.provider.BeginGetCredentialResponse
|
||||
import androidx.credentials.provider.BeginGetPasswordOption
|
||||
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
|
||||
import androidx.credentials.provider.PublicKeyCredentialEntry
|
||||
import com.bitwarden.sdk.Fido2CredentialStore
|
||||
import com.bitwarden.vault.CipherView
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
|
||||
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFido2CredentialAutofillView
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAssertionOptions
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkConstructor
|
||||
import io.mockk.runs
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkConstructor
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
@ -35,19 +60,35 @@ class Fido2ProviderProcessorTest {
|
|||
|
||||
private val context: Context = mockk()
|
||||
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
|
||||
private val mutableCiphersStateFlow = MutableStateFlow<DataState<List<CipherView>>>(
|
||||
DataState.Loaded(emptyList()),
|
||||
)
|
||||
private val authRepository: AuthRepository = mockk {
|
||||
every { activeUserId } returns "mockActiveUserId"
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
}
|
||||
private val vaultRepository: VaultRepository = mockk {
|
||||
every { ciphersStateFlow } returns mutableCiphersStateFlow
|
||||
}
|
||||
private val passkeyAssertionOptions = createMockPasskeyAssertionOptions(number = 1)
|
||||
private val fido2CredentialManager: Fido2CredentialManager = mockk {
|
||||
every { getPasskeyAssertionOptionsOrNull(any()) } returns passkeyAssertionOptions
|
||||
}
|
||||
private val fido2CredentialStore: Fido2CredentialStore = mockk()
|
||||
private val intentManager: IntentManager = mockk()
|
||||
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
|
||||
private val cancellationSignal: CancellationSignal = mockk()
|
||||
|
||||
private val json = PlatformNetworkModule.providesJson()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
fido2Processor = Fido2ProviderProcessorImpl(
|
||||
context,
|
||||
authRepository,
|
||||
vaultRepository,
|
||||
fido2CredentialStore,
|
||||
fido2CredentialManager,
|
||||
intentManager,
|
||||
dispatcherManager,
|
||||
)
|
||||
|
@ -207,6 +248,238 @@ class Fido2ProviderProcessorTest {
|
|||
assertEquals(mockAccount.email, captureSlot.captured.createEntries[index].accountName)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processGetCredentialRequest should invoke callback with error when user state is null`() {
|
||||
val request: BeginGetCredentialRequest = mockk()
|
||||
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
|
||||
val captureSlot = slot<GetCredentialException>()
|
||||
every { cancellationSignal.setOnCancelListener(any()) } just runs
|
||||
every { callback.onError(capture(captureSlot)) } just runs
|
||||
|
||||
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
|
||||
|
||||
verify(exactly = 1) { callback.onError(any()) }
|
||||
|
||||
verify(exactly = 0) { callback.onResult(any()) }
|
||||
|
||||
assert(captureSlot.captured is GetCredentialUnknownException)
|
||||
assertEquals("Active user is required.", captureSlot.captured.errorMessage)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `processGetCredentialRequest should invoke callback with authentication action when vault is locked`() {
|
||||
val request: BeginGetCredentialRequest = mockk()
|
||||
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
|
||||
val captureSlot = slot<BeginGetCredentialResponse>()
|
||||
val mockIntent: PendingIntent = mockk()
|
||||
mutableUserStateFlow.value = DEFAULT_USER_STATE
|
||||
.copy(
|
||||
accounts = listOf(
|
||||
DEFAULT_USER_STATE
|
||||
.accounts
|
||||
.first { it.userId == "mockUserId-1" }
|
||||
.copy(isVaultUnlocked = false),
|
||||
),
|
||||
)
|
||||
every { cancellationSignal.setOnCancelListener(any()) } just runs
|
||||
every { callback.onResult(capture(captureSlot)) } just runs
|
||||
every { context.getString(any()) } returns "mockTitle"
|
||||
every {
|
||||
intentManager.createFido2UnlockPendingIntent(
|
||||
action = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT",
|
||||
requestCode = any(),
|
||||
)
|
||||
} returns mockIntent
|
||||
|
||||
val expected = AuthenticationAction(
|
||||
title = "mockTitle",
|
||||
pendingIntent = mockIntent,
|
||||
)
|
||||
|
||||
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
|
||||
|
||||
verify(exactly = 0) { callback.onError(any()) }
|
||||
verify(exactly = 1) {
|
||||
callback.onResult(any())
|
||||
intentManager.createFido2UnlockPendingIntent(
|
||||
action = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT",
|
||||
requestCode = any(),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
expected.title,
|
||||
captureSlot.captured.authenticationActions.first().title,
|
||||
)
|
||||
assertEquals(
|
||||
expected.pendingIntent,
|
||||
captureSlot.captured.authenticationActions.first().pendingIntent,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `processGetCredentialRequest should invoke callback with error when option is not BeginGetPublicKeyCredentialOption`() {
|
||||
val request: BeginGetCredentialRequest = mockk {
|
||||
every { beginGetCredentialOptions } returns listOf(mockk<BeginGetPasswordOption>())
|
||||
}
|
||||
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
|
||||
val captureSlot = slot<GetCredentialException>()
|
||||
mutableUserStateFlow.value = DEFAULT_USER_STATE
|
||||
every { cancellationSignal.setOnCancelListener(any()) } just runs
|
||||
every { callback.onError(capture(captureSlot)) } just runs
|
||||
|
||||
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
|
||||
|
||||
verify(exactly = 1) { callback.onError(any()) }
|
||||
verify(exactly = 0) { callback.onResult(any()) }
|
||||
|
||||
assert(captureSlot.captured is GetCredentialUnsupportedException)
|
||||
assertEquals("Unsupported option.", captureSlot.captured.errorMessage)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `processGetCredentialRequest should invoke callback with error when option does not contain valid request json`() {
|
||||
val mockOption = BeginGetPublicKeyCredentialOption(
|
||||
candidateQueryData = Bundle(),
|
||||
id = "",
|
||||
requestJson = json.encodeToString(passkeyAssertionOptions),
|
||||
)
|
||||
val request: BeginGetCredentialRequest = mockk {
|
||||
every { beginGetCredentialOptions } returns listOf(mockOption)
|
||||
}
|
||||
every {
|
||||
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any())
|
||||
} returns null
|
||||
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
|
||||
val captureSlot = slot<GetCredentialException>()
|
||||
mutableUserStateFlow.value = DEFAULT_USER_STATE
|
||||
every { cancellationSignal.setOnCancelListener(any()) } just runs
|
||||
every { callback.onError(capture(captureSlot)) } just runs
|
||||
|
||||
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
|
||||
|
||||
verify(exactly = 1) { callback.onError(any()) }
|
||||
verify(exactly = 0) { callback.onResult(any()) }
|
||||
|
||||
assert(captureSlot.captured is GetCredentialUnknownException)
|
||||
assertEquals("Invalid data.", captureSlot.captured.errorMessage)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `processGetCredentialRequest should invoke callback with error when discovering passkey fails`() {
|
||||
val mockOption = BeginGetPublicKeyCredentialOption(
|
||||
candidateQueryData = Bundle(),
|
||||
id = "",
|
||||
requestJson = json.encodeToString(passkeyAssertionOptions),
|
||||
)
|
||||
val request: BeginGetCredentialRequest = mockk {
|
||||
every { beginGetCredentialOptions } returns listOf(mockOption)
|
||||
}
|
||||
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
|
||||
val captureSlot = slot<GetCredentialException>()
|
||||
val mockCipherViews = listOf(createMockCipherView(number = 1))
|
||||
mutableUserStateFlow.value = DEFAULT_USER_STATE
|
||||
mutableCiphersStateFlow.value = DataState.Loaded(mockCipherViews)
|
||||
every { cancellationSignal.setOnCancelListener(any()) } just runs
|
||||
every { callback.onError(capture(captureSlot)) } just runs
|
||||
coEvery {
|
||||
vaultRepository.silentlyDiscoverCredentials(
|
||||
userId = DEFAULT_USER_STATE.activeUserId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = "mockRelyingPartyId-1",
|
||||
)
|
||||
} returns Throwable().asFailure()
|
||||
|
||||
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
|
||||
|
||||
verify(exactly = 1) { callback.onError(any()) }
|
||||
verify(exactly = 0) { callback.onResult(any()) }
|
||||
coVerify(exactly = 1) {
|
||||
vaultRepository.silentlyDiscoverCredentials(
|
||||
userId = DEFAULT_USER_STATE.activeUserId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = "mockRelyingPartyId-1",
|
||||
)
|
||||
}
|
||||
|
||||
assert(captureSlot.captured is GetCredentialUnknownException)
|
||||
assertEquals("Error decrypting credentials.", captureSlot.captured.errorMessage)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `processGetCredentialRequest should invoke callback with filtered and discovered passkeys`() {
|
||||
val mockOption = BeginGetPublicKeyCredentialOption(
|
||||
candidateQueryData = Bundle(),
|
||||
id = "",
|
||||
requestJson = json.encodeToString(passkeyAssertionOptions),
|
||||
)
|
||||
val request: BeginGetCredentialRequest = mockk {
|
||||
every { beginGetCredentialOptions } returns listOf(mockOption)
|
||||
}
|
||||
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
|
||||
val captureSlot = slot<BeginGetCredentialResponse>()
|
||||
val mockCipherViews = listOf(createMockCipherView(number = 1))
|
||||
val mockFido2CredentialAutofillViews = listOf(
|
||||
createMockFido2CredentialAutofillView(number = 1),
|
||||
)
|
||||
val mockIntent: PendingIntent = mockk()
|
||||
val mockPublicKeyCredentialEntry: PublicKeyCredentialEntry = mockk()
|
||||
mutableUserStateFlow.value = DEFAULT_USER_STATE
|
||||
mutableCiphersStateFlow.value = DataState.Loaded(mockCipherViews)
|
||||
every { cancellationSignal.setOnCancelListener(any()) } just runs
|
||||
every { callback.onResult(capture(captureSlot)) } just runs
|
||||
coEvery {
|
||||
vaultRepository.silentlyDiscoverCredentials(
|
||||
userId = DEFAULT_USER_STATE.activeUserId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = "mockRelyingPartyId-1",
|
||||
)
|
||||
} returns mockFido2CredentialAutofillViews.asSuccess()
|
||||
every {
|
||||
intentManager.createFido2GetCredentialPendingIntent(
|
||||
action = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY",
|
||||
credentialId = mockFido2CredentialAutofillViews.first().credentialId.toString(),
|
||||
cipherId = mockFido2CredentialAutofillViews.first().cipherId,
|
||||
requestCode = any(),
|
||||
)
|
||||
} returns mockIntent
|
||||
|
||||
mockkConstructor(PublicKeyCredentialEntry.Builder::class)
|
||||
every {
|
||||
anyConstructed<PublicKeyCredentialEntry.Builder>().build()
|
||||
} returns mockPublicKeyCredentialEntry
|
||||
|
||||
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
|
||||
|
||||
verify(exactly = 0) { callback.onError(any()) }
|
||||
verify(exactly = 1) {
|
||||
callback.onResult(any())
|
||||
intentManager.createFido2GetCredentialPendingIntent(
|
||||
action = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY",
|
||||
credentialId = mockFido2CredentialAutofillViews.first().credentialId.toString(),
|
||||
cipherId = mockFido2CredentialAutofillViews.first().cipherId,
|
||||
requestCode = any(),
|
||||
)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
vaultRepository.silentlyDiscoverCredentials(
|
||||
userId = DEFAULT_USER_STATE.activeUserId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = "mockRelyingPartyId-1",
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(1, captureSlot.captured.credentialEntries.size)
|
||||
assertEquals(mockPublicKeyCredentialEntry, captureSlot.captured.credentialEntries.first())
|
||||
|
||||
unmockkConstructor(PublicKeyCredentialEntry.Builder::class)
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_USER_STATE = UserState(
|
||||
|
|
|
@ -536,6 +536,66 @@ class AutofillParserTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `parse should choose first fillable AutofillView for partition when there is no focused view`() {
|
||||
// Setup
|
||||
setupAssistStructureWithAllAutofillViewTypes()
|
||||
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
|
||||
data = AutofillView.Data(
|
||||
autofillId = cardAutofillId,
|
||||
autofillOptions = emptyList(),
|
||||
autofillType = AUTOFILL_TYPE,
|
||||
isFocused = false,
|
||||
textValue = null,
|
||||
),
|
||||
monthValue = null,
|
||||
)
|
||||
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
|
||||
data = AutofillView.Data(
|
||||
autofillId = loginAutofillId,
|
||||
autofillOptions = emptyList(),
|
||||
autofillType = AUTOFILL_TYPE,
|
||||
isFocused = false,
|
||||
textValue = null,
|
||||
),
|
||||
)
|
||||
val autofillPartition = AutofillPartition.Card(
|
||||
views = listOf(cardAutofillView),
|
||||
)
|
||||
val expected = AutofillRequest.Fillable(
|
||||
ignoreAutofillIds = emptyList(),
|
||||
inlinePresentationSpecs = inlinePresentationSpecs,
|
||||
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
|
||||
packageName = PACKAGE_NAME,
|
||||
partition = autofillPartition,
|
||||
uri = URI,
|
||||
)
|
||||
every { cardViewNode.toAutofillView() } returns cardAutofillView
|
||||
every { loginViewNode.toAutofillView() } returns loginAutofillView
|
||||
|
||||
// Test
|
||||
val actual = parser.parse(
|
||||
autofillAppInfo = autofillAppInfo,
|
||||
fillRequest = fillRequest,
|
||||
)
|
||||
|
||||
// Verify
|
||||
assertEquals(expected, actual)
|
||||
verify(exactly = 1) {
|
||||
fillRequest.getInlinePresentationSpecs(
|
||||
autofillAppInfo = autofillAppInfo,
|
||||
isInlineAutofillEnabled = true,
|
||||
)
|
||||
fillRequest.getMaxInlineSuggestionsCount(
|
||||
autofillAppInfo = autofillAppInfo,
|
||||
isInlineAutofillEnabled = true,
|
||||
)
|
||||
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
|
||||
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse should return empty inline suggestions when inline autofill is disabled`() {
|
||||
// Setup
|
||||
|
|
|
@ -18,6 +18,7 @@ import com.bitwarden.sdk.ClientCiphers
|
|||
import com.bitwarden.sdk.ClientCrypto
|
||||
import com.bitwarden.sdk.ClientExporters
|
||||
import com.bitwarden.sdk.ClientFido2
|
||||
import com.bitwarden.sdk.ClientFido2Authenticator
|
||||
import com.bitwarden.sdk.ClientFido2Client
|
||||
import com.bitwarden.sdk.ClientPasswordHistory
|
||||
import com.bitwarden.sdk.ClientPlatform
|
||||
|
@ -42,6 +43,7 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
|||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialSearchUserInterfaceImpl
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
|
@ -1134,6 +1136,67 @@ class VaultSdkSourceTest {
|
|||
cipherViews = arrayOf(mockCipherView),
|
||||
)
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silentlyDiscoverCredentials should return results when successful`() = runTest {
|
||||
val userId = "userId"
|
||||
val fido2CredentialStore: Fido2CredentialStore = mockk()
|
||||
val relyingPartyId = "relyingPartyId"
|
||||
val mockAutofillView = Fido2CredentialAutofillView(
|
||||
credentialId = byteArrayOf(0),
|
||||
cipherId = "mockCipherId",
|
||||
rpId = "mockRpId",
|
||||
userNameForUi = "mockUserNameForUi",
|
||||
userHandle = "mockUserHandle".toByteArray(),
|
||||
)
|
||||
val autofillViews = listOf(mockAutofillView)
|
||||
|
||||
val authenticator: ClientFido2Authenticator = mockk {
|
||||
coEvery { silentlyDiscoverCredentials(relyingPartyId) } returns autofillViews
|
||||
}
|
||||
every {
|
||||
clientFido2.authenticator(
|
||||
userInterface = any(),
|
||||
credentialStore = fido2CredentialStore,
|
||||
)
|
||||
} returns authenticator
|
||||
|
||||
val result = vaultSdkSource.silentlyDiscoverCredentials(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
autofillViews.asSuccess(),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silentlyDiscoverCredentials should return Failure when Bitwarden exception is thrown`() =
|
||||
runTest {
|
||||
val userId = "userId"
|
||||
val fido2CredentialStore: Fido2CredentialStore = mockk()
|
||||
val relyingPartyId = "relyingPartyId"
|
||||
|
||||
coEvery {
|
||||
clientFido2
|
||||
.authenticator(
|
||||
userInterface = Fido2CredentialSearchUserInterfaceImpl(),
|
||||
credentialStore = fido2CredentialStore,
|
||||
)
|
||||
.silentlyDiscoverCredentials(relyingPartyId)
|
||||
} throws BitwardenException.E("mockException")
|
||||
|
||||
val result = vaultSdkSource.silentlyDiscoverCredentials(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
)
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
|
||||
|
||||
import com.bitwarden.fido.Fido2CredentialAutofillView
|
||||
import com.bitwarden.vault.Fido2CredentialView
|
||||
import java.time.Instant
|
||||
|
||||
|
@ -21,3 +22,16 @@ fun createMockFido2CredentialView(number: Int): Fido2CredentialView = Fido2Crede
|
|||
discoverable = "mockDiscoverable-$number",
|
||||
creationDate = Instant.now(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a [createMockFido2CredentialAutofillView] instance for testing.
|
||||
*/
|
||||
fun createMockFido2CredentialAutofillView(
|
||||
number: Int,
|
||||
): Fido2CredentialAutofillView = Fido2CredentialAutofillView(
|
||||
credentialId = "mockCredentialId-$number".toByteArray(),
|
||||
cipherId = "mockCipherId-$number",
|
||||
rpId = "mockRpId-$number",
|
||||
userNameForUi = "mockUserNameForUi-$number",
|
||||
userHandle = "mockUserHandle-$number".toByteArray(),
|
||||
)
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.bitwarden.core.InitOrgCryptoRequest
|
|||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.exporters.ExportFormat
|
||||
import com.bitwarden.fido.Fido2CredentialAutofillView
|
||||
import com.bitwarden.sdk.Fido2CredentialStore
|
||||
import com.bitwarden.send.SendType
|
||||
import com.bitwarden.send.SendView
|
||||
import com.bitwarden.vault.CipherView
|
||||
|
@ -4256,6 +4257,42 @@ class VaultRepositoryTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silentlyDiscoverCredentials should return result`() = runTest {
|
||||
val userId = "userId"
|
||||
val fido2CredentialStore: Fido2CredentialStore = mockk()
|
||||
val relyingPartyId = "relyingPartyId"
|
||||
val expected: Result<List<Fido2CredentialAutofillView>> = mockk()
|
||||
coEvery {
|
||||
vaultSdkSource.silentlyDiscoverCredentials(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
)
|
||||
} returns expected
|
||||
|
||||
turbineScope {
|
||||
val result = vaultRepository.silentlyDiscoverCredentials(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
expected,
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
coVerify {
|
||||
vaultSdkSource.silentlyDiscoverCredentials(
|
||||
userId = userId,
|
||||
fido2CredentialStore = fido2CredentialStore,
|
||||
relyingPartyId = relyingPartyId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//region Helper functions
|
||||
|
||||
/**
|
||||
|
|
|
@ -219,6 +219,85 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
|||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fido2 master password prompt dialog should display based on state`() {
|
||||
val dialogTitle = "Master password confirmation"
|
||||
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(dialogTitle).assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultAddEditState.DialogState.Fido2MasterPasswordPrompt)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(dialogTitle)
|
||||
.assertIsDisplayed()
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Master password")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performTextInput("password")
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Submit")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
|
||||
password = "password",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fido2 master password error dialog should display based on state`() {
|
||||
val dialogMessage = "Invalid master password. Try again."
|
||||
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(dialogMessage).assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultAddEditState.DialogState.Fido2MasterPasswordError)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(dialogMessage)
|
||||
.assertIsDisplayed()
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.RetryFido2PasswordVerificationClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clicking dismiss dialog on Fido2Error dialog should send Fido2ErrorDialogDismissed action`() {
|
||||
|
|
|
@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
|
||||
|
@ -126,6 +127,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
|||
private val fido2CredentialManager = mockk<Fido2CredentialManager> {
|
||||
every { isUserVerified } returns false
|
||||
every { isUserVerified = any() } just runs
|
||||
every { authenticationAttempts } returns 0
|
||||
every { authenticationAttempts = any() } just runs
|
||||
every { hasAuthenticationAttemptsRemaining() } returns true
|
||||
}
|
||||
private val vaultRepository: VaultRepository = mockk {
|
||||
every { vaultDataStateFlow } returns mutableVaultDataFlow
|
||||
|
@ -3168,11 +3172,145 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
|||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UserVerificationNotSupported should set isUserVerified to false and show Fido2ErrorDialog`() {
|
||||
fun `UserVerificationNotSupported should display Fido2ErrorDialog when active account not found`() {
|
||||
mutableUserStateFlow.value = null
|
||||
viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationNotSupported)
|
||||
verify { fido2CredentialManager.isUserVerified = false }
|
||||
assertEquals(
|
||||
VaultAddEditState.DialogState.Fido2Error(R.string.passkey_operation_failed_because_user_could_not_be_verified.asText()),
|
||||
VaultAddEditState.DialogState.Fido2Error(
|
||||
message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialog,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `UserVerificationNotSupported should display Fido2MasterPasswordPrompt when user has password but no pin`() {
|
||||
viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationNotSupported)
|
||||
verify { fido2CredentialManager.isUserVerified = false }
|
||||
assertEquals(
|
||||
VaultAddEditState.DialogState.Fido2MasterPasswordPrompt,
|
||||
viewModel.stateFlow.value.dialog,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `MasterPasswordFido2VerificationSubmit should display Fido2Error when password verification fails`() {
|
||||
val password = "password"
|
||||
coEvery {
|
||||
authRepository.validatePassword(password = password)
|
||||
} returns ValidatePasswordResult.Error
|
||||
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
VaultAddEditState.DialogState.Fido2Error(
|
||||
message = R.string.passkey_operation_failed_because_user_could_not_be_verified
|
||||
.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialog,
|
||||
)
|
||||
coVerify {
|
||||
authRepository.validatePassword(password = password)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `MasterPasswordFido2VerificationSubmit should display Fido2MasterPasswordError when user has retries remaining`() {
|
||||
val password = "password"
|
||||
coEvery {
|
||||
authRepository.validatePassword(password = password)
|
||||
} returns ValidatePasswordResult.Success(isValid = false)
|
||||
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
VaultAddEditState.DialogState.Fido2MasterPasswordError,
|
||||
viewModel.stateFlow.value.dialog,
|
||||
)
|
||||
coVerify {
|
||||
authRepository.validatePassword(password = password)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `MasterPasswordFido2VerificationSubmit should display Fido2Error when user has no retries remaining`() {
|
||||
val password = "password"
|
||||
every { fido2CredentialManager.hasAuthenticationAttemptsRemaining() } returns false
|
||||
coEvery {
|
||||
authRepository.validatePassword(password = password)
|
||||
} returns ValidatePasswordResult.Success(isValid = false)
|
||||
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
VaultAddEditState.DialogState.Fido2Error(
|
||||
message = R.string.passkey_operation_failed_because_user_could_not_be_verified
|
||||
.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialog,
|
||||
)
|
||||
coVerify {
|
||||
authRepository.validatePassword(password = password)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `MasterPasswordFido2VerificationSubmit should register credential when password authenticated successfully`() =
|
||||
runTest {
|
||||
val password = "password"
|
||||
coEvery {
|
||||
authRepository.validatePassword(password = password)
|
||||
} returns ValidatePasswordResult.Success(isValid = true)
|
||||
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
coVerify {
|
||||
authRepository.validatePassword(password = password)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissFido2PasswordVerificationDialogClick should display Fido2ErrorDialog`() {
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
VaultAddEditState.DialogState.Fido2Error(
|
||||
message = R.string.passkey_operation_failed_because_user_could_not_be_verified
|
||||
.asText(),
|
||||
),
|
||||
viewModel.stateFlow.value.dialog,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RetryFido2PasswordVerificationClick should display Fido2MasterPasswordPrompt`() {
|
||||
viewModel.trySendAction(VaultAddEditAction.Common.RetryFido2PasswordVerificationClick)
|
||||
|
||||
assertEquals(
|
||||
VaultAddEditState.DialogState.Fido2MasterPasswordPrompt,
|
||||
viewModel.stateFlow.value.dialog,
|
||||
)
|
||||
}
|
||||
|
@ -3404,6 +3542,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
//region Helper functions
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.addedit.util
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PublicKeyCredentialDescriptor
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
|
||||
|
||||
/**
|
||||
* Returns a mock FIDO 2 [PasskeyAssertionOptions] object to simulate a credential
|
||||
* creation request.
|
||||
*/
|
||||
fun createMockPasskeyAssertionOptions(
|
||||
number: Int,
|
||||
) = PasskeyAssertionOptions(
|
||||
challenge = "mockChallenge-$number",
|
||||
allowCredentials = listOf(
|
||||
PublicKeyCredentialDescriptor(
|
||||
type = "mockPublicKeyCredentialDescriptorType-$number",
|
||||
id = "mockPublicKeyCredentialDescriptorId-$number",
|
||||
transports = listOf("mockPublicKeyCredentialDescriptorTransports-$number"),
|
||||
),
|
||||
),
|
||||
relyingPartyId = "mockRelyingPartyId-$number",
|
||||
userVerification = "mockUserVerification-$number",
|
||||
)
|
|
@ -1,6 +1,7 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.addedit.util
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.PublicKeyCredentialDescriptor
|
||||
|
||||
/**
|
||||
* Returns a mock FIDO 2 [PasskeyAttestationOptions] object to simulate a credential
|
||||
|
@ -15,7 +16,7 @@ fun createMockPasskeyAttestationOptions(
|
|||
.AuthenticatorSelectionCriteria(userVerification = userVerificationRequirement),
|
||||
challenge = "mockPublicKeyCredentialCreationOptionsChallenge-$number",
|
||||
excludeCredentials = listOf(
|
||||
PasskeyAttestationOptions.PublicKeyCredentialDescriptor(
|
||||
PublicKeyCredentialDescriptor(
|
||||
type = "mockPublicKeyCredentialDescriptorType-$number",
|
||||
id = "mockPublicKeyCredentialDescriptorId-$number",
|
||||
transports = listOf("mockPublicKeyCredentialDescriptorTransports-$number"),
|
|
@ -146,6 +146,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
every { isUserVerified = any() } just runs
|
||||
every { authenticationAttempts } returns 0
|
||||
every { authenticationAttempts = any() } just runs
|
||||
every { hasAuthenticationAttemptsRemaining() } returns true
|
||||
}
|
||||
|
||||
private val organizationEventManager = mockk<OrganizationEventManager> {
|
||||
|
@ -2471,7 +2472,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
val viewModel = createVaultItemListingViewModel()
|
||||
val selectedCipherId = "selectedCipherId"
|
||||
val password = "password"
|
||||
every { fido2CredentialManager.authenticationAttempts } returns 5
|
||||
every { fido2CredentialManager.hasAuthenticationAttemptsRemaining() } returns false
|
||||
coEvery {
|
||||
authRepository.validatePassword(password = password)
|
||||
} returns ValidatePasswordResult.Success(isValid = false)
|
||||
|
|
|
@ -38,8 +38,8 @@ kotlin = "2.0.0"
|
|||
kotlinxCollectionsImmutable = "0.3.7"
|
||||
kotlinxCoroutines = "1.8.1"
|
||||
kotlinxSerialization = "1.7.1"
|
||||
kotlinxKover = "0.8.2"
|
||||
ksp = "2.0.0-1.0.22"
|
||||
kotlinxKover = "0.8.3"
|
||||
ksp = "2.0.0-1.0.23"
|
||||
mockk = "1.13.12"
|
||||
okhttp = "4.12.0"
|
||||
retrofitBom = "2.11.0"
|
||||
|
|
Loading…
Reference in a new issue