From d9ef87e21f7588ec010d78d2b56aa31b752d8d41 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Wed, 11 Dec 2024 13:17:51 -0500 Subject: [PATCH] [PM-15609] Move FIDO2 origin validation logic to Fido2OriginManager (#4426) --- .../autofill/fido2/di/Fido2ProviderModule.kt | 19 +- .../fido2/manager/Fido2CredentialManager.kt | 10 - .../manager/Fido2CredentialManagerImpl.kt | 147 +------- .../fido2/manager/Fido2OriginManager.kt | 32 ++ .../fido2/manager/Fido2OriginManagerImpl.kt | 172 +++++++++ .../fido2/model/Fido2ValidateOriginResult.kt | 45 ++- .../platform/util/CallingAppInfoExtensions.kt | 11 +- .../itemlisting/VaultItemListingViewModel.kt | 12 +- .../com/x8bit/bitwarden/MainViewModelTest.kt | 16 +- .../manager/Fido2CredentialManagerTest.kt | 275 +-------------- .../fido2/manager/Fido2OriginManagerTest.kt | 332 ++++++++++++++++++ .../util/CallingAppInfoExtensionsTest.kt | 2 +- .../VaultItemListingViewModelTest.kt | 32 +- 13 files changed, 643 insertions(+), 462 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManagerImpl.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManagerTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt index 7388e78317..c6b52ce30c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt @@ -8,6 +8,8 @@ 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 import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManagerImpl +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManagerImpl import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorImpl import com.x8bit.bitwarden.data.platform.manager.AssetManager @@ -58,17 +60,26 @@ object Fido2ProviderModule { @Provides @Singleton fun provideFido2CredentialManager( - assetManager: AssetManager, - digitalAssetLinkService: DigitalAssetLinkService, vaultSdkSource: VaultSdkSource, fido2CredentialStore: Fido2CredentialStore, + fido2OriginManager: Fido2OriginManager, json: Json, ): Fido2CredentialManager = Fido2CredentialManagerImpl( - assetManager = assetManager, - digitalAssetLinkService = digitalAssetLinkService, vaultSdkSource = vaultSdkSource, fido2CredentialStore = fido2CredentialStore, + fido2OriginManager = fido2OriginManager, json = json, ) + + @Provides + @Singleton + fun provideFido2OriginManager( + assetManager: AssetManager, + digitalAssetLinkService: DigitalAssetLinkService, + ): Fido2OriginManager = + Fido2OriginManagerImpl( + assetManager = assetManager, + digitalAssetLinkService = digitalAssetLinkService, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt index f5031508f0..530ab9cce7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt @@ -1,12 +1,10 @@ package com.x8bit.bitwarden.data.autofill.fido2.manager -import androidx.credentials.provider.CallingAppInfo import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions @@ -26,14 +24,6 @@ interface Fido2CredentialManager { */ var authenticationAttempts: Int - /** - * Attempt to validate the RP and origin of the provided [callingAppInfo] and [relyingPartyId]. - */ - suspend fun validateOrigin( - callingAppInfo: CallingAppInfo, - relyingPartyId: String, - ): Fido2ValidateOriginResult - /** * Attempt to extract FIDO 2 passkey attestation options from the system [requestJson], or null. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt index 86f1aab10d..bb2c6eca2c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt @@ -6,8 +6,6 @@ import com.bitwarden.fido.Origin import com.bitwarden.fido.UnverifiedAssetLink 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.service.DigitalAssetLinkService import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult @@ -15,12 +13,10 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResu import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions -import com.x8bit.bitwarden.data.platform.manager.AssetManager import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull 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.AuthenticateFido2CredentialRequest import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest @@ -31,18 +27,14 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -private const val GOOGLE_ALLOW_LIST_FILE_NAME = "fido2_privileged_google.json" -private const val COMMUNITY_ALLOW_LIST_FILE_NAME = "fido2_privileged_community.json" - /** * Primary implementation of [Fido2CredentialManager]. */ @Suppress("TooManyFunctions") class Fido2CredentialManagerImpl( - private val assetManager: AssetManager, - private val digitalAssetLinkService: DigitalAssetLinkService, private val vaultSdkSource: VaultSdkSource, private val fido2CredentialStore: Fido2CredentialStore, + private val fido2OriginManager: Fido2OriginManager, private val json: Json, ) : Fido2CredentialManager, Fido2CredentialStore by fido2CredentialStore { @@ -108,16 +100,14 @@ class Fido2CredentialManagerImpl( ) } - override suspend fun validateOrigin( + private suspend fun validateOrigin( callingAppInfo: CallingAppInfo, relyingPartyId: String, - ): Fido2ValidateOriginResult { - return if (callingAppInfo.isOriginPopulated()) { - validatePrivilegedAppOrigin(callingAppInfo) - } else { - validateCallingApplicationAssetLinks(callingAppInfo, relyingPartyId) - } - } + ): Fido2ValidateOriginResult = fido2OriginManager + .validateOrigin( + callingAppInfo = callingAppInfo, + relyingPartyId = relyingPartyId, + ) override fun getPasskeyAttestationOptionsOrNull( requestJson: String, @@ -168,7 +158,7 @@ class Fido2CredentialManagerImpl( Fido2CredentialAssertionResult.Error } - Fido2ValidateOriginResult.Success -> { + is Fido2ValidateOriginResult.Success -> { vaultSdkSource .authenticateFido2Credential( request = AuthenticateFido2CredentialRequest( @@ -200,127 +190,6 @@ class Fido2CredentialManagerImpl( } } - private suspend fun validateCallingApplicationAssetLinks( - callingAppInfo: CallingAppInfo, - relyingPartyId: String, - ): Fido2ValidateOriginResult { - return digitalAssetLinkService - .getDigitalAssetLinkForRp(relyingParty = relyingPartyId) - .onFailure { - return Fido2ValidateOriginResult.Error.AssetLinkNotFound - } - .map { statements -> - statements - .filterMatchingAppStatementsOrNull( - rpPackageName = callingAppInfo.packageName, - ) - ?: return Fido2ValidateOriginResult.Error.ApplicationNotFound - } - .map { matchingStatements -> - callingAppInfo - .getSignatureFingerprintAsHexString() - ?.let { certificateFingerprint -> - matchingStatements - .filterMatchingAppSignaturesOrNull( - signature = certificateFingerprint, - ) - } - ?: return Fido2ValidateOriginResult.Error.ApplicationNotVerified - } - .fold( - onSuccess = { - Fido2ValidateOriginResult.Success - }, - onFailure = { - Fido2ValidateOriginResult.Error.Unknown - }, - ) - } - - private suspend fun validatePrivilegedAppOrigin( - callingAppInfo: CallingAppInfo, - ): Fido2ValidateOriginResult { - val googleAllowListResult = - validatePrivilegedAppSignatureWithGoogleList(callingAppInfo) - return when (googleAllowListResult) { - is Fido2ValidateOriginResult.Success -> { - // Application was found and successfully validated against the Google allow list so - // we can return the result as the final validation result. - googleAllowListResult - } - - is Fido2ValidateOriginResult.Error -> { - // Check the community allow list if the Google allow list failed, and return the - // result as the final validation result. - validatePrivilegedAppSignatureWithCommunityList(callingAppInfo) - } - } - } - - private suspend fun validatePrivilegedAppSignatureWithGoogleList( - callingAppInfo: CallingAppInfo, - ): Fido2ValidateOriginResult = - validatePrivilegedAppSignatureWithAllowList( - callingAppInfo = callingAppInfo, - fileName = GOOGLE_ALLOW_LIST_FILE_NAME, - ) - - private suspend fun validatePrivilegedAppSignatureWithCommunityList( - callingAppInfo: CallingAppInfo, - ): Fido2ValidateOriginResult = - validatePrivilegedAppSignatureWithAllowList( - callingAppInfo = callingAppInfo, - fileName = COMMUNITY_ALLOW_LIST_FILE_NAME, - ) - - private suspend fun validatePrivilegedAppSignatureWithAllowList( - callingAppInfo: CallingAppInfo, - fileName: String, - ): Fido2ValidateOriginResult = - assetManager - .readAsset(fileName) - .map { allowList -> - callingAppInfo.validatePrivilegedApp( - allowList = allowList, - ) - } - .fold( - onSuccess = { it }, - onFailure = { Fido2ValidateOriginResult.Error.Unknown }, - ) - - /** - * Returns statements targeting the calling Android application, or null. - */ - private fun List.filterMatchingAppStatementsOrNull( - rpPackageName: String, - ): List? = - filter { statement -> - val target = statement.target - target.namespace == "android_app" && - target.packageName == rpPackageName && - statement.relation.containsAll( - listOf( - "delegate_permission/common.get_login_creds", - "delegate_permission/common.handle_all_urls", - ), - ) - } - .takeUnless { it.isEmpty() } - - /** - * Returns statements that match the given [signature], or null. - */ - private fun List.filterMatchingAppSignaturesOrNull( - signature: String, - ): List? = - filter { statement -> - statement.target.sha256CertFingerprints - ?.contains(signature) - ?: false - } - .takeUnless { it.isEmpty() } - override fun hasAuthenticationAttemptsRemaining(): Boolean = authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManager.kt new file mode 100644 index 0000000000..85d74b01dd --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManager.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.data.autofill.fido2.manager + +import androidx.credentials.provider.CallingAppInfo +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult + +/** + * Responsible for managing FIDO2 origin validation. + */ +interface Fido2OriginManager { + + /** + * Validates the origin of a calling app. + * + * @param callingAppInfo The calling app info. + * @param relyingPartyId The relying party ID. + * + * @return The result of the validation. + */ + suspend fun validateOrigin( + callingAppInfo: CallingAppInfo, + relyingPartyId: String, + ): Fido2ValidateOriginResult + + /** + * Returns the privileged app origin, or null if the calling app is not allowed. + * + * @param callingAppInfo The calling app info. + * + * @return The privileged app origin, or null. + */ + suspend fun getPrivilegedAppOriginOrNull(callingAppInfo: CallingAppInfo): String? +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManagerImpl.kt new file mode 100644 index 0000000000..68eb184819 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManagerImpl.kt @@ -0,0 +1,172 @@ +package com.x8bit.bitwarden.data.autofill.fido2.manager + +import androidx.credentials.provider.CallingAppInfo +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult +import com.x8bit.bitwarden.data.platform.manager.AssetManager +import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString +import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp +import timber.log.Timber + +private const val GOOGLE_ALLOW_LIST_FILE_NAME = "fido2_privileged_google.json" +private const val COMMUNITY_ALLOW_LIST_FILE_NAME = "fido2_privileged_community.json" + +/** + * Primary implementation of [Fido2OriginManager]. + */ +@Suppress("TooManyFunctions") +class Fido2OriginManagerImpl( + private val assetManager: AssetManager, + private val digitalAssetLinkService: DigitalAssetLinkService, +) : Fido2OriginManager { + + override suspend fun validateOrigin( + callingAppInfo: CallingAppInfo, + relyingPartyId: String, + ): Fido2ValidateOriginResult { + return if (callingAppInfo.isOriginPopulated()) { + validatePrivilegedAppOrigin(callingAppInfo) + } else { + validateCallingApplicationAssetLinks(callingAppInfo, relyingPartyId) + } + } + + override suspend fun getPrivilegedAppOriginOrNull(callingAppInfo: CallingAppInfo): String? { + if (!callingAppInfo.isOriginPopulated()) return null + return callingAppInfo.getOrigin(getGoogleAllowListOrNull().orEmpty()) + ?: callingAppInfo.getOrigin(getCommunityAllowListOrNull().orEmpty()) + ?.takeUnless { !callingAppInfo.isOriginPopulated() } + } + + private suspend fun validateCallingApplicationAssetLinks( + callingAppInfo: CallingAppInfo, + relyingPartyId: String, + ): Fido2ValidateOriginResult = digitalAssetLinkService + .getDigitalAssetLinkForRp(relyingParty = relyingPartyId) + .onFailure { + return Fido2ValidateOriginResult.Error.AssetLinkNotFound + } + .mapCatching { statements -> + statements + .filterMatchingAppStatementsOrNull( + rpPackageName = callingAppInfo.packageName, + ) + ?: return Fido2ValidateOriginResult.Error.ApplicationNotFound + } + .mapCatching { matchingStatements -> + callingAppInfo + .getSignatureFingerprintAsHexString() + ?.let { certificateFingerprint -> + matchingStatements + .filterMatchingAppSignaturesOrNull( + signature = certificateFingerprint, + ) + } + ?: return Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified + } + .fold( + onSuccess = { + Fido2ValidateOriginResult.Success(null) + }, + onFailure = { + Fido2ValidateOriginResult.Error.Unknown + }, + ) + + private suspend fun validatePrivilegedAppOrigin( + callingAppInfo: CallingAppInfo, + ): Fido2ValidateOriginResult { + val googleAllowListResult = + validatePrivilegedAppSignatureWithGoogleList(callingAppInfo) + return when (googleAllowListResult) { + is Fido2ValidateOriginResult.Success -> { + // Application was found and successfully validated against the Google allow list so + // we can return the result as the final validation result. + googleAllowListResult + } + + is Fido2ValidateOriginResult.Error -> { + // Check the community allow list if the Google allow list failed, and return the + // result as the final validation result. + validatePrivilegedAppSignatureWithCommunityList(callingAppInfo) + } + } + } + + private suspend fun validatePrivilegedAppSignatureWithGoogleList( + callingAppInfo: CallingAppInfo, + ): Fido2ValidateOriginResult = + validatePrivilegedAppSignatureWithAllowList( + callingAppInfo = callingAppInfo, + fileName = GOOGLE_ALLOW_LIST_FILE_NAME, + ) + + private suspend fun validatePrivilegedAppSignatureWithCommunityList( + callingAppInfo: CallingAppInfo, + ): Fido2ValidateOriginResult = + validatePrivilegedAppSignatureWithAllowList( + callingAppInfo = callingAppInfo, + fileName = COMMUNITY_ALLOW_LIST_FILE_NAME, + ) + + private suspend fun validatePrivilegedAppSignatureWithAllowList( + callingAppInfo: CallingAppInfo, + fileName: String, + ): Fido2ValidateOriginResult = + assetManager + .readAsset(fileName) + .mapCatching { allowList -> + callingAppInfo.validatePrivilegedApp( + allowList = allowList, + ) + } + .fold( + onSuccess = { it }, + onFailure = { Fido2ValidateOriginResult.Error.Unknown }, + ) + + /** + * Returns statements targeting the calling Android application, or null. + */ + private fun List.filterMatchingAppStatementsOrNull( + rpPackageName: String, + ): List? = + filter { statement -> + val target = statement.target + target.namespace == "android_app" && + target.packageName == rpPackageName && + statement.relation.containsAll( + listOf( + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls", + ), + ) + } + .takeUnless { it.isEmpty() } + + /** + * Returns statements that match the given [signature], or null. + */ + private fun List.filterMatchingAppSignaturesOrNull( + signature: String, + ): List? = + filter { statement -> + statement.target.sha256CertFingerprints + ?.contains(signature) + ?: false + } + .takeUnless { it.isEmpty() } + + private suspend fun getGoogleAllowListOrNull(): String? = + assetManager + .readAsset(GOOGLE_ALLOW_LIST_FILE_NAME) + .onFailure { Timber.e(it, "Failed to read Google allow list.") } + .getOrNull() + + private suspend fun getCommunityAllowListOrNull(): String? = + assetManager + .readAsset(COMMUNITY_ALLOW_LIST_FILE_NAME) + .onFailure { Timber.e(it, "Failed to read Community allow list.") } + .getOrNull() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt index 55df7e1714..34ade4824f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt @@ -1,5 +1,8 @@ package com.x8bit.bitwarden.data.autofill.fido2.model +import androidx.annotation.StringRes +import com.x8bit.bitwarden.R + /** * Models the result of validating the origin of a FIDO2 request. */ @@ -7,49 +10,75 @@ sealed class Fido2ValidateOriginResult { /** * Represents a successful origin validation. + * + * @param origin The origin of the calling app, or null if the calling app is not privileged. */ - data object Success : Fido2ValidateOriginResult() + data class Success(val origin: String?) : Fido2ValidateOriginResult() /** * Represents a validation error. */ sealed class Error : Fido2ValidateOriginResult() { + /** + * The string resource ID of the error message. + */ + @get:StringRes + abstract val messageResId: Int /** * Indicates the digital asset links file could not be located. */ - data object AssetLinkNotFound : Error() + data object AssetLinkNotFound : Error() { + override val messageResId = + R.string.passkey_operation_failed_because_of_missing_asset_links + } /** * Indicates the application package name was not found in the digital asset links file. */ - data object ApplicationNotFound : Error() + data object ApplicationNotFound : Error() { + override val messageResId = + R.string.passkey_operation_failed_because_app_not_found_in_asset_links + } /** * Indicates the application fingerprint was not found the digital asset links file. */ - data object ApplicationNotVerified : Error() + data object ApplicationFingerprintNotVerified : Error() { + override val messageResId = + R.string.passkey_operation_failed_because_app_could_not_be_verified + } /** * Indicates the calling application is privileged but its package name is not found within * the privileged app allow list. */ - data object PrivilegedAppNotAllowed : Error() + data object PrivilegedAppNotAllowed : Error() { + override val messageResId = + R.string.passkey_operation_failed_because_browser_is_not_privileged + } /** * Indicates the calling app is privileged but but no matching signing certificate signature * is present in the allow list. */ - data object PrivilegedAppSignatureNotFound : Error() + data object PrivilegedAppSignatureNotFound : Error() { + override val messageResId = + R.string.passkey_operation_failed_because_browser_signature_does_not_match + } /** * Indicates passkeys are not supported for the requesting application. */ - data object PasskeyNotSupportedForApp : Error() + data object PasskeyNotSupportedForApp : Error() { + override val messageResId = R.string.passkeys_not_supported_for_this_app + } /** * Indicates an unknown error was encountered while validating the origin. */ - data object Unknown : Error() + data object Unknown : Error() { + override val messageResId = R.string.generic_error_message + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt index a5b4e50020..912816cbf7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt @@ -41,16 +41,17 @@ fun CallingAppInfo.validatePrivilegedApp(allowList: String): Fido2ValidateOrigin } return try { - if (getOrigin(allowList) != null) { - Fido2ValidateOriginResult.Success - } else { + val origin = getOrigin(allowList) + if (origin.isNullOrEmpty()) { Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp + } else { + Fido2ValidateOriginResult.Success(origin) } - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { // We know the package name is in the allow list so we can infer that this exception is // thrown because no matching signature is found. Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // The allow list is not formatted correctly so we notify the user passkeys are not // supported for this application Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index bec2053a70..cc7ce95036 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult @@ -106,6 +107,7 @@ class VaultItemListingViewModel @Inject constructor( private val specialCircumstanceManager: SpecialCircumstanceManager, private val policyManager: PolicyManager, private val fido2CredentialManager: Fido2CredentialManager, + private val fido2OriginManager: Fido2OriginManager, private val organizationEventManager: OrganizationEventManager, ) : BaseViewModel( initialState = run { @@ -727,7 +729,7 @@ class VaultItemListingViewModel @Inject constructor( return } viewModelScope.launch { - val validateOriginResult = fido2CredentialManager + val validateOriginResult = fido2OriginManager .validateOrigin( callingAppInfo = request.callingAppInfo, relyingPartyId = relyingPartyId, @@ -737,7 +739,7 @@ class VaultItemListingViewModel @Inject constructor( handleFido2OriginValidationFail(validateOriginResult) } - Fido2ValidateOriginResult.Success -> { + is Fido2ValidateOriginResult.Success -> { sendAction( VaultItemListingsAction.Internal.Fido2AssertionResultReceive( result = fido2CredentialManager.authenticateFido2Credential( @@ -1384,7 +1386,7 @@ class VaultItemListingViewModel @Inject constructor( showFido2ErrorDialog() return@launch } - val validateOriginResult = fido2CredentialManager + val validateOriginResult = fido2OriginManager .validateOrigin( callingAppInfo = action.request.callingAppInfo, relyingPartyId = options.relyingParty.id, @@ -1394,7 +1396,7 @@ class VaultItemListingViewModel @Inject constructor( handleFido2OriginValidationFail(validateOriginResult) } - Fido2ValidateOriginResult.Success -> { + is Fido2ValidateOriginResult.Success -> { observeVaultData() } } @@ -1427,7 +1429,7 @@ class VaultItemListingViewModel @Inject constructor( R.string.passkey_operation_failed_because_app_not_found_in_asset_links } - Fido2ValidateOriginResult.Error.ApplicationNotVerified -> { + Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified -> { R.string.passkey_operation_failed_because_app_could_not_be_verified } diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index 368d275642..ce5b518106 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest @@ -121,6 +122,9 @@ class MainViewModelTest : BaseViewModelTest() { every { isUserVerified } returns true every { isUserVerified = any() } just runs } + private val fido2OriginManager = mockk { + coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null) + } private val savedStateHandle = SavedStateHandle() @BeforeEach @@ -614,11 +618,11 @@ class MainViewModelTest : BaseViewModelTest() { ) coEvery { - fido2CredentialManager.validateOrigin( + fido2OriginManager.validateOrigin( fido2CreateCredentialRequest.callingAppInfo, fido2CreateCredentialRequest.requestJson, ) - } returns Fido2ValidateOriginResult.Success + } returns Fido2ValidateOriginResult.Success(null) viewModel.trySendAction( MainAction.ReceiveFirstIntent( @@ -668,11 +672,11 @@ class MainViewModelTest : BaseViewModelTest() { mockFido2CreateCredentialRequest = fido2CreateCredentialRequest, ) coEvery { - fido2CredentialManager.validateOrigin( + fido2OriginManager.validateOrigin( fido2CreateCredentialRequest.callingAppInfo, fido2CreateCredentialRequest.requestJson, ) - } returns Fido2ValidateOriginResult.Success + } returns Fido2ValidateOriginResult.Success(null) viewModel.trySendAction( MainAction.ReceiveFirstIntent( @@ -698,11 +702,11 @@ class MainViewModelTest : BaseViewModelTest() { mockFido2CreateCredentialRequest = fido2CreateCredentialRequest, ) coEvery { - fido2CredentialManager.validateOrigin( + fido2OriginManager.validateOrigin( fido2CreateCredentialRequest.callingAppInfo, fido2CreateCredentialRequest.requestJson, ) - } returns Fido2ValidateOriginResult.Success + } returns Fido2ValidateOriginResult.Success(null) viewModel.trySendAction( MainAction.ReceiveFirstIntent( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt index f5ae82bd4b..45c0d49a3c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt @@ -3,16 +3,12 @@ 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.fido.Origin import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse import com.bitwarden.fido.UnverifiedAssetLink 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.service.DigitalAssetLinkService import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2AttestationResponse -import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2PublicKeyCredential @@ -21,8 +17,6 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions 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.platform.util.decodeFromStringOrNull import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource @@ -34,7 +28,6 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPublicKeyAt import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKeyCredential import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAssertionOptions import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAttestationOptions -import io.mockk.Ordering import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -61,13 +54,8 @@ class Fido2CredentialManagerTest { private lateinit var fido2CredentialManager: Fido2CredentialManager - private val assetManager: AssetManager = mockk { - coEvery { readAsset(any()) } returns DEFAULT_ALLOW_LIST.asSuccess() - } - private val digitalAssetLinkService = mockk { - coEvery { - getDigitalAssetLinkForRp(relyingParty = any()) - } returns DEFAULT_STATEMENT_LIST.asSuccess() + private val fido2OriginManager = mockk { + coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null) } private val json = mockk { every { @@ -80,28 +68,10 @@ class Fido2CredentialManagerTest { decodeFromStringOrNull(DEFAULT_FIDO2_AUTH_REQUEST_JSON) } returns createMockPasskeyAssertionOptions(number = 1) } - private val mockPrivilegedCallingAppInfo = mockk { - every { packageName } returns DEFAULT_PACKAGE_NAME - every { isOriginPopulated() } returns true - every { getOrigin(any()) } returns DEFAULT_PACKAGE_NAME - } - private val mockPrivilegedAppRequest = mockk { - every { callingAppInfo } returns mockPrivilegedCallingAppInfo - every { requestJson } returns "{}" - } private val mockSigningInfo = mockk { every { apkContentsSigners } returns arrayOf(Signature("0987654321ABCDEF")) every { hasMultipleSigners() } returns false } - private val mockUnprivilegedCallingAppInfo = CallingAppInfo( - packageName = DEFAULT_PACKAGE_NAME, - signingInfo = mockSigningInfo, - origin = null, - ) - private val mockUnprivilegedAppRequest = mockk { - every { callingAppInfo } returns mockUnprivilegedCallingAppInfo - every { requestJson } returns "{}" - } private val mockMessageDigest = mockk { every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray() } @@ -114,10 +84,9 @@ class Fido2CredentialManagerTest { every { MessageDigest.getInstance(any()) } returns mockMessageDigest fido2CredentialManager = Fido2CredentialManagerImpl( - assetManager = assetManager, - digitalAssetLinkService = digitalAssetLinkService, vaultSdkSource = mockVaultSdkSource, fido2CredentialStore = mockFido2CredentialStore, + fido2OriginManager = fido2OriginManager, json = json, ) } @@ -130,201 +99,6 @@ class Fido2CredentialManagerTest { ) } - @Test - fun `validateOrigin should load allow list when origin is populated`() = - runTest { - fido2CredentialManager.validateOrigin( - mockPrivilegedAppRequest.callingAppInfo, - mockPrivilegedAppRequest.requestJson, - ) - - coVerify(exactly = 1) { - assetManager.readAsset( - fileName = GOOGLE_ALLOW_LIST_FILENAME, - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `validateOrigin should validate with community allow list when google allow list validation fails`() = - runTest { - coEvery { - assetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) - } returns MISSING_PACKAGE_ALLOW_LIST.asSuccess() - every { - mockPrivilegedCallingAppInfo.getOrigin( - privilegedAllowlist = MISSING_PACKAGE_ALLOW_LIST, - ) - } throws IllegalStateException() - coEvery { - assetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) - } returns DEFAULT_ALLOW_LIST.asSuccess() - every { - mockPrivilegedCallingAppInfo.getOrigin( - privilegedAllowlist = DEFAULT_ALLOW_LIST, - ) - } returns DEFAULT_PACKAGE_NAME - - fido2CredentialManager.validateOrigin( - mockPrivilegedAppRequest.callingAppInfo, - mockPrivilegedAppRequest.requestJson, - ) - - coVerify(ordering = Ordering.ORDERED) { - assetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) - assetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) - } - } - - @Test - fun `validateOrigin should return Success when privileged app is allowed`() = - runTest { - assertEquals( - Fido2ValidateOriginResult.Success, - fido2CredentialManager.validateOrigin( - mockPrivilegedAppRequest.callingAppInfo, - mockPrivilegedAppRequest.requestJson, - ), - ) - } - - @Suppress("MaxLineLength") - @Test - fun `validateOrigin should return PrivilegedAppSignatureNotFound when privileged app signature is not found in allow list`() = - runTest { - every { mockPrivilegedCallingAppInfo.getOrigin(any()) } throws IllegalStateException() - - assertEquals( - Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound, - fido2CredentialManager.validateOrigin( - mockPrivilegedAppRequest.callingAppInfo, - mockPrivilegedAppRequest.requestJson, - ), - ) - } - - @Suppress("MaxLineLength") - @Test - fun `validateOrigin should return PrivilegedAppNotAllowed when privileged app package name is not found in allow list`() = - runTest { - coEvery { assetManager.readAsset(any()) } returns MISSING_PACKAGE_ALLOW_LIST.asSuccess() - - assertEquals( - Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed, - fido2CredentialManager.validateOrigin(mockPrivilegedCallingAppInfo, "{}"), - ) - } - - @Test - fun `validateOrigin should return error when allow list is unreadable`() = runTest { - coEvery { assetManager.readAsset(any()) } returns IllegalStateException().asFailure() - - assertEquals( - Fido2ValidateOriginResult.Error.Unknown, - fido2CredentialManager.validateOrigin( - mockPrivilegedAppRequest.callingAppInfo, - mockPrivilegedAppRequest.requestJson, - ), - ) - } - - @Test - fun `validateOrigin should return PasskeyNotSupportedForApp when allow list is invalid`() = - runTest { - every { - mockPrivilegedCallingAppInfo.getOrigin(any()) - } throws IllegalArgumentException() - - assertEquals( - Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp, - fido2CredentialManager.validateOrigin(mockPrivilegedCallingAppInfo, "{}"), - ) - } - - @Test - fun `validateOrigin should return success when asset links contains matching statement`() = - runTest { - assertEquals( - Fido2ValidateOriginResult.Success, - fido2CredentialManager.validateOrigin( - mockUnprivilegedAppRequest.callingAppInfo, - mockUnprivilegedAppRequest.requestJson, - ), - ) - } - - @Test - fun `validateOrigin should return error when asset links are unavailable`() = runTest { - coEvery { - digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any()) - } returns Throwable().asFailure() - - assertEquals( - fido2CredentialManager.validateOrigin( - mockUnprivilegedAppRequest.callingAppInfo, - mockUnprivilegedAppRequest.requestJson, - ), - Fido2ValidateOriginResult.Error.AssetLinkNotFound, - ) - } - - @Test - fun `validateOrigin should return error when asset links does not contain package name`() = - runTest { - every { mockUnprivilegedAppRequest.callingAppInfo } returns CallingAppInfo( - packageName = "its.a.trap", - signingInfo = mockSigningInfo, - origin = null, - ) - assertEquals( - Fido2ValidateOriginResult.Error.ApplicationNotFound, - fido2CredentialManager.validateOrigin( - mockUnprivilegedAppRequest.callingAppInfo, - mockUnprivilegedAppRequest.requestJson, - ), - ) - } - - @Suppress("MaxLineLength") - @Test - fun `validateOrigin should return error when asset links does not contain android app namespace`() = - runTest { - coEvery { - digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any()) - } returns listOf( - DEFAULT_STATEMENT.copy( - target = DEFAULT_STATEMENT.target.copy( - namespace = "its_a_trap", - ), - ), - ) - .asSuccess() - - assertEquals( - Fido2ValidateOriginResult.Error.ApplicationNotFound, - fido2CredentialManager.validateOrigin( - mockUnprivilegedAppRequest.callingAppInfo, - mockUnprivilegedAppRequest.requestJson, - ), - ) - } - - @Test - fun `validateOrigin should return error when asset links certificate hash no match`() = - runTest { - every { - mockMessageDigest.digest(any()) - } returns "ITSATRAP".toByteArray() - assertEquals( - Fido2ValidateOriginResult.Error.ApplicationNotVerified, - fido2CredentialManager.validateOrigin( - mockUnprivilegedAppRequest.callingAppInfo, - mockUnprivilegedAppRequest.requestJson, - ), - ) - } - @Test fun `getPasskeyAttestationOptionsOrNull should return passkey options when deserialized`() = runTest { @@ -967,7 +741,6 @@ class Fido2CredentialManagerTest { ) coVerify { - assetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) mockVaultSdkSource.authenticateFido2Credential( request = any(), fido2CredentialStore = any(), @@ -1021,8 +794,8 @@ class Fido2CredentialManagerTest { val mockSelectedCipherView = createMockCipherView(number = 1) coEvery { - digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = "mockRelyingPartyId-1") - } returns IllegalStateException().asFailure() + fido2OriginManager.validateOrigin(any(), any()) + } returns Fido2ValidateOriginResult.Error.Unknown val result = fido2CredentialManager.authenticateFido2Credential( userId = "activeUserId", @@ -1053,22 +826,6 @@ private val DEFAULT_ORIGIN = Origin.Android( assetLinkUrl = "bitwarden.com", ), ) -private val DEFAULT_STATEMENT = DigitalAssetLinkResponseJson( - relation = listOf( - "delegate_permission/common.get_login_creds", - "delegate_permission/common.handle_all_urls", - ), - target = DigitalAssetLinkResponseJson.Target( - namespace = "android_app", - packageName = DEFAULT_PACKAGE_NAME, - sha256CertFingerprints = listOf( - DEFAULT_CERT_FINGERPRINT, - ), - ), -) -private const val GOOGLE_ALLOW_LIST_FILENAME = "fido2_privileged_google.json" -private const val COMMUNITY_ALLOW_LIST_FILENAME = "fido2_privileged_community.json" -private val DEFAULT_STATEMENT_LIST = listOf(DEFAULT_STATEMENT) private const val DEFAULT_ALLOW_LIST = """ { "apps": [ @@ -1091,28 +848,6 @@ private const val DEFAULT_ALLOW_LIST = """ ] } """ -private const val MISSING_PACKAGE_ALLOW_LIST = """ -{ - "apps": [ - { - "type": "android", - "info": { - "package_name": "com.android.chrome", - "signatures": [ - { - "build": "release", - "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" - }, - { - "build": "userdebug", - "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" - } - ] - } - } - ] -} -""" private const val DEFAULT_FIDO2_AUTH_REQUEST_JSON = """ { "allowCredentials": [ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManagerTest.kt new file mode 100644 index 0000000000..8b61f49725 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2OriginManagerTest.kt @@ -0,0 +1,332 @@ +package com.x8bit.bitwarden.data.autofill.fido2.manager + +import android.content.pm.Signature +import android.util.Base64 +import androidx.credentials.provider.CallingAppInfo +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult +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 io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.security.MessageDigest + +class Fido2OriginManagerTest { + + private val mockAssetManager = mockk() + private val mockDigitalAssetLinkService = mockk() + private val mockPrivilegedAppInfo = mockk { + every { isOriginPopulated() } returns true + every { packageName } returns DEFAULT_PACKAGE_NAME + every { getOrigin(any()) } returns DEFAULT_ORIGIN + } + private val mockNonPrivilegedAppInfo = mockk { + every { isOriginPopulated() } returns false + every { packageName } returns DEFAULT_PACKAGE_NAME + every { getOrigin(any()) } returns null + every { signingInfo } returns mockk { + every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE)) + every { hasMultipleSigners() } returns false + } + } + private val mockMessageDigest = mockk { + every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray() + } + + private val fido2OriginManager = Fido2OriginManagerImpl( + assetManager = mockAssetManager, + digitalAssetLinkService = mockDigitalAssetLinkService, + ) + + @BeforeEach + fun setUp() { + mockkStatic( + MessageDigest::class, + Base64::class, + ) + every { MessageDigest.getInstance(any()) } returns mockMessageDigest + } + + @AfterEach + fun tearDown() { + unmockkStatic( + MessageDigest::class, + Base64::class, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return Success when calling app is Privileged and is in Google allow list`() = + runTest { + coEvery { + mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) + } returns DEFAULT_ALLOW_LIST.asSuccess() + + val result = fido2OriginManager.validateOrigin( + callingAppInfo = mockPrivilegedAppInfo, + relyingPartyId = "relyingPartyId", + ) + coVerify(exactly = 1) { + mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) + } + assertEquals( + Fido2ValidateOriginResult.Success(DEFAULT_ORIGIN), + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return Success when calling app is Privileged and is in the Community allow list but not the Google allow list`() = + runTest { + coEvery { + mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) + } returns FAIL_ALLOW_LIST.asSuccess() + coEvery { + mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) + } returns DEFAULT_ALLOW_LIST.asSuccess() + + val result = fido2OriginManager.validateOrigin( + callingAppInfo = mockPrivilegedAppInfo, + relyingPartyId = "relyingPartyId", + ) + coVerify(exactly = 1) { + mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) + mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) + } + assertEquals( + Fido2ValidateOriginResult.Success(DEFAULT_ORIGIN), + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return ApplicationNotFound when calling app is Privileged but not in either allow list`() = + runTest { + coEvery { + mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) + } returns FAIL_ALLOW_LIST.asSuccess() + coEvery { + mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) + } returns FAIL_ALLOW_LIST.asSuccess() + + val result = fido2OriginManager.validateOrigin( + callingAppInfo = mockPrivilegedAppInfo, + relyingPartyId = "relyingPartyId", + ) + + coVerify(exactly = 1) { + mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) + mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) + } + assertEquals( + Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed, + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return Success when calling app is NonPrivileged and has a valid asset link entry`() = + runTest { + coEvery { + mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID) + } returns listOf(DEFAULT_STATEMENT).asSuccess() + + val result = fido2OriginManager.validateOrigin( + callingAppInfo = mockNonPrivilegedAppInfo, + relyingPartyId = DEFAULT_RP_ID, + ) + + assertEquals( + Fido2ValidateOriginResult.Success(null), + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return ApplicationFingerprintNotVerified when calling app is NonPrivileged but signature does not match asset link entry`() = + runTest { + coEvery { + mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID) + } returns listOf( + DEFAULT_STATEMENT.copy( + target = DEFAULT_STATEMENT.target.copy( + sha256CertFingerprints = listOf("invalid_fingerprint"), + ), + ), + ) + .asSuccess() + + assertEquals( + Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified, + fido2OriginManager.validateOrigin( + callingAppInfo = mockNonPrivilegedAppInfo, + relyingPartyId = DEFAULT_RP_ID, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return ApplicationNotFound when calling app is NonPrivileged and packageName has no asset link entry`() = + runTest { + coEvery { + mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID) + } returns listOf( + DEFAULT_STATEMENT.copy( + target = DEFAULT_STATEMENT.target.copy( + packageName = "invalid_package_name", + ), + ), + ) + .asSuccess() + + assertEquals( + Fido2ValidateOriginResult.Error.ApplicationNotFound, + fido2OriginManager.validateOrigin( + callingAppInfo = mockNonPrivilegedAppInfo, + relyingPartyId = DEFAULT_RP_ID, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return AssetLinkNotFound when calling app is NonPrivileged and asset link does not exist`() = + runTest { + coEvery { + mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID) + } returns RuntimeException().asFailure() + + assertEquals( + Fido2ValidateOriginResult.Error.AssetLinkNotFound, + fido2OriginManager.validateOrigin( + callingAppInfo = mockNonPrivilegedAppInfo, + relyingPartyId = DEFAULT_RP_ID, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return Unknown error when calling app is NonPrivileged and exception is caught while filtering asset links`() = + runTest { + coEvery { + mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID) + } returns listOf(DEFAULT_STATEMENT).asSuccess() + every { + mockNonPrivilegedAppInfo.packageName + } throws IllegalStateException() + + assertEquals( + Fido2ValidateOriginResult.Error.Unknown, + fido2OriginManager.validateOrigin( + callingAppInfo = mockNonPrivilegedAppInfo, + relyingPartyId = DEFAULT_RP_ID, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return Unknown error when calling app is Privileged and allow list file read fails`() = + runTest { + coEvery { + mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID) + } returns listOf(DEFAULT_STATEMENT).asSuccess() + coEvery { + mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) + } returns IllegalStateException().asFailure() + coEvery { + mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) + } returns IllegalStateException().asFailure() + + assertEquals( + Fido2ValidateOriginResult.Error.Unknown, + fido2OriginManager.validateOrigin( + callingAppInfo = mockPrivilegedAppInfo, + relyingPartyId = DEFAULT_RP_ID, + ), + ) + } +} + +private const val DEFAULT_PACKAGE_NAME = "com.x8bit.bitwarden" +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 const val DEFAULT_RP_ID = "bitwarden.com" +private const val DEFAULT_ORIGIN = "bitwarden.com" +private const val GOOGLE_ALLOW_LIST_FILENAME = "fido2_privileged_google.json" +private const val COMMUNITY_ALLOW_LIST_FILENAME = "fido2_privileged_community.json" +private const val DEFAULT_ALLOW_LIST = """ +{ + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.x8bit.bitwarden", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + } + ] +} +""" +private const val FAIL_ALLOW_LIST = """ +{ + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.not.bitwarden", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF" + } + ] + } + } + ] +} +""" +private val DEFAULT_STATEMENT = DigitalAssetLinkResponseJson( + relation = listOf( + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls", + ), + target = DigitalAssetLinkResponseJson.Target( + namespace = "android_app", + packageName = DEFAULT_PACKAGE_NAME, + sha256CertFingerprints = listOf( + DEFAULT_CERT_FINGERPRINT, + ), + ), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt index e1e648440e..d61c4c0485 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt @@ -120,7 +120,7 @@ class CallingAppInfoExtensionsTest { } assertEquals( - Fido2ValidateOriginResult.Success, + Fido2ValidateOriginResult.Success("origin"), mockAppInfo.validatePrivilegedApp( allowList = DEFAULT_ALLOW_LIST, ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 975c76acff..d859bf4be7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest @@ -165,7 +166,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND) } returns emptyFlow() } private val fido2CredentialManager: Fido2CredentialManager = mockk { - coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success every { isUserVerified } returns false every { isUserVerified = any() } just runs every { authenticationAttempts } returns 0 @@ -173,6 +173,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { hasAuthenticationAttemptsRemaining() } returns true every { getPasskeyAttestationOptionsOrNull(any()) } returns mockk(relaxed = true) } + private val fido2OriginManager: Fido2OriginManager = mockk { + coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null) + } private val organizationEventManager = mockk { every { trackEvent(event = any()) } just runs @@ -1562,8 +1565,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns DecryptFido2CredentialAutofillViewResult.Success(emptyList()) coEvery { - fido2CredentialManager.validateOrigin(any(), any()) - } returns Fido2ValidateOriginResult.Success + fido2OriginManager.validateOrigin(any(), any()) + } returns Fido2ValidateOriginResult.Success("") mockFilteredCiphers = listOf(cipherView1) @@ -1618,7 +1621,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { vaultRepository.getDecryptedFido2CredentialAutofillViews( cipherViewList = listOf(cipherView1, cipherView2), ) - fido2CredentialManager.validateOrigin(any(), any()) + fido2OriginManager.validateOrigin(any(), any()) } } @@ -2163,7 +2166,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createVaultItemListingViewModel() coVerify(ordering = Ordering.ORDERED) { - fido2CredentialManager.validateOrigin(any(), any()) + fido2OriginManager.validateOrigin(any(), any()) vaultRepository.vaultDataStateFlow } } @@ -2181,7 +2184,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) coEvery { - fido2CredentialManager.validateOrigin(any(), any()) + fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.Unknown val viewModel = createVaultItemListingViewModel() @@ -2212,7 +2215,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) coEvery { - fido2CredentialManager.validateOrigin(any(), any()) + fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed val viewModel = createVaultItemListingViewModel() @@ -2243,7 +2246,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) coEvery { - fido2CredentialManager.validateOrigin(any(), any()) + fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound val viewModel = createVaultItemListingViewModel() @@ -2274,7 +2277,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) coEvery { - fido2CredentialManager.validateOrigin(any(), any()) + fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp val viewModel = createVaultItemListingViewModel() @@ -2305,7 +2308,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) coEvery { - fido2CredentialManager.validateOrigin(any(), any()) + fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.ApplicationNotFound val viewModel = createVaultItemListingViewModel() @@ -2336,7 +2339,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) coEvery { - fido2CredentialManager.validateOrigin(any(), any()) + fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.AssetLinkNotFound val viewModel = createVaultItemListingViewModel() @@ -2367,8 +2370,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) coEvery { - fido2CredentialManager.validateOrigin(any(), any()) - } returns Fido2ValidateOriginResult.Error.ApplicationNotVerified + fido2OriginManager.validateOrigin(any(), any()) + } returns Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified val viewModel = createVaultItemListingViewModel() @@ -2790,7 +2793,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns mockAssertionOptions coEvery { - fido2CredentialManager.validateOrigin(any(), any()) + fido2OriginManager.validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Error.Unknown val viewModel = createVaultItemListingViewModel() @@ -4054,6 +4057,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { policyManager = policyManager, fido2CredentialManager = fido2CredentialManager, organizationEventManager = organizationEventManager, + fido2OriginManager = fido2OriginManager, ) @Suppress("MaxLineLength")