[PM-22441] Refactor DigitalAssetLinkService to use source website (#5351)

This commit is contained in:
Patrick Honkonen 2025-06-13 12:03:27 -04:00 committed by GitHub
parent 861a4281fa
commit 7de770ca03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 494 additions and 72 deletions

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 = {

View File

@ -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?
}

View File

@ -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<PasskeyAssertionOptions>(getPublicKeyCredentialOption.requestJson)
?.relyingPartyId
override fun parse(
createPublicKeyCredentialRequest: CreatePublicKeyCredentialRequest,
): String? = json
.decodeFromStringOrNull<PasskeyAttestationOptions>(
createPublicKeyCredentialRequest.requestJson,
)
?.relyingParty
?.id
override fun parse(
beginGetPublicKeyCredentialOption: BeginGetPublicKeyCredentialOption,
): String? = json
.decodeFromStringOrNull<PasskeyAssertionOptions>(
beginGetPublicKeyCredentialOption.requestJson,
)
?.relyingPartyId
}

View File

@ -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<VaultItemListingState, VaultItemListingEvent, VaultItemListingsAction>(
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) {

View File

@ -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 = """

View File

@ -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<GetPublicKeyCredentialOption> {
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<GetPublicKeyCredentialOption> {
every { requestJson } returns INVALID_ASSERTION_OPTIONS_JSON
},
)
assertNull(result)
}
@Test
fun `parse CreatePublicKeyCredentialRequest should return relyingPartyId`() {
val result = relyingPartyParser.parse(
mockk<CreatePublicKeyCredentialRequest> {
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<CreatePublicKeyCredentialRequest> {
every { requestJson } returns INVALID_ATTESTATION_OPTIONS_JSON
},
)
assertNull(result)
}
@Test
fun `parse BeginGetPublicKeyCredentialOption should return relyingPartyId`() {
val result = relyingPartyParser.parse(
mockk<BeginGetPublicKeyCredentialOption> {
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<BeginGetPublicKeyCredentialOption> {
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()

View File

@ -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<String>(), toastDescriptorOverride = any<Text>()) } just runs
}
@ -212,7 +212,12 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
coEvery { getCredentialEntries(any()) } returns emptyList<CredentialEntry>().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<OrganizationEventManager> {
@ -235,17 +240,26 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
every { packageName } returns "mockPackageName"
every { isOriginPopulated() } returns false
}
private val mockGetPublicKeyCredentialOption = mockk<GetPublicKeyCredentialOption> {
every { requestJson } returns "mockRequestJson"
}
private val mockProviderGetCredentialRequest = mockk<ProviderGetCredentialRequest> {
every { credentialOptions } returns listOf(mockk<GetPublicKeyCredentialOption>())
every { credentialOptions } returns listOf(mockGetPublicKeyCredentialOption)
every { callingAppInfo } returns mockCallingAppInfo
}
private val mockBeginGetPublicKeyCredentialOption = mockk<BeginGetPublicKeyCredentialOption>()
private val mockBeginGetPublicKeyCredentialOption = mockk<BeginGetPublicKeyCredentialOption> {
every { requestJson } returns "mockRequestJson"
}
private val mockBeginGetCredentialRequest = mockk<BeginGetCredentialRequest> {
every { beginGetCredentialOptions } returns listOf(mockBeginGetPublicKeyCredentialOption)
every { callingAppInfo } returns mockCallingAppInfo
}
val mockProviderCreateCredentialRequest = mockk<ProviderCreateCredentialRequest> {
every { callingRequest } returns mockk<CreatePublicKeyCredentialRequest>(relaxed = true)
private val mockCreatePublicKeyCredentialRequest = mockk<CreatePublicKeyCredentialRequest> {
every { requestJson } returns "mockRequestJson"
every { origin } returns "mockOrigin"
}
private val mockProviderCreateCredentialRequest = mockk<ProviderCreateCredentialRequest> {
every { callingRequest } returns mockCreatePublicKeyCredentialRequest
every { callingAppInfo } returns mockCallingAppInfo
}
private val mutableSnackbarDataFlow: MutableSharedFlow<BitwardenSnackbarData> =
@ -255,6 +269,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
getSnackbarDataFlow(relay = any(), relays = anyVararg())
} returns mutableSnackbarDataFlow
}
private val relyingPartyParser = mockk<RelyingPartyParser> {
every { parse(any<BeginGetPublicKeyCredentialOption>()) } returns DEFAULT_RELYING_PARTY_ID
every { parse(any<GetPublicKeyCredentialOption>()) } returns DEFAULT_RELYING_PARTY_ID
every { parse(any<CreatePublicKeyCredentialRequest>()) } 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<PublicKeyCredentialEntry>().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"

View File

@ -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<String>,
): NetworkResult<DigitalAssetLinkCheckResponseJson>
}

View File

@ -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<String>,
): Result<DigitalAssetLinkCheckResponseJson>
}

View File

@ -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<String>,
): Result<DigitalAssetLinkCheckResponseJson> = digitalAssetLinkApi
.checkDigitalAssetLinksRelations(
sourcePackageName = packageName,
sourceCertificateFingerprint = certificateFingerprint,
targetPackageName = packageName,
targetCertificateFingerprint = certificateFingerprint,
relation = relation,
sourceWebSite = sourceWebSite,
targetPackageName = targetPackageName,
targetCertificateFingerprint = targetCertificateFingerprint,
relations = relations,
)
.toResult()
}

View File

@ -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()