PM-9408: Show bottom sheet with passkey options (#3444)

This commit is contained in:
Shannon Draeker 2024-07-22 14:07:22 -06:00 committed by GitHub
parent 0e44b21361
commit 62154f5261
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 784 additions and 18 deletions

View file

@ -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>

View file

@ -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,
)

View file

@ -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.
*/

View file

@ -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 {

View file

@ -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?,
)

View file

@ -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.
*/

View file

@ -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>,
)

View file

@ -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,

View file

@ -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>>
}

View file

@ -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)

View file

@ -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()
}

View file

@ -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.
*/

View file

@ -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)
}

View file

@ -91,6 +91,26 @@ interface IntentManager {
requestCode: Int,
): PendingIntent
/**
* 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.
*/

View file

@ -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.
@ -210,6 +224,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)

View file

@ -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>

View file

@ -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
@ -57,6 +59,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 +282,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 +296,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 +304,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`() =

View file

@ -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(

View file

@ -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)
}
}

View file

@ -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(),
)

View file

@ -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
/**

View file

@ -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",
)

View file

@ -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"),