[PM-15609] Move FIDO2 origin validation logic to Fido2OriginManager (#4426)

This commit is contained in:
Patrick Honkonen
2024-12-11 13:17:51 -05:00
committed by GitHub
parent 7b3ad98698
commit d9ef87e21f
13 changed files with 643 additions and 462 deletions

View File

@@ -8,6 +8,8 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorImpl
import com.x8bit.bitwarden.data.platform.manager.AssetManager
@@ -58,17 +60,26 @@ object Fido2ProviderModule {
@Provides
@Singleton
fun provideFido2CredentialManager(
assetManager: AssetManager,
digitalAssetLinkService: DigitalAssetLinkService,
vaultSdkSource: VaultSdkSource,
fido2CredentialStore: Fido2CredentialStore,
fido2OriginManager: Fido2OriginManager,
json: Json,
): Fido2CredentialManager =
Fido2CredentialManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
vaultSdkSource = vaultSdkSource,
fido2CredentialStore = fido2CredentialStore,
fido2OriginManager = fido2OriginManager,
json = json,
)
@Provides
@Singleton
fun provideFido2OriginManager(
assetManager: AssetManager,
digitalAssetLinkService: DigitalAssetLinkService,
): Fido2OriginManager =
Fido2OriginManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
)
}

View File

@@ -1,12 +1,10 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
@@ -26,14 +24,6 @@ interface Fido2CredentialManager {
*/
var authenticationAttempts: Int
/**
* Attempt to validate the RP and origin of the provided [callingAppInfo] and [relyingPartyId].
*/
suspend fun validateOrigin(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult
/**
* Attempt to extract FIDO 2 passkey attestation options from the system [requestJson], or null.
*/

View File

@@ -6,8 +6,6 @@ import com.bitwarden.fido.Origin
import com.bitwarden.fido.UnverifiedAssetLink
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
@@ -15,12 +13,10 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResu
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull
import com.x8bit.bitwarden.data.platform.util.getAppOrigin
import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
@@ -31,18 +27,14 @@ import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val GOOGLE_ALLOW_LIST_FILE_NAME = "fido2_privileged_google.json"
private const val COMMUNITY_ALLOW_LIST_FILE_NAME = "fido2_privileged_community.json"
/**
* Primary implementation of [Fido2CredentialManager].
*/
@Suppress("TooManyFunctions")
class Fido2CredentialManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
private val vaultSdkSource: VaultSdkSource,
private val fido2CredentialStore: Fido2CredentialStore,
private val fido2OriginManager: Fido2OriginManager,
private val json: Json,
) : Fido2CredentialManager,
Fido2CredentialStore by fido2CredentialStore {
@@ -108,16 +100,14 @@ class Fido2CredentialManagerImpl(
)
}
override suspend fun validateOrigin(
private suspend fun validateOrigin(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult {
return if (callingAppInfo.isOriginPopulated()) {
validatePrivilegedAppOrigin(callingAppInfo)
} else {
validateCallingApplicationAssetLinks(callingAppInfo, relyingPartyId)
}
}
): Fido2ValidateOriginResult = fido2OriginManager
.validateOrigin(
callingAppInfo = callingAppInfo,
relyingPartyId = relyingPartyId,
)
override fun getPasskeyAttestationOptionsOrNull(
requestJson: String,
@@ -168,7 +158,7 @@ class Fido2CredentialManagerImpl(
Fido2CredentialAssertionResult.Error
}
Fido2ValidateOriginResult.Success -> {
is Fido2ValidateOriginResult.Success -> {
vaultSdkSource
.authenticateFido2Credential(
request = AuthenticateFido2CredentialRequest(
@@ -200,127 +190,6 @@ class Fido2CredentialManagerImpl(
}
}
private suspend fun validateCallingApplicationAssetLinks(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult {
return digitalAssetLinkService
.getDigitalAssetLinkForRp(relyingParty = relyingPartyId)
.onFailure {
return Fido2ValidateOriginResult.Error.AssetLinkNotFound
}
.map { statements ->
statements
.filterMatchingAppStatementsOrNull(
rpPackageName = callingAppInfo.packageName,
)
?: return Fido2ValidateOriginResult.Error.ApplicationNotFound
}
.map { matchingStatements ->
callingAppInfo
.getSignatureFingerprintAsHexString()
?.let { certificateFingerprint ->
matchingStatements
.filterMatchingAppSignaturesOrNull(
signature = certificateFingerprint,
)
}
?: return Fido2ValidateOriginResult.Error.ApplicationNotVerified
}
.fold(
onSuccess = {
Fido2ValidateOriginResult.Success
},
onFailure = {
Fido2ValidateOriginResult.Error.Unknown
},
)
}
private suspend fun validatePrivilegedAppOrigin(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult {
val googleAllowListResult =
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
return when (googleAllowListResult) {
is Fido2ValidateOriginResult.Success -> {
// Application was found and successfully validated against the Google allow list so
// we can return the result as the final validation result.
googleAllowListResult
}
is Fido2ValidateOriginResult.Error -> {
// Check the community allow list if the Google allow list failed, and return the
// result as the final validation result.
validatePrivilegedAppSignatureWithCommunityList(callingAppInfo)
}
}
}
private suspend fun validatePrivilegedAppSignatureWithGoogleList(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult =
validatePrivilegedAppSignatureWithAllowList(
callingAppInfo = callingAppInfo,
fileName = GOOGLE_ALLOW_LIST_FILE_NAME,
)
private suspend fun validatePrivilegedAppSignatureWithCommunityList(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult =
validatePrivilegedAppSignatureWithAllowList(
callingAppInfo = callingAppInfo,
fileName = COMMUNITY_ALLOW_LIST_FILE_NAME,
)
private suspend fun validatePrivilegedAppSignatureWithAllowList(
callingAppInfo: CallingAppInfo,
fileName: String,
): Fido2ValidateOriginResult =
assetManager
.readAsset(fileName)
.map { allowList ->
callingAppInfo.validatePrivilegedApp(
allowList = allowList,
)
}
.fold(
onSuccess = { it },
onFailure = { Fido2ValidateOriginResult.Error.Unknown },
)
/**
* Returns statements targeting the calling Android application, or null.
*/
private fun List<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.get_login_creds",
"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() }
override fun hasAuthenticationAttemptsRemaining(): Boolean =
authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS

View File

@@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.provider.CallingAppInfo
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
/**
* Responsible for managing FIDO2 origin validation.
*/
interface Fido2OriginManager {
/**
* Validates the origin of a calling app.
*
* @param callingAppInfo The calling app info.
* @param relyingPartyId The relying party ID.
*
* @return The result of the validation.
*/
suspend fun validateOrigin(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult
/**
* Returns the privileged app origin, or null if the calling app is not allowed.
*
* @param callingAppInfo The calling app info.
*
* @return The privileged app origin, or null.
*/
suspend fun getPrivilegedAppOriginOrNull(callingAppInfo: CallingAppInfo): String?
}

View File

@@ -0,0 +1,172 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.provider.CallingAppInfo
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import timber.log.Timber
private const val GOOGLE_ALLOW_LIST_FILE_NAME = "fido2_privileged_google.json"
private const val COMMUNITY_ALLOW_LIST_FILE_NAME = "fido2_privileged_community.json"
/**
* Primary implementation of [Fido2OriginManager].
*/
@Suppress("TooManyFunctions")
class Fido2OriginManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
) : Fido2OriginManager {
override suspend fun validateOrigin(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult {
return if (callingAppInfo.isOriginPopulated()) {
validatePrivilegedAppOrigin(callingAppInfo)
} else {
validateCallingApplicationAssetLinks(callingAppInfo, relyingPartyId)
}
}
override suspend fun getPrivilegedAppOriginOrNull(callingAppInfo: CallingAppInfo): String? {
if (!callingAppInfo.isOriginPopulated()) return null
return callingAppInfo.getOrigin(getGoogleAllowListOrNull().orEmpty())
?: callingAppInfo.getOrigin(getCommunityAllowListOrNull().orEmpty())
?.takeUnless { !callingAppInfo.isOriginPopulated() }
}
private suspend fun validateCallingApplicationAssetLinks(
callingAppInfo: CallingAppInfo,
relyingPartyId: String,
): Fido2ValidateOriginResult = digitalAssetLinkService
.getDigitalAssetLinkForRp(relyingParty = relyingPartyId)
.onFailure {
return Fido2ValidateOriginResult.Error.AssetLinkNotFound
}
.mapCatching { statements ->
statements
.filterMatchingAppStatementsOrNull(
rpPackageName = callingAppInfo.packageName,
)
?: return Fido2ValidateOriginResult.Error.ApplicationNotFound
}
.mapCatching { matchingStatements ->
callingAppInfo
.getSignatureFingerprintAsHexString()
?.let { certificateFingerprint ->
matchingStatements
.filterMatchingAppSignaturesOrNull(
signature = certificateFingerprint,
)
}
?: return Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified
}
.fold(
onSuccess = {
Fido2ValidateOriginResult.Success(null)
},
onFailure = {
Fido2ValidateOriginResult.Error.Unknown
},
)
private suspend fun validatePrivilegedAppOrigin(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult {
val googleAllowListResult =
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
return when (googleAllowListResult) {
is Fido2ValidateOriginResult.Success -> {
// Application was found and successfully validated against the Google allow list so
// we can return the result as the final validation result.
googleAllowListResult
}
is Fido2ValidateOriginResult.Error -> {
// Check the community allow list if the Google allow list failed, and return the
// result as the final validation result.
validatePrivilegedAppSignatureWithCommunityList(callingAppInfo)
}
}
}
private suspend fun validatePrivilegedAppSignatureWithGoogleList(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult =
validatePrivilegedAppSignatureWithAllowList(
callingAppInfo = callingAppInfo,
fileName = GOOGLE_ALLOW_LIST_FILE_NAME,
)
private suspend fun validatePrivilegedAppSignatureWithCommunityList(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult =
validatePrivilegedAppSignatureWithAllowList(
callingAppInfo = callingAppInfo,
fileName = COMMUNITY_ALLOW_LIST_FILE_NAME,
)
private suspend fun validatePrivilegedAppSignatureWithAllowList(
callingAppInfo: CallingAppInfo,
fileName: String,
): Fido2ValidateOriginResult =
assetManager
.readAsset(fileName)
.mapCatching { allowList ->
callingAppInfo.validatePrivilegedApp(
allowList = allowList,
)
}
.fold(
onSuccess = { it },
onFailure = { Fido2ValidateOriginResult.Error.Unknown },
)
/**
* Returns statements targeting the calling Android application, or null.
*/
private fun List<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.get_login_creds",
"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() }
private suspend fun getGoogleAllowListOrNull(): String? =
assetManager
.readAsset(GOOGLE_ALLOW_LIST_FILE_NAME)
.onFailure { Timber.e(it, "Failed to read Google allow list.") }
.getOrNull()
private suspend fun getCommunityAllowListOrNull(): String? =
assetManager
.readAsset(COMMUNITY_ALLOW_LIST_FILE_NAME)
.onFailure { Timber.e(it, "Failed to read Community allow list.") }
.getOrNull()
}

View File

@@ -1,5 +1,8 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import androidx.annotation.StringRes
import com.x8bit.bitwarden.R
/**
* Models the result of validating the origin of a FIDO2 request.
*/
@@ -7,49 +10,75 @@ sealed class Fido2ValidateOriginResult {
/**
* Represents a successful origin validation.
*
* @param origin The origin of the calling app, or null if the calling app is not privileged.
*/
data object Success : Fido2ValidateOriginResult()
data class Success(val origin: String?) : Fido2ValidateOriginResult()
/**
* Represents a validation error.
*/
sealed class Error : Fido2ValidateOriginResult() {
/**
* The string resource ID of the error message.
*/
@get:StringRes
abstract val messageResId: Int
/**
* Indicates the digital asset links file could not be located.
*/
data object AssetLinkNotFound : Error()
data object AssetLinkNotFound : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_of_missing_asset_links
}
/**
* Indicates the application package name was not found in the digital asset links file.
*/
data object ApplicationNotFound : Error()
data object ApplicationNotFound : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_app_not_found_in_asset_links
}
/**
* Indicates the application fingerprint was not found the digital asset links file.
*/
data object ApplicationNotVerified : Error()
data object ApplicationFingerprintNotVerified : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_app_could_not_be_verified
}
/**
* Indicates the calling application is privileged but its package name is not found within
* the privileged app allow list.
*/
data object PrivilegedAppNotAllowed : Error()
data object PrivilegedAppNotAllowed : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_browser_is_not_privileged
}
/**
* Indicates the calling app is privileged but but no matching signing certificate signature
* is present in the allow list.
*/
data object PrivilegedAppSignatureNotFound : Error()
data object PrivilegedAppSignatureNotFound : Error() {
override val messageResId =
R.string.passkey_operation_failed_because_browser_signature_does_not_match
}
/**
* Indicates passkeys are not supported for the requesting application.
*/
data object PasskeyNotSupportedForApp : Error()
data object PasskeyNotSupportedForApp : Error() {
override val messageResId = R.string.passkeys_not_supported_for_this_app
}
/**
* Indicates an unknown error was encountered while validating the origin.
*/
data object Unknown : Error()
data object Unknown : Error() {
override val messageResId = R.string.generic_error_message
}
}
}

View File

@@ -41,16 +41,17 @@ fun CallingAppInfo.validatePrivilegedApp(allowList: String): Fido2ValidateOrigin
}
return try {
if (getOrigin(allowList) != null) {
Fido2ValidateOriginResult.Success
} else {
val origin = getOrigin(allowList)
if (origin.isNullOrEmpty()) {
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
} else {
Fido2ValidateOriginResult.Success(origin)
}
} catch (e: IllegalStateException) {
} catch (_: IllegalStateException) {
// We know the package name is in the allow list so we can infer that this exception is
// thrown because no matching signature is found.
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound
} catch (e: IllegalArgumentException) {
} catch (_: IllegalArgumentException) {
// The allow list is not formatted correctly so we notify the user passkeys are not
// supported for this application
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp

View File

@@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
@@ -106,6 +107,7 @@ class VaultItemListingViewModel @Inject constructor(
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val policyManager: PolicyManager,
private val fido2CredentialManager: Fido2CredentialManager,
private val fido2OriginManager: Fido2OriginManager,
private val organizationEventManager: OrganizationEventManager,
) : BaseViewModel<VaultItemListingState, VaultItemListingEvent, VaultItemListingsAction>(
initialState = run {
@@ -727,7 +729,7 @@ class VaultItemListingViewModel @Inject constructor(
return
}
viewModelScope.launch {
val validateOriginResult = fido2CredentialManager
val validateOriginResult = fido2OriginManager
.validateOrigin(
callingAppInfo = request.callingAppInfo,
relyingPartyId = relyingPartyId,
@@ -737,7 +739,7 @@ class VaultItemListingViewModel @Inject constructor(
handleFido2OriginValidationFail(validateOriginResult)
}
Fido2ValidateOriginResult.Success -> {
is Fido2ValidateOriginResult.Success -> {
sendAction(
VaultItemListingsAction.Internal.Fido2AssertionResultReceive(
result = fido2CredentialManager.authenticateFido2Credential(
@@ -1384,7 +1386,7 @@ class VaultItemListingViewModel @Inject constructor(
showFido2ErrorDialog()
return@launch
}
val validateOriginResult = fido2CredentialManager
val validateOriginResult = fido2OriginManager
.validateOrigin(
callingAppInfo = action.request.callingAppInfo,
relyingPartyId = options.relyingParty.id,
@@ -1394,7 +1396,7 @@ class VaultItemListingViewModel @Inject constructor(
handleFido2OriginValidationFail(validateOriginResult)
}
Fido2ValidateOriginResult.Success -> {
is Fido2ValidateOriginResult.Success -> {
observeVaultData()
}
}
@@ -1427,7 +1429,7 @@ class VaultItemListingViewModel @Inject constructor(
R.string.passkey_operation_failed_because_app_not_found_in_asset_links
}
Fido2ValidateOriginResult.Error.ApplicationNotVerified -> {
Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified -> {
R.string.passkey_operation_failed_because_app_could_not_be_verified
}

View File

@@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
@@ -121,6 +122,9 @@ class MainViewModelTest : BaseViewModelTest() {
every { isUserVerified } returns true
every { isUserVerified = any() } just runs
}
private val fido2OriginManager = mockk<Fido2OriginManager> {
coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null)
}
private val savedStateHandle = SavedStateHandle()
@BeforeEach
@@ -614,11 +618,11 @@ class MainViewModelTest : BaseViewModelTest() {
)
coEvery {
fido2CredentialManager.validateOrigin(
fido2OriginManager.validateOrigin(
fido2CreateCredentialRequest.callingAppInfo,
fido2CreateCredentialRequest.requestJson,
)
} returns Fido2ValidateOriginResult.Success
} returns Fido2ValidateOriginResult.Success(null)
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@@ -668,11 +672,11 @@ class MainViewModelTest : BaseViewModelTest() {
mockFido2CreateCredentialRequest = fido2CreateCredentialRequest,
)
coEvery {
fido2CredentialManager.validateOrigin(
fido2OriginManager.validateOrigin(
fido2CreateCredentialRequest.callingAppInfo,
fido2CreateCredentialRequest.requestJson,
)
} returns Fido2ValidateOriginResult.Success
} returns Fido2ValidateOriginResult.Success(null)
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@@ -698,11 +702,11 @@ class MainViewModelTest : BaseViewModelTest() {
mockFido2CreateCredentialRequest = fido2CreateCredentialRequest,
)
coEvery {
fido2CredentialManager.validateOrigin(
fido2OriginManager.validateOrigin(
fido2CreateCredentialRequest.callingAppInfo,
fido2CreateCredentialRequest.requestJson,
)
} returns Fido2ValidateOriginResult.Success
} returns Fido2ValidateOriginResult.Success(null)
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(

View File

@@ -3,16 +3,12 @@ package com.x8bit.bitwarden.data.autofill.fido2.manager
import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.util.Base64
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.fido.ClientData
import com.bitwarden.fido.Origin
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
import com.bitwarden.fido.UnverifiedAssetLink
import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2AttestationResponse
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2PublicKeyCredential
@@ -21,8 +17,6 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialRequest
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@@ -34,7 +28,6 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPublicKeyAt
import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKeyCredential
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAssertionOptions
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAttestationOptions
import io.mockk.Ordering
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -61,13 +54,8 @@ class Fido2CredentialManagerTest {
private lateinit var fido2CredentialManager: Fido2CredentialManager
private val assetManager: AssetManager = mockk {
coEvery { readAsset(any()) } returns DEFAULT_ALLOW_LIST.asSuccess()
}
private val digitalAssetLinkService = mockk<DigitalAssetLinkService> {
coEvery {
getDigitalAssetLinkForRp(relyingParty = any())
} returns DEFAULT_STATEMENT_LIST.asSuccess()
private val fido2OriginManager = mockk<Fido2OriginManager> {
coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null)
}
private val json = mockk<Json> {
every {
@@ -80,28 +68,10 @@ class Fido2CredentialManagerTest {
decodeFromStringOrNull<PasskeyAssertionOptions>(DEFAULT_FIDO2_AUTH_REQUEST_JSON)
} returns createMockPasskeyAssertionOptions(number = 1)
}
private val mockPrivilegedCallingAppInfo = mockk<CallingAppInfo> {
every { packageName } returns DEFAULT_PACKAGE_NAME
every { isOriginPopulated() } returns true
every { getOrigin(any()) } returns DEFAULT_PACKAGE_NAME
}
private val mockPrivilegedAppRequest = mockk<Fido2CreateCredentialRequest> {
every { callingAppInfo } returns mockPrivilegedCallingAppInfo
every { requestJson } returns "{}"
}
private val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature("0987654321ABCDEF"))
every { hasMultipleSigners() } returns false
}
private val mockUnprivilegedCallingAppInfo = CallingAppInfo(
packageName = DEFAULT_PACKAGE_NAME,
signingInfo = mockSigningInfo,
origin = null,
)
private val mockUnprivilegedAppRequest = mockk<Fido2CreateCredentialRequest> {
every { callingAppInfo } returns mockUnprivilegedCallingAppInfo
every { requestJson } returns "{}"
}
private val mockMessageDigest = mockk<MessageDigest> {
every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray()
}
@@ -114,10 +84,9 @@ class Fido2CredentialManagerTest {
every { MessageDigest.getInstance(any()) } returns mockMessageDigest
fido2CredentialManager = Fido2CredentialManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
vaultSdkSource = mockVaultSdkSource,
fido2CredentialStore = mockFido2CredentialStore,
fido2OriginManager = fido2OriginManager,
json = json,
)
}
@@ -130,201 +99,6 @@ class Fido2CredentialManagerTest {
)
}
@Test
fun `validateOrigin should load allow list when origin is populated`() =
runTest {
fido2CredentialManager.validateOrigin(
mockPrivilegedAppRequest.callingAppInfo,
mockPrivilegedAppRequest.requestJson,
)
coVerify(exactly = 1) {
assetManager.readAsset(
fileName = GOOGLE_ALLOW_LIST_FILENAME,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should validate with community allow list when google allow list validation fails`() =
runTest {
coEvery {
assetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
} returns MISSING_PACKAGE_ALLOW_LIST.asSuccess()
every {
mockPrivilegedCallingAppInfo.getOrigin(
privilegedAllowlist = MISSING_PACKAGE_ALLOW_LIST,
)
} throws IllegalStateException()
coEvery {
assetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME)
} returns DEFAULT_ALLOW_LIST.asSuccess()
every {
mockPrivilegedCallingAppInfo.getOrigin(
privilegedAllowlist = DEFAULT_ALLOW_LIST,
)
} returns DEFAULT_PACKAGE_NAME
fido2CredentialManager.validateOrigin(
mockPrivilegedAppRequest.callingAppInfo,
mockPrivilegedAppRequest.requestJson,
)
coVerify(ordering = Ordering.ORDERED) {
assetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
assetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME)
}
}
@Test
fun `validateOrigin should return Success when privileged app is allowed`() =
runTest {
assertEquals(
Fido2ValidateOriginResult.Success,
fido2CredentialManager.validateOrigin(
mockPrivilegedAppRequest.callingAppInfo,
mockPrivilegedAppRequest.requestJson,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return PrivilegedAppSignatureNotFound when privileged app signature is not found in allow list`() =
runTest {
every { mockPrivilegedCallingAppInfo.getOrigin(any()) } throws IllegalStateException()
assertEquals(
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound,
fido2CredentialManager.validateOrigin(
mockPrivilegedAppRequest.callingAppInfo,
mockPrivilegedAppRequest.requestJson,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return PrivilegedAppNotAllowed when privileged app package name is not found in allow list`() =
runTest {
coEvery { assetManager.readAsset(any()) } returns MISSING_PACKAGE_ALLOW_LIST.asSuccess()
assertEquals(
Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed,
fido2CredentialManager.validateOrigin(mockPrivilegedCallingAppInfo, "{}"),
)
}
@Test
fun `validateOrigin should return error when allow list is unreadable`() = runTest {
coEvery { assetManager.readAsset(any()) } returns IllegalStateException().asFailure()
assertEquals(
Fido2ValidateOriginResult.Error.Unknown,
fido2CredentialManager.validateOrigin(
mockPrivilegedAppRequest.callingAppInfo,
mockPrivilegedAppRequest.requestJson,
),
)
}
@Test
fun `validateOrigin should return PasskeyNotSupportedForApp when allow list is invalid`() =
runTest {
every {
mockPrivilegedCallingAppInfo.getOrigin(any())
} throws IllegalArgumentException()
assertEquals(
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp,
fido2CredentialManager.validateOrigin(mockPrivilegedCallingAppInfo, "{}"),
)
}
@Test
fun `validateOrigin should return success when asset links contains matching statement`() =
runTest {
assertEquals(
Fido2ValidateOriginResult.Success,
fido2CredentialManager.validateOrigin(
mockUnprivilegedAppRequest.callingAppInfo,
mockUnprivilegedAppRequest.requestJson,
),
)
}
@Test
fun `validateOrigin should return error when asset links are unavailable`() = runTest {
coEvery {
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any())
} returns Throwable().asFailure()
assertEquals(
fido2CredentialManager.validateOrigin(
mockUnprivilegedAppRequest.callingAppInfo,
mockUnprivilegedAppRequest.requestJson,
),
Fido2ValidateOriginResult.Error.AssetLinkNotFound,
)
}
@Test
fun `validateOrigin should return error when asset links does not contain package name`() =
runTest {
every { mockUnprivilegedAppRequest.callingAppInfo } returns CallingAppInfo(
packageName = "its.a.trap",
signingInfo = mockSigningInfo,
origin = null,
)
assertEquals(
Fido2ValidateOriginResult.Error.ApplicationNotFound,
fido2CredentialManager.validateOrigin(
mockUnprivilegedAppRequest.callingAppInfo,
mockUnprivilegedAppRequest.requestJson,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return error when asset links does not contain android app namespace`() =
runTest {
coEvery {
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any())
} returns listOf(
DEFAULT_STATEMENT.copy(
target = DEFAULT_STATEMENT.target.copy(
namespace = "its_a_trap",
),
),
)
.asSuccess()
assertEquals(
Fido2ValidateOriginResult.Error.ApplicationNotFound,
fido2CredentialManager.validateOrigin(
mockUnprivilegedAppRequest.callingAppInfo,
mockUnprivilegedAppRequest.requestJson,
),
)
}
@Test
fun `validateOrigin should return error when asset links certificate hash no match`() =
runTest {
every {
mockMessageDigest.digest(any())
} returns "ITSATRAP".toByteArray()
assertEquals(
Fido2ValidateOriginResult.Error.ApplicationNotVerified,
fido2CredentialManager.validateOrigin(
mockUnprivilegedAppRequest.callingAppInfo,
mockUnprivilegedAppRequest.requestJson,
),
)
}
@Test
fun `getPasskeyAttestationOptionsOrNull should return passkey options when deserialized`() =
runTest {
@@ -967,7 +741,6 @@ class Fido2CredentialManagerTest {
)
coVerify {
assetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
mockVaultSdkSource.authenticateFido2Credential(
request = any(),
fido2CredentialStore = any(),
@@ -1021,8 +794,8 @@ class Fido2CredentialManagerTest {
val mockSelectedCipherView = createMockCipherView(number = 1)
coEvery {
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = "mockRelyingPartyId-1")
} returns IllegalStateException().asFailure()
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.Unknown
val result = fido2CredentialManager.authenticateFido2Credential(
userId = "activeUserId",
@@ -1053,22 +826,6 @@ private val DEFAULT_ORIGIN = Origin.Android(
assetLinkUrl = "bitwarden.com",
),
)
private val DEFAULT_STATEMENT = DigitalAssetLinkResponseJson(
relation = listOf(
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls",
),
target = DigitalAssetLinkResponseJson.Target(
namespace = "android_app",
packageName = DEFAULT_PACKAGE_NAME,
sha256CertFingerprints = listOf(
DEFAULT_CERT_FINGERPRINT,
),
),
)
private const val GOOGLE_ALLOW_LIST_FILENAME = "fido2_privileged_google.json"
private const val COMMUNITY_ALLOW_LIST_FILENAME = "fido2_privileged_community.json"
private val DEFAULT_STATEMENT_LIST = listOf(DEFAULT_STATEMENT)
private const val DEFAULT_ALLOW_LIST = """
{
"apps": [
@@ -1091,28 +848,6 @@ private const val DEFAULT_ALLOW_LIST = """
]
}
"""
private const val MISSING_PACKAGE_ALLOW_LIST = """
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.android.chrome",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
}
]
}
"""
private const val DEFAULT_FIDO2_AUTH_REQUEST_JSON = """
{
"allowCredentials": [

View File

@@ -0,0 +1,332 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import android.content.pm.Signature
import android.util.Base64
import androidx.credentials.provider.CallingAppInfo
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.security.MessageDigest
class Fido2OriginManagerTest {
private val mockAssetManager = mockk<AssetManager>()
private val mockDigitalAssetLinkService = mockk<DigitalAssetLinkService>()
private val mockPrivilegedAppInfo = mockk<CallingAppInfo> {
every { isOriginPopulated() } returns true
every { packageName } returns DEFAULT_PACKAGE_NAME
every { getOrigin(any()) } returns DEFAULT_ORIGIN
}
private val mockNonPrivilegedAppInfo = mockk<CallingAppInfo> {
every { isOriginPopulated() } returns false
every { packageName } returns DEFAULT_PACKAGE_NAME
every { getOrigin(any()) } returns null
every { signingInfo } returns mockk {
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE))
every { hasMultipleSigners() } returns false
}
}
private val mockMessageDigest = mockk<MessageDigest> {
every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray()
}
private val fido2OriginManager = Fido2OriginManagerImpl(
assetManager = mockAssetManager,
digitalAssetLinkService = mockDigitalAssetLinkService,
)
@BeforeEach
fun setUp() {
mockkStatic(
MessageDigest::class,
Base64::class,
)
every { MessageDigest.getInstance(any()) } returns mockMessageDigest
}
@AfterEach
fun tearDown() {
unmockkStatic(
MessageDigest::class,
Base64::class,
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return Success when calling app is Privileged and is in Google allow list`() =
runTest {
coEvery {
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
} returns DEFAULT_ALLOW_LIST.asSuccess()
val result = fido2OriginManager.validateOrigin(
callingAppInfo = mockPrivilegedAppInfo,
relyingPartyId = "relyingPartyId",
)
coVerify(exactly = 1) {
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
}
assertEquals(
Fido2ValidateOriginResult.Success(DEFAULT_ORIGIN),
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return Success when calling app is Privileged and is in the Community allow list but not the Google allow list`() =
runTest {
coEvery {
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
} returns FAIL_ALLOW_LIST.asSuccess()
coEvery {
mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME)
} returns DEFAULT_ALLOW_LIST.asSuccess()
val result = fido2OriginManager.validateOrigin(
callingAppInfo = mockPrivilegedAppInfo,
relyingPartyId = "relyingPartyId",
)
coVerify(exactly = 1) {
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME)
}
assertEquals(
Fido2ValidateOriginResult.Success(DEFAULT_ORIGIN),
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return ApplicationNotFound when calling app is Privileged but not in either allow list`() =
runTest {
coEvery {
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
} returns FAIL_ALLOW_LIST.asSuccess()
coEvery {
mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME)
} returns FAIL_ALLOW_LIST.asSuccess()
val result = fido2OriginManager.validateOrigin(
callingAppInfo = mockPrivilegedAppInfo,
relyingPartyId = "relyingPartyId",
)
coVerify(exactly = 1) {
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME)
}
assertEquals(
Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed,
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return Success when calling app is NonPrivileged and has a valid asset link entry`() =
runTest {
coEvery {
mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID)
} returns listOf(DEFAULT_STATEMENT).asSuccess()
val result = fido2OriginManager.validateOrigin(
callingAppInfo = mockNonPrivilegedAppInfo,
relyingPartyId = DEFAULT_RP_ID,
)
assertEquals(
Fido2ValidateOriginResult.Success(null),
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return ApplicationFingerprintNotVerified when calling app is NonPrivileged but signature does not match asset link entry`() =
runTest {
coEvery {
mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID)
} returns listOf(
DEFAULT_STATEMENT.copy(
target = DEFAULT_STATEMENT.target.copy(
sha256CertFingerprints = listOf("invalid_fingerprint"),
),
),
)
.asSuccess()
assertEquals(
Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified,
fido2OriginManager.validateOrigin(
callingAppInfo = mockNonPrivilegedAppInfo,
relyingPartyId = DEFAULT_RP_ID,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return ApplicationNotFound when calling app is NonPrivileged and packageName has no asset link entry`() =
runTest {
coEvery {
mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID)
} returns listOf(
DEFAULT_STATEMENT.copy(
target = DEFAULT_STATEMENT.target.copy(
packageName = "invalid_package_name",
),
),
)
.asSuccess()
assertEquals(
Fido2ValidateOriginResult.Error.ApplicationNotFound,
fido2OriginManager.validateOrigin(
callingAppInfo = mockNonPrivilegedAppInfo,
relyingPartyId = DEFAULT_RP_ID,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return AssetLinkNotFound when calling app is NonPrivileged and asset link does not exist`() =
runTest {
coEvery {
mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID)
} returns RuntimeException().asFailure()
assertEquals(
Fido2ValidateOriginResult.Error.AssetLinkNotFound,
fido2OriginManager.validateOrigin(
callingAppInfo = mockNonPrivilegedAppInfo,
relyingPartyId = DEFAULT_RP_ID,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return Unknown error when calling app is NonPrivileged and exception is caught while filtering asset links`() =
runTest {
coEvery {
mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID)
} returns listOf(DEFAULT_STATEMENT).asSuccess()
every {
mockNonPrivilegedAppInfo.packageName
} throws IllegalStateException()
assertEquals(
Fido2ValidateOriginResult.Error.Unknown,
fido2OriginManager.validateOrigin(
callingAppInfo = mockNonPrivilegedAppInfo,
relyingPartyId = DEFAULT_RP_ID,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return Unknown error when calling app is Privileged and allow list file read fails`() =
runTest {
coEvery {
mockDigitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = DEFAULT_RP_ID)
} returns listOf(DEFAULT_STATEMENT).asSuccess()
coEvery {
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
} returns IllegalStateException().asFailure()
coEvery {
mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME)
} returns IllegalStateException().asFailure()
assertEquals(
Fido2ValidateOriginResult.Error.Unknown,
fido2OriginManager.validateOrigin(
callingAppInfo = mockPrivilegedAppInfo,
relyingPartyId = DEFAULT_RP_ID,
),
)
}
}
private const val DEFAULT_PACKAGE_NAME = "com.x8bit.bitwarden"
private const val DEFAULT_APP_SIGNATURE = "0987654321ABCDEF"
private const val DEFAULT_CERT_FINGERPRINT = "30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46"
private const val DEFAULT_RP_ID = "bitwarden.com"
private const val DEFAULT_ORIGIN = "bitwarden.com"
private const val GOOGLE_ALLOW_LIST_FILENAME = "fido2_privileged_google.json"
private const val COMMUNITY_ALLOW_LIST_FILENAME = "fido2_privileged_community.json"
private const val DEFAULT_ALLOW_LIST = """
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.x8bit.bitwarden",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
}
]
}
"""
private const val FAIL_ALLOW_LIST = """
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.not.bitwarden",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF"
}
]
}
}
]
}
"""
private val DEFAULT_STATEMENT = DigitalAssetLinkResponseJson(
relation = listOf(
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls",
),
target = DigitalAssetLinkResponseJson.Target(
namespace = "android_app",
packageName = DEFAULT_PACKAGE_NAME,
sha256CertFingerprints = listOf(
DEFAULT_CERT_FINGERPRINT,
),
),
)

View File

@@ -120,7 +120,7 @@ class CallingAppInfoExtensionsTest {
}
assertEquals(
Fido2ValidateOriginResult.Success,
Fido2ValidateOriginResult.Success("origin"),
mockAppInfo.validatePrivilegedApp(
allowList = DEFAULT_ALLOW_LIST,
),

View File

@@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2OriginManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
@@ -165,7 +166,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
every { getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND) } returns emptyFlow()
}
private val fido2CredentialManager: Fido2CredentialManager = mockk {
coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success
every { isUserVerified } returns false
every { isUserVerified = any() } just runs
every { authenticationAttempts } returns 0
@@ -173,6 +173,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
every { hasAuthenticationAttemptsRemaining() } returns true
every { getPasskeyAttestationOptionsOrNull(any()) } returns mockk(relaxed = true)
}
private val fido2OriginManager: Fido2OriginManager = mockk {
coEvery { validateOrigin(any(), any()) } returns Fido2ValidateOriginResult.Success(null)
}
private val organizationEventManager = mockk<OrganizationEventManager> {
every { trackEvent(event = any()) } just runs
@@ -1562,8 +1565,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
} returns DecryptFido2CredentialAutofillViewResult.Success(emptyList())
coEvery {
fido2CredentialManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Success
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Success("")
mockFilteredCiphers = listOf(cipherView1)
@@ -1618,7 +1621,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
vaultRepository.getDecryptedFido2CredentialAutofillViews(
cipherViewList = listOf(cipherView1, cipherView2),
)
fido2CredentialManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any(), any())
}
}
@@ -2163,7 +2166,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
createVaultItemListingViewModel()
coVerify(ordering = Ordering.ORDERED) {
fido2CredentialManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any(), any())
vaultRepository.vaultDataStateFlow
}
}
@@ -2181,7 +2184,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
coEvery {
fido2CredentialManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.Unknown
val viewModel = createVaultItemListingViewModel()
@@ -2212,7 +2215,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
coEvery {
fido2CredentialManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed
val viewModel = createVaultItemListingViewModel()
@@ -2243,7 +2246,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
coEvery {
fido2CredentialManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound
val viewModel = createVaultItemListingViewModel()
@@ -2274,7 +2277,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
coEvery {
fido2CredentialManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
val viewModel = createVaultItemListingViewModel()
@@ -2305,7 +2308,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
coEvery {
fido2CredentialManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.ApplicationNotFound
val viewModel = createVaultItemListingViewModel()
@@ -2336,7 +2339,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
coEvery {
fido2CredentialManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.AssetLinkNotFound
val viewModel = createVaultItemListingViewModel()
@@ -2367,8 +2370,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
coEvery {
fido2CredentialManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.ApplicationNotVerified
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.ApplicationFingerprintNotVerified
val viewModel = createVaultItemListingViewModel()
@@ -2790,7 +2793,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
} returns mockAssertionOptions
coEvery {
fido2CredentialManager.validateOrigin(any(), any())
fido2OriginManager.validateOrigin(any(), any())
} returns Fido2ValidateOriginResult.Error.Unknown
val viewModel = createVaultItemListingViewModel()
@@ -4054,6 +4057,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
policyManager = policyManager,
fido2CredentialManager = fido2CredentialManager,
organizationEventManager = organizationEventManager,
fido2OriginManager = fido2OriginManager,
)
@Suppress("MaxLineLength")