Use Google's Digital Asset Links API to verify digital asset links (#5101)

This commit is contained in:
Patrick Honkonen
2025-04-30 13:39:04 +00:00
committed by GitHub
parent 240bca3c2f
commit fe1fe770c7
19 changed files with 163 additions and 515 deletions
@@ -24,11 +24,9 @@ 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
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentialsRequest
@@ -136,9 +134,6 @@ class MainViewModelTest : BaseViewModelTest() {
every { isUserVerified } returns true
every { isUserVerified = any() } just runs
}
private val fido2OriginManager = mockk<Fido2OriginManager> {
coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null)
}
private val savedStateHandle = SavedStateHandle()
private val appResumeManager: AppResumeManager = mockk {
@@ -749,12 +744,6 @@ class MainViewModelTest : BaseViewModelTest() {
val mockIntent = createMockIntent(
mockFido2CreateCredentialRequest = fido2CreateCredentialRequest,
)
coEvery {
fido2OriginManager.validateOrigin(
callingAppInfo = any(),
relyingPartyId = any(),
)
} returns Fido2ValidateOriginResult.Success(null)
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@@ -780,12 +769,6 @@ class MainViewModelTest : BaseViewModelTest() {
val mockIntent = createMockIntent(
mockFido2CreateCredentialRequest = fido2CreateCredentialRequest,
)
coEvery {
fido2OriginManager.validateOrigin(
callingAppInfo = any(),
relyingPartyId = any(),
)
} returns Fido2ValidateOriginResult.Success(null)
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@@ -454,25 +454,6 @@ class Fido2CredentialManagerTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `registerFido2Credential should return InvalidAppSignature when calling app is not privileged and signature is invalid`() =
runTest {
every { mockCallingAppInfo.isOriginPopulated() } returns false
every { mockSigningInfo.hasMultipleSigners() } returns true
val result = fido2CredentialManager.registerFido2Credential(
userId = "mockUserId",
callingAppInfo = mockCallingAppInfo,
createPublicKeyCredentialRequest = mockCreatePublicKeyCredentialRequest,
selectedCipherView = createMockCipherView(number = 1),
)
assertEquals(
Fido2RegisterCredentialResult.Error.InvalidAppSignature,
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `registerFido2Credential should return MissingHostUrl when calling app if privileged and origin is missing`() =
@@ -5,7 +5,7 @@ import android.util.Base64
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.model.DigitalAssetLinkResponseJson
import com.bitwarden.network.model.DigitalAssetLinkCheckResponseJson
import com.bitwarden.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.platform.manager.AssetManager
@@ -76,7 +76,6 @@ class Fido2OriginManagerTest {
val result = fido2OriginManager.validateOrigin(
callingAppInfo = mockPrivilegedAppInfo,
relyingPartyId = "relyingPartyId",
)
coVerify(exactly = 1) {
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
@@ -100,7 +99,6 @@ class Fido2OriginManagerTest {
val result = fido2OriginManager.validateOrigin(
callingAppInfo = mockPrivilegedAppInfo,
relyingPartyId = "relyingPartyId",
)
coVerify(exactly = 1) {
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
@@ -125,7 +123,6 @@ class Fido2OriginManagerTest {
val result = fido2OriginManager.validateOrigin(
callingAppInfo = mockPrivilegedAppInfo,
relyingPartyId = "relyingPartyId",
)
coVerify(exactly = 1) {
@@ -143,12 +140,15 @@ class Fido2OriginManagerTest {
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()
mockDigitalAssetLinkService.checkDigitalAssetLinksRelations(
packageName = DEFAULT_PACKAGE_NAME,
certificateFingerprint = DEFAULT_CERT_FINGERPRINT,
relation = "delegate_permission/common.handle_all_urls",
)
} returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE.asSuccess()
val result = fido2OriginManager.validateOrigin(
callingAppInfo = mockNonPrivilegedAppInfo,
relyingPartyId = DEFAULT_RP_ID,
)
assertEquals(
@@ -159,49 +159,21 @@ class Fido2OriginManagerTest {
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return ApplicationFingerprintNotVerified when calling app is NonPrivileged but signature does not match asset link entry`() =
fun `validateOrigin should return PasskeysNotSupportedForApp 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"),
),
),
)
mockDigitalAssetLinkService.checkDigitalAssetLinksRelations(
packageName = DEFAULT_PACKAGE_NAME,
certificateFingerprint = DEFAULT_CERT_FINGERPRINT,
relation = "delegate_permission/common.handle_all_urls",
)
} returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE
.copy(linked = false)
.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,
),
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp,
fido2OriginManager.validateOrigin(callingAppInfo = mockNonPrivilegedAppInfo),
)
}
@@ -210,35 +182,16 @@ class Fido2OriginManagerTest {
fun `validateOrigin should return AssetLinkNotFound when calling app is NonPrivileged and asset link does not exist`() =
runTest {
coEvery {
mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID)
mockDigitalAssetLinkService.checkDigitalAssetLinksRelations(
packageName = DEFAULT_PACKAGE_NAME,
certificateFingerprint = DEFAULT_CERT_FINGERPRINT,
relation = "delegate_permission/common.handle_all_urls",
)
} 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,
),
fido2OriginManager.validateOrigin(callingAppInfo = mockNonPrivilegedAppInfo),
)
}
@@ -246,9 +199,6 @@ class Fido2OriginManagerTest {
@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()
@@ -260,7 +210,6 @@ class Fido2OriginManagerTest {
Fido2ValidateOriginResult.Error.Unknown,
fido2OriginManager.validateOrigin(
callingAppInfo = mockPrivilegedAppInfo,
relyingPartyId = DEFAULT_RP_ID,
),
)
}
@@ -269,7 +218,6 @@ class Fido2OriginManagerTest {
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"
@@ -317,16 +265,9 @@ private const val FAIL_ALLOW_LIST = """
]
}
"""
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 val DEFAULT_ASSET_LINKS_CHECK_RESPONSE =
DigitalAssetLinkCheckResponseJson(
linked = true,
maxAge = "30s",
debugString = null,
)
@@ -198,7 +198,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
} returns UserVerificationRequirement.PREFERRED
}
private val fido2OriginManager: Fido2OriginManager = mockk {
coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null)
coEvery { validateOrigin(any()) } returns Fido2ValidateOriginResult.Success(null)
}
private val organizationEventManager = mockk<OrganizationEventManager> {
@@ -289,7 +289,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any())
} returns createMockPasskeyAttestationOptions(number = 1)
coEvery {
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Success(null)
val viewModel = createVaultItemListingViewModel()
@@ -1871,7 +1871,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
} returns DecryptFido2CredentialAutofillViewResult.Success(emptyList())
coEvery {
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Success("")
mockFilteredCiphers = listOf(cipherView1)
@@ -1925,7 +1925,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
vaultRepository.getDecryptedFido2CredentialAutofillViews(
cipherViewList = listOf(cipherView1, cipherView2),
)
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
}
}
@@ -2594,16 +2594,13 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(
callingAppInfo = any(),
relyingPartyId = any(),
)
fido2OriginManager.validateOrigin(callingAppInfo = any())
} returns Fido2ValidateOriginResult.Success("mockOrigin")
createVaultItemListingViewModel()
coVerify(ordering = Ordering.ORDERED) {
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
vaultRepository.vaultDataStateFlow
}
}
@@ -2631,7 +2628,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Error.Unknown
val viewModel = createVaultItemListingViewModel()
@@ -2670,7 +2667,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed
val viewModel = createVaultItemListingViewModel()
@@ -2709,7 +2706,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound
val viewModel = createVaultItemListingViewModel()
@@ -2748,7 +2745,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
val viewModel = createVaultItemListingViewModel()
@@ -2762,45 +2759,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2ValidateOriginResult should update dialog state on ApplicationNotFound error`() =
runTest {
val fido2CreateCredentialRequest = Fido2CreateCredentialRequest(
userId = "mockUserId",
isUserPreVerified = false,
requestData = bundleOf(),
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CreateCredentialRequest = fido2CreateCredentialRequest,
)
every {
ProviderCreateCredentialRequest.fromBundle(any())
} returns mockk(relaxed = true) {
every {
callingRequest
} returns mockk<CreatePublicKeyCredentialRequest>(relaxed = true)
}
every {
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.ApplicationNotFound
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingState.DialogState.Fido2OperationFail(
R.string.an_error_has_occurred.asText(),
R.string.passkey_operation_failed_because_app_not_found_in_asset_links.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2ValidateOriginResult should update dialog state on AssetLinkNotFound error`() =
@@ -2826,7 +2784,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Error.AssetLinkNotFound
val viewModel = createVaultItemListingViewModel()
@@ -2840,45 +2798,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2ValidateOriginResult should update dialog state on ApplicationNotVerified error`() =
runTest {
val fido2CreateCredentialRequest = Fido2CreateCredentialRequest(
userId = "mockUserId",
isUserPreVerified = false,
requestData = bundleOf(),
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CreateCredentialRequest = fido2CreateCredentialRequest,
)
every {
ProviderCreateCredentialRequest.fromBundle(any())
} returns mockk(relaxed = true) {
every {
callingRequest
} returns mockk<CreatePublicKeyCredentialRequest>(relaxed = true)
}
every {
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingState.DialogState.Fido2OperationFail(
R.string.an_error_has_occurred.asText(),
R.string.passkey_operation_failed_because_app_could_not_be_verified.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2RegisterCredentialResult Error should show toast and emit CompleteFido2Registration result`() =
@@ -3038,10 +2957,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(
callingAppInfo = any(),
relyingPartyId = any(),
)
fido2OriginManager.validateOrigin(callingAppInfo = any())
} returns Fido2ValidateOriginResult.Success("mockOrigin")
every {
vaultRepository
@@ -3247,10 +3163,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any())
} returns mockk(relaxed = true)
coEvery {
fido2OriginManager.validateOrigin(
callingAppInfo = any(),
relyingPartyId = any(),
)
fido2OriginManager.validateOrigin(callingAppInfo = any())
} returns Fido2ValidateOriginResult.Error.Unknown
val dataState = DataState.Loaded(
@@ -3463,54 +3376,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `Fido2AssertionRequest should show error dialog when relyingPartyId is null`() = 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 {
vaultRepository
.ciphersStateFlow
.value
.data
} returns listOf(mockCipherView)
every { fido2CredentialManager.isUserVerified } returns true
every {
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any())
} returns createMockPasskeyAssertionOptions(
number = 1,
userVerificationRequirement = UserVerificationRequirement.DISCOURAGED,
relyingPartyId = 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
assertEquals(
VaultItemListingState.DialogState.Fido2OperationFail(
title = R.string.an_error_has_occurred.asText(),
message = R.string.passkey_operation_failed_because_the_request_is_invalid.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Test
fun `Fido2AssertionRequest should show error dialog when validateOrigin is not Success`() =
runTest {
@@ -3540,7 +3405,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any())
} returns mockAssertionOptions
coEvery {
fido2OriginManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Error.Unknown
val dataState = DataState.Loaded(