Skip to content

Commit

Permalink
WIP: Implement single tap passkey flows
Browse files Browse the repository at this point in the history
This change adds a biometric prompt to the passkey autofill flow.

The biometric prompt will be shown when creating or authenticating with a passkey if the user has supported device biometrics enabled.

This change also adds an isUserVerified flag to the Fido2 requests to determine if the user has verified their identity using the single tap flow.
  • Loading branch information
SaintPatrck committed Jan 9, 2025
1 parent b5d73c9 commit 4558b28
Show file tree
Hide file tree
Showing 21 changed files with 282 additions and 81 deletions.
19 changes: 12 additions & 7 deletions app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CreateCredentialRequestOrNull
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2GetCredentialsRequestOrNull
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
Expand Down Expand Up @@ -257,7 +257,7 @@ class MainViewModel @Inject constructor(
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull()
val fido2CreateCredentialRequestData = intent.getFido2CreateCredentialRequestOrNull()
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
val fido2CredentialAssertionRequest = intent.getFido2AssertionRequestOrNull()
val fido2GetCredentialsRequest = intent.getFido2GetCredentialsRequestOrNull()
Expand Down Expand Up @@ -318,25 +318,30 @@ class MainViewModel @Inject constructor(
)
}

fido2CredentialRequestData != null -> {
fido2CreateCredentialRequestData != null -> {
// Set the user's verification status when a new FIDO 2 request is received to force
// explicit verification if the user's vault is unlocked when the request is
// received.
fido2CredentialManager.isUserVerified = false
fido2CredentialManager.isUserVerified =
fido2CreateCredentialRequestData.isUserVerified
?: fido2CredentialManager.isUserVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Save(
fido2CreateCredentialRequest = fido2CredentialRequestData,
fido2CreateCredentialRequest = fido2CreateCredentialRequestData,
)

// Switch accounts if the selected user is not the active user.
if (authRepository.activeUserId != null &&
authRepository.activeUserId != fido2CredentialRequestData.userId
authRepository.activeUserId != fido2CreateCredentialRequestData.userId
) {
authRepository.switchAccount(fido2CredentialRequestData.userId)
authRepository.switchAccount(fido2CreateCredentialRequestData.userId)
}
}

fido2CredentialAssertionRequest != null -> {
fido2CredentialManager.isUserVerified =
fido2CredentialAssertionRequest.isUserVerified
?: false
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = fido2CredentialAssertionRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorImpl
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
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
Expand Down Expand Up @@ -44,6 +46,8 @@ object Fido2ProviderModule {
fido2CredentialManager: Fido2CredentialManager,
dispatcherManager: DispatcherManager,
intentManager: IntentManager,
biometricsEncryptionManager: BiometricsEncryptionManager,
featureFlagManager: FeatureFlagManager,
clock: Clock,
): Fido2ProviderProcessor =
Fido2ProviderProcessorImpl(
Expand All @@ -54,6 +58,8 @@ object Fido2ProviderModule {
fido2CredentialManager,
intentManager,
clock,
biometricsEncryptionManager,
featureFlagManager,
dispatcherManager,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ data class Fido2CreateCredentialRequest(
val packageName: String,
val signingInfo: SigningInfo,
val origin: String?,
val isUserVerified: Boolean?,
) : Parcelable {
val callingAppInfo: CallingAppInfo
get() = CallingAppInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ data class Fido2CredentialAssertionRequest(
val packageName: String,
val signingInfo: SigningInfo,
val origin: String?,
val isUserVerified: Boolean?,
) : Parcelable {
val callingAppInfo: CallingAppInfo
get() = CallingAppInfo(packageName, signingInfo, origin)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import android.os.Build
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.ClearCredentialUnsupportedException
import androidx.credentials.exceptions.CreateCredentialCancellationException
Expand All @@ -22,6 +24,7 @@ import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.BiometricPromptData
import androidx.credentials.provider.CreateEntry
import androidx.credentials.provider.CredentialEntry
import androidx.credentials.provider.ProviderClearCredentialStateRequest
Expand All @@ -34,9 +37,13 @@ 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.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
Expand All @@ -45,6 +52,7 @@ import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.launch
import java.time.Clock
import java.util.concurrent.atomic.AtomicInteger
import javax.crypto.Cipher

private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY"
const val GET_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY"
Expand All @@ -54,7 +62,7 @@ const val UNLOCK_ACCOUNT_INTENT = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOU
* The default implementation of [Fido2ProviderProcessor]. Its purpose is to handle FIDO2 related
* processing.
*/
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions")
@RequiresApi(Build.VERSION_CODES.S)
class Fido2ProviderProcessorImpl(
private val context: Context,
Expand All @@ -64,6 +72,8 @@ class Fido2ProviderProcessorImpl(
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
private val clock: Clock,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager,
) : Fido2ProviderProcessor {

Expand Down Expand Up @@ -127,7 +137,7 @@ class Fido2ProviderProcessorImpl(

private fun UserState.Account.toCreateEntry(isActive: Boolean): CreateEntry {
val accountName = name ?: email
return CreateEntry
val entryBuilder = CreateEntry
.Builder(
accountName = accountName,
pendingIntent = intentManager.createFido2CreationPendingIntent(
Expand All @@ -145,7 +155,20 @@ class Fido2ProviderProcessorImpl(
// Set the last used time to "now" so the active account is the default option in the
// system prompt.
.setLastUsedTime(if (isActive) clock.instant() else null)
.build()
.setAutoSelectAllowed(true)

return if (
!isVaultUnlocked ||
featureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyCreation) == false
) {
entryBuilder.build()
} else {
entryBuilder
.setBiometricPromptDataIfSupported(
cipher = biometricsEncryptionManager.getOrCreateCipher(userId),
)
.build()
}
}

override fun processGetCredentialRequest(
Expand Down Expand Up @@ -258,32 +281,81 @@ class Fido2ProviderProcessorImpl(
private fun List<Fido2CredentialAutofillView>.toCredentialEntries(
userId: String,
option: BeginGetPublicKeyCredentialOption,
): List<CredentialEntry> =
this
): List<CredentialEntry> {
return this
.map {
PublicKeyCredentialEntry
val publicKeyEntryBuilder = PublicKeyCredentialEntry
.Builder(
context = context,
username = it.userNameForUi ?: context.getString(R.string.no_username),
pendingIntent = intentManager
.createFido2GetCredentialPendingIntent(
action = GET_PASSKEY_INTENT,
userId = userId,
credentialId = it.credentialId.toString(),
cipherId = it.cipherId,
requestCode = requestCode.getAndIncrement(),
),
pendingIntent = intentManager.createFido2GetCredentialPendingIntent(
action = GET_PASSKEY_INTENT,
userId = userId,
credentialId = it.credentialId.toString(),
cipherId = it.cipherId,
requestCode = requestCode.getAndIncrement(),
),
beginGetPublicKeyCredentialOption = option,
)
.setIcon(
Icon
.createWithResource(
context,
R.drawable.ic_bw_passkey,
),
Icon.createWithResource(
context,
R.drawable.ic_bw_passkey,
),
)

if (!featureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication)) {
publicKeyEntryBuilder.build()
} else {
publicKeyEntryBuilder
.setBiometricPromptDataIfSupported(
cipher = biometricsEncryptionManager.getOrCreateCipher(userId),
)
.build()
}
}
}

private fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher?,
): PublicKeyCredentialEntry.Builder {
if (isBuildVersionBelow(Build.VERSION_CODES.VANILLA_ICE_CREAM)) return this
return BiometricPromptData.Builder().buildPromptDataOrNull(cipher)
?.let { setBiometricPromptData(it) }
?: this
}

private fun CreateEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher?,
): CreateEntry.Builder {
if (isBuildVersionBelow(Build.VERSION_CODES.VANILLA_ICE_CREAM)) return this
return BiometricPromptData.Builder().buildPromptDataOrNull(cipher)
?.let { setBiometricPromptData(it) }
?: this
}

@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
private fun BiometricPromptData.Builder.buildPromptDataOrNull(
cipher: Cipher?,
): BiometricPromptData? {
val promptBuilder = BiometricPromptData.Builder()
when {
cipher == null -> {
promptBuilder.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_WEAK,
)
}

else -> {
promptBuilder
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG,
)
.build()
.setCryptoObject(BiometricPrompt.CryptoObject(cipher))
}
}
return promptBuilder.build()
}

override fun processClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_USER_ID
* Checks if this [Intent] contains a [Fido2CreateCredentialRequest] related to an ongoing FIDO 2
* credential creation process.
*/
fun Intent.getFido2CredentialRequestOrNull(): Fido2CreateCredentialRequest? {
fun Intent.getFido2CreateCredentialRequestOrNull(): Fido2CreateCredentialRequest? {
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null

val systemRequest = PendingIntentHandler
Expand All @@ -39,6 +39,7 @@ fun Intent.getFido2CredentialRequestOrNull(): Fido2CreateCredentialRequest? {
packageName = systemRequest.callingAppInfo.packageName,
signingInfo = systemRequest.callingAppInfo.signingInfo,
origin = systemRequest.callingAppInfo.origin,
isUserVerified = systemRequest.biometricPromptResult?.isSuccessful ?: false,
)
}

Expand Down Expand Up @@ -67,6 +68,9 @@ fun Intent.getFido2AssertionRequestOrNull(): Fido2CredentialAssertionRequest? {
val userId: String = getStringExtra(EXTRA_KEY_USER_ID)
?: return null

val isUserVerified = systemRequest.biometricPromptResult?.isSuccessful
?: false

return Fido2CredentialAssertionRequest(
userId = userId,
cipherId = cipherId,
Expand All @@ -76,6 +80,7 @@ fun Intent.getFido2AssertionRequestOrNull(): Fido2CredentialAssertionRequest? {
packageName = systemRequest.callingAppInfo.packageName,
signingInfo = systemRequest.callingAppInfo.signingInfo,
origin = systemRequest.callingAppInfo.origin,
isUserVerified = isUserVerified,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ sealed class FlagKey<out T : Any> {
NewDevicePermanentDismiss,
NewDeviceTemporaryDismiss,
IgnoreEnvironmentCheck,
SingleTapPasskeyCreation,
SingleTapPasskeyAuthentication,
)
}
}
Expand Down Expand Up @@ -171,6 +173,24 @@ sealed class FlagKey<out T : Any> {
override val isRemotelyConfigured: Boolean = false
}

/**
* Data object holding the feature flag key to enable single tap passkey creation.
*/
data object SingleTapPasskeyCreation : FlagKey<Boolean>() {
override val keyName: String = "single-tap-passkey-creation"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}

/**
* Data object holding the feature flag key to enable single tap passkey authentication.
*/
data object SingleTapPasskeyAuthentication : FlagKey<Boolean>() {
override val keyName: String = "single-tap-passkey-authentication"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}

//region Dummy keys for testing
/**
* Data object holding the key for a [Boolean] flag to be used in tests.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,7 @@ class Fido2CompletionManagerImpl(
)
}

Fido2GetCredentialsResult.Error,
-> {
Fido2GetCredentialsResult.Error -> {
PendingIntentHandler.setGetCredentialException(
resultIntent,
GetCredentialUnknownException(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.NewDevicePermanentDismiss,
FlagKey.NewDeviceTemporaryDismiss,
FlagKey.IgnoreEnvironmentCheck,
FlagKey.SingleTapPasskeyCreation,
FlagKey.SingleTapPasskeyAuthentication,
-> BooleanFlagItem(
label = flagKey.getDisplayLabel(),
key = flagKey as FlagKey<Boolean>,
Expand Down Expand Up @@ -87,4 +89,7 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.NewDevicePermanentDismiss -> stringResource(R.string.new_device_permanent_dismiss)
FlagKey.NewDeviceTemporaryDismiss -> stringResource(R.string.new_device_temporary_dismiss)
FlagKey.IgnoreEnvironmentCheck -> stringResource(R.string.ignore_environment_check)
FlagKey.SingleTapPasskeyCreation -> stringResource(R.string.single_tap_passkey_creation)
FlagKey.SingleTapPasskeyAuthentication ->
stringResource(R.string.single_tap_passkey_authentication)
}
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1112,4 +1112,6 @@ Do you want to switch to this account?</string>
<string name="review_flow_launched">Review flow launched!</string>
<string name="copy_private_key">Copy private key</string>
<string name="you_can_change_your_account_email_on_the_bitwarden_web_app">You can change your account email on the Bitwarden web app.</string>
<string name="single_tap_passkey_creation">Single tap passkey creation</string>
<string name="single_tap_passkey_authentication">Single tap passkey sign-on</string>
</resources>
Loading

0 comments on commit 4558b28

Please sign in to comment.