mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
[PM-8137] Allow registering a passkey to a new cipher (#3329)
This commit is contained in:
parent
a2572d996b
commit
d182b4edf1
33 changed files with 1711 additions and 66 deletions
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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 client’s 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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>>
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(),
|
||||
)
|
|
@ -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),
|
||||
),
|
||||
)
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue