diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt index a0e7dd9a00..69089007d5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt @@ -14,6 +14,8 @@ import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl import com.x8bit.bitwarden.data.credentials.manager.OriginManager import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl +import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser +import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParserImpl import com.x8bit.bitwarden.data.credentials.processor.CredentialProviderProcessor import com.x8bit.bitwarden.data.credentials.processor.CredentialProviderProcessorImpl import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository @@ -121,4 +123,10 @@ object CredentialProviderModule { privilegedAppDiskSource = privilegedAppDiskSource, json = json, ) + + @Provides + @Singleton + fun provideRelyingPartyParser( + json: Json, + ): RelyingPartyParser = RelyingPartyParserImpl(json) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManager.kt index fa2d1834a1..5a7951ce01 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManager.kt @@ -11,11 +11,13 @@ interface OriginManager { /** * Validates the origin of a calling app. * + * @param relyingPartyId The ID of the relying party that sent the request. * @param callingAppInfo The calling app info. * * @return The result of the validation. */ suspend fun validateOrigin( + relyingPartyId: String, callingAppInfo: CallingAppInfo, ): ValidateOriginResult } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt index 5ced992250..fc4def9284 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.credentials.manager import androidx.credentials.provider.CallingAppInfo import com.bitwarden.network.service.DigitalAssetLinkService +import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.platform.manager.AssetManager @@ -26,25 +27,28 @@ class OriginManagerImpl( ) : OriginManager { override suspend fun validateOrigin( + relyingPartyId: String, callingAppInfo: CallingAppInfo, ): ValidateOriginResult { return if (callingAppInfo.isOriginPopulated()) { validatePrivilegedAppOrigin(callingAppInfo) } else { - validateCallingApplicationAssetLinks(callingAppInfo) + validateCallingApplicationAssetLinks(relyingPartyId, callingAppInfo) } } private suspend fun validateCallingApplicationAssetLinks( + relyingPartyId: String, callingAppInfo: CallingAppInfo, ): ValidateOriginResult { return digitalAssetLinkService .checkDigitalAssetLinksRelations( - packageName = callingAppInfo.packageName, - certificateFingerprint = callingAppInfo + sourceWebSite = relyingPartyId.prefixHttpsIfNecessary(), + targetPackageName = callingAppInfo.packageName, + targetCertificateFingerprint = callingAppInfo .getSignatureFingerprintAsHexString() .orEmpty(), - relation = DELEGATE_PERMISSION_HANDLE_ALL_URLS, + relations = listOf(DELEGATE_PERMISSION_HANDLE_ALL_URLS), ) .fold( onSuccess = { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/parser/RelyingPartyParser.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/parser/RelyingPartyParser.kt new file mode 100644 index 0000000000..d6d355e216 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/parser/RelyingPartyParser.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.data.credentials.parser + +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption + +/** + * A tool for parsing relying party data from the Credential Manager requests. + */ +interface RelyingPartyParser { + /** + * Parse the relying party ID from the [GetPublicKeyCredentialOption]. + */ + fun parse(getPublicKeyCredentialOption: GetPublicKeyCredentialOption): String? + + /** + * Parse the relying party ID from the [CreatePublicKeyCredentialRequest]. + */ + fun parse(createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest): String? + + /** + * Parse the relying party ID from the [BeginGetPublicKeyCredentialOption]. + */ + fun parse(beginGetPublicKeyCredentialOption: BeginGetPublicKeyCredentialOption): String? +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/parser/RelyingPartyParserImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/parser/RelyingPartyParserImpl.kt new file mode 100644 index 0000000000..c4a333d703 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/parser/RelyingPartyParserImpl.kt @@ -0,0 +1,40 @@ +package com.x8bit.bitwarden.data.credentials.parser + +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import com.bitwarden.core.data.util.decodeFromStringOrNull +import com.x8bit.bitwarden.data.credentials.model.PasskeyAssertionOptions +import com.x8bit.bitwarden.data.credentials.model.PasskeyAttestationOptions +import kotlinx.serialization.json.Json + +/** + * Default implementation of [RelyingPartyParser]. + */ +class RelyingPartyParserImpl( + private val json: Json, +) : RelyingPartyParser { + + override fun parse( + getPublicKeyCredentialOption: GetPublicKeyCredentialOption, + ): String? = json + .decodeFromStringOrNull(getPublicKeyCredentialOption.requestJson) + ?.relyingPartyId + + override fun parse( + createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest, + ): String? = json + .decodeFromStringOrNull( + createPublicKeyCredentialRequest.requestJson, + ) + ?.relyingParty + ?.id + + override fun parse( + beginGetPublicKeyCredentialOption: BeginGetPublicKeyCredentialOption, + ): String? = json + .decodeFromStringOrNull( + beginGetPublicKeyCredentialOption.requestJson, + ) + ?.relyingPartyId +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index af58547803..f948c14ce7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult +import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.credentials.util.getCreatePasskeyCredentialRequestOrNull import com.x8bit.bitwarden.data.platform.manager.PolicyManager @@ -136,6 +137,7 @@ class VaultItemListingViewModel @Inject constructor( private val organizationEventManager: OrganizationEventManager, private val networkConnectionManager: NetworkConnectionManager, private val snackbarRelayManager: SnackbarRelayManager, + private val relyingPartyParser: RelyingPartyParser, ) : BaseViewModel( initialState = run { val userState = requireNotNull(authRepository.userStateFlow.value) @@ -1028,6 +1030,7 @@ class VaultItemListingViewModel @Inject constructor( } } + @Suppress("LongMethod") private fun authenticateFido2Credential( request: ProviderGetCredentialRequest, cipherView: CipherView, @@ -1048,10 +1051,20 @@ class VaultItemListingViewModel @Inject constructor( ) return } + val relyingPartyId = relyingPartyParser.parse(option) + ?: run { + showCredentialManagerErrorDialog( + R.string.passkey_operation_failed_because_relying_party_cannot_be_identified + .asText(), + ) + return + } viewModelScope.launch { - val validateOriginResult = originManager - .validateOrigin(callingAppInfo = request.callingAppInfo) + .validateOrigin( + relyingPartyId = relyingPartyId, + callingAppInfo = request.callingAppInfo, + ) when (validateOriginResult) { is ValidateOriginResult.Error -> { @@ -1796,9 +1809,20 @@ class VaultItemListingViewModel @Inject constructor( private fun handleRegisterFido2CredentialRequestReceive( action: VaultItemListingsAction.Internal.CreateCredentialRequestReceive, ) { + val relyingPartyId = action.request.providerRequest + .getCreatePasskeyCredentialRequestOrNull() + ?.let { relyingPartyParser.parse(it) } + ?: run { + showCredentialManagerErrorDialog( + R.string.passkey_operation_failed_because_relying_party_cannot_be_identified + .asText(), + ) + return + } viewModelScope.launch { val validateOriginResult = originManager .validateOrigin( + relyingPartyId = relyingPartyId, callingAppInfo = action.request.callingAppInfo, ) when (validateOriginResult) { @@ -1861,8 +1885,22 @@ class VaultItemListingViewModel @Inject constructor( return } + val relyingPartyId = request + .beginGetPublicKeyCredentialOptions + .mapNotNull { relyingPartyParser.parse(it) } + .distinct() + .firstOrNull() + ?: run { + showCredentialManagerErrorDialog( + R.string.passkey_operation_failed_because_relying_party_cannot_be_identified + .asText(), + ) + return + } + viewModelScope.launch { val validateOriginResult = originManager.validateOrigin( + relyingPartyId = relyingPartyId, callingAppInfo = callingAppInfo, ) when (validateOriginResult) { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt index 5d5d23904b..764b0095e9 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt @@ -51,7 +51,7 @@ class OriginManagerTest { every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray() } - private val fido2OriginManager = OriginManagerImpl( + private val originManager = OriginManagerImpl( assetManager = mockAssetManager, digitalAssetLinkService = mockDigitalAssetLinkService, privilegedAppRepository = mockPrivilegedAppRepository, @@ -83,7 +83,8 @@ class OriginManagerTest { mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) } returns DEFAULT_ALLOW_LIST.asSuccess() - val result = fido2OriginManager.validateOrigin( + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_ORIGIN, callingAppInfo = mockPrivilegedAppInfo, ) coVerify(exactly = 1) { @@ -106,7 +107,8 @@ class OriginManagerTest { mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) } returns DEFAULT_ALLOW_LIST.asSuccess() - val result = fido2OriginManager.validateOrigin( + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_ORIGIN, callingAppInfo = mockPrivilegedAppInfo, ) coVerify(exactly = 1) { @@ -133,7 +135,8 @@ class OriginManagerTest { mockPrivilegedAppRepository.getUserTrustedAllowListJson() } returns DEFAULT_ALLOW_LIST - val result = fido2OriginManager.validateOrigin( + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_ORIGIN, callingAppInfo = mockPrivilegedAppInfo, ) coVerify(exactly = 1) { @@ -161,7 +164,8 @@ class OriginManagerTest { mockPrivilegedAppRepository.getUserTrustedAllowListJson() } returns FAIL_ALLOW_LIST - val result = fido2OriginManager.validateOrigin( + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_ORIGIN, callingAppInfo = mockPrivilegedAppInfo, ) @@ -181,13 +185,15 @@ class OriginManagerTest { runTest { coEvery { mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( - packageName = DEFAULT_PACKAGE_NAME, - certificateFingerprint = DEFAULT_CERT_FINGERPRINT, - relation = "delegate_permission/common.handle_all_urls", + sourceWebSite = "https://$DEFAULT_RELYING_PARTY_ID", + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT, + relations = listOf("delegate_permission/common.handle_all_urls"), ) } returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE.asSuccess() - val result = fido2OriginManager.validateOrigin( + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, callingAppInfo = mockNonPrivilegedAppInfo, ) @@ -203,9 +209,10 @@ class OriginManagerTest { runTest { coEvery { mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( - packageName = DEFAULT_PACKAGE_NAME, - certificateFingerprint = DEFAULT_CERT_FINGERPRINT, - relation = "delegate_permission/common.handle_all_urls", + sourceWebSite = "https://$DEFAULT_RELYING_PARTY_ID", + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT, + relations = listOf("delegate_permission/common.handle_all_urls"), ) } returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE .copy(linked = false) @@ -213,7 +220,10 @@ class OriginManagerTest { assertEquals( ValidateOriginResult.Error.PasskeyNotSupportedForApp, - fido2OriginManager.validateOrigin(callingAppInfo = mockNonPrivilegedAppInfo), + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockNonPrivilegedAppInfo, + ), ) } @@ -223,15 +233,19 @@ class OriginManagerTest { runTest { coEvery { mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( - packageName = DEFAULT_PACKAGE_NAME, - certificateFingerprint = DEFAULT_CERT_FINGERPRINT, - relation = "delegate_permission/common.handle_all_urls", + sourceWebSite = "https://$DEFAULT_RELYING_PARTY_ID", + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT, + relations = listOf("delegate_permission/common.handle_all_urls"), ) } returns RuntimeException().asFailure() assertEquals( ValidateOriginResult.Error.AssetLinkNotFound, - fido2OriginManager.validateOrigin(callingAppInfo = mockNonPrivilegedAppInfo), + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockNonPrivilegedAppInfo, + ), ) } @@ -247,7 +261,8 @@ class OriginManagerTest { mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) } returns FAIL_ALLOW_LIST.asSuccess() - val result = fido2OriginManager.validateOrigin( + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_ORIGIN, callingAppInfo = mockPrivilegedAppInfo, ) assertEquals( @@ -266,6 +281,7 @@ 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_ORIGIN = "bitwarden.com" +private const val DEFAULT_RELYING_PARTY_ID = "www.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 = """ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/parser/RelyingPartyParserTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/parser/RelyingPartyParserTest.kt new file mode 100644 index 0000000000..a86e70da6b --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/parser/RelyingPartyParserTest.kt @@ -0,0 +1,180 @@ +package com.x8bit.bitwarden.data.credentials.parser + +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import com.bitwarden.core.di.CoreModule +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull + +class RelyingPartyParserTest { + + private val relyingPartyParser = RelyingPartyParserImpl(json = CoreModule.providesJson()) + + @Test + fun `parse GetPublicKeyCredentialOption should return relyingPartyId`() { + val result = relyingPartyParser.parse( + mockk { + every { requestJson } returns DEFAULT_ASSERTION_OPTIONS_JSON + }, + ) + + assertEquals( + DEFAULT_RELYING_PARTY_ID, + result, + ) + } + + @Test + fun `parse GetPublicKeyCredentialOption should return null if relyingPartyId is missing`() { + val result = relyingPartyParser.parse( + mockk { + every { requestJson } returns INVALID_ASSERTION_OPTIONS_JSON + }, + ) + + assertNull(result) + } + + @Test + fun `parse CreatePublicKeyCredentialRequest should return relyingPartyId`() { + val result = relyingPartyParser.parse( + mockk { + every { requestJson } returns DEFAULT_ATTESTATION_OPTIONS_JSON + }, + ) + + assertEquals( + DEFAULT_RELYING_PARTY_ID, + result, + ) + } + + @Test + fun `parse CreatePublicKeyCredentialRequest should return null if relyingPartyId is missing`() { + val result = relyingPartyParser.parse( + mockk { + every { requestJson } returns INVALID_ATTESTATION_OPTIONS_JSON + }, + ) + + assertNull(result) + } + + @Test + fun `parse BeginGetPublicKeyCredentialOption should return relyingPartyId`() { + val result = relyingPartyParser.parse( + mockk { + every { requestJson } returns DEFAULT_ASSERTION_OPTIONS_JSON + }, + ) + + assertEquals( + DEFAULT_RELYING_PARTY_ID, + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `parse BeginGetPublicKeyCredentialOption should return null if relyingPartyId is missing`() { + val result = relyingPartyParser.parse( + mockk { + every { requestJson } returns INVALID_ASSERTION_OPTIONS_JSON + }, + ) + + assertNull(result) + } +} + +private const val DEFAULT_RELYING_PARTY_ID = "www.bitwarden.com" +private val DEFAULT_ATTESTATION_OPTIONS_JSON = """ +{ + "attestation": "direct", + "authenticatorSelection": { + "residentKey": "required", + "userVerification": "preferred" + }, + "challenge": "tZ1rLJ_paLC8IMmg", + "excludeCredentials": [], + "extensions": { + "credProps": true + }, + "pubKeyCredParams": [ + { + "alg": -7, + "type": "public-key" + }, + { + "alg": -257, + "type": "public-key" + } + ], + "rp": { + "id": "$DEFAULT_RELYING_PARTY_ID", + "name": "mockRpName" + }, + "user": { + "displayName": "mockDisplayName", + "id": "UmhpTE9NOUY", + "name": "mockUserName" + } +} +""" + .trimIndent() +private val INVALID_ATTESTATION_OPTIONS_JSON = """ +{ + "attestation": "direct", + "authenticatorSelection": { + "residentKey": "required", + "userVerification": "preferred" + }, + "challenge": "tZ1rLJ_paLC8IMmg", + "excludeCredentials": [], + "extensions": { + "credProps": true + }, + "pubKeyCredParams": [ + { + "alg": -7, + "type": "public-key" + }, + { + "alg": -257, + "type": "public-key" + } + ], + "rp": { + "name": "mockRpName" + }, + "user": { + "displayName": "mockDisplayName", + "id": "UmhpTE9NOUY", + "name": "mockUserName" + } +} +""" + .trimIndent() +private val DEFAULT_ASSERTION_OPTIONS_JSON = """ +{ + "challenge": "FFeZc7g-BPSAPo", + "allowCredentials": [], + "timeout": 60000, + "userVerification": "preferred", + "rpId": "$DEFAULT_RELYING_PARTY_ID" +} +""" + .trimIndent() +private val INVALID_ASSERTION_OPTIONS_JSON = """ +{ + "challenge": "FFeZc7g-BPSAPo", + "allowCredentials": [], + "timeout": 60000, + "userVerification": "preferred", +} +""" + .trimIndent() diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 4f1ce3eda9..31af42501a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -56,6 +56,7 @@ import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult import com.x8bit.bitwarden.data.credentials.model.createMockCreateCredentialRequest import com.x8bit.bitwarden.data.credentials.model.createMockFido2CredentialAssertionRequest import com.x8bit.bitwarden.data.credentials.model.createMockGetCredentialsRequest +import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager @@ -151,7 +152,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { Instant.parse("2023-10-27T12:00:00Z"), ZoneOffset.UTC, ) - private val clipboardManager: BitwardenClipboardManager = mockk { every { setText(text = any(), toastDescriptorOverride = any()) } just runs } @@ -212,7 +212,12 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { coEvery { getCredentialEntries(any()) } returns emptyList().asSuccess() } private val originManager: OriginManager = mockk { - coEvery { validateOrigin(any()) } returns ValidateOriginResult.Success(null) + coEvery { + validateOrigin( + relyingPartyId = any(), + callingAppInfo = any(), + ) + } returns ValidateOriginResult.Success(null) } private val organizationEventManager = mockk { @@ -235,17 +240,26 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { packageName } returns "mockPackageName" every { isOriginPopulated() } returns false } + private val mockGetPublicKeyCredentialOption = mockk { + every { requestJson } returns "mockRequestJson" + } private val mockProviderGetCredentialRequest = mockk { - every { credentialOptions } returns listOf(mockk()) + every { credentialOptions } returns listOf(mockGetPublicKeyCredentialOption) every { callingAppInfo } returns mockCallingAppInfo } - private val mockBeginGetPublicKeyCredentialOption = mockk() + private val mockBeginGetPublicKeyCredentialOption = mockk { + every { requestJson } returns "mockRequestJson" + } private val mockBeginGetCredentialRequest = mockk { every { beginGetCredentialOptions } returns listOf(mockBeginGetPublicKeyCredentialOption) every { callingAppInfo } returns mockCallingAppInfo } - val mockProviderCreateCredentialRequest = mockk { - every { callingRequest } returns mockk(relaxed = true) + private val mockCreatePublicKeyCredentialRequest = mockk { + every { requestJson } returns "mockRequestJson" + every { origin } returns "mockOrigin" + } + private val mockProviderCreateCredentialRequest = mockk { + every { callingRequest } returns mockCreatePublicKeyCredentialRequest every { callingAppInfo } returns mockCallingAppInfo } private val mutableSnackbarDataFlow: MutableSharedFlow = @@ -255,6 +269,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { getSnackbarDataFlow(relay = any(), relays = anyVararg()) } returns mutableSnackbarDataFlow } + private val relyingPartyParser = mockk { + every { parse(any()) } returns DEFAULT_RELYING_PARTY_ID + every { parse(any()) } returns DEFAULT_RELYING_PARTY_ID + every { parse(any()) } returns DEFAULT_RELYING_PARTY_ID + } @BeforeEach fun setUp() { @@ -309,7 +328,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createCredentialRequest = createCredentialRequest, ) coEvery { - originManager.validateOrigin(any()) + originManager.validateOrigin(any(), any()) } returns ValidateOriginResult.Success(null) val viewModel = createVaultItemListingViewModel() @@ -1923,7 +1942,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns DecryptFido2CredentialAutofillViewResult.Success(emptyList()) coEvery { - originManager.validateOrigin(any()) + originManager.validateOrigin(any(), any()) } returns ValidateOriginResult.Success("") mockFilteredCiphers = listOf(cipherView1) @@ -1977,7 +1996,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { vaultRepository.getDecryptedFido2CredentialAutofillViews( cipherViewList = listOf(cipherView1, cipherView2), ) - originManager.validateOrigin(any()) + originManager.validateOrigin(any(), any()) } } @@ -2633,13 +2652,16 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createMockCreateCredentialRequest(number = 1), ) coEvery { - originManager.validateOrigin(mockCallingAppInfo) + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockCallingAppInfo, + ) } returns ValidateOriginResult.Success("mockOrigin") createVaultItemListingViewModel() coVerify(ordering = Ordering.ORDERED) { - originManager.validateOrigin(any()) + originManager.validateOrigin(any(), any()) vaultRepository.vaultDataStateFlow } } @@ -2651,7 +2673,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createMockCreateCredentialRequest(number = 1), ) coEvery { - originManager.validateOrigin(mockCallingAppInfo) + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockCallingAppInfo, + ) } returns ValidateOriginResult.Error.Unknown val viewModel = createVaultItemListingViewModel() @@ -2674,7 +2699,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createCredentialRequest = createMockCreateCredentialRequest(number = 1), ) coEvery { - originManager.validateOrigin(mockCallingAppInfo) + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockCallingAppInfo, + ) } returns ValidateOriginResult.Error.PrivilegedAppNotAllowed val viewModel = createVaultItemListingViewModel() @@ -2698,7 +2726,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createCredentialRequest = createMockCreateCredentialRequest(number = 1), ) coEvery { - originManager.validateOrigin(mockCallingAppInfo) + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockCallingAppInfo, + ) } returns ValidateOriginResult.Error.PrivilegedAppSignatureNotFound val viewModel = createVaultItemListingViewModel() @@ -2721,7 +2752,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createCredentialRequest = createMockCreateCredentialRequest(number = 1), ) coEvery { - originManager.validateOrigin(mockCallingAppInfo) + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockCallingAppInfo, + ) } returns ValidateOriginResult.Error.PasskeyNotSupportedForApp val viewModel = createVaultItemListingViewModel() @@ -2744,7 +2778,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createCredentialRequest = createMockCreateCredentialRequest(number = 1), ) coEvery { - originManager.validateOrigin(mockCallingAppInfo) + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockCallingAppInfo, + ) } returns ValidateOriginResult.Error.AssetLinkNotFound val viewModel = createVaultItemListingViewModel() @@ -2918,7 +2955,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { bitwardenCredentialManager.getCredentialEntries(any()) } returns emptyList().asSuccess() coEvery { - originManager.validateOrigin(callingAppInfo = any()) + originManager.validateOrigin(relyingPartyId = any(), callingAppInfo = any()) } returns ValidateOriginResult.Success("mockOrigin") every { vaultRepository @@ -2996,7 +3033,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertEquals( VaultItemListingState.DialogState.CredentialManagerOperationFail( title = R.string.an_error_has_occurred.asText(), - message = R.string.generic_error_message.asText(), + message = + R.string.passkey_operation_failed_because_relying_party_cannot_be_identified + .asText(), ), viewModel.stateFlow.value.dialogState, ) @@ -3066,7 +3105,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { mockGetCredentialsRequest, ) coEvery { - originManager.validateOrigin(callingAppInfo = any()) + originManager.validateOrigin(relyingPartyId = any(), callingAppInfo = any()) } returns ValidateOriginResult.Error.Unknown val dataState = DataState.Loaded( @@ -3273,6 +3312,59 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `Fido2Assertion should show error dialog when relying party cannot be identified`() = + runTest { + setupMockUri() + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + val mockCipherView = createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { bitwardenCredentialManager.isUserVerified } returns true + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf(mockCipherView) + every { + relyingPartyParser.parse(mockGetPublicKeyCredentialOption) + } returns null + + val dataState = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(mockCipherView), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + ) + val viewModel = createVaultItemListingViewModel() + mutableVaultDataStateFlow.value = dataState + + coVerify(exactly = 0) { + originManager.validateOrigin(any(), any()) + } + viewModel.stateFlow.test { + assertEquals( + VaultItemListingState.DialogState.CredentialManagerOperationFail( + title = R.string.an_error_has_occurred.asText(), + message = + R.string.passkey_operation_failed_because_relying_party_cannot_be_identified + .asText(), + ), + awaitItem().dialogState, + ) + } + } + @Test fun `Fido2AssertionRequest should show error dialog when validateOrigin is not Success`() = runTest { @@ -3295,7 +3387,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { .data } returns listOf(mockCipherView) coEvery { - originManager.validateOrigin(any()) + originManager.validateOrigin(any(), any()) } returns ValidateOriginResult.Error.Unknown val dataState = DataState.Loaded( @@ -3851,7 +3943,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( fido2AssertionRequest = mockAssertionRequest, ) - every { ProviderGetCredentialRequest.fromBundle(any()) } returns mockk(relaxed = true) { @@ -4579,7 +4670,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { mockCallingAppInfo.getSignatureFingerprintAsHexString() } returns "mockSignature" coEvery { - originManager.validateOrigin(any()) + originManager.validateOrigin(any(), any()) } returns ValidateOriginResult.Error.PrivilegedAppNotAllowed val viewModel = createVaultItemListingViewModel() @@ -4650,7 +4741,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { mockCallingAppInfo.getSignatureFingerprintAsHexString() } returns "mockSignature" coEvery { - originManager.validateOrigin(mockCallingAppInfo) + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockCallingAppInfo, + ) } returns ValidateOriginResult.Error.PrivilegedAppNotAllowed coEvery { bitwardenCredentialManager.getCredentialEntries(any()) @@ -4770,7 +4864,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { cipherId = cipherView.id!!, ), ) - every { mockCallingAppInfo.getSignatureFingerprintAsHexString() } returns "mockSignature" @@ -4792,7 +4885,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) every { bitwardenCredentialManager.isUserVerified } returns true coEvery { - originManager.validateOrigin(mockCallingAppInfo) + originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockCallingAppInfo, + ) } returns ValidateOriginResult.Success("mockOrigin") mutableVaultDataStateFlow.value = DataState.Loaded( @@ -5016,6 +5112,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { networkConnectionManager = networkConnectionManager, privilegedAppRepository = privilegedAppRepository, snackbarRelayManager = snackbarRelayManager, + relyingPartyParser = relyingPartyParser, ) @Suppress("MaxLineLength") @@ -5069,3 +5166,5 @@ private val DEFAULT_USER_STATE = UserState( activeUserId = "activeUserId", accounts = listOf(DEFAULT_ACCOUNT), ) + +private const val DEFAULT_RELYING_PARTY_ID = "www.bitwarden.com" diff --git a/network/src/main/kotlin/com/bitwarden/network/api/DigitalAssetLinkApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/DigitalAssetLinkApi.kt index 537e1fcbe4..713721f633 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/DigitalAssetLinkApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/DigitalAssetLinkApi.kt @@ -13,19 +13,22 @@ import retrofit2.http.Query internal interface DigitalAssetLinkApi { /** - * Checks if the given [relation] exists in a digital asset link file. + * Checks if the given [relations] are declared in the digital asset link file for the given + * [sourceWebSite] for the given [targetPackageName] with a [targetCertificateFingerprint]. + * + * @param sourceWebSite The host of the source digital asset links file. + * @param targetPackageName The package name of the target application. + * @param targetCertificateFingerprint The certificate fingerprint of the target application. */ @GET("v1/assetlinks:check") suspend fun checkDigitalAssetLinksRelations( - @Query("source.androidApp.packageName") - sourcePackageName: String, - @Query("source.androidApp.certificate.sha256Fingerprint") - sourceCertificateFingerprint: String, + @Query("source.web.site") + sourceWebSite: String, @Query("target.androidApp.packageName") targetPackageName: String, @Query("target.androidApp.certificate.sha256Fingerprint") targetCertificateFingerprint: String, @Query("relation") - relation: String, + relations: List, ): NetworkResult } diff --git a/network/src/main/kotlin/com/bitwarden/network/service/DigitalAssetLinkService.kt b/network/src/main/kotlin/com/bitwarden/network/service/DigitalAssetLinkService.kt index bda071850a..fce13787ab 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/DigitalAssetLinkService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/DigitalAssetLinkService.kt @@ -7,12 +7,17 @@ import com.bitwarden.network.model.DigitalAssetLinkCheckResponseJson */ interface DigitalAssetLinkService { /** - * Checks if the given [packageName] with a given [certificateFingerprint] has the given - * [relation]. + * Checks if the given [relations] are declared in the digital asset link file for the given + * [sourceWebSite] for the given [targetPackageName] with a [targetCertificateFingerprint]. + * + * @param sourceWebSite The host of the source digital asset links file. + * @param targetPackageName The package name of the target application. + * @param targetCertificateFingerprint The certificate fingerprint of the target application. */ suspend fun checkDigitalAssetLinksRelations( - packageName: String, - certificateFingerprint: String, - relation: String, + sourceWebSite: String, + targetPackageName: String, + targetCertificateFingerprint: String, + relations: List, ): Result } diff --git a/network/src/main/kotlin/com/bitwarden/network/service/DigitalAssetLinkServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/DigitalAssetLinkServiceImpl.kt index a57add42e4..2796ffb192 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/DigitalAssetLinkServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/DigitalAssetLinkServiceImpl.kt @@ -12,16 +12,16 @@ internal class DigitalAssetLinkServiceImpl( ) : DigitalAssetLinkService { override suspend fun checkDigitalAssetLinksRelations( - packageName: String, - certificateFingerprint: String, - relation: String, + sourceWebSite: String, + targetPackageName: String, + targetCertificateFingerprint: String, + relations: List, ): Result = digitalAssetLinkApi .checkDigitalAssetLinksRelations( - sourcePackageName = packageName, - sourceCertificateFingerprint = certificateFingerprint, - targetPackageName = packageName, - targetCertificateFingerprint = certificateFingerprint, - relation = relation, + sourceWebSite = sourceWebSite, + targetPackageName = targetPackageName, + targetCertificateFingerprint = targetCertificateFingerprint, + relations = relations, ) .toResult() } diff --git a/network/src/test/kotlin/com/bitwarden/network/service/DigitalAssetLinkServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/DigitalAssetLinkServiceTest.kt index d9b1c31f6c..c19da25143 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/DigitalAssetLinkServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/DigitalAssetLinkServiceTest.kt @@ -28,10 +28,11 @@ class DigitalAssetLinkServiceTest : BaseServiceTest() { ) .asSuccess(), digitalAssetLinkService.checkDigitalAssetLinksRelations( - packageName = "com.x8bit.bitwarden", - certificateFingerprint = + sourceWebSite = "https://www.bitwarden.com", + targetPackageName = "com.x8bit.bitwarden", + targetCertificateFingerprint = "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13", - relation = "delegate_permission/common.handle_all_urls", + relations = listOf("delegate_permission/common.handle_all_urls"), ), ) } @@ -42,4 +43,5 @@ private val CHECK_DIGITAL_ASSET_LINKS_RELATIONS_SUCCESS_JSON = """ "linked": true, "maxAge": "47.535162130s" } -""".trimIndent() +""" + .trimIndent()