[PM-9410] Implement FIDO 2 Get Credentials completion (#3639)
Some checks failed
Crowdin Push / Crowdin Push (push) Waiting to run
Scan / Check PR run (push) Failing after 1s
Scan / Quality scan (push) Has been skipped
Scan / SAST scan (push) Has been skipped
Test / Check PR run (push) Failing after 0s
Test / Test (push) Has been skipped

This commit is contained in:
Patrick Honkonen 2024-07-29 16:50:20 -04:00 committed by GitHub
parent 0e90bbb905
commit deb8f811e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 283 additions and 5 deletions

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import com.bitwarden.fido.Fido2CredentialAutofillView
/**
* Represents the result of a FIDO 2 Get Credentials request.
*/
sealed class Fido2GetCredentialResult {
/**
* Indicates credentials were successfully queried.
*
* @param options Original request options provided by the relying party.
* @param credentials Collection of [Fido2CredentialAutofillView]s matching the original request
* parameters. This may be an empty list if no matching values were found.
*/
data class Success(
val options: BeginGetPublicKeyCredentialOption,
val credentials: List<Fido2CredentialAutofillView>,
) : Fido2GetCredentialResult()
/**
* Indicates an error was encountered when querying for matching credentials.
*/
data object Error : Fido2GetCredentialResult()
}

View file

@ -41,7 +41,7 @@ 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 GET_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY"
const val UNLOCK_ACCOUNT_INTENT = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT"
/**
@ -225,6 +225,7 @@ class Fido2ProviderProcessorImpl(
DecryptFido2CredentialAutofillViewResult.Error -> {
throw GetCredentialUnknownException("Error decrypting credentials.")
}
is DecryptFido2CredentialAutofillViewResult.Success -> {
result
.fido2CredentialAutofillViews

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.autofill.fido2.manager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
/**
@ -17,4 +18,9 @@ interface Fido2CompletionManager {
* Complete the FIDO 2 credential assertion process with the provided [result].
*/
fun completeFido2Assertion(result: Fido2CredentialAssertionResult)
/**
* Complete the FIDO 2 "Get credentials" process with the provided [result].
*/
fun completeFido2GetCredentialRequest(result: Fido2GetCredentialResult)
}

View file

@ -10,9 +10,16 @@ import androidx.credentials.PublicKeyCredential
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.PendingIntentHandler
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.processor.GET_PASSKEY_INTENT
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlin.random.Random
/**
* Primary implementation of [Fido2CompletionManager] when the build version is
@ -21,6 +28,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResu
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class Fido2CompletionManagerImpl(
private val activity: Activity,
private val intentManager: IntentManager,
) : Fido2CompletionManager {
override fun completeFido2Registration(result: Fido2RegisterCredentialResult) {
@ -84,4 +92,53 @@ class Fido2CompletionManagerImpl(
it.finish()
}
}
override fun completeFido2GetCredentialRequest(result: Fido2GetCredentialResult) {
val resultIntent = Intent()
val responseBuilder = BeginGetCredentialResponse.Builder()
when (result) {
is Fido2GetCredentialResult.Success -> {
val entries = result
.credentials
.map {
val pendingIntent = intentManager
.createFido2GetCredentialPendingIntent(
action = GET_PASSKEY_INTENT,
credentialId = it.credentialId.toString(),
cipherId = it.cipherId,
requestCode = Random.nextInt(),
)
PublicKeyCredentialEntry
.Builder(
context = activity,
username = it.userNameForUi
?: activity.getString(R.string.no_username),
pendingIntent = pendingIntent,
beginGetPublicKeyCredentialOption = result.options,
)
.build()
}
PendingIntentHandler
.setBeginGetCredentialResponse(
resultIntent,
responseBuilder
.setCredentialEntries(entries)
// Explicitly clear any pending authentication actions since we only
// display results from the active account.
.setAuthenticationActions(emptyList())
.build(),
)
}
Fido2GetCredentialResult.Error,
-> {
PendingIntentHandler.setGetCredentialException(
resultIntent,
GetCredentialUnknownException(),
)
}
}
activity.setResult(Activity.RESULT_OK, resultIntent)
activity.finish()
}
}

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.autofill.fido2.manager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
/**
@ -11,4 +12,6 @@ object Fido2CompletionManagerUnsupportedApiImpl : Fido2CompletionManager {
override fun completeFido2Registration(result: Fido2RegisterCredentialResult) = Unit
override fun completeFido2Assertion(result: Fido2CredentialAssertionResult) = Unit
override fun completeFido2GetCredentialRequest(result: Fido2GetCredentialResult) = Unit
}

View file

@ -32,15 +32,16 @@ import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManagerImp
@Composable
fun LocalManagerProvider(content: @Composable () -> Unit) {
val activity = LocalContext.current as Activity
val fido2IntentManager: IntentManager = IntentManagerImpl(activity)
val fido2CompletionManager =
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
Fido2CompletionManagerUnsupportedApiImpl
} else {
Fido2CompletionManagerImpl(activity)
Fido2CompletionManagerImpl(activity, fido2IntentManager)
}
CompositionLocalProvider(
LocalPermissionsManager provides PermissionsManagerImpl(activity),
LocalIntentManager provides IntentManagerImpl(activity),
LocalIntentManager provides fido2IntentManager,
LocalExitManager provides ExitManagerImpl(activity),
LocalBiometricsManager provides BiometricsManagerImpl(activity),
LocalNfcManager provides NfcManagerImpl(activity),

View file

@ -1,10 +1,21 @@
package com.x8bit.bitwarden.ui.autofill.fido2.manager
import android.app.Activity
import android.app.PendingIntent
import android.content.Intent
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.PendingIntentHandler
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.processor.GET_PASSKEY_INTENT
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFido2CredentialAutofillView
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CIPHER_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CREDENTIAL_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.Called
import io.mockk.MockKVerificationScope
import io.mockk.Ordering
@ -13,11 +24,16 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkConstructor
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
@ -25,6 +41,7 @@ import org.junit.jupiter.api.Test
class Fido2CompletionManagerTest {
private val mockActivity = mockk<Activity> {
every { packageName } returns "packageName"
every { setResult(Activity.RESULT_OK, any()) } just runs
every { finish() } just runs
}
@ -56,25 +73,41 @@ class Fido2CompletionManagerTest {
mockActivity wasNot Called
}
}
@Test
fun `completeFido2GetCredentials should perform no operations`() {
val mockGetCredentialResult = mockk<Fido2GetCredentialResult>()
fido2CompletionManager.completeFido2GetCredentialRequest(mockGetCredentialResult)
verify {
mockGetCredentialResult wasNot Called
mockActivity wasNot Called
}
}
}
@Nested
inner class DefaultImplementation {
private val mockIntentManager = mockk<IntentManager>()
@BeforeEach
fun setUp() {
fido2CompletionManager = Fido2CompletionManagerImpl(mockActivity)
fido2CompletionManager = Fido2CompletionManagerImpl(mockActivity, mockIntentManager)
mockkConstructor(Intent::class)
mockkObject(PendingIntentHandler.Companion)
every {
PendingIntentHandler.setCreateCredentialException(any(), any())
} just runs
every {
PendingIntentHandler.setBeginGetCredentialResponse(any(), any())
} just runs
}
@AfterEach
fun tearDown() {
unmockkConstructor(Intent::class)
unmockkConstructor(Intent::class, PublicKeyCredentialEntry.Builder::class)
unmockkObject(PendingIntentHandler.Companion)
unmockkStatic(PendingIntent::class)
}
@Suppress("MaxLineLength")
@ -136,6 +169,127 @@ class Fido2CompletionManagerTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `completeFido2GetCredentials should set BeginGetCredentialResponse, set activity result, then finish activity when result is Success`() {
fido2CompletionManager
.completeFido2GetCredentialRequest(
Fido2GetCredentialResult.Success(
options = mockk(),
credentials = emptyList(),
),
)
verifyActivityResultIsSetAndFinishedAfter {
PendingIntentHandler.setBeginGetCredentialResponse(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `completeFido2GetCredentials should create a PublicKeyCredentialEntry and clear authentication actions when result is Success`() {
mockkConstructor(PublicKeyCredentialEntry.Builder::class)
mockkStatic(PendingIntent::class)
val mockCredentialEntry = mockk<PublicKeyCredentialEntry>()
val mockFido2AutofillView = createMockFido2CredentialAutofillView(number = 1)
val mockFido2AutofillViewList = listOf(mockFido2AutofillView)
every {
anyConstructed<PublicKeyCredentialEntry.Builder>().build()
} returns mockCredentialEntry
every {
mockIntentManager.createFido2GetCredentialPendingIntent(
action = GET_PASSKEY_INTENT,
credentialId = mockFido2AutofillView.credentialId.toString(),
cipherId = mockFido2AutofillView.cipherId,
requestCode = any(),
)
} returns mockk()
every { mockActivity.getString(any()) } returns "No username"
fido2CompletionManager
.completeFido2GetCredentialRequest(
Fido2GetCredentialResult.Success(
options = mockk(),
credentials = mockFido2AutofillViewList,
),
)
val responseSlot = slot<BeginGetCredentialResponse>()
verify {
anyConstructed<PublicKeyCredentialEntry.Builder>().build()
PendingIntentHandler.setBeginGetCredentialResponse(
intent = any(),
response = capture(responseSlot),
)
}
assertEquals(
listOf(mockCredentialEntry),
responseSlot.captured.credentialEntries,
)
assertTrue(responseSlot.captured.authenticationActions.isEmpty())
}
@Suppress("MaxLineLength")
@Test
fun `completeFido2GetCredentials should set username to default value when userNameForUi is null`() {
mockkConstructor(PublicKeyCredentialEntry.Builder::class)
mockkStatic(PendingIntent::class)
val mockCredentialEntry = mockk<PublicKeyCredentialEntry>()
val mockFido2AutofillView = createMockFido2CredentialAutofillView(number = 1)
.copy(userNameForUi = null)
val mockFido2AutofillViewList = listOf(mockFido2AutofillView)
every {
anyConstructed<PublicKeyCredentialEntry.Builder>().build()
} returns mockCredentialEntry
every {
mockIntentManager.createFido2GetCredentialPendingIntent(
action = GET_PASSKEY_INTENT,
credentialId = mockFido2AutofillView.credentialId.toString(),
cipherId = mockFido2AutofillView.cipherId,
requestCode = any(),
)
} returns mockk()
every { mockActivity.getString(any()) } returns "No Username"
fido2CompletionManager
.completeFido2GetCredentialRequest(
Fido2GetCredentialResult.Success(
options = mockk(),
credentials = mockFido2AutofillViewList,
),
)
val responseSlot = slot<BeginGetCredentialResponse>()
verify {
mockActivity.getString(R.string.no_username)
anyConstructed<PublicKeyCredentialEntry.Builder>().build()
PendingIntentHandler.setBeginGetCredentialResponse(
intent = any(),
response = capture(responseSlot),
)
}
assertEquals(
listOf(mockCredentialEntry),
responseSlot.captured.credentialEntries,
)
}
@Suppress("MaxLineLength")
@Test
fun `completeFido2GetCredentials should set GetCredentialException, set activity result, then finish activity when result is Error`() {
fido2CompletionManager
.completeFido2GetCredentialRequest(Fido2GetCredentialResult.Error)
verifyActivityResultIsSetAndFinishedAfter {
PendingIntentHandler.setGetCredentialException(any(), any())
}
}
/**
* Convenience function to ensure the given [calls] are performed before setting the
* [mockActivity] result and calling finish. This sequence is expected to be performed for
@ -150,5 +304,35 @@ class Fido2CompletionManagerTest {
mockActivity.finish()
}
}
private fun setupMockCompletionIntent(
mockFido2AutofillView1: Fido2CredentialAutofillView,
mockCredentialEntry1: PublicKeyCredentialEntry,
): Intent {
val mockIntent1 = mockk<Intent> {
every {
putExtra(
EXTRA_KEY_CIPHER_ID,
mockFido2AutofillView1.cipherId,
)
} returns this
every {
putExtra(
EXTRA_KEY_CREDENTIAL_ID,
mockFido2AutofillView1.credentialId.toString(),
)
} returns this
}
every {
anyConstructed<PublicKeyCredentialEntry.Builder>()
.build()
} returns mockCredentialEntry1
every { anyConstructed<Intent>().setPackage(any()) } returns mockIntent1
every {
PendingIntent.getActivity(mockActivity, any(), mockIntent1, any())
} returns mockk()
return mockIntent1
}
}
}