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

This commit is contained in:
Patrick Honkonen 2025-04-30 09:39:04 -04:00 committed by GitHub
parent 240bca3c2f
commit fe1fe770c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 163 additions and 515 deletions

View File

@ -113,9 +113,7 @@ class Fido2CredentialManagerImpl(
packageName = callingAppInfo.packageName,
sha256CertFingerprint = callingAppInfo
.getSignatureFingerprintAsHexString()
?: return Fido2CredentialAssertionResult
.Error
.InvalidAppSignature,
.orEmpty(),
host = hostUrl,
assetLinkUrl = hostUrl,
),
@ -183,7 +181,7 @@ class Fido2CredentialManagerImpl(
val signatureFingerprint = callingAppInfo
.getSignatureFingerprintAsHexString()
?: return Fido2RegisterCredentialResult.Error.InvalidAppSignature
.orEmpty()
val sdkOrigin = Origin.Android(
UnverifiedAssetLink(

View File

@ -12,12 +12,10 @@ 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
}

View File

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.network.model.DigitalAssetLinkResponseJson
import com.bitwarden.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.platform.manager.AssetManager
@ -11,11 +10,11 @@ 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"
private const val DELEGATE_PERMISSION_HANDLE_ALL_URLS = "delegate_permission/common.handle_all_urls"
/**
* Primary implementation of [Fido2OriginManager].
*/
@Suppress("TooManyFunctions")
class Fido2OriginManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
@ -23,49 +22,38 @@ class Fido2OriginManagerImpl(
override suspend fun validateOrigin(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult {
return if (callingAppInfo.isOriginPopulated()) {
validatePrivilegedAppOrigin(callingAppInfo)
} else {
validateCallingApplicationAssetLinks(callingAppInfo, relyingPartyId)
validateCallingApplicationAssetLinks(callingAppInfo)
}
}
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
},
)
): Fido2ValidateOriginResult {
return digitalAssetLinkService
.checkDigitalAssetLinksRelations(
packageName = callingAppInfo.packageName,
certificateFingerprint = callingAppInfo
.getSignatureFingerprintAsHexString()
.orEmpty(),
relation = DELEGATE_PERMISSION_HANDLE_ALL_URLS,
)
.fold(
onSuccess = {
if (it.linked) {
Fido2ValidateOriginResult.Success(null)
} else {
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
}
},
onFailure = {
Fido2ValidateOriginResult.Error.AssetLinkNotFound
},
)
}
private suspend fun validatePrivilegedAppOrigin(
callingAppInfo: CallingAppInfo,
@ -121,35 +109,4 @@ class Fido2OriginManagerImpl(
Fido2ValidateOriginResult.Error.Unknown
},
)
/**
* Returns statements targeting the calling Android application, or null.
*/
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppStatementsOrNull(
rpPackageName: String,
): List<DigitalAssetLinkResponseJson>? =
filter { statement ->
val target = statement.target
target.namespace == "android_app" &&
target.packageName == rpPackageName &&
statement.relation.containsAll(
listOf(
"delegate_permission/common.handle_all_urls",
),
)
}
.takeUnless { it.isEmpty() }
/**
* Returns statements that match the given [signature], or null.
*/
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppSignaturesOrNull(
signature: String,
): List<DigitalAssetLinkResponseJson>? =
filter { statement ->
statement.target.sha256CertFingerprints
?.contains(signature)
?: false
}
.takeUnless { it.isEmpty() }
}

View File

@ -24,21 +24,6 @@ sealed class Fido2CredentialAssertionResult {
*/
data object MissingHostUrl : Error()
/**
* Indicates the calling application signature was invalid.
*/
data object InvalidAppSignature : Error()
/**
* Indicates origin validation failed.
*
* @property originValidationError The specific error that caused the origin validation to
* fail.
*/
data class OriginValidationFailed(
val originValidationError: Fido2ValidateOriginResult.Error,
) : Error()
/**
* Indicates an internal error occurred.
*/

View File

@ -22,16 +22,6 @@ sealed class Fido2ValidateOriginResult {
*/
data object AssetLinkNotFound : Error()
/**
* Indicates the application package name was not found in the digital asset links file.
*/
data object ApplicationNotFound : Error()
/**
* Indicates the application fingerprint was not found the digital asset links file.
*/
data object ApplicationFingerprintNotVerified : Error()
/**
* Indicates the calling application is privileged but its package name is not found within
* the privileged app allow list.

View File

@ -889,22 +889,10 @@ class VaultItemListingViewModel @Inject constructor(
)
return
}
val relyingPartyId = fido2CredentialManager
.getPasskeyAssertionOptionsOrNull(option.requestJson)
?.relyingPartyId
?: run {
showFido2ErrorDialog(
R.string.passkey_operation_failed_because_the_request_is_invalid.asText(),
)
return
}
viewModelScope.launch {
val validateOriginResult = fido2OriginManager
.validateOrigin(
callingAppInfo = request.callingAppInfo,
relyingPartyId = relyingPartyId,
)
.validateOrigin(callingAppInfo = request.callingAppInfo)
when (validateOriginResult) {
is Fido2ValidateOriginResult.Error -> {
@ -1638,23 +1626,10 @@ class VaultItemListingViewModel @Inject constructor(
private fun handleFido2RegisterCredentialRequestReceive(
action: VaultItemListingsAction.Internal.Fido2RegisterCredentialRequestReceive,
) {
val relyingPartyId = action.request
.providerRequest
.getCreatePasskeyCredentialRequestOrNull()
?.let { fido2CredentialManager.getPasskeyAttestationOptionsOrNull(it.requestJson) }
?.relyingParty
?.id
?: run {
showFido2ErrorDialog(
R.string.passkey_operation_failed_because_the_request_is_invalid.asText(),
)
return
}
viewModelScope.launch {
val validateOriginResult = fido2OriginManager
.validateOrigin(
callingAppInfo = action.request.callingAppInfo,
relyingPartyId = relyingPartyId,
)
when (validateOriginResult) {
is Fido2ValidateOriginResult.Error -> {
@ -1734,7 +1709,6 @@ class VaultItemListingViewModel @Inject constructor(
viewModelScope.launch {
val validateOriginResult = fido2OriginManager.validateOrigin(
callingAppInfo = callingAppInfo,
relyingPartyId = relyingPartyId,
)
when (validateOriginResult) {
is Fido2ValidateOriginResult.Success -> {

View File

@ -15,10 +15,6 @@ val Fido2CredentialAssertionResult.Error.messageResourceId: Int
R.string.passkey_registration_failed_due_to_an_internal_error
}
Fido2CredentialAssertionResult.Error.InvalidAppSignature -> {
R.string.passkey_operation_failed_because_app_signature_is_invalid
}
Fido2CredentialAssertionResult.Error.MissingHostUrl -> {
R.string.passkey_operation_failed_because_host_url_is_not_present_in_request
}
@ -26,8 +22,4 @@ val Fido2CredentialAssertionResult.Error.messageResourceId: Int
Fido2CredentialAssertionResult.Error.MissingRpId -> {
R.string.passkey_operation_failed_because_relying_party_cannot_be_identified
}
is Fido2CredentialAssertionResult.Error.OriginValidationFailed -> {
originValidationError.messageResourceId
}
}

View File

@ -11,14 +11,6 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
val Fido2ValidateOriginResult.Error.messageResourceId: Int
@StringRes
get() = when (this) {
Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified -> {
R.string.passkey_operation_failed_because_app_could_not_be_verified
}
Fido2ValidateOriginResult.Error.ApplicationNotFound -> {
R.string.passkey_operation_failed_because_app_not_found_in_asset_links
}
Fido2ValidateOriginResult.Error.AssetLinkNotFound -> {
R.string.passkey_operation_failed_because_of_missing_asset_links
}

View File

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

View File

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

View File

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

View File

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

View File

@ -131,7 +131,9 @@ internal class BitwardenServiceClientImpl(
override val digitalAssetLinkService: DigitalAssetLinkService by lazy {
DigitalAssetLinkServiceImpl(
digitalAssetLinkApi = retrofits.createStaticRetrofit().create(),
digitalAssetLinkApi = retrofits
.createStaticRetrofit(baseUrl = "https://digitalassetlinks.googleapis.com/")
.create(),
)
}

View File

@ -1,20 +1,31 @@
package com.bitwarden.network.api
import com.bitwarden.network.model.DigitalAssetLinkResponseJson
import androidx.annotation.Keep
import com.bitwarden.network.model.DigitalAssetLinkCheckResponseJson
import com.bitwarden.network.model.NetworkResult
import retrofit2.http.GET
import retrofit2.http.Url
import retrofit2.http.Query
/**
* Defines calls to an RP digital asset link file.
* Defines calls to a digital asset link file.
*/
@Keep
interface DigitalAssetLinkApi {
/**
* Attempts to download the asset links file from the RP.
* Checks if the given [relation] exists in a digital asset link file.
*/
@GET
suspend fun getDigitalAssetLinks(
@Url url: String,
): NetworkResult<List<DigitalAssetLinkResponseJson>>
@GET("v1/assetlinks:check")
suspend fun checkDigitalAssetLinksRelations(
@Query("source.androidApp.packageName")
sourcePackageName: String,
@Query("source.androidApp.certificate.sha256Fingerprint")
sourceCertificateFingerprint: String,
@Query("target.androidApp.packageName")
targetPackageName: String,
@Query("target.androidApp.certificate.sha256Fingerprint")
targetCertificateFingerprint: String,
@Query("relation")
relation: String,
): NetworkResult<DigitalAssetLinkCheckResponseJson>
}

View File

@ -0,0 +1,28 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the response from a digital asset link check.
*
* Modeled after the [CheckAssetLinks](https://developers.google.com/digital-asset-links/reference/rest/v1/assetlinks/check)
* response from the Google Digital Asset Links API.
*
* @property linked Indicates whether the asset link is linked.
* @property maxAge From serving time, how much longer the response should be considered valid
* barring further updates. A duration in seconds with up to nine fractional digits, terminated by
* 's'. Example: "3.5s".
* @property debugString Human-readable message containing information intended to help end users
* understand, reproduce and debug the result.
*/
@Suppress("MaxLineLength")
@Serializable
data class DigitalAssetLinkCheckResponseJson(
@SerialName("linked")
val linked: Boolean = false,
@SerialName("maxAge")
val maxAge: String?,
@SerialName("debugString")
val debugString: String?,
)

View File

@ -1,32 +0,0 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models a response from an RP digital asset link request.
*/
@Serializable
data class DigitalAssetLinkResponseJson(
@SerialName("relation")
val relation: List<String>,
@SerialName("target")
val target: Target,
) {
/**
* Represents targets for an asset link statement.
*/
@Serializable
data class Target(
@SerialName("namespace")
val namespace: String,
@SerialName("package_name")
val packageName: String?,
@SerialName("sha256_cert_fingerprints")
val sha256CertFingerprints: List<String>?,
)
}

View File

@ -1,17 +1,18 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.DigitalAssetLinkResponseJson
import com.bitwarden.network.model.DigitalAssetLinkCheckResponseJson
/**
* Provides an API for querying digital asset links.
*/
interface DigitalAssetLinkService {
/**
* Attempt to retrieve the asset links file from the provided [relyingParty].
* Checks if the given [packageName] with a given [certificateFingerprint] has the given
* [relation].
*/
suspend fun getDigitalAssetLinkForRp(
scheme: String = "https://",
relyingParty: String,
): Result<List<DigitalAssetLinkResponseJson>>
suspend fun checkDigitalAssetLinksRelations(
packageName: String,
certificateFingerprint: String,
relation: String,
): Result<DigitalAssetLinkCheckResponseJson>
}

View File

@ -1,7 +1,7 @@
package com.bitwarden.network.service
import com.bitwarden.network.api.DigitalAssetLinkApi
import com.bitwarden.network.model.DigitalAssetLinkResponseJson
import com.bitwarden.network.model.DigitalAssetLinkCheckResponseJson
import com.bitwarden.network.util.toResult
/**
@ -11,13 +11,17 @@ class DigitalAssetLinkServiceImpl(
private val digitalAssetLinkApi: DigitalAssetLinkApi,
) : DigitalAssetLinkService {
override suspend fun getDigitalAssetLinkForRp(
scheme: String,
relyingParty: String,
): Result<List<DigitalAssetLinkResponseJson>> =
digitalAssetLinkApi
.getDigitalAssetLinks(
url = "$scheme$relyingParty/.well-known/assetlinks.json",
)
.toResult()
override suspend fun checkDigitalAssetLinksRelations(
packageName: String,
certificateFingerprint: String,
relation: String,
): Result<DigitalAssetLinkCheckResponseJson> = digitalAssetLinkApi
.checkDigitalAssetLinksRelations(
sourcePackageName = packageName,
sourceCertificateFingerprint = certificateFingerprint,
targetPackageName = packageName,
targetCertificateFingerprint = certificateFingerprint,
relation = relation,
)
.toResult()
}

View File

@ -1,8 +1,9 @@
package com.bitwarden.network.service
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.api.DigitalAssetLinkApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.DigitalAssetLinkResponseJson
import com.bitwarden.network.model.DigitalAssetLinkCheckResponseJson
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
@ -10,7 +11,6 @@ import org.junit.jupiter.api.Test
import retrofit2.create
class DigitalAssetLinkServiceTest : BaseServiceTest() {
private val digitalAssetLinkApi: DigitalAssetLinkApi = retrofit.create()
private val digitalAssetLinkService: DigitalAssetLinkService = DigitalAssetLinkServiceImpl(
@ -18,50 +18,28 @@ class DigitalAssetLinkServiceTest : BaseServiceTest() {
)
@Test
fun `getDigitalAssetLinkForRp should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(GET_DIGITAL_ASSET_LINK_SUCCESS_JSON))
val result = digitalAssetLinkService.getDigitalAssetLinkForRp(
scheme = url.scheme,
relyingParty = url.host,
)
fun `checkDigitalAssetLinksRelations should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(CHECK_DIGITAL_ASSET_LINKS_RELATIONS_SUCCESS_JSON))
assertEquals(
createDigitalAssetLinkResponse(),
result.getOrThrow(),
DigitalAssetLinkCheckResponseJson(
linked = true,
maxAge = "47.535162130s",
debugString = null,
)
.asSuccess(),
digitalAssetLinkService.checkDigitalAssetLinksRelations(
packageName = "com.x8bit.bitwarden",
certificateFingerprint =
"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",
),
)
}
}
@Suppress("MaxLineLength")
private fun createDigitalAssetLinkResponse() = listOf(
DigitalAssetLinkResponseJson(
relation = listOf(
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls",
),
target = DigitalAssetLinkResponseJson.Target(
namespace = "android_app",
packageName = "com.mock.package",
sha256CertFingerprints = listOf(
"00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F",
),
),
),
)
private const val GET_DIGITAL_ASSET_LINK_SUCCESS_JSON = """
[
{
"relation": [
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.mock.package",
"sha256_cert_fingerprints": [
"00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F"
]
}
}
]
"""
private val CHECK_DIGITAL_ASSET_LINKS_RELATIONS_SUCCESS_JSON = """
{
"linked": true,
"maxAge": "47.535162130s"
}
""".trimIndent()