[PM-8137] Allow registering a passkey to a new cipher (#3329)

This commit is contained in:
Patrick Honkonen 2024-07-05 11:35:28 -04:00 committed by GitHub
parent a2572d996b
commit d182b4edf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1711 additions and 66 deletions

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.autofill.fido2.di
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
@ -11,6 +12,7 @@ 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.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.Module
import dagger.Provides
@ -48,11 +50,15 @@ object Fido2ProviderModule {
fun provideFido2CredentialManager(
assetManager: AssetManager,
digitalAssetLinkService: DigitalAssetLinkService,
vaultSdkSource: VaultSdkSource,
fido2CredentialStore: Fido2CredentialStore,
json: Json,
): Fido2CredentialManager =
Fido2CredentialManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
vaultSdkSource = vaultSdkSource,
fido2CredentialStore = fido2CredentialStore,
json = json,
)
}

View file

@ -26,11 +26,11 @@ interface Fido2CredentialManager {
): PublicKeyCredentialCreationOptions?
/**
* Attempt to create a FIDO2 credential from the given [credentialRequest] and associate it to
* the given [cipherView].
* Register a new FIDO 2 credential to a users vault.
*/
fun createCredentialForCipher(
credentialRequest: Fido2CredentialRequest,
cipherView: CipherView,
suspend fun registerFido2Credential(
userId: String,
fido2CredentialRequest: Fido2CredentialRequest,
selectedCipherView: CipherView,
): Fido2CreateCredentialResult
}

View file

@ -2,6 +2,10 @@ package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.fido.ClientData
import com.bitwarden.sdk.CheckUserResult
import com.bitwarden.sdk.CipherViewWrapper
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions
@ -13,9 +17,15 @@ import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.platform.util.getCallingAppApkFingerprint
import com.x8bit.bitwarden.data.platform.util.getAppOrigin
import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
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.util.toAndroidAttestationResponse
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val ALLOW_LIST_FILE_NAME = "fido2_privileged_allow_list.json"
@ -26,8 +36,58 @@ private const val ALLOW_LIST_FILE_NAME = "fido2_privileged_allow_list.json"
class Fido2CredentialManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
private val vaultSdkSource: VaultSdkSource,
private val fido2CredentialStore: Fido2CredentialStore,
private val json: Json,
) : Fido2CredentialManager {
) : Fido2CredentialManager,
Fido2CredentialStore by fido2CredentialStore {
override suspend fun registerFido2Credential(
userId: String,
fido2CredentialRequest: Fido2CredentialRequest,
selectedCipherView: CipherView,
): Fido2CreateCredentialResult {
val clientData = if (fido2CredentialRequest.callingAppInfo.isOriginPopulated()) {
fido2CredentialRequest.callingAppInfo.getAppSigningSignatureFingerprint()
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: return Fido2CreateCredentialResult.Error(
exception = CreateCredentialUnknownException(
errorMessage = "Application contains multiple signing certificates.",
),
)
} else {
ClientData.DefaultWithExtraData(
androidPackageName = fido2CredentialRequest
.callingAppInfo
.getAppOrigin(),
)
}
val origin = fido2CredentialRequest.origin
?: fido2CredentialRequest.callingAppInfo.getAppOrigin()
return vaultSdkSource.registerFido2Credential(
request = RegisterFido2CredentialRequest(
userId = userId,
origin = origin,
requestJson = """{"publicKey": ${fido2CredentialRequest.requestJson}}""",
clientData = clientData,
selectedCipherView = selectedCipherView,
isUserVerificationSupported = true,
),
fido2CredentialStore = this,
// TODO: [PM-8137] Determine if user verification is supported
checkUser = { _, _ -> CheckUserResult(true, true) },
checkUserAndPickCredential = { _, _ -> CipherViewWrapper(selectedCipherView) },
)
.map { it.toAndroidAttestationResponse() }
.mapCatching { json.encodeToString(it) }
.fold(
onSuccess = { Fido2CreateCredentialResult.Success(it) },
onFailure = {
Fido2CreateCredentialResult.Error(CreateCredentialUnknownException())
},
)
}
override suspend fun validateOrigin(
fido2CredentialRequest: Fido2CredentialRequest,
@ -72,10 +132,13 @@ class Fido2CredentialManagerImpl(
?: return Fido2ValidateOriginResult.Error.ApplicationNotFound
}
.map { matchingStatements ->
matchingStatements
.filterMatchingAppSignaturesOrNull(
signature = callingAppInfo.getCallingAppApkFingerprint(),
)
callingAppInfo.getSignatureFingerprintAsHexString()
?.let { certificateFingerprint ->
matchingStatements
.filterMatchingAppSignaturesOrNull(
signature = certificateFingerprint,
)
}
?: return Fido2ValidateOriginResult.Error.ApplicationNotVerified
}
.fold(
@ -103,14 +166,6 @@ class Fido2CredentialManagerImpl(
onFailure = { Fido2ValidateOriginResult.Error.Unknown },
)
override fun createCredentialForCipher(
credentialRequest: Fido2CredentialRequest,
cipherView: CipherView,
): Fido2CreateCredentialResult {
// TODO [PM-8137]: Create and save passkey to cipher.
return Fido2CreateCredentialResult.Error(CreateCredentialUnknownException())
}
/**
* Returns statements targeting the calling Android application, or null.
*/

View file

@ -0,0 +1,64 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the authenticator's response to a clients request for the creation of a new public
* key credential.
*
* Refer to https://w3c.github.io/webauthn/#iface-authenticatorattestationresponse for details.
*/
@Serializable
data class Fido2AttestationResponse(
@SerialName("id")
val id: String,
@SerialName("type")
val type: String,
@SerialName("rawId")
val rawId: String,
@SerialName("response")
val response: RegistrationResponse,
@SerialName("clientExtensionResults")
val clientExtensionResults: ClientExtensionResults?,
@SerialName("authenticatorAttachment")
val authenticatorAttachment: String?,
) {
/**
* Represents the registration result data expected from a FIDO2 credential registration
* request.
*/
@Serializable
data class RegistrationResponse(
@SerialName("clientDataJSON")
val clientDataJson: String,
@SerialName("attestationObject")
val attestationObject: String,
@SerialName("transports")
val transports: List<String>?,
@SerialName("publicKeyAlgorithm")
val publicKeyAlgorithm: Long,
@SerialName("publicKey")
val publicKey: String?,
@SerialName("authenticatorData")
val authenticatorData: String?,
)
/**
* Represents an extension processing result produced by the client.
*/
@Serializable
data class ClientExtensionResults(
@SerialName("credProps")
val credentialProperties: CredentialProperties,
) {
/**
* Represents properties for newly created credential.
*/
@Serializable
data class CredentialProperties(
@SerialName("rk")
val residentKey: Boolean,
)
}
}

View file

@ -44,3 +44,9 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
)
}
}
/**
* Returns true when the cipher is not deleted and contains at least one FIDO 2 credential.
*/
val CipherView.isActiveWithFido2Credentials: Boolean
get() = deletedDate == null && login?.fido2Credentials.isNullOrEmpty().not()

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.util
import android.util.Base64
import androidx.credentials.provider.CallingAppInfo
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull
@ -18,15 +19,13 @@ fun CallingAppInfo.getFido2RpIdOrNull(): String? =
}
/**
* Returns the signing certificate hash formatted as a hex string.
* Returns the application's signing certificate hash formatted as a hex string if it has a single
* signing certificate. Otherwise `null` is returned.
*/
@OptIn(ExperimentalStdlibApi::class)
fun CallingAppInfo.getCallingAppApkFingerprint(): String {
val cert = signingInfo.apkContentsSigners[0].toByteArray()
val md = MessageDigest.getInstance("SHA-256")
val certHash = md.digest(cert)
return certHash
.joinToString(":") { b ->
fun CallingAppInfo.getSignatureFingerprintAsHexString(): String? {
return getAppSigningSignatureFingerprint()
?.joinToString(":") { b ->
b.toHexString(HexFormat.UpperCase)
}
}
@ -57,3 +56,27 @@ fun CallingAppInfo.validatePrivilegedApp(allowList: String): Fido2ValidateOrigin
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
}
}
/**
* Returns the signing key hash of the calling application formatted as an origin URI for an
* unprivileged application.
*/
fun CallingAppInfo.getAppOrigin(): String {
val certHash = getAppSigningSignatureFingerprint()
return "android:apk-key-hash:${Base64.encodeToString(certHash, ENCODING_FLAGS)}"
}
/**
* Returns a [ByteArray] containing the application's signing certificate signature hash. If
* multiple signers are identified `null` is returned.
*/
fun CallingAppInfo.getAppSigningSignatureFingerprint(): ByteArray? {
if (signingInfo.hasMultipleSigners()) return null
val signature = signingInfo.apkContentsSigners.first()
val md = MessageDigest.getInstance(SHA_ALGORITHM)
return md.digest(signature.toByteArray())
}
private const val SHA_ALGORITHM = "SHA-256"
private const val ENCODING_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING

View file

@ -8,6 +8,15 @@ import com.bitwarden.bitwarden.InitUserCryptoRequest
import com.bitwarden.bitwarden.UpdatePasswordResponse
import com.bitwarden.core.DateTime
import com.bitwarden.crypto.TrustDeviceResponse
import com.bitwarden.fido.CheckUserOptions
import com.bitwarden.fido.ClientData
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse
import com.bitwarden.sdk.CheckUserResult
import com.bitwarden.sdk.CipherViewWrapper
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.sdk.UiHint
import com.bitwarden.send.Send
import com.bitwarden.send.SendView
import com.bitwarden.vault.Attachment
@ -18,12 +27,14 @@ import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.Collection
import com.bitwarden.vault.CollectionView
import com.bitwarden.vault.Fido2CredentialNewView
import com.bitwarden.vault.Folder
import com.bitwarden.vault.FolderView
import com.bitwarden.vault.PasswordHistory
import com.bitwarden.vault.PasswordHistoryView
import com.bitwarden.vault.TotpResponse
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import java.io.File
/**
@ -405,4 +416,69 @@ interface VaultSdkSource {
ciphers: List<Cipher>,
format: ExportFormat,
): Result<String>
/**
* Register a new FIDO 2 credential to a cipher.
*
* @param checkUser Receives [CheckUserOptions] and [UiHint] indicating what interactions and
* prompts must be presented to the user during registration. A [CheckUserResult] is expected
* when interactions are completed.
* @param checkUserAndPickCredential Receives [CheckUserOptions] indicating user
* verification requirements and a [Fido2CredentialNewView] representing the newly registered
* credential. A [CipherViewWrapper] containing the selectedCipherView updated with the
* [Fido2CredentialNewView] is expected in response.
*
* @return Result of the FIDO 2 credential registration. If successful, a
* [PublicKeyCredentialAuthenticatorAttestationResponse] is provided.
*/
suspend fun registerFido2Credential(
request: RegisterFido2CredentialRequest,
fido2CredentialStore: Fido2CredentialStore,
checkUser: suspend (CheckUserOptions, UiHint?) -> CheckUserResult,
checkUserAndPickCredential: suspend (
options: CheckUserOptions,
newCredential: Fido2CredentialNewView,
) -> CipherViewWrapper,
): Result<PublicKeyCredentialAuthenticatorAttestationResponse>
/**
* Authenticate a user with a FIDO 2 credential.
*
* @param userId Active user's ID.
* @param origin Origin of the relying party request.
* @param requestJson JSON provided by the relying party.
* @param clientData Client metadata about the relying party or calling application.
* @param isVerificationSupported Whether user verification can be performed on this device.
* @param checkUser Receives [CheckUserOptions] and [UiHint] indicating what interactions and
* prompts must be presented to the user for registration to complete. A [CheckUserResult] is
* expected when interactions are completed.
* @param pickCredentialForAuthentication Receives a collection of [CipherView]s that can be
* chosen to perform authentication with.
*
* @return Result of the FIDO 2 credential registration. If successful, a
* [PublicKeyCredentialAuthenticatorAttestationResponse] is provided.
*/
@Suppress("LongParameterList")
suspend fun authenticateFido2Credential(
userId: String,
origin: String,
requestJson: String,
clientData: ClientData,
isVerificationSupported: Boolean,
checkUser: suspend (CheckUserOptions, UiHint?) -> CheckUserResult,
pickCredentialForAuthentication: suspend (List<CipherView>) -> CipherViewWrapper,
fido2CredentialStore: Fido2CredentialStore,
): Result<PublicKeyCredentialAuthenticatorAssertionResponse>
/**
* Decrypt a list of FIDO 2 credential autofill view items associated with the given
* [cipherViews].
*
* This should only be called after a successful call to [initializeCrypto] for the associated
* user.
*/
suspend fun decryptFido2CredentialAutofillViews(
userId: String,
vararg cipherViews: CipherView,
): Result<List<Fido2CredentialAutofillView>>
}

View file

@ -7,9 +7,18 @@ import com.bitwarden.bitwarden.InitUserCryptoRequest
import com.bitwarden.bitwarden.UpdatePasswordResponse
import com.bitwarden.core.DateTime
import com.bitwarden.crypto.TrustDeviceResponse
import com.bitwarden.fido.CheckUserOptions
import com.bitwarden.fido.ClientData
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse
import com.bitwarden.sdk.BitwardenException
import com.bitwarden.sdk.CheckUserResult
import com.bitwarden.sdk.CipherViewWrapper
import com.bitwarden.sdk.Client
import com.bitwarden.sdk.ClientVault
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.sdk.UiHint
import com.bitwarden.send.Send
import com.bitwarden.send.SendView
import com.bitwarden.vault.Attachment
@ -19,13 +28,21 @@ import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.Collection
import com.bitwarden.vault.CollectionView
import com.bitwarden.vault.Fido2CredentialNewView
import com.bitwarden.vault.Folder
import com.bitwarden.vault.FolderView
import com.bitwarden.vault.PasswordHistory
import com.bitwarden.vault.PasswordHistoryView
import com.bitwarden.vault.TotpResponse
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.util.asFailure
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.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import java.io.File
/**
@ -432,6 +449,102 @@ class VaultSdkSourceImpl(
)
}
override suspend fun registerFido2Credential(
request: RegisterFido2CredentialRequest,
fido2CredentialStore: Fido2CredentialStore,
checkUser: suspend (CheckUserOptions, UiHint?) -> CheckUserResult,
checkUserAndPickCredential: suspend (
options: CheckUserOptions,
newCredential: Fido2CredentialNewView,
) -> CipherViewWrapper,
): Result<PublicKeyCredentialAuthenticatorAttestationResponse> = runCatching {
callbackFlow {
try {
val client = getClient(request.userId)
.platform()
.fido2()
.client(
userInterface = Fido2CredentialRegistrationUserInterfaceImpl(
isVerificationSupported = request.isUserVerificationSupported,
checkUser = checkUser,
checkUserAndPickCredentialForCreation = checkUserAndPickCredential,
),
credentialStore = fido2CredentialStore,
)
val result = client
.register(
origin = request.origin,
request = request.requestJson,
clientData = request.clientData,
)
send(result)
} catch (e: BitwardenException) {
e.asFailure()
} finally {
close()
}
awaitClose()
}
.first()
}
@Suppress("MaxLineLength")
override suspend fun authenticateFido2Credential(
userId: String,
origin: String,
requestJson: String,
clientData: ClientData,
isVerificationSupported: Boolean,
checkUser: suspend (CheckUserOptions, UiHint?) -> CheckUserResult,
pickCredentialForAuthentication: suspend (List<CipherView>) -> CipherViewWrapper,
fido2CredentialStore: Fido2CredentialStore,
): Result<PublicKeyCredentialAuthenticatorAssertionResponse> = runCatching {
callbackFlow {
try {
val client = getClient(userId)
.platform()
.fido2()
.client(
userInterface = Fido2CredentialAuthenticationUserInterfaceImpl(
isVerificationSupported = isVerificationSupported,
checkUser = checkUser,
pickCredentialForAuthentication = pickCredentialForAuthentication,
),
credentialStore = fido2CredentialStore,
)
val result = client.authenticate(
origin = origin,
request = requestJson,
clientData = clientData,
)
send(result)
} catch (e: BitwardenException) {
e.asFailure()
} finally {
close()
}
awaitClose()
}
.first()
}
override suspend fun decryptFido2CredentialAutofillViews(
userId: String,
vararg cipherViews: CipherView,
): Result<List<Fido2CredentialAutofillView>> = runCatching {
cipherViews.flatMap {
getClient(userId)
.platform()
.fido2()
.decryptFido2AutofillCredentials(it)
}
}
private suspend fun getClient(
userId: String,
): Client = sdkClientManager.getOrCreateClient(userId = userId)

View file

@ -1,10 +1,14 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.di
import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.BitwardenFeatureFlagManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.BitwardenFeatureFlagManagerImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSourceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialStoreImpl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,4 +35,16 @@ object VaultSdkModule {
@Singleton
fun providesBitwardenFeatureFlagManager(): BitwardenFeatureFlagManager =
BitwardenFeatureFlagManagerImpl()
@Provides
@Singleton
fun providesFido2CredentialStore(
vaultSdkSource: VaultSdkSource,
authRepository: AuthRepository,
vaultRepository: VaultRepository,
): Fido2CredentialStore = Fido2CredentialStoreImpl(
vaultSdkSource = vaultSdkSource,
authRepository = authRepository,
vaultRepository = vaultRepository,
)
}

View file

@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.fido.CheckUserOptions
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
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Implementation of [Fido2UserInterface] for authenticating with a FIDO 2 credential.
*/
@OmitFromCoverage
class Fido2CredentialAuthenticationUserInterfaceImpl(
private val isVerificationSupported: Boolean,
private val checkUser: suspend (CheckUserOptions, UiHint?) -> CheckUserResult,
private val pickCredentialForAuthentication: suspend (List<CipherView>) -> CipherViewWrapper,
) : Fido2UserInterface {
override suspend fun checkUser(
options: CheckUserOptions,
hint: UiHint,
): CheckUserResult = checkUser.invoke(options, hint)
override suspend fun checkUserAndPickCredentialForCreation(
options: CheckUserOptions,
newCredential: Fido2CredentialNewView,
): CipherViewWrapper = throw IllegalStateException()
override suspend fun isVerificationEnabled(): Boolean = isVerificationSupported
override suspend fun pickCredentialForAuthentication(
availableCredentials: List<CipherView>,
): CipherViewWrapper = pickCredentialForAuthentication.invoke(availableCredentials)
}

View file

@ -0,0 +1,40 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.fido.CheckUserOptions
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
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Implementation of [Fido2UserInterface] for registering new FIDO 2 credentials.
*/
@OmitFromCoverage
class Fido2CredentialRegistrationUserInterfaceImpl(
private val isVerificationSupported: Boolean,
private val checkUser: suspend (CheckUserOptions, UiHint?) -> CheckUserResult,
private val checkUserAndPickCredentialForCreation: suspend (
options: CheckUserOptions,
newCredential: Fido2CredentialNewView,
) -> CipherViewWrapper,
) : Fido2UserInterface {
override suspend fun checkUser(
options: CheckUserOptions,
hint: UiHint,
): CheckUserResult = checkUser.invoke(options, hint)
override suspend fun checkUserAndPickCredentialForCreation(
options: CheckUserOptions,
newCredential: Fido2CredentialNewView,
): CipherViewWrapper = checkUserAndPickCredentialForCreation.invoke(options, newCredential)
override suspend fun isVerificationEnabled(): Boolean = isVerificationSupported
override suspend fun pickCredentialForAuthentication(
availableCredentials: List<CipherView>,
): CipherViewWrapper = throw IllegalStateException()
}

View file

@ -0,0 +1,105 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
/**
* Primary implementation of [Fido2CredentialStore].
*/
@OmitFromCoverage
class Fido2CredentialStoreImpl(
private val vaultSdkSource: VaultSdkSource,
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
) : Fido2CredentialStore {
/**
* Return all active ciphers that contain FIDO 2 credentials.
*/
override suspend fun allCredentials(): List<CipherView> {
vaultRepository.sync()
return vaultRepository.ciphersStateFlow.value.data
?.filter { it.isActiveWithFido2Credentials }
?: emptyList()
}
/**
* Returns ciphers that contain FIDO 2 credentials for the given [ripId] with the provided
* [ids].
*
* @param ids Optional list of FIDO 2 credential ID's to find.
* @param ripId Relying Party ID to find.
*/
override suspend fun findCredentials(ids: List<ByteArray>?, ripId: String): List<CipherView> {
val userId = getActiveUserIdOrThrow()
vaultRepository.sync()
val ciphersWithFido2Credentials = vaultRepository.ciphersStateFlow.value.data
?.filter { it.isActiveWithFido2Credentials }
.orEmpty()
return vaultSdkSource
.decryptFido2CredentialAutofillViews(
userId = userId,
cipherViews = ciphersWithFido2Credentials.toTypedArray(),
)
.map { decryptedFido2CredentialViews ->
decryptedFido2CredentialViews.filterMatchingCredentials(
ids,
ripId,
)
}
.map { matchingFido2Credentials ->
ciphersWithFido2Credentials.filter { cipherView ->
matchingFido2Credentials.any { it.cipherId == cipherView.id }
}
}
.fold(
onSuccess = { it },
onFailure = { throw it },
)
}
/**
* Save the provided [cred] to the users vault.
*/
override suspend fun saveCredential(cred: Cipher) {
val userId = getActiveUserIdOrThrow()
vaultSdkSource
.decryptCipher(userId, cred)
.map { decryptedCipherView ->
decryptedCipherView.id
?.let { vaultRepository.updateCipher(it, decryptedCipherView) }
?: vaultRepository.createCipher(decryptedCipherView)
}
.onFailure { throw it }
}
private fun getActiveUserIdOrThrow() = authRepository.userStateFlow.value?.activeUserId
?: throw IllegalStateException("Active user is required.")
/**
* Return a filtered list containing elements that match the given [relyingPartyId] and a
* credential ID contained in [credentialIds].
*/
private fun List<Fido2CredentialAutofillView>.filterMatchingCredentials(
credentialIds: List<ByteArray>?,
relyingPartyId: String,
): List<Fido2CredentialAutofillView> {
val skipCredentialIdFiltering = credentialIds.isNullOrEmpty()
return filter { fido2CredentialView ->
fido2CredentialView.rpId == relyingPartyId &&
(skipCredentialIdFiltering ||
credentialIds?.contains(fido2CredentialView.credentialId) == true)
}
}
}

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.vault.CipherView
/**
* Models the result of querying for ciphers with FIDO 2 credentials.
*/
sealed class FindFido2CredentialsResult {
/**
* Indicates the query was executed successfully.
*/
data class Success(val cipherViews: List<CipherView>) : FindFido2CredentialsResult()
/**
* Indicates the query was not executed successfully.
*/
data object Error : FindFido2CredentialsResult()
}

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.fido.ClientData
import com.bitwarden.vault.CipherView
/**
* Models a FIDO 2 registration request to the Bitwarden SDK.
*
* @param userId User whom the credential is being registered to.
* @param origin Origin of the Relying Party. This can either be a Relying Party's URL or their
* application fingerprint.
* @param requestJson Registration request JSON received from the OS.
* @param clientData Metadata containing either privileged application certificate hash or Android
* package name of the Relying Party.
* @param selectedCipherView [CipherView] the new FIDO 2 credential will be saved to.
* @param isUserVerificationSupported Whether the device or application are capable of performing
* user verification.
*/
data class RegisterFido2CredentialRequest(
val userId: String,
val origin: String,
val requestJson: String,
val clientData: ClientData,
val selectedCipherView: CipherView,
val isUserVerificationSupported: Boolean,
)

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
/**
* Models the result of saving a FIDO 2 credential.
*/
sealed class SaveCredentialResult {
/**
* Indicates the credential has been saved.
*/
data object Success : SaveCredentialResult()
/**
* Indicates the credential was not saved.
*/
data object Error : SaveCredentialResult()
}

View file

@ -0,0 +1,49 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.data.vault.datasource.sdk.util
import android.util.Base64
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2AttestationResponse
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Converts the SDK attestation response to a [Fido2AttestationResponse] that can be serialized into
* the expected system JSON.
*/
@Suppress("MaxLineLength")
fun PublicKeyCredentialAuthenticatorAttestationResponse.toAndroidAttestationResponse(): Fido2AttestationResponse =
Fido2AttestationResponse(
id = id,
type = ty,
rawId = rawId.base64EncodeForFido2Response(),
response = Fido2AttestationResponse.RegistrationResponse(
clientDataJson = response.clientDataJson.base64EncodeForFido2Response(),
attestationObject = response.attestationObject.base64EncodeForFido2Response(),
transports = response.transports,
publicKeyAlgorithm = response.publicKeyAlgorithm,
publicKey = response.publicKey?.base64EncodeForFido2Response(),
authenticatorData = response.authenticatorData.base64EncodeForFido2Response(),
),
clientExtensionResults = clientExtensionResults
.credProps
?.rk
?.let {
Fido2AttestationResponse.ClientExtensionResults(
credentialProperties = Fido2AttestationResponse
.ClientExtensionResults
.CredentialProperties(residentKey = it),
)
},
authenticatorAttachment = authenticatorAttachment,
)
/**
* Attestation response fields of type [ByteArray] must be base 64 encoded in a url safe format
* without newline or padding symbols according to the FIDO 2 spec.
*/
private fun ByteArray.base64EncodeForFido2Response(): String =
Base64.encodeToString(
this,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
)

View file

@ -6,6 +6,7 @@ import com.bitwarden.bitwarden.InitOrgCryptoRequest
import com.bitwarden.bitwarden.InitUserCryptoMethod
import com.bitwarden.core.DateTime
import com.bitwarden.crypto.Kdf
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.send.Send
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
@ -105,6 +106,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.time.Clock
import java.time.temporal.ChronoUnit
@ -135,6 +137,7 @@ class VaultRepositoryImpl(
private val userLogoutManager: UserLogoutManager,
pushManager: PushManager,
private val clock: Clock,
private val json: Json,
dispatcherManager: DispatcherManager,
) : VaultRepository,
CipherManager by cipherManager,
@ -858,6 +861,22 @@ class VaultRepositoryImpl(
)
}
/**
* Return a filtered list containing elements that match the given [relyingPartyId] and a
* credential ID contained in [credentialIds].
*/
private fun List<Fido2CredentialAutofillView>.filterMatchingCredentials(
credentialIds: List<ByteArray>,
relyingPartyId: String,
): List<Fido2CredentialAutofillView> {
val skipCredentialIdFiltering = credentialIds.isEmpty()
return filter { fido2CredentialView ->
fido2CredentialView.rpId == relyingPartyId &&
(skipCredentialIdFiltering ||
credentialIds.contains(fido2CredentialView.credentialId))
}
}
/**
* Checks if the given [userId] has an associated encrypted PIN key but not a pin-protected user
* key. This indicates a scenario in which a user has requested PIN unlocking but requires

View file

@ -21,6 +21,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import java.time.Clock
import javax.inject.Singleton
@ -50,6 +51,7 @@ object VaultRepositoryModule {
pushManager: PushManager,
userLogoutManager: UserLogoutManager,
clock: Clock,
json: Json,
): VaultRepository = VaultRepositoryImpl(
syncService = syncService,
sendsService = sendsService,
@ -67,5 +69,6 @@ object VaultRepositoryModule {
pushManager = pushManager,
userLogoutManager = userLogoutManager,
clock = clock,
json = json,
)
}

View file

@ -22,6 +22,7 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
@ -39,6 +40,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager
import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
@ -64,6 +66,7 @@ fun VaultAddEditScreen(
permissionsManager: PermissionsManager = LocalPermissionsManager.current,
intentManager: IntentManager = LocalIntentManager.current,
exitManager: ExitManager = LocalExitManager.current,
fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current,
onNavigateToManualCodeEntryScreen: () -> Unit,
onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit,
onNavigateToAttachments: (cipherId: String) -> Unit,
@ -108,6 +111,10 @@ fun VaultAddEditScreen(
"https://bitwarden.com/help/managing-items/#protect-individual-items".toUri(),
)
}
is VaultAddEditEvent.CompleteFido2Create -> {
fido2CompletionManager.completeFido2Create(event.result)
}
}
}

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit
import android.os.Parcelable
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
@ -9,6 +10,8 @@ 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.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@ -348,6 +351,13 @@ class VaultAddEditViewModel @Inject constructor(
)
}
specialCircumstanceManager.specialCircumstance
?.toFido2RequestOrNull()
?.let { request ->
registerFido2Credential(request, content)
return@onContent
}
viewModelScope.launch {
when (val vaultAddEditType = state.vaultAddEditType) {
is VaultAddEditType.AddItem -> {
@ -371,6 +381,34 @@ class VaultAddEditViewModel @Inject constructor(
}
}
private fun registerFido2Credential(
request: Fido2CredentialRequest,
content: VaultAddEditState.ViewState.Content,
) {
viewModelScope.launch {
val activeUserId = authRepository.activeUserId
?: run {
sendAction(
VaultAddEditAction.Internal.Fido2RegisterCredentialResultReceive(
result = Fido2CreateCredentialResult.Error(
exception = CreateCredentialUnknownException(),
),
),
)
return@launch
}
val result: Fido2CreateCredentialResult =
fido2CredentialManager.registerFido2Credential(
userId = activeUserId,
fido2CredentialRequest = request,
selectedCipherView = content.toCipherView(),
)
sendAction(
VaultAddEditAction.Internal.Fido2RegisterCredentialResultReceive(result),
)
}
}
private fun handleAttachmentsClick() {
onEdit { sendEvent(VaultAddEditEvent.NavigateToAttachments(it.vaultItemId)) }
}
@ -1099,6 +1137,10 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditAction.Internal.PasswordBreachReceive -> {
handlePasswordBreachReceive(action)
}
is VaultAddEditAction.Internal.Fido2RegisterCredentialResultReceive -> {
handleFido2RegisterCredentialResultReceive(action)
}
}
}
@ -1347,6 +1389,22 @@ class VaultAddEditViewModel @Inject constructor(
}
}
private fun handleFido2RegisterCredentialResultReceive(
action: VaultAddEditAction.Internal.Fido2RegisterCredentialResultReceive,
) {
mutableStateFlow.update { it.copy(dialog = null) }
when (action.result) {
is Fido2CreateCredentialResult.Error -> {
sendEvent(VaultAddEditEvent.ShowToast(R.string.an_error_has_occurred.asText()))
}
is Fido2CreateCredentialResult.Success -> {
sendEvent(VaultAddEditEvent.ShowToast(R.string.item_updated.asText()))
}
}
sendEvent(VaultAddEditEvent.CompleteFido2Create(action.result))
}
//endregion Internal Type Handlers
//region Utility Functions
@ -1943,6 +2001,15 @@ sealed class VaultAddEditEvent {
data class NavigateToGeneratorModal(
val generatorMode: GeneratorMode.Modal,
) : VaultAddEditEvent()
/**
* Complete the current FIDO 2 credential creation process.
*
* @property result the result of FIDO 2 credential creation.
*/
data class CompleteFido2Create(
val result: Fido2CreateCredentialResult,
) : VaultAddEditEvent()
}
/**
@ -2426,5 +2493,12 @@ sealed class VaultAddEditAction {
data class DeleteCipherReceive(
val result: DeleteCipherResult,
) : Internal()
/**
* Indicates that the FIDO 2 registration result has been received.
*/
data class Fido2RegisterCredentialResultReceive(
val result: Fido2CreateCredentialResult,
) : Internal()
}
}

View file

@ -297,10 +297,25 @@ class VaultItemListingViewModel @Inject constructor(
if (state.isFido2Creation) {
val cipherView = getCipherViewOrNull(action.id) ?: return
val credentialRequest = state.fido2CredentialRequest ?: return
fido2CredentialManager.createCredentialForCipher(
credentialRequest = credentialRequest,
cipherView = cipherView,
)
mutableStateFlow.update {
it.copy(
dialogState = VaultItemListingState.DialogState.Loading(
message = R.string.saving.asText(),
),
)
}
viewModelScope.launch {
val result = fido2CredentialManager.registerFido2Credential(
state.activeAccountSummary.userId,
fido2CredentialRequest = credentialRequest,
selectedCipherView = cipherView,
)
sendAction(
VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive(
result = result,
),
)
}
return
}
@ -524,6 +539,10 @@ class VaultItemListingViewModel @Inject constructor(
is VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive -> {
handleValidateFido2OriginResultReceive(action)
}
is VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive -> {
handleFido2RegisterCredentialResultReceive(action)
}
}
}
@ -739,6 +758,22 @@ class VaultItemListingViewModel @Inject constructor(
}
}
private fun handleFido2RegisterCredentialResultReceive(
action: VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive,
) {
mutableStateFlow.update { it.copy(dialogState = null) }
when (action.result) {
is Fido2CreateCredentialResult.Error -> {
sendEvent(VaultItemListingEvent.ShowToast(R.string.an_error_has_occurred.asText()))
}
is Fido2CreateCredentialResult.Success -> {
sendEvent(VaultItemListingEvent.ShowToast(R.string.item_updated.asText()))
}
}
sendEvent(VaultItemListingEvent.CompleteFido2Create(action.result))
}
private fun handleValidateFido2OriginResultReceive(
action: VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive,
) {
@ -1485,6 +1520,13 @@ sealed class VaultItemListingsAction {
data class ValidateFido2OriginResultReceive(
val result: Fido2ValidateOriginResult,
) : Internal()
/**
* Indicates that a result for FIDO 2 credential registration has been received.
*/
data class Fido2RegisterCredentialResultReceive(
val result: Fido2CreateCredentialResult,
) : Internal()
}
}

View file

@ -2,28 +2,39 @@ package com.x8bit.bitwarden.data.autofill.fido2.manager
import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.util.Base64
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.fido.ClientData
import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2AttestationResponse
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
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
import com.x8bit.bitwarden.data.platform.util.asSuccess
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.createMockPublicKeyCredentialCreationOptions
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
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.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
@ -57,6 +68,7 @@ class Fido2CredentialManagerTest {
}
private val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature("0987654321ABCDEF"))
every { hasMultipleSigners() } returns false
}
private val mockUnprivilegedCallingAppInfo = CallingAppInfo(
packageName = "com.x8bit.bitwarden",
@ -68,24 +80,28 @@ class Fido2CredentialManagerTest {
every { requestJson } returns "{}"
}
private val mockMessageDigest = mockk<MessageDigest> {
every { digest(any()) } returns "0987654321ABCDEF".toByteArray()
every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray()
}
private val mockVaultSdkSource = mockk<VaultSdkSource>()
private val mockFido2CredentialStore = mockk<Fido2CredentialStore>()
@BeforeEach
fun setUp() {
mockkStatic(MessageDigest::class)
mockkStatic(MessageDigest::class, Base64::class)
every { MessageDigest.getInstance(any()) } returns mockMessageDigest
fido2CredentialManager = Fido2CredentialManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
vaultSdkSource = mockVaultSdkSource,
fido2CredentialStore = mockFido2CredentialStore,
json = json,
)
}
@AfterEach
fun tearDown() {
unmockkStatic(MessageDigest::class)
unmockkStatic(MessageDigest::class, Base64::class)
}
@Test
@ -275,34 +291,252 @@ class Fido2CredentialManagerTest {
@Suppress("MaxLineLength")
@Test
fun `getPasskeyCreateOptionsOrNull should return null when IllegalArgumentException is thrown`() =
fun `getPasskeyCreateOptionsOrNull should return null when IllegalArgumentException is thrown`() {
every {
json.decodeFromString<PublicKeyCredentialCreationOptions>(any())
} throws IllegalArgumentException()
assertNull(fido2CredentialManager.getPasskeyCreateOptionsOrNull(requestJson = ""))
}
@Suppress("MaxLineLength")
@Test
fun `registerFido2Credential should construct ClientData DefaultWithCustomHash when callingAppInfo origin is populated`() =
runTest {
every {
json.decodeFromString<PublicKeyCredentialCreationOptions>(any())
} throws IllegalArgumentException()
assertNull(
fido2CredentialManager.getPasskeyCreateOptionsOrNull(
requestJson = "",
),
val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE))
every { hasMultipleSigners() } returns false
}
val mockFido2CreateCredentialRequest = createMockFido2CredentialRequest(
number = 1,
origin = "origin",
signingInfo = mockSigningInfo,
)
val mockCipherView = createMockCipherView(1)
val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1)
every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
every { json.encodeToString<Fido2AttestationResponse>(any(), any()) } returns ""
val requestCaptureSlot = slot<RegisterFido2CredentialRequest>()
coEvery {
mockVaultSdkSource.registerFido2Credential(
request = capture(requestCaptureSlot),
fido2CredentialStore = any(),
checkUser = any(),
checkUserAndPickCredential = any(),
)
} coAnswers {
mockRegistrationResponse
.asSuccess()
}
fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
fido2CredentialRequest = mockFido2CreateCredentialRequest,
selectedCipherView = mockCipherView,
)
assertTrue(
requestCaptureSlot.captured.clientData is ClientData.DefaultWithCustomHash,
)
}
@Suppress("MaxLineLength")
@Test
fun `registerFido2Credential should construct ClientData DefaultWithExtraData when callingAppInfo origin is null`() =
runTest {
val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE))
every { hasMultipleSigners() } returns false
}
val mockFido2Request = createMockFido2CredentialRequest(
number = 1,
signingInfo = mockSigningInfo,
)
val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1)
every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
every { json.encodeToString<Fido2AttestationResponse>(any(), any()) } returns ""
val requestCaptureSlot = slot<RegisterFido2CredentialRequest>()
coEvery {
mockVaultSdkSource.registerFido2Credential(
request = capture(requestCaptureSlot),
fido2CredentialStore = any(),
checkUser = any(),
checkUserAndPickCredential = any(),
)
} coAnswers {
mockRegistrationResponse
.asSuccess()
}
fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
fido2CredentialRequest = mockFido2Request,
selectedCipherView = createMockCipherView(1),
)
}
@Test
fun `createCredentialForCipher should return error while not implemented`() {
val result = fido2CredentialManager.createCredentialForCipher(
credentialRequest = mockk(),
cipherView = mockk(),
)
fun `registerFido2Credential should wrap request in webauthn json object`() =
runTest {
val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE))
every { hasMultipleSigners() } returns false
}
val mockFido2CreateCredentialRequest = createMockFido2CredentialRequest(
number = 1,
origin = "origin",
signingInfo = mockSigningInfo,
)
val mockCipherView = createMockCipherView(1)
val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1)
assertTrue(
result is Fido2CreateCredentialResult.Error,
)
}
every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
every { json.encodeToString<Fido2AttestationResponse>(any(), any()) } returns ""
val requestCaptureSlot = slot<RegisterFido2CredentialRequest>()
coEvery {
mockVaultSdkSource.registerFido2Credential(
request = capture(requestCaptureSlot),
fido2CredentialStore = any(),
checkUser = any(),
checkUserAndPickCredential = any(),
)
} coAnswers {
mockRegistrationResponse
.asSuccess()
}
fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
fido2CredentialRequest = mockFido2CreateCredentialRequest,
selectedCipherView = mockCipherView,
)
assertEquals(
"""{"publicKey": ${mockFido2CreateCredentialRequest.requestJson}}""",
requestCaptureSlot.captured.requestJson,
)
}
@Test
fun `registerFido2Credential should register FIDO 2 credential to active user ID`() =
runTest {
val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE))
every { hasMultipleSigners() } returns false
}
val mockFido2CreateCredentialRequest = createMockFido2CredentialRequest(
number = 1,
origin = "origin",
signingInfo = mockSigningInfo,
)
val mockCipherView = createMockCipherView(1)
val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1)
every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
every { json.encodeToString<Fido2AttestationResponse>(any(), any()) } returns ""
val requestCaptureSlot = slot<RegisterFido2CredentialRequest>()
coEvery {
mockVaultSdkSource.registerFido2Credential(
request = capture(requestCaptureSlot),
fido2CredentialStore = any(),
checkUser = any(),
checkUserAndPickCredential = any(),
)
} coAnswers {
mockRegistrationResponse
.asSuccess()
}
fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
fido2CredentialRequest = mockFido2CreateCredentialRequest,
selectedCipherView = mockCipherView,
)
assertEquals(
"mockUserId",
requestCaptureSlot.captured.userId,
)
assertNotEquals(
"mockUserId",
mockFido2CreateCredentialRequest.userId,
)
}
@Suppress("MaxLineLength")
@Test
fun `registerFido2Credential should return Error when getAppSigningSignatureFingerprint is null`() =
runTest {
val mockSigningInfo = mockk<SigningInfo> {
every { hasMultipleSigners() } returns true
}
val mockFido2CredentialRequest = createMockFido2CredentialRequest(
number = 1,
origin = "origin",
signingInfo = mockSigningInfo,
)
val result = fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
fido2CredentialRequest = mockFido2CredentialRequest,
selectedCipherView = createMockCipherView(number = 1),
)
assertTrue(
result is Fido2CreateCredentialResult.Error,
)
}
@Test
fun `registerFido2Credential should return Error when deserialization fails`() =
runTest {
val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE))
every { hasMultipleSigners() } returns false
}
val mockFido2CredentialRequest = createMockFido2CredentialRequest(
number = 1,
origin = "origin",
signingInfo = mockSigningInfo,
)
val mockRegistrationResponse = createMockPublicKeyAttestationResponse(number = 1)
every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
every {
json.encodeToString<Fido2AttestationResponse>(
any(),
any(),
)
} throws IllegalArgumentException()
coEvery {
mockVaultSdkSource.registerFido2Credential(
request = any(),
fido2CredentialStore = any(),
checkUser = any(),
checkUserAndPickCredential = any(),
)
} coAnswers {
mockRegistrationResponse
.asSuccess()
}
val result = fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
fido2CredentialRequest = mockFido2CredentialRequest,
selectedCipherView = createMockCipherView(number = 1),
)
assertTrue(
result is Fido2CreateCredentialResult.Error,
)
}
}
@Suppress("MaxLineLength")
private const val DEFAULT_CERT_FINGERPRINT =
"30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46"
private const val DEFAULT_APP_SIGNATURE = "0987654321ABCDEF"
private const val DEFAULT_CERT_FINGERPRINT = "30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46"
private val DEFAULT_STATEMENT = DigitalAssetLinkResponseJson(
relation = listOf(
"delegate_permission/common.get_login_creds",

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import android.content.pm.SigningInfo
/**
* Creates a mock [Fido2CredentialRequest] with a given [number].
*/
fun createMockFido2CredentialRequest(
number: Int,
origin: String? = null,
signingInfo: SigningInfo = SigningInfo(),
): Fido2CredentialRequest =
Fido2CredentialRequest(
userId = "mockUserId-$number",
requestJson = """{"request": {"number": $number}}""",
packageName = "com.x8bit.bitwarden",
signingInfo = signingInfo,
origin = origin,
)

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class CipherViewExtensionsTest {
@ -133,4 +134,46 @@ class CipherViewExtensionsTest {
)
}
}
@Suppress("MaxLineLength")
@Test
fun `isActiveWithFido2Credentials should return true when type is login, deleted date is null, and fido2 credentials are not null`() {
assertTrue(
createMockCipherView(number = 1)
.isActiveWithFido2Credentials,
)
}
@Test
fun `isActiveWithFido2Credentials should return false when deleted date is not null`() {
assertFalse(
createMockCipherView(number = 1, isDeleted = true)
.isActiveWithFido2Credentials,
)
}
@Test
fun `isActiveWithFido2Credentials should return false when type is not login`() {
assertFalse(
createMockCipherView(number = 1, cipherType = CipherType.CARD)
.isActiveWithFido2Credentials,
)
}
@Test
fun `isActiveWithFido2Credentials should return false when login is null`() {
assertFalse(
createMockCipherView(number = 1)
.copy(login = null)
.isActiveWithFido2Credentials,
)
}
@Test
fun `isActiveWithFido2Credentials should return false when fido2Credentials is null`() {
assertFalse(
createMockCipherView(number = 1, fido2Credentials = null)
.isActiveWithFido2Credentials,
)
}
}

View file

@ -80,6 +80,7 @@ class CallingAppInfoExtensionsTest {
val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_SIGNATURE))
every { hasMultipleSigners() } returns false
}
val appInfo = mockk<CallingAppInfo> {
every { packageName } returns "packageName"
@ -88,10 +89,29 @@ class CallingAppInfoExtensionsTest {
}
assertEquals(
DEFAULT_SIGNATURE_HASH,
appInfo.getCallingAppApkFingerprint(),
appInfo.getSignatureFingerprintAsHexString(),
)
}
@Test
fun `getCallingAppApkFingerprint should return null when app has multiple signers`() {
val mockMessageDigest = mockk<MessageDigest> {
every { digest(any()) } returns DEFAULT_SIGNATURE.toByteArray()
}
every { MessageDigest.getInstance(any()) } returns mockMessageDigest
every { Base64.encodeToString(any(), any()) } returns DEFAULT_SIGNATURE
val mockSigningInfo = mockk<SigningInfo> {
every { hasMultipleSigners() } returns true
}
val appInfo = mockk<CallingAppInfo> {
every { packageName } returns "packageName"
every { signingInfo } returns mockSigningInfo
every { origin } returns null
}
assertNull(appInfo.getSignatureFingerprintAsHexString())
}
@Test
fun `validatePrivilegedApp should return Success when privileged app is allowed`() {
val mockAppInfo = mockk<CallingAppInfo> {
@ -123,6 +143,22 @@ class CallingAppInfoExtensionsTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `validatePrivilegedApp should return PrivilegedAppSignatureNotFound when IllegalStateException is thrown`() {
val appInfo = mockk<CallingAppInfo> {
every { packageName } returns "com.x8bit.bitwarden"
every { getOrigin(any()) } throws IllegalStateException()
}
assertEquals(
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound,
appInfo.validatePrivilegedApp(
allowList = INVALID_ALLOW_LIST,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validatePrivilegedApp should return PrivilegedAppNotAllowed when calling app is not present in allow list`() {
@ -138,6 +174,52 @@ class CallingAppInfoExtensionsTest {
),
)
}
@Test
fun `validatePrivilegedApp should return PasskeyNotSupportedForApp when getOrigin is null`() {
val appInfo = mockk<CallingAppInfo> {
every { getOrigin(any()) } returns null
every { packageName } returns "com.x8bit.bitwarden"
}
assertEquals(
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp,
appInfo.validatePrivilegedApp(DEFAULT_ALLOW_LIST),
)
}
@Test
fun `getAppOrigin should return apk key hash as origin`() {
val mockMessageDigest = mockk<MessageDigest> {
every { digest(any()) } returns DEFAULT_SIGNATURE.toByteArray()
}
every { MessageDigest.getInstance(any()) } returns mockMessageDigest
every { Base64.encodeToString(any(), any()) } returns DEFAULT_SIGNATURE
val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_SIGNATURE))
every { hasMultipleSigners() } returns false
}
val appInfo = mockk<CallingAppInfo> {
every { signingInfo } returns mockSigningInfo
}
assertEquals(
"android:apk-key-hash:$DEFAULT_SIGNATURE",
appInfo.getAppOrigin(),
)
}
@Suppress("MaxLineLength")
@Test
fun `getAppSigningSignatureFingerprint should return null when calling app has multiple signers`() {
val mockAppInfo = mockk<CallingAppInfo> {
every { signingInfo } returns mockk {
every { hasMultipleSigners() } returns true
}
}
assertNull(mockAppInfo.getAppSigningSignatureFingerprint())
}
}
private const val DEFAULT_SIGNATURE = "0987654321ABCDEF"

View file

@ -10,7 +10,14 @@ private val MOCK_ZONED_DATE_TIME = ZonedDateTime.parse("2023-10-27T12:00:00Z")
/**
* Create a mock [SyncResponseJson.Cipher] with a given [number].
*/
fun createMockCipher(number: Int, hasNullUri: Boolean = false): SyncResponseJson.Cipher =
fun createMockCipher(
number: Int,
hasNullUri: Boolean = false,
fido2Credentials: List<SyncResponseJson.Cipher.Fido2Credential> = listOf(
createMockFido2Credential(number),
),
isDeleted: Boolean = true,
): SyncResponseJson.Cipher =
SyncResponseJson.Cipher(
id = "mockId-$number",
organizationId = "mockOrganizationId-$number",
@ -19,9 +26,13 @@ fun createMockCipher(number: Int, hasNullUri: Boolean = false): SyncResponseJson
name = "mockName-$number",
notes = "mockNotes-$number",
type = CipherTypeJson.LOGIN,
login = createMockLogin(number = number, hasNullUri = hasNullUri),
login = createMockLogin(
number = number,
hasNullUri = hasNullUri,
fido2Credentials = fido2Credentials,
),
creationDate = MOCK_ZONED_DATE_TIME,
deletedDate = MOCK_ZONED_DATE_TIME,
deletedDate = if (isDeleted) MOCK_ZONED_DATE_TIME else null,
revisionDate = MOCK_ZONED_DATE_TIME,
attachments = listOf(createMockAttachment(number = number)),
card = createMockCard(number = number),
@ -119,7 +130,13 @@ fun createMockField(number: Int): SyncResponseJson.Cipher.Field =
/**
* Create a mock [SyncResponseJson.Cipher.Login] with a given [number].
*/
fun createMockLogin(number: Int, hasNullUri: Boolean = false): SyncResponseJson.Cipher.Login =
fun createMockLogin(
number: Int,
hasNullUri: Boolean = false,
fido2Credentials: List<SyncResponseJson.Cipher.Fido2Credential> = listOf(
createMockFido2Credential(number),
),
): SyncResponseJson.Cipher.Login =
SyncResponseJson.Cipher.Login(
username = "mockUsername-$number",
password = "mockPassword-$number",
@ -128,7 +145,7 @@ fun createMockLogin(number: Int, hasNullUri: Boolean = false): SyncResponseJson.
uri = if (hasNullUri) null else "mockUri-$number",
uris = listOf(createMockUri(number = number)),
totp = "mockTotp-$number",
fido2Credentials = listOf(createMockFido2Credential(number)),
fido2Credentials = fido2Credentials,
)
fun createMockFido2Credential(number: Int) = SyncResponseJson.Cipher.Fido2Credential(

View file

@ -7,16 +7,28 @@ import com.bitwarden.bitwarden.InitUserCryptoRequest
import com.bitwarden.bitwarden.UpdatePasswordResponse
import com.bitwarden.core.DateTime
import com.bitwarden.crypto.TrustDeviceResponse
import com.bitwarden.fido.CheckUserOptions
import com.bitwarden.fido.ClientData
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse
import com.bitwarden.fido.Verification
import com.bitwarden.sdk.BitwardenException
import com.bitwarden.sdk.CheckUserResult
import com.bitwarden.sdk.CipherViewWrapper
import com.bitwarden.sdk.Client
import com.bitwarden.sdk.ClientAuth
import com.bitwarden.sdk.ClientCiphers
import com.bitwarden.sdk.ClientCrypto
import com.bitwarden.sdk.ClientExporters
import com.bitwarden.sdk.ClientFido2
import com.bitwarden.sdk.ClientFido2Client
import com.bitwarden.sdk.ClientPasswordHistory
import com.bitwarden.sdk.ClientPlatform
import com.bitwarden.sdk.ClientSends
import com.bitwarden.sdk.ClientVault
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.sdk.Fido2UserInterface
import com.bitwarden.sdk.UiHint
import com.bitwarden.send.Send
import com.bitwarden.send.SendView
import com.bitwarden.vault.Attachment
@ -35,6 +47,8 @@ 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.InitializeCryptoResult
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.createMockSdkCipher
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFolder
import io.mockk.coEvery
@ -42,18 +56,29 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.security.MessageDigest
@Suppress("LargeClass")
class VaultSdkSourceTest {
private val clientAuth = mockk<ClientAuth>()
private val clientCrypto = mockk<ClientCrypto>()
private val clientPlatform = mockk<ClientPlatform>()
private val fido2 = mockk<ClientFido2Client> {
coEvery { register(any(), any(), any()) }
}
private val clientFido2 = mockk<ClientFido2> {
every { client(any(), any()) } returns fido2
}
private val clientPlatform = mockk<ClientPlatform> {
every { fido2() } returns clientFido2
}
private val clientPasswordHistory = mockk<ClientPasswordHistory>()
private val clientSends = mockk<ClientSends>()
private val clientVault = mockk<ClientVault> {
@ -74,6 +99,7 @@ class VaultSdkSourceTest {
coEvery { getOrCreateClient(any()) } returns client
every { destroyClient(any()) } just runs
}
private val mockFido2CredentialStore: Fido2CredentialStore = mockk()
private val vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl(
sdkClientManager = sdkClientManager,
)
@ -980,4 +1006,180 @@ class VaultSdkSourceTest {
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `registerFido2Credential should return attestation response when registration completes`() =
runTest {
mockkStatic(MessageDigest::class) {
every { MessageDigest.getInstance(any()) } returns mockk<MessageDigest> {
every { digest(any()) } returns DEFAULT_SIGNATURE.toByteArray()
}
val mockCipherView = createMockCipherView(1)
val mockAttestation = mockk<PublicKeyCredentialAuthenticatorAttestationResponse>()
coEvery { fido2.register(any(), any(), any()) } returns mockAttestation
val result = vaultSdkSource.registerFido2Credential(
request = RegisterFido2CredentialRequest(
userId = "mockUserId",
origin = "www.bitwarden.com",
requestJson = "requestJson",
clientData = ClientData.DefaultWithCustomHash(
hash = DEFAULT_SIGNATURE.toByteArray(),
),
selectedCipherView = mockCipherView,
isUserVerificationSupported = true,
),
checkUser = { _, _ -> CheckUserResult(true, true) },
checkUserAndPickCredential = { _, _ ->
CipherViewWrapper(mockCipherView)
},
fido2CredentialStore = mockFido2CredentialStore,
)
assertEquals(
mockAttestation.asSuccess(),
result,
)
}
}
@Test
fun `registerFido2Credential should invoke checkUser when called by the SDK`() = runTest {
val checkUserResult = CheckUserResult(true, true)
val checkUserOptionsSlot = slot<CheckUserOptions>()
val uiHintSlot = slot<UiHint>()
val mockUserInterface = mockk<Fido2UserInterface> {
coEvery {
checkUser(
capture(checkUserOptionsSlot),
capture(uiHintSlot),
)
} returns checkUserResult
}
val mockCipherView = createMockCipherView(number = 1)
val mockAttestation = mockk<PublicKeyCredentialAuthenticatorAttestationResponse>()
val mockCheckUserOptions = CheckUserOptions(true, Verification.REQUIRED)
coEvery { fido2.register(any(), any(), any()) } coAnswers {
mockUserInterface.checkUser(
mockCheckUserOptions,
UiHint.InformNoCredentialsFound,
)
mockAttestation
}
vaultSdkSource.registerFido2Credential(
request = RegisterFido2CredentialRequest(
userId = "mockUserId",
origin = "www.bitwarden.com",
requestJson = "requestJson",
clientData = ClientData.DefaultWithCustomHash(
hash = DEFAULT_SIGNATURE.toByteArray(),
),
selectedCipherView = mockCipherView,
isUserVerificationSupported = true,
),
checkUser = { _, _ -> checkUserResult },
checkUserAndPickCredential = { _, _ -> CipherViewWrapper(mockCipherView) },
fido2CredentialStore = mockFido2CredentialStore,
)
coVerify { mockUserInterface.checkUser(any(), any()) }
assertEquals(
mockCheckUserOptions,
checkUserOptionsSlot.captured,
)
assertEquals(
UiHint.InformNoCredentialsFound,
uiHintSlot.captured,
)
}
@Suppress("MaxLineLength")
@Test
fun `authenticateFido2Credential should return assertion response when registration completes`() =
runTest {
mockkStatic(MessageDigest::class) {
every { MessageDigest.getInstance(any()) } returns mockk<MessageDigest> {
every { digest(any()) } returns DEFAULT_SIGNATURE.toByteArray()
}
val mockCipherView = createMockCipherView(1)
val mockAssertion = mockk<PublicKeyCredentialAuthenticatorAssertionResponse>()
coEvery { fido2.authenticate(any(), any(), any()) } returns mockAssertion
val result = vaultSdkSource.authenticateFido2Credential(
userId = "mockUserId",
origin = "www.bitwarden.com",
requestJson = "requestJson",
clientData = ClientData.DefaultWithCustomHash(DEFAULT_SIGNATURE.toByteArray()),
isVerificationSupported = true,
checkUser = { _, _ -> CheckUserResult(true, true) },
pickCredentialForAuthentication = { CipherViewWrapper(mockCipherView) },
fido2CredentialStore = mockFido2CredentialStore,
)
assertEquals(
mockAssertion.asSuccess(),
result,
)
}
}
@Test
fun `authenticateFido2Credential should invoke checkUser when called by the SDK`() = runTest {
val checkUserResult = CheckUserResult(true, true)
val checkUserOptionsSlot = slot<CheckUserOptions>()
val uiHintSlot = slot<UiHint>()
val mockUserInterface = mockk<Fido2UserInterface> {
coEvery {
checkUser(
capture(checkUserOptionsSlot),
capture(uiHintSlot),
)
} returns checkUserResult
}
val mockCipherView = createMockCipherView(number = 1)
val mockAssertion = mockk<PublicKeyCredentialAuthenticatorAssertionResponse>()
val mockCheckUserOptions = CheckUserOptions(true, Verification.REQUIRED)
coEvery { fido2.authenticate(any(), any(), any()) } coAnswers {
mockUserInterface.checkUser(
mockCheckUserOptions,
UiHint.InformNoCredentialsFound,
)
mockAssertion
}
vaultSdkSource.authenticateFido2Credential(
userId = "mockUserId",
origin = "www.bitwarden.com",
requestJson = "requestJson",
clientData = ClientData.DefaultWithCustomHash(DEFAULT_SIGNATURE.toByteArray()),
isVerificationSupported = true,
checkUser = { _, _ -> checkUserResult },
pickCredentialForAuthentication = { CipherViewWrapper(mockCipherView) },
fido2CredentialStore = mockFido2CredentialStore,
)
coVerify { mockUserInterface.checkUser(any(), any()) }
assertEquals(
mockCheckUserOptions,
checkUserOptionsSlot.captured,
)
assertEquals(
UiHint.InformNoCredentialsFound,
uiHintSlot.captured,
)
}
}
private const val DEFAULT_SIGNATURE = "0987654321ABCDEF"

View file

@ -45,6 +45,10 @@ fun createMockCipherView(
totp: String? = "mockTotp-$number",
folderId: String? = "mockId-$number",
clock: Clock = FIXED_CLOCK,
fido2Credentials: List<Fido2Credential>? = createMockSdkFido2CredentialList(
number = 1,
clock = clock,
),
): CipherView =
CipherView(
id = "mockId-$number",
@ -59,6 +63,7 @@ fun createMockCipherView(
number = number,
totp = totp,
clock = clock,
fido2Credentials = fido2Credentials,
)
.takeIf { cipherType == CipherType.LOGIN },
creationDate = clock.instant(),
@ -91,6 +96,7 @@ fun createMockLoginView(
number: Int,
totp: String? = "mockTotp-$number",
clock: Clock = FIXED_CLOCK,
fido2Credentials: List<Fido2Credential>? = createMockSdkFido2CredentialList(number, clock),
): LoginView =
LoginView(
username = "mockUsername-$number",
@ -99,7 +105,7 @@ fun createMockLoginView(
autofillOnPageLoad = false,
uris = listOf(createMockUriView(number = number)),
totp = totp,
fido2Credentials = createMockSdkFido2CredentialList(number, clock),
fido2Credentials = fido2Credentials,
)
/**

View file

@ -0,0 +1,23 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.vault.Fido2CredentialView
import java.time.Instant
/**
* Creates a [Fido2CredentialView] instance for testing.
*/
fun createMockFido2CredentialView(number: Int): Fido2CredentialView = Fido2CredentialView(
credentialId = "mockCredentialId-$number",
keyType = "mockKeyType-$number",
keyAlgorithm = "mockKeyAlgorithm-$number",
keyCurve = "mockKeyCurve-$number",
keyValue = "mockKeyValue-$number",
rpId = "mockRpId-$number",
userHandle = "mockUserHandle-$number".toByteArray(),
userName = "mockUserName-$number",
counter = "$number",
rpName = "mockRpName-$number",
userDisplayName = "mockUserDisplayName-$number",
discoverable = "mockDiscoverable-$number",
creationDate = Instant.now(),
)

View file

@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.fido.AuthenticatorAttestationResponse
import com.bitwarden.fido.ClientExtensionResults
import com.bitwarden.fido.CredPropsResult
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse
import com.bitwarden.fido.SelectedCredential
/**
* Creates a mock [PublicKeyCredentialAuthenticatorAttestationResponse] for testing.
*/
fun createMockPublicKeyAttestationResponse(number: Int) =
PublicKeyCredentialAuthenticatorAttestationResponse(
id = "mockId",
rawId = "0987654321".toByteArray(),
ty = "mockTy",
authenticatorAttachment = "mockAuthenticatorAttachment",
clientExtensionResults = ClientExtensionResults(
credProps = CredPropsResult(
rk = true,
authenticatorDisplayName = "mockDisplayName",
),
),
response = AuthenticatorAttestationResponse(
clientDataJson = "mockClientDataJson".toByteArray(),
authenticatorData = "mockAuthenticatorData".toByteArray(),
publicKey = "mockPublicKey".toByteArray(),
publicKeyAlgorithm = 0L,
attestationObject = "mockAttestationObject".toByteArray(),
transports = emptyList(),
),
selectedCredential = SelectedCredential(
createMockCipherView(number),
createMockFido2CredentialView(number),
),
)

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import android.util.Base64
import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.bitwarden.bitwarden.ExportFormat
@ -9,7 +10,6 @@ import com.bitwarden.bitwarden.InitUserCryptoMethod
import com.bitwarden.core.DateTime
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.CollectionView
import com.bitwarden.vault.Folder
@ -23,6 +23,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
@ -90,7 +91,6 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.model.createMockDomainsData
import com.x8bit.bitwarden.data.vault.repository.util.toDomainsData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList
@ -114,6 +114,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
@ -122,6 +123,7 @@ import org.junit.jupiter.api.Test
import retrofit2.HttpException
import java.io.File
import java.net.UnknownHostException
import java.security.MessageDigest
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
@ -135,6 +137,7 @@ class VaultRepositoryTest {
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val json: Json = PlatformNetworkModule.providesJson()
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
private val userLogoutManager: UserLogoutManager = mockk {
every { logout(any(), any()) } just runs
@ -220,20 +223,23 @@ class VaultRepositoryTest {
fileManager = fileManager,
clock = clock,
userLogoutManager = userLogoutManager,
json = json,
)
@BeforeEach
fun setup() {
mockkStatic(SyncResponseJson.Domains::toDomainsData)
mockkStatic(Uri::class)
mockkStatic(MessageDigest::class)
mockkStatic(Base64::class)
}
@AfterEach
fun tearDown() {
unmockkStatic(SyncResponseJson.Domains::toDomainsData)
unmockkStatic(Uri::class)
unmockkStatic(Instant::class)
unmockkStatic(Cipher::toEncryptedNetworkCipherResponse)
unmockkStatic(MessageDigest::class)
unmockkStatic(Base64::class)
}
@Test
@ -4368,7 +4374,7 @@ class VaultRepositoryTest {
return mockUri
}
//endregion Helper functions
//endregion Helper functions
}
private val MOCK_PROFILE = AccountJson.Profile(

View file

@ -33,8 +33,10 @@ import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.core.net.toUri
import com.bitwarden.vault.UriMatchType
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
@ -93,6 +95,9 @@ class VaultAddEditScreenTest : BaseComposeTest() {
private val intentManager: IntentManager = mockk {
every { launchUri(any()) } just runs
}
private val fido2CompletionManager: Fido2CompletionManager = mockk {
every { completeFido2Create(any()) } just runs
}
@Before
fun setup() {
@ -110,6 +115,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
permissionsManager = fakePermissionManager,
exitManager = exitManager,
intentManager = intentManager,
fido2CompletionManager = fido2CompletionManager,
)
}
}
@ -186,6 +192,15 @@ class VaultAddEditScreenTest : BaseComposeTest() {
assertEquals(GeneratorMode.Modal.Username(website), onNavigateToGeneratorModalType)
}
@Test
fun `on CompleteFido2Create even should invoke Fido2CompletionManager`() {
val result = Fido2CreateCredentialResult.Success(
registrationResponse = "mockRegistrationResponse",
)
mutableEventFlow.tryEmit(VaultAddEditEvent.CompleteFido2Create(result = result))
verify { fido2CompletionManager.completeFido2Create(result) }
}
@Test
fun `close button should update according to state`() {
composeTestRule.onNodeWithContentDescription("Close").assertIsDisplayed()

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit
import android.content.pm.SigningInfo
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import app.cash.turbine.turbineScope
@ -16,6 +17,7 @@ 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.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
@ -681,6 +683,170 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `in add mode during fido2, SaveClick should show dialog, register credential, show toast once an item is saved, and emit ExitApp`() =
runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "mockUserId",
requestJson = "mockRequestJson",
packageName = "mockPackageName",
signingInfo = mockk<SigningInfo>(),
origin = null,
)
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
val stateWithDialog = createVaultAddItemState(
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
dialogState = VaultAddEditState.DialogState.Loading(
R.string.saving.asText(),
),
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
),
)
.copy(shouldExitOnSave = true)
val stateWithName = createVaultAddItemState(
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
),
)
.copy(shouldExitOnSave = true)
mutableVaultDataFlow.value = DataState.Loaded(
createVaultData(),
)
val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
),
)
val mockCreateResult = Fido2CreateCredentialResult.Success(
registrationResponse = "mockRegistrationResponse",
)
coEvery {
fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
selectedCipherView = any(),
fido2CredentialRequest = fido2CredentialRequest,
)
} returns mockCreateResult
every { authRepository.activeUserId } returns "mockUserId"
turbineScope {
val stateTurbine = viewModel.stateFlow.testIn(backgroundScope)
val eventTurbine = viewModel.eventFlow.testIn(backgroundScope)
viewModel.trySendAction(VaultAddEditAction.Common.SaveClick)
assertEquals(stateWithName, stateTurbine.awaitItem())
assertEquals(stateWithDialog, stateTurbine.awaitItem())
assertEquals(
VaultAddEditEvent.ShowToast(R.string.item_updated.asText()),
eventTurbine.awaitItem(),
)
assertEquals(
VaultAddEditEvent.CompleteFido2Create(result = mockCreateResult),
eventTurbine.awaitItem(),
)
assertEquals(stateWithName, stateTurbine.awaitItem())
coVerify(exactly = 1) {
fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
fido2CredentialRequest = fido2CredentialRequest,
selectedCipherView = any(),
)
}
}
}
@Suppress("MaxLineLength")
@Test
fun `in add mode during fido2, SaveClick should show dialog, register credential, show toast on error, and emit ExitApp`() =
runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "mockUserId",
requestJson = "mockRequestJson",
packageName = "mockPackageName",
signingInfo = mockk<SigningInfo>(),
origin = null,
)
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
val stateWithDialog = createVaultAddItemState(
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
dialogState = VaultAddEditState.DialogState.Loading(
R.string.saving.asText(),
),
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
),
)
.copy(shouldExitOnSave = true)
val stateWithName = createVaultAddItemState(
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
),
)
.copy(shouldExitOnSave = true)
mutableVaultDataFlow.value = DataState.Loaded(
createVaultData(),
)
val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
),
)
val mockCreateResult = Fido2CreateCredentialResult.Error(
exception = CreateCredentialUnknownException(),
)
coEvery {
fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
selectedCipherView = any(),
fido2CredentialRequest = fido2CredentialRequest,
)
} returns mockCreateResult
every { authRepository.activeUserId } returns "mockUserId"
turbineScope {
val stateTurbine = viewModel.stateFlow.testIn(backgroundScope)
val eventTurbine = viewModel.eventFlow.testIn(backgroundScope)
viewModel.trySendAction(VaultAddEditAction.Common.SaveClick)
assertEquals(stateWithName, stateTurbine.awaitItem())
assertEquals(stateWithDialog, stateTurbine.awaitItem())
assertEquals(
VaultAddEditEvent.ShowToast(R.string.an_error_has_occurred.asText()),
eventTurbine.awaitItem(),
)
assertEquals(
VaultAddEditEvent.CompleteFido2Create(result = mockCreateResult),
eventTurbine.awaitItem(),
)
assertEquals(stateWithName, stateTurbine.awaitItem())
coVerify(exactly = 1) {
fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
selectedCipherView = any(),
fido2CredentialRequest = fido2CredentialRequest,
)
}
}
}
@Test
fun `in add mode, createCipherInOrganization success should ShowToast and NavigateBack`() =
runTest {