[PM-9410] Introduce FIDO 2 Get Credentials Request special circumstance (#3637)

This commit is contained in:
Patrick Honkonen 2024-07-29 11:54:23 -04:00 committed by GitHub
parent 39250e5cb4
commit b0079fca5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 208 additions and 9 deletions

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import android.content.pm.SigningInfo
import android.os.Bundle
import android.os.Parcelable
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.CallingAppInfo
import kotlinx.parcelize.Parcelize
/**
* Models a FIDO 2 request to retrieve FIDO credentials parsed from the launching intent.
*/
@Parcelize
data class Fido2GetCredentialsRequest(
val candidateQueryData: Bundle,
val id: String,
val requestJson: String,
val clientDataHash: ByteArray? = null,
val packageName: String,
val signingInfo: SigningInfo,
val origin: String?,
) : Parcelable {
val callingAppInfo: CallingAppInfo
get() = CallingAppInfo(packageName, signingInfo, origin)
val getCredentialsRequest: BeginGetPublicKeyCredentialOption
get() = BeginGetPublicKeyCredentialOption(
candidateQueryData,
id,
requestJson,
clientDataHash,
)
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.manager.model
import android.os.Parcelable
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
@ -67,6 +68,15 @@ sealed class SpecialCircumstance : Parcelable {
val fido2AssertionRequest: Fido2CredentialAssertionRequest,
) : SpecialCircumstance()
/**
* The app was launched via the credential manager framework request to retrieve passkeys
* associated with the requesting entity.
*/
@Parcelize
data class Fido2GetCredentials(
val fido2GetCredentialsRequest: Fido2GetCredentialsRequest,
) : SpecialCircumstance()
/**
* The app was launched via deeplink to the generator.
*/

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.manager.util
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@ -19,6 +20,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
is SpecialCircumstance.Fido2Assertion -> null
is SpecialCircumstance.Fido2GetCredentials -> null
}
/**
@ -34,6 +36,7 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
is SpecialCircumstance.Fido2Assertion -> null
is SpecialCircumstance.Fido2GetCredentials -> null
}
/**
@ -53,3 +56,12 @@ fun SpecialCircumstance.toFido2AssertionRequestOrNull(): Fido2CredentialAssertio
is SpecialCircumstance.Fido2Assertion -> this.fido2AssertionRequest
else -> null
}
/**
* Returns [Fido2CredentialAssertionRequest] when contained in the given [SpecialCircumstance].
*/
fun SpecialCircumstance.toFido2GetCredentialsRequestOrNull(): Fido2GetCredentialsRequest? =
when (this) {
is SpecialCircumstance.Fido2GetCredentials -> this.fido2GetCredentialsRequest
else -> null
}

View file

@ -108,6 +108,7 @@ fun RootNavScreen(
is RootNavState.VaultUnlockedForAuthRequest,
is RootNavState.VaultUnlockedForFido2Save,
is RootNavState.VaultUnlockedForFido2Assertion,
is RootNavState.VaultUnlockedForFido2GetCredentials,
-> VAULT_UNLOCKED_GRAPH_ROUTE
}
val currentRoute = navController.currentDestination?.rootLevelRoute()
@ -186,15 +187,10 @@ fun RootNavScreen(
)
}
is RootNavState.VaultUnlockedForFido2Save -> {
navController.navigateToVaultUnlockedGraph(rootNavOptions)
navController.navigateToVaultItemListingAsRoot(
vaultItemListingType = VaultItemListingType.Login,
navOptions = rootNavOptions,
)
}
is RootNavState.VaultUnlockedForFido2Assertion -> {
is RootNavState.VaultUnlockedForFido2Save,
is RootNavState.VaultUnlockedForFido2Assertion,
is RootNavState.VaultUnlockedForFido2GetCredentials,
-> {
navController.navigateToVaultUnlockedGraph(rootNavOptions)
navController.navigateToVaultItemListingAsRoot(
vaultItemListingType = VaultItemListingType.Login,

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
@ -109,6 +110,13 @@ class RootNavViewModel @Inject constructor(
)
}
is SpecialCircumstance.Fido2GetCredentials -> {
RootNavState.VaultUnlockedForFido2GetCredentials(
activeUserId = userState.activeUserId,
fido2GetCredentialsRequest = specialCircumstance.fido2GetCredentialsRequest,
)
}
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
null,
@ -211,6 +219,16 @@ sealed class RootNavState : Parcelable {
val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest,
) : RootNavState()
/**
* App should unlock the user's vault and retrieve FIDO 2 credentials associated to the relying
* party.
*/
@Parcelize
data class VaultUnlockedForFido2GetCredentials(
val activeUserId: String,
val fido2GetCredentialsRequest: Fido2GetCredentialsRequest,
) : RootNavState()
/**
* App should show the new send screen for an unlocked user.
*/

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import android.content.pm.SigningInfo
import android.os.Bundle
fun createMockFido2GetCredentialsRequest(
number: Int,
signingInfo: SigningInfo = SigningInfo(),
origin: String? = null,
): Fido2GetCredentialsRequest = Fido2GetCredentialsRequest(
candidateQueryData = Bundle(),
id = "mockId-$number",
requestJson = "requestJson-$number",
clientDataHash = null,
packageName = "mockPackageName-$number",
signingInfo = signingInfo,
origin = origin,
)

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.manager.util
import android.content.pm.SigningInfo
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@ -47,6 +48,9 @@ class SpecialCircumstanceExtensionsTest {
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = mockk(),
),
SpecialCircumstance.Fido2GetCredentials(
fido2GetCredentialsRequest = mockk(),
),
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
)
@ -92,6 +96,9 @@ class SpecialCircumstanceExtensionsTest {
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = mockk(),
),
SpecialCircumstance.Fido2GetCredentials(
fido2GetCredentialsRequest = mockk(),
),
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
)
@ -121,6 +128,9 @@ class SpecialCircumstanceExtensionsTest {
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = mockk(),
),
SpecialCircumstance.Fido2GetCredentials(
fido2GetCredentialsRequest = mockk(),
),
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
)
@ -184,6 +194,9 @@ class SpecialCircumstanceExtensionsTest {
SpecialCircumstance.Fido2Save(
fido2CredentialRequest = mockk(),
),
SpecialCircumstance.Fido2GetCredentials(
fido2GetCredentialsRequest = mockk(),
),
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
)
@ -191,4 +204,50 @@ class SpecialCircumstanceExtensionsTest {
assertNull(specialCircumstance.toFido2AssertionRequestOrNull())
}
}
@Suppress("MaxLineLength")
@Test
fun `toFido2GetCredentialsRequestOrNull should return a non-null value for Fido2GetCredentials`() {
val fido2GetCredentialsRequest = createMockFido2GetCredentialsRequest(number = 1)
assertEquals(
fido2GetCredentialsRequest,
SpecialCircumstance
.Fido2GetCredentials(
fido2GetCredentialsRequest = fido2GetCredentialsRequest,
)
.toFido2GetCredentialsRequestOrNull(),
)
}
@Test
fun `toFido2GetCredentialsRequestOrNull should return a null value for other types`() {
listOf(
SpecialCircumstance.AutofillSelection(
autofillSelectionData = mockk(),
shouldFinishWhenComplete = true,
),
SpecialCircumstance.AutofillSave(
autofillSaveItem = mockk(),
),
SpecialCircumstance.ShareNewSend(
data = mockk(),
shouldFinishWhenComplete = true,
),
SpecialCircumstance.PasswordlessRequest(
passwordlessRequestData = mockk(),
shouldFinishWhenComplete = true,
),
SpecialCircumstance.Fido2Save(
fido2CredentialRequest = mockk(),
),
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = mockk(),
),
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
)
.forEach { specialCircumstance ->
assertNull(specialCircumstance.toFido2GetCredentialsRequestOrNull())
}
}
}

View file

@ -178,5 +178,19 @@ class RootNavScreenTest : BaseComposeTest() {
navOptions = expectedNavOptions,
)
}
// Make sure navigating to vault unlocked for Fido2GetCredentials works as expected:
rootNavStateFlow.value =
RootNavState.VaultUnlockedForFido2GetCredentials(
activeUserId = "activeUserId",
fido2GetCredentialsRequest = mockk(),
)
composeTestRule
.runOnIdle {
fakeNavHostController.assertLastNavigation(
route = "vault_item_listing_as_root/login",
navOptions = expectedNavOptions,
)
}
}
}

View file

@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
@ -490,6 +491,44 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `when the active user has an unlocked vault but there is a Fido2GetCredentials special circumstance the nav state should be VaultUnlockedForFido2GetCredentials`() {
val fido2GetCredentialsRequest = createMockFido2GetCredentialsRequest(number = 1)
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2GetCredentials(fido2GetCredentialsRequest)
mutableUserStateFlow.tryEmit(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarHexColor",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
),
),
),
)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultUnlockedForFido2GetCredentials(
activeUserId = "activeUserId",
fido2GetCredentialsRequest = fido2GetCredentialsRequest,
),
viewModel.stateFlow.value,
)
}
@Test
fun `when the active user has a locked vault the nav state should be VaultLocked`() {
mutableUserStateFlow.tryEmit(