[PM-30703] Update Credential Exchange to alpha04 and introduce payload parser

This commit updates the Credential Exchange (CXF) implementation to align with the `androidx.credentials.providerevents:1.0.0-alpha04` library. The primary objective is to adapt to the breaking changes introduced in this version, which simplifies the API and removes the custom JSON request/response structure in favor of direct parameters.

A key part of this refactor is the introduction of a dedicated `CredentialExchangePayloadParser`. This parser is responsible for validating and processing the incoming JSON payload from the exporting credential manager.

Behavioral changes:
- The structure of the `ImportCredentialsRequest` sent to the credential provider has changed. It no longer uses a custom JSON string but instead passes `credentialTypes` and `knownExtensions` directly.
- The response from the credential provider is no longer a nested JSON structure with a Base64 encoded payload. It is now a direct JSON object representing the exported data.

Specific changes:
- **Dependencies**:
    - Upgraded `androidx.credentials.providerevents` to `1.0.0-alpha04`.
    - Added Hilt dependencies to the `cxf` module for dependency injection.

- **CXF Payload Parsing**:
    - Created `CredentialExchangePayloadParser` interface and its `CredentialExchangePayloadParserImpl` implementation to handle the parsing of incoming CXF JSON data.
    - Introduced a `CredentialExchangePayload` sealed class to represent the different outcomes of parsing: `Importable`, `NoItems`, and `Error`.
    - Added a `CxfModule` to provide the parser implementation via Hilt.
    - Added comprehensive unit tests for `CredentialExchangePayloadParserImpl` to cover valid payloads, version checks, invalid JSON, and error conditions.

- **Import Logic**:
    - Refactored `CredentialExchangeImporterImpl` to use the new `ImportCredentialsRequest` constructor, passing `credentialTypes` and `knownExtensions` directly instead of building a JSON string.
    - Updated `CredentialExchangeImportManagerImpl` to use the new `CredentialExchangePayloadParser`. The manager now delegates payload validation and parsing, simplifying its own logic to focus on the import, upload, and sync process.
    - Removed the now-obsolete manual parsing of the two-layered CXP/CXF JSON structure and Base64 decoding.

- **Export Logic**:
    - In `CredentialExchangeRegistryImpl`, added the `exportMatcher` WASM binary required by the `alpha04` API when registering an export flow.
    - Simplified `CredentialExchangeCompletionManagerImpl` to directly return the export data as a JSON string, removing the previous logic that wrapped it in a `CXP` protocol message with Base64 encoding.

- **Testing**:
    - Updated unit tests across `app` and `cxf` modules (`CredentialExchangeImporterTest`, `MainViewModelTest`, `CredentialExchangeImportManagerTest`, etc.) to reflect the API changes in `ImportCredentialsRequest` and the new parsing flow.
    - Removed outdated test logic related to the old JSON structure and Base64 decoding.
This commit is contained in:
Patrick Honkonen 2026-01-12 16:29:04 -05:00
parent 954571ff4a
commit d71150d572
No known key found for this signature in database
GPG Key ID: 27C65CF8B03CC9FB
24 changed files with 868 additions and 703 deletions

View File

@ -448,7 +448,8 @@ class MainViewModel @Inject constructor(
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = importCredentialsRequest.uri,
requestJson = importCredentialsRequest.request.requestJson,
credentialTypes = importCredentialsRequest.request.credentialTypes,
knownExtensions = importCredentialsRequest.request.knownExtensions,
),
)
}

View File

@ -1,17 +1,13 @@
package com.x8bit.bitwarden.data.vault.manager
import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.cxf.model.CredentialExchangeExportResponse
import com.bitwarden.cxf.model.CredentialExchangeProtocolMessage
import com.bitwarden.cxf.model.CredentialExchangePayload
import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.network.model.ImportCiphersJsonRequest
import com.bitwarden.network.model.ImportCiphersResponseJson
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.util.base64UrlDecodeOrNull
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherType
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.util.hasRestrictItemTypes
@ -19,16 +15,6 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.ImportCxfPayloadResult
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
private val SUPPORTED_CXP_FORMAT_VERSIONS = mapOf(
0 to setOf(0),
)
private val SUPPORTED_CXF_FORMAT_VERSIONS = mapOf(
0 to setOf(0),
1 to setOf(0),
)
/**
* Default implementation of [CredentialExchangeImportManager].
@ -38,118 +24,87 @@ class CredentialExchangeImportManagerImpl(
private val ciphersService: CiphersService,
private val vaultSyncManager: VaultSyncManager,
private val policyManager: PolicyManager,
private val json: Json,
private val credentialExchangePayloadParser: CredentialExchangePayloadParser,
) : CredentialExchangeImportManager {
@Suppress("LongMethod")
override suspend fun importCxfPayload(
userId: String,
payload: String,
): ImportCxfPayloadResult {
val credentialExchangeExportResult = json
.decodeFromStringOrNull<CredentialExchangeProtocolMessage>(payload)
?: return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Invalid CXP JSON."),
)
if (SUPPORTED_CXP_FORMAT_VERSIONS[credentialExchangeExportResult.version.major]
?.contains(credentialExchangeExportResult.version.minor) != true
) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException(
"Unsupported CXF version: ${credentialExchangeExportResult.version}.",
),
)
}
val decodedPayload = credentialExchangeExportResult.payload
.base64UrlDecodeOrNull()
?: return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unable to decode payload."),
)
val exportResponse = json
.decodeFromStringOrNull<CredentialExchangeExportResponse>(decodedPayload)
?: return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unable to decode header."),
)
if (SUPPORTED_CXF_FORMAT_VERSIONS[exportResponse.version.major]
?.contains(exportResponse.version.minor) != true
) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unsupported CXF version."),
)
}
if (exportResponse.accounts.isEmpty()) {
return ImportCxfPayloadResult.NoItems
}
val accountsJson = try {
json.encodeToString(
value = exportResponse.accounts.firstOrNull(),
)
} catch (_: SerializationException) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unable to re-encode accounts."),
)
}
return vaultSdkSource
.importCxf(
userId = userId,
payload = accountsJson,
)
.flatMap { cipherList ->
// Filter out card ciphers if RESTRICT_ITEM_TYPES policy is active
val filteredCipherList = if (policyManager.hasRestrictItemTypes()) {
cipherList.filter { cipher -> cipher.type != CipherType.CARD }
} else {
cipherList
}
if (filteredCipherList.isEmpty()) {
// If no ciphers were returned, we can skip the remaining steps and return the
// appropriate result.
return ImportCxfPayloadResult.NoItems
}
ciphersService
.importCiphers(
request = ImportCiphersJsonRequest(
ciphers = filteredCipherList.map {
it.toEncryptedNetworkCipher(
encryptedFor = userId,
)
},
folders = emptyList(),
folderRelationships = emptyList(),
),
)
.flatMap { importCiphersResponseJson ->
when (importCiphersResponseJson) {
is ImportCiphersResponseJson.Invalid -> {
ImportCredentialsUnknownErrorException().asFailure()
}
ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult
.Success(itemCount = filteredCipherList.size)
.asSuccess()
}
}
}
): ImportCxfPayloadResult =
when (val exportResponse = credentialExchangePayloadParser.parse(payload)) {
is CredentialExchangePayload.Importable -> {
import(
userId = userId,
accountsJson = exportResponse.accountsJson,
)
}
.map {
when (val syncResult = vaultSyncManager.syncForResult(forced = true)) {
is SyncVaultDataResult.Success -> it
is SyncVaultDataResult.Error -> {
ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable)
CredentialExchangePayload.NoItems -> {
ImportCxfPayloadResult.NoItems
}
is CredentialExchangePayload.Error -> {
ImportCxfPayloadResult.Error(exportResponse.throwable)
}
}
private suspend fun import(
userId: String,
accountsJson: String,
): ImportCxfPayloadResult = vaultSdkSource
.importCxf(userId = userId, payload = accountsJson)
.flatMap { cipherList ->
// Filter out card ciphers if RESTRICT_ITEM_TYPES policy is active
val filteredCipherList = if (policyManager.hasRestrictItemTypes()) {
cipherList.filter { cipher -> cipher.type != CipherType.CARD }
} else {
cipherList
}
if (filteredCipherList.isEmpty()) {
// If no ciphers were returned, we can skip the remaining steps and return the
// appropriate result.
return ImportCxfPayloadResult.NoItems
}
uploadCiphers(userId = userId, ciphers = filteredCipherList)
}
.map { syncVault(it) }
.fold(
onSuccess = { it },
onFailure = { ImportCxfPayloadResult.Error(error = it) },
)
private suspend fun uploadCiphers(
userId: String,
ciphers: List<Cipher>,
): Result<ImportCxfPayloadResult.Success> {
val request = ImportCiphersJsonRequest(
ciphers = ciphers.map { it.toEncryptedNetworkCipher(encryptedFor = userId) },
folders = emptyList(),
folderRelationships = emptyList(),
)
return ciphersService
.importCiphers(request)
.flatMap { response ->
when (response) {
is ImportCiphersResponseJson.Invalid -> {
Result.failure(ImportCredentialsUnknownErrorException())
}
is ImportCiphersResponseJson.Success -> {
Result.success(
ImportCxfPayloadResult.Success(itemCount = ciphers.size),
)
}
}
}
.fold(
onSuccess = { it },
onFailure = { ImportCxfPayloadResult.Error(error = it) },
)
}
private suspend fun syncVault(result: ImportCxfPayloadResult): ImportCxfPayloadResult =
when (val syncResult = vaultSyncManager.syncForResult(forced = true)) {
is SyncVaultDataResult.Success -> result
is SyncVaultDataResult.Error -> {
ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable)
}
}
}

View File

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.manager.di
import android.content.Context
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
@ -49,7 +50,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import java.time.Clock
import javax.inject.Singleton
@ -252,12 +252,12 @@ object VaultManagerModule {
ciphersService: CiphersService,
vaultSyncManager: VaultSyncManager,
policyManager: PolicyManager,
json: Json,
credentialExchangePayloadParser: CredentialExchangePayloadParser,
): CredentialExchangeImportManager = CredentialExchangeImportManagerImpl(
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
vaultSyncManager = vaultSyncManager,
policyManager = policyManager,
json = json,
credentialExchangePayloadParser = credentialExchangePayloadParser,
)
}

View File

@ -82,14 +82,18 @@ import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkConstructor
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
@ -242,6 +246,7 @@ class MainViewModelTest : BaseViewModelTest() {
ProviderCreateCredentialRequest.Companion,
ProviderGetCredentialRequest.Companion,
)
unmockkConstructor(JSONObject::class)
}
@Suppress("MaxLineLength")
@ -1124,9 +1129,16 @@ class MainViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent with import credentials request data should set the special circumstance to CredentialExchangeExport`() {
mockkConstructor(JSONObject::class)
every {
anyConstructed<JSONObject>().put(any<String>(), any<JSONArray>())
} returns mockk()
val viewModel = createViewModel()
val importCredentialsRequestData = ProviderImportCredentialsRequest(
request = ImportCredentialsRequest("mockRequestJson"),
request = ImportCredentialsRequest(
setOf("mockCredentialType-1"),
setOf(),
),
callingAppInfo = mockk(),
uri = mockk(),
credId = "mockCredId",
@ -1145,7 +1157,8 @@ class MainViewModelTest : BaseViewModelTest() {
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = importCredentialsRequestData.uri,
requestJson = importCredentialsRequestData.request.requestJson,
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
),
),
specialCircumstanceManager.specialCircumstance,

View File

@ -354,7 +354,8 @@ class SpecialCircumstanceExtensionsTest {
fun `toImportCredentialsRequestDataOrNull should return a non-null value for ImportCredentials`() {
val importCredentialsRequestData = ImportCredentialsRequestData(
uri = mockk(),
requestJson = "",
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
)
assertEquals(
importCredentialsRequestData,

View File

@ -1,18 +1,16 @@
package com.x8bit.bitwarden.data.vault.manager
import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.cxf.model.CredentialExchangeExportResponse
import com.bitwarden.cxf.model.CredentialExchangeProtocolMessage
import com.bitwarden.cxf.model.CredentialExchangeVersion
import com.bitwarden.cxf.model.CredentialExchangePayload
import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.network.model.ImportCiphersJsonRequest
import com.bitwarden.network.model.ImportCiphersResponseJson
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.createMockPolicy
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.util.base64UrlDecodeOrNull
import com.bitwarden.vault.Cipher
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@ -25,17 +23,11 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertNotNull
@ -49,16 +41,10 @@ class CredentialExchangeImportManagerTest {
getActivePolicies(any())
} returns emptyList()
}
private val json = mockk<Json> {
every {
decodeFromStringOrNull<CredentialExchangeProtocolMessage>(any())
} returns DEFAULT_CXP_MESSAGE
every {
decodeFromStringOrNull<CredentialExchangeExportResponse>(any())
} returns DEFAULT_CXF_EXPORT_RESPONSE
every {
encodeToString(value = DEFAULT_ACCOUNT, serializer = any())
} returns DEFAULT_ACCOUNT_JSON
private val credentialExchangePayloadParser: CredentialExchangePayloadParser = mockk {
every { parse(DEFAULT_PAYLOAD) } returns CredentialExchangePayload.Importable(
accountsJson = DEFAULT_ACCOUNT_JSON,
)
}
private val importManager = CredentialExchangeImportManagerImpl(
@ -66,102 +52,148 @@ class CredentialExchangeImportManagerTest {
ciphersService = ciphersService,
vaultSyncManager = vaultSyncManager,
policyManager = policyManager,
json = json,
credentialExchangePayloadParser = credentialExchangePayloadParser,
)
@BeforeEach
fun setUp() {
mockkStatic(String::base64UrlDecodeOrNull)
every {
DEFAULT_PAYLOAD.base64UrlDecodeOrNull()
} returns DEFAULT_PAYLOAD
}
@Nested
inner class ParserResultHandling {
@Test
fun `when parser returns Error, should return Error`() = runTest {
val parserException = ImportCredentialsInvalidJsonException("Invalid JSON")
every {
credentialExchangePayloadParser.parse(DEFAULT_PAYLOAD)
} returns CredentialExchangePayload.Error(parserException)
@AfterEach
fun tearDown() {
unmockkStatic(String::base64UrlDecodeOrNull)
}
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
@Test
fun `when vaultSdkSource importCxf fails, should return Error`() = runTest {
val exception = RuntimeException("SDK import failed")
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns exception.asFailure()
coEvery {
ciphersService.importCiphers(any())
} just awaits
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.Error(exception), result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
assertTrue(result is ImportCxfPayloadResult.Error)
assertEquals(parserException, (result as ImportCxfPayloadResult.Error).error)
}
coVerify(exactly = 0) {
ciphersService.importCiphers(any())
@Test
fun `when parser returns NoItems, should return NoItems`() = runTest {
every {
credentialExchangePayloadParser.parse(DEFAULT_PAYLOAD)
} returns CredentialExchangePayload.NoItems
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.NoItems, result)
}
}
@Test
fun `when ciphersService importCiphers fails, should return Error`() = runTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
@Nested
inner class ImportFlow {
@Test
fun `when vaultSdkSource importCxf fails, should return Error`() = runTest {
val exception = RuntimeException("SDK import failed")
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns exception.asFailure()
val exception = RuntimeException("Network import failed")
val capturedRequest = slot<ImportCiphersJsonRequest>()
coEvery {
ciphersService.importCiphers(capture(capturedRequest))
} returns exception.asFailure()
coEvery {
ciphersService.importCiphers(any())
} just awaits
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.Error(exception), result)
assertEquals(1, capturedRequest.captured.ciphers.size)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
assertEquals(ImportCxfPayloadResult.Error(exception), result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
}
coVerify(exactly = 0) {
ciphersService.importCiphers(any())
}
}
}
@Test
fun `when ciphersService importCiphers returns Invalid, should return Error`() = runTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
@Test
fun `when ciphersService importCiphers fails, should return Error`() = runTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
coEvery {
ciphersService.importCiphers(any())
} returns ImportCiphersResponseJson
.Invalid(validationErrors = emptyMap())
.asSuccess()
val exception = RuntimeException("Network import failed")
val capturedRequest = slot<ImportCiphersJsonRequest>()
coEvery {
ciphersService.importCiphers(capture(capturedRequest))
} returns exception.asFailure()
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
val error = (result as? ImportCxfPayloadResult.Error)?.error
assertNotNull(error)
assertTrue(error is ImportCredentialsUnknownErrorException)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
assertEquals(ImportCxfPayloadResult.Error(exception), result)
assertEquals(1, capturedRequest.captured.ciphers.size)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
}
}
}
@Suppress("MaxLineLength")
@Test
fun `when ciphersService importCiphers is Success and sync fails should return SyncFailed`() =
runTest {
@Test
fun `when ciphersService importCiphers returns Invalid, should return Error`() = runTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
coEvery {
ciphersService.importCiphers(any())
} returns ImportCiphersResponseJson
.Invalid(validationErrors = emptyMap())
.asSuccess()
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
val error = (result as? ImportCxfPayloadResult.Error)?.error
assertNotNull(error)
assertTrue(error is ImportCredentialsUnknownErrorException)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
}
}
@Suppress("MaxLineLength")
@Test
fun `when ciphersService importCiphers is Success and sync fails should return SyncFailed`() =
runTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
coEvery {
ciphersService.importCiphers(any())
} returns ImportCiphersResponseJson.Success.asSuccess()
val throwable = Throwable("Error!")
coEvery {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Error(throwable)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(
ImportCxfPayloadResult.SyncFailed(throwable),
result,
)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
}
}
@Test
fun `when all steps succeed, should return Success`() = runTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
@ -172,219 +204,6 @@ class CredentialExchangeImportManagerTest {
coEvery {
ciphersService.importCiphers(any())
} returns ImportCiphersResponseJson.Success.asSuccess()
val throwable = Throwable("Error!")
coEvery {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Error(throwable)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
assertEquals(
ImportCxfPayloadResult.SyncFailed(throwable),
result,
)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
}
}
@Test
fun `when all steps succeed, should return Success`() = runTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
coEvery {
ciphersService.importCiphers(any())
} returns ImportCiphersResponseJson.Success.asSuccess()
coEvery {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
assertEquals(ImportCxfPayloadResult.Success(itemCount = 1), result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `when importCxf returns empty cipher list, should skip importCiphers and sync and return NoItems`() =
runTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns emptyList<Cipher>().asSuccess()
coEvery {
ciphersService.importCiphers(any())
} just awaits
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
assertEquals(ImportCxfPayloadResult.NoItems, result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
}
coVerify(exactly = 0) {
ciphersService.importCiphers(any())
}
}
@Suppress("MaxLineLength")
@Test
fun `when payload cannot be deserialized into CredentialExchangeProtocolMessage, should return Error`() =
runTest {
every {
json.decodeFromStringOrNull<CredentialExchangeProtocolMessage>(any())
} returns null
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertTrue(result is ImportCxfPayloadResult.Error)
}
@Test
fun `when payload version is not supported, should return Error`() = runTest {
// Verify unsupported major version returns Error
every {
json.decodeFromStringOrNull<CredentialExchangeProtocolMessage>(any())
} returns DEFAULT_CXP_MESSAGE.copy(
version = DEFAULT_CXP_VERSION.copy(major = 1),
)
var result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertTrue(result is ImportCxfPayloadResult.Error)
// Verify unsupported minor version returns Error
every {
json.decodeFromStringOrNull<CredentialExchangeProtocolMessage>(any())
} returns DEFAULT_CXP_MESSAGE.copy(
version = DEFAULT_CXP_VERSION.copy(minor = 1),
)
result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertTrue(result is ImportCxfPayloadResult.Error)
}
@Test
fun `when decodedPayload is null, should return Error`() = runTest {
every {
DEFAULT_CXP_MESSAGE.payload.base64UrlDecodeOrNull()
} returns null
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertTrue(result is ImportCxfPayloadResult.Error)
}
@Test
fun `when CredentialExchangeExportResponse json is invalid, should return Error`() = runTest {
every {
json.decodeFromStringOrNull<CredentialExchangeExportResponse>(DEFAULT_PAYLOAD)
} returns null
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertTrue(result is ImportCxfPayloadResult.Error)
}
@Test
fun `when CredentialExchangeExportResponse version is not supported, should return Error`() =
runTest {
every {
json.decodeFromStringOrNull<CredentialExchangeExportResponse>(DEFAULT_PAYLOAD)
} returns DEFAULT_CXF_EXPORT_RESPONSE.copy(
version = DEFAULT_CXF_VERSION.copy(major = 2),
)
var result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertTrue(result is ImportCxfPayloadResult.Error)
every {
json.decodeFromStringOrNull<CredentialExchangeExportResponse>(DEFAULT_PAYLOAD)
} returns DEFAULT_CXF_EXPORT_RESPONSE.copy(
version = DEFAULT_CXF_VERSION.copy(minor = 1),
)
result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertTrue(result is ImportCxfPayloadResult.Error)
}
@Test
fun `when CredentialExchangeExportResponse accounts is empty, should return NoItems`() =
runTest {
every {
json.decodeFromStringOrNull<CredentialExchangeExportResponse>(DEFAULT_PAYLOAD)
} returns DEFAULT_CXF_EXPORT_RESPONSE.copy(
accounts = emptyList(),
)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertTrue(result is ImportCxfPayloadResult.NoItems)
}
@Suppress("MaxLineLength")
@Test
fun `when CredentialExchangeExportResponse account cannot be serialized, should return Error`() =
runTest {
every {
json.encodeToString(DEFAULT_CXF_EXPORT_RESPONSE.accounts.firstOrNull())
} throws SerializationException()
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertTrue(result is ImportCxfPayloadResult.Error)
}
@Test
fun `when user has restrict item types policy, card ciphers should be filtered out`() =
runTest {
every {
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns listOf(
createMockPolicy(
id = "mockId-1",
organizationId = "mockId-1",
type = PolicyTypeJson.RESTRICT_ITEM_TYPES,
isEnabled = true,
data = null,
),
)
val loginCipher = createMockSdkCipher(number = 1)
val cardCipher = createMockSdkCipher(number = 2).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
val mixedCipherList = listOf(loginCipher, cardCipher)
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns mixedCipherList.asSuccess()
val capturedRequest = slot<ImportCiphersJsonRequest>()
coEvery {
ciphersService.importCiphers(capture(capturedRequest))
} returns ImportCiphersResponseJson.Success.asSuccess()
coEvery {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
@ -392,8 +211,6 @@ class CredentialExchangeImportManagerTest {
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.Success(itemCount = 1), result)
// Verify only the login cipher was imported, card was filtered out
assertEquals(1, capturedRequest.captured.ciphers.size)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
@ -401,174 +218,221 @@ class CredentialExchangeImportManagerTest {
}
}
@Test
fun `when user has no restrict item types policy, card ciphers should not be filtered`() =
runTest {
every {
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns emptyList()
@Suppress("MaxLineLength")
@Test
fun `when importCxf returns empty cipher list, should skip importCiphers and sync and return NoItems`() =
runTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns emptyList<Cipher>().asSuccess()
coEvery {
ciphersService.importCiphers(any())
} just awaits
val loginCipher = createMockSdkCipher(number = 1)
val cardCipher = createMockSdkCipher(number = 2).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
val mixedCipherList = listOf(loginCipher, cardCipher)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
assertEquals(ImportCxfPayloadResult.NoItems, result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
}
coVerify(exactly = 0) {
ciphersService.importCiphers(any())
}
}
}
@Nested
inner class PolicyFiltering {
@Test
fun `when user has restrict item types policy, card ciphers should be filtered out`() =
runTest {
every {
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns listOf(
createMockPolicy(
id = "mockId-1",
organizationId = "mockId-1",
type = PolicyTypeJson.RESTRICT_ITEM_TYPES,
isEnabled = true,
data = null,
),
)
} returns mixedCipherList.asSuccess()
val capturedRequest = slot<ImportCiphersJsonRequest>()
coEvery {
ciphersService.importCiphers(capture(capturedRequest))
} returns ImportCiphersResponseJson.Success.asSuccess()
coEvery {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.Success(itemCount = 2), result)
// Verify both ciphers were imported
assertEquals(2, capturedRequest.captured.ciphers.size)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
}
}
@Test
fun `when user has restrict policy disabled, card ciphers should not be filtered`() =
runTest {
every {
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns listOf(
createMockPolicy(
id = "mockId-1",
organizationId = "mockId-1",
type = PolicyTypeJson.RESTRICT_ITEM_TYPES,
isEnabled = false,
data = null,
),
)
val loginCipher = createMockSdkCipher(number = 1)
val cardCipher = createMockSdkCipher(number = 2).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
val mixedCipherList = listOf(loginCipher, cardCipher)
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
val loginCipher = createMockSdkCipher(number = 1)
val cardCipher = createMockSdkCipher(number = 2).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
} returns mixedCipherList.asSuccess()
val mixedCipherList = listOf(loginCipher, cardCipher)
val capturedRequest = slot<ImportCiphersJsonRequest>()
coEvery {
ciphersService.importCiphers(capture(capturedRequest))
} returns ImportCiphersResponseJson.Success.asSuccess()
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns mixedCipherList.asSuccess()
coEvery {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
val capturedRequest = slot<ImportCiphersJsonRequest>()
coEvery {
ciphersService.importCiphers(capture(capturedRequest))
} returns ImportCiphersResponseJson.Success.asSuccess()
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
coEvery {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
assertEquals(ImportCxfPayloadResult.Success(itemCount = 2), result)
// Verify both ciphers were imported when policy is disabled
assertEquals(2, capturedRequest.captured.ciphers.size)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.Success(itemCount = 1), result)
// Verify only the login cipher was imported, card was filtered out
assertEquals(1, capturedRequest.captured.ciphers.size)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
}
}
}
@Test
fun `when user has restrict policy and all ciphers are cards, should return NoItems`() =
runTest {
every {
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns listOf(
createMockPolicy(
id = "mockId-1",
organizationId = "mockId-1",
type = PolicyTypeJson.RESTRICT_ITEM_TYPES,
isEnabled = true,
data = null,
),
)
@Test
fun `when user has no restrict item types policy, card ciphers should not be filtered`() =
runTest {
every {
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns emptyList()
val cardCipher1 = createMockSdkCipher(number = 1).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
val cardCipher2 = createMockSdkCipher(number = 2).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
val allCardsList = listOf(cardCipher1, cardCipher2)
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
val loginCipher = createMockSdkCipher(number = 1)
val cardCipher = createMockSdkCipher(number = 2).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
} returns allCardsList.asSuccess()
val mixedCipherList = listOf(loginCipher, cardCipher)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns mixedCipherList.asSuccess()
assertEquals(ImportCxfPayloadResult.NoItems, result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
val capturedRequest = slot<ImportCiphersJsonRequest>()
coEvery {
ciphersService.importCiphers(capture(capturedRequest))
} returns ImportCiphersResponseJson.Success.asSuccess()
coEvery {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.Success(itemCount = 2), result)
// Verify both ciphers were imported
assertEquals(2, capturedRequest.captured.ciphers.size)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
}
}
// Verify importCiphers was never called since all items were filtered
coVerify(exactly = 0) {
ciphersService.importCiphers(any())
@Test
fun `when user has restrict policy disabled, card ciphers should not be filtered`() =
runTest {
every {
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns listOf(
createMockPolicy(
id = "mockId-1",
organizationId = "mockId-1",
type = PolicyTypeJson.RESTRICT_ITEM_TYPES,
isEnabled = false,
data = null,
),
)
val loginCipher = createMockSdkCipher(number = 1)
val cardCipher = createMockSdkCipher(number = 2).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
val mixedCipherList = listOf(loginCipher, cardCipher)
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns mixedCipherList.asSuccess()
val capturedRequest = slot<ImportCiphersJsonRequest>()
coEvery {
ciphersService.importCiphers(capture(capturedRequest))
} returns ImportCiphersResponseJson.Success.asSuccess()
coEvery {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.Success(itemCount = 2), result)
// Verify both ciphers were imported when policy is disabled
assertEquals(2, capturedRequest.captured.ciphers.size)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
}
}
}
@Test
fun `when user has restrict policy and all ciphers are cards, should return NoItems`() =
runTest {
every {
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns listOf(
createMockPolicy(
id = "mockId-1",
organizationId = "mockId-1",
type = PolicyTypeJson.RESTRICT_ITEM_TYPES,
isEnabled = true,
data = null,
),
)
val cardCipher1 = createMockSdkCipher(number = 1).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
val cardCipher2 = createMockSdkCipher(number = 2).copy(
type = com.bitwarden.vault.CipherType.CARD,
)
val allCardsList = listOf(cardCipher1, cardCipher2)
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns allCardsList.asSuccess()
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
assertEquals(ImportCxfPayloadResult.NoItems, result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
}
// Verify importCiphers was never called since all items were filtered
coVerify(exactly = 0) {
ciphersService.importCiphers(any())
}
}
}
}
private const val DEFAULT_USER_ID = "mockId-1"
private const val DEFAULT_PAYLOAD = "mockPayload-1"
private val DEFAULT_CIPHER: Cipher = createMockSdkCipher(number = 1)
private val DEFAULT_CIPHER_LIST: List<Cipher> = listOf(DEFAULT_CIPHER)
private val DEFAULT_CXP_VERSION = CredentialExchangeVersion(
major = 0,
minor = 0,
)
private val DEFAULT_CXF_VERSION = CredentialExchangeVersion(
major = 1,
minor = 0,
)
private val DEFAULT_CXP_MESSAGE: CredentialExchangeProtocolMessage =
CredentialExchangeProtocolMessage(
version = DEFAULT_CXP_VERSION,
exporterRpId = "mockRpId-1",
exporterDisplayName = "mockDisplayName-1",
payload = DEFAULT_PAYLOAD,
)
private val DEFAULT_ACCOUNT: CredentialExchangeExportResponse.Account =
CredentialExchangeExportResponse.Account(
id = "mockId-1",
username = "mockUsername-1",
email = "mockEmail-1",
collections = JsonArray(content = emptyList()),
items = JsonArray(content = emptyList()),
)
private val DEFAULT_CXF_EXPORT_RESPONSE: CredentialExchangeExportResponse =
CredentialExchangeExportResponse(
version = DEFAULT_CXF_VERSION,
exporterRpId = "mockRpId-1",
exporterDisplayName = "mockDisplayName-1",
timestamp = 0,
accounts = listOf(DEFAULT_ACCOUNT),
)
private val DEFAULT_ACCOUNT_JSON = """
{

View File

@ -1555,7 +1555,8 @@ class RootNavViewModelTest : BaseViewModelTest() {
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = mockk(),
requestJson = "mockRequestJson",
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
),
)
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_MULTIPLE_ACCOUNTS_STATE)
@ -1573,7 +1574,8 @@ class RootNavViewModelTest : BaseViewModelTest() {
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = mockk(),
requestJson = "mockRequestJson",
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
),
)
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)

View File

@ -191,7 +191,8 @@ private val LOCKED_ACCOUNT_SUMMARY = AccountSummary(
)
private val DEFAULT_IMPORT_REQUEST = ImportCredentialsRequestData(
uri = mockk(),
requestJson = "mockRequestJson",
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
)
private val DEFAULT_STATE = SelectAccountState(

View File

@ -302,5 +302,6 @@ private val DEFAULT_USER_STATE = UserState(
)
private val DEFAULT_IMPORT_REQUEST = ImportCredentialsRequestData(
uri = mockk(),
requestJson = "mockRequestJson",
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
)

View File

@ -200,7 +200,8 @@ private val DEFAULT_STATE = ReviewExportState(
),
importCredentialsRequestData = ImportCredentialsRequestData(
uri = Uri.EMPTY,
requestJson = "",
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
),
hasOtherAccounts = true,
dialog = null,

View File

@ -415,7 +415,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
private val MOCK_URI = mockk<Uri>()
private val DEFAULT_REQUEST_DATA = ImportCredentialsRequestData(
uri = MOCK_URI,
requestJson = "mockRequestJson",
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
)
private val DEFAULT_CONTENT_VIEW_STATE = ReviewExportState.ViewState.Content(
itemTypeCounts = ReviewExportState.ItemTypeCounts(

View File

@ -3,9 +3,11 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.google.services)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
@ -52,6 +54,8 @@ dependencies {
implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.providerevents)
implementation(libs.androidx.credentials.providerevents.play.services)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization)
implementation(libs.timber)

View File

@ -0,0 +1,24 @@
package com.bitwarden.cxf.di
import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.cxf.parser.CredentialExchangePayloadParserImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
/**
* Provides dependencies from the CXF module.
*/
@Module
@InstallIn(SingletonComponent::class)
object CxfModule {
@Provides
fun provideCredentialExchangePayloadParser(
json: Json,
): CredentialExchangePayloadParser = CredentialExchangePayloadParserImpl(
json = json,
)
}

View File

@ -6,12 +6,10 @@ import androidx.credentials.providerevents.exception.ImportCredentialsCancellati
import androidx.credentials.providerevents.exception.ImportCredentialsException
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
import androidx.credentials.providerevents.transfer.ImportCredentialsRequest
import androidx.credentials.providerevents.transfer.KnownExtensions
import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult
import timber.log.Timber
private const val CXP_FORMAT_VERSION_MAJOR = 0
private const val CXP_FORMAT_VERSION_MINOR = 0
/**
* Default implementation of [CredentialExchangeImporter].
*
@ -28,45 +26,28 @@ internal class CredentialExchangeImporterImpl(
override suspend fun importCredentials(
credentialTypes: List<String>,
): ImportCredentialsSelectionResult {
return try {
val response = providerEventsManager.importCredentials(
context = activity,
request = ImportCredentialsRequest(
// Format the request according to the FIDO CXP spec.
// TODO: [PM-25663] Link to the correct documentation once it's available.
requestJson = """
{
"version": {
"major":$CXP_FORMAT_VERSION_MAJOR,
"minor":$CXP_FORMAT_VERSION_MINOR
},
"mode": ["direct"],
"importerRpId": "${activity.packageName}",
"importerDisplayName": "${activity.applicationInfo.name}",
"credentialTypes": [
${credentialTypes.joinToString { "\"$it\"" }}
]
}
"""
.trimIndent(),
),
)
ImportCredentialsSelectionResult.Success(
response = response.response.responseJson,
callingAppInfo = response.callingAppInfo,
)
} catch (e: ImportCredentialsCancellationException) {
Timber.e(e, "User cancelled import from selected credential manager.")
ImportCredentialsSelectionResult.Cancelled
} catch (e: ImportCredentialsException) {
Timber.e(e, "Failed to import items from selected credential manager.")
ImportCredentialsSelectionResult.Failure(error = e)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Timber.e(e, "Failed to import items from selected credential manager.")
ImportCredentialsSelectionResult.Failure(
error = ImportCredentialsUnknownErrorException(),
)
}
): ImportCredentialsSelectionResult = try {
val response = providerEventsManager.importCredentials(
context = activity,
request = ImportCredentialsRequest(
credentialTypes = credentialTypes.toSet(),
knownExtensions = setOf(KnownExtensions.KNOWN_EXTENSION_SHARED),
),
)
ImportCredentialsSelectionResult.Success(
response = response.response.responseJson,
callingAppInfo = response.callingAppInfo,
)
} catch (e: ImportCredentialsCancellationException) {
Timber.e(e, "User cancelled import from selected credential manager.")
ImportCredentialsSelectionResult.Cancelled
} catch (e: ImportCredentialsException) {
Timber.e(e, "Failed to import items from selected credential manager.")
ImportCredentialsSelectionResult.Failure(error = e)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Timber.e(e, "Failed to import items from selected credential manager.")
ImportCredentialsSelectionResult.Failure(
error = ImportCredentialsUnknownErrorException(),
)
}
}

View File

@ -7,10 +7,7 @@ import androidx.credentials.providerevents.exception.ImportCredentialsException
import androidx.credentials.providerevents.transfer.ImportCredentialsResponse
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
import java.time.Clock
import kotlin.io.encoding.Base64
private const val CXP_FORMAT_VERSION_MAJOR = 0
private const val CXP_FORMAT_VERSION_MINOR = 0
private const val CXF_FORMAT_VERSION_MAJOR = 1
private const val CXF_FORMAT_VERSION_MINOR = 0
@ -32,9 +29,6 @@ internal class CredentialExchangeCompletionManagerImpl(
}
is ExportCredentialsResult.Success -> {
val exporterRpId = activity.packageName
val exporterDisplayName = activity.applicationInfo.name
val headerJson = """
{
"version": {
@ -49,29 +43,12 @@ internal class CredentialExchangeCompletionManagerImpl(
"""
.trimIndent()
val encodedPayload = Base64.UrlSafe
.withPadding(Base64.PaddingOption.ABSENT)
.encode(headerJson.toByteArray())
val responseJson = """
{
"version": {
"major": $CXP_FORMAT_VERSION_MAJOR,
"minor": $CXP_FORMAT_VERSION_MINOR
},
"exporterRpId": "$exporterRpId",
"exporterDisplayName": "$exporterDisplayName",
"payload": "$encodedPayload"
}
"""
.trimIndent()
IntentHandler.setImportCredentialsResponse(
context = activity,
intent = intent,
uri = exportResult.uri,
response = ImportCredentialsResponse(
responseJson = responseJson,
responseJson = headerJson,
),
)
}

View File

@ -0,0 +1,25 @@
package com.bitwarden.cxf.model
/**
* Represents the result of parsing a CXF payload.
*/
sealed class CredentialExchangePayload {
/**
* Indicates that the payload is importable.
*/
data class Importable(
val accountsJson: String,
) : CredentialExchangePayload()
/**
* Indicates that the payload contains no importable items.
*/
data object NoItems : CredentialExchangePayload()
/**
* An error occurred while parsing the payload.
*/
data class Error(
val throwable: Throwable,
) : CredentialExchangePayload()
}

View File

@ -8,10 +8,12 @@ import kotlinx.parcelize.Parcelize
* A request to import the provider's credentials.
*
* @property uri the FileProvider uri that the importer will read the response from.
* @property requestJson the request to import the provider's credentials.
* @property credentialTypes the credential types that the requester supports.
* @property knownExtensions the known extensions that the importer supports.
*/
@Parcelize
data class ImportCredentialsRequestData(
val uri: Uri,
val requestJson: String,
val credentialTypes: Set<String>,
val knownExtensions: Set<String>,
) : Parcelable

View File

@ -0,0 +1,14 @@
package com.bitwarden.cxf.parser
import com.bitwarden.cxf.model.CredentialExchangePayload
/**
* Parser for Credential Exchange Payload JSON strings.
*/
interface CredentialExchangePayloadParser {
/**
* Parses a Credential Exchange Payload JSON string into a [CredentialExchangePayload].
*/
fun parse(payload: String): CredentialExchangePayload
}

View File

@ -0,0 +1,79 @@
package com.bitwarden.cxf.parser
import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.cxf.model.CredentialExchangeExportResponse
import com.bitwarden.cxf.model.CredentialExchangePayload
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
private val SUPPORTED_CXF_VERSIONS = mapOf(
0 to setOf(0),
1 to setOf(0),
)
/**
* Default implementation of the [CredentialExchangePayloadParser].
*/
internal class CredentialExchangePayloadParserImpl(
private val json: Json,
) : CredentialExchangePayloadParser {
override fun parse(payload: String): CredentialExchangePayload =
parseInternal(payload)
?: CredentialExchangePayload.Error(
ImportCredentialsInvalidJsonException(
"Invalid Credential Exchange JSON.",
),
)
/**
* Attempts to parse the alpha04+ Credential Exchange API JSON payload into a
* [CredentialExchangePayload] object.
*
* @return A [CredentialExchangePayload] object if the payload can be serialized directly into a
* [CredentialExchangeExportResponse], otherwise `null`.
*/
private fun parseInternal(payload: String): CredentialExchangePayload? =
json
.decodeFromStringOrNull<CredentialExchangeExportResponse>(payload)
?.let { exportResponse ->
when {
!isCxfVersionSupported(exportResponse) -> {
CredentialExchangePayload.Error(
ImportCredentialsInvalidJsonException(
"Unsupported CXF version.",
),
)
}
exportResponse.accounts.isEmpty() -> {
CredentialExchangePayload.NoItems
}
else -> {
try {
// We only support single account import, silently ignore additional
// accounts.
val accountsJson = json.encodeToString(
value = exportResponse.accounts.first(),
)
CredentialExchangePayload.Importable(
accountsJson = accountsJson,
)
} catch (_: SerializationException) {
CredentialExchangePayload.Error(
ImportCredentialsInvalidJsonException(
"Unable to serialize accounts.",
),
)
}
}
}
}
private fun isCxfVersionSupported(
response: CredentialExchangeExportResponse,
): Boolean = SUPPORTED_CXF_VERSIONS[response.version.major]
?.contains(response.version.minor) == true
}

View File

@ -11,6 +11,7 @@ import androidx.credentials.providerevents.transfer.RegisterExportResponse
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.cxf.R
import com.bitwarden.cxf.registry.model.RegistrationRequest
import timber.log.Timber
import java.util.UUID
@ -25,6 +26,17 @@ internal class CredentialExchangeRegistryImpl(
private val providerEventsManager: ProviderEventsManager =
ProviderEventsManager.create(application)
/**
* This is the default wasm binary provided by Google that runs the logic of deciding whether
* the registered exporter can support the incoming import request.
*
* See https://github.com/danjkim/identity-samples/tree/main/CredentialProvider/credential_exchange_matcher
* for source code and documentation.
*/
private val exportMatcher: ByteArray by lazy {
application.resources.openRawResource(R.raw.export_matcher).readBytes()
}
override suspend fun register(
registrationRequest: RegistrationRequest,
): Result<RegisterExportResponse> {
@ -47,6 +59,7 @@ internal class CredentialExchangeRegistryImpl(
supportedCredentialTypes = registrationRequest.credentialTypes,
),
),
exportMatcher = exportMatcher,
)
return try {
providerEventsManager
@ -65,6 +78,7 @@ internal class CredentialExchangeRegistryImpl(
// API is not currently available.
request = RegisterExportRequest(
entries = emptyList(),
exportMatcher = byteArrayOf(),
),
)
.asSuccess()

Binary file not shown.

View File

@ -4,16 +4,22 @@ import android.app.Activity
import androidx.credentials.provider.CallingAppInfo
import androidx.credentials.providerevents.ProviderEventsManager
import androidx.credentials.providerevents.exception.ImportCredentialsCancellationException
import androidx.credentials.providerevents.exception.ImportCredentialsException
import androidx.credentials.providerevents.transfer.ImportCredentialsRequest
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
import androidx.credentials.providerevents.transfer.ImportCredentialsResponse
import androidx.credentials.providerevents.transfer.ProviderImportCredentialsResponse
import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import kotlinx.coroutines.test.runTest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class CredentialExchangeImporterTest {
@ -27,30 +33,38 @@ class CredentialExchangeImporterTest {
providerEventsManager = mockProviderEventsManager,
)
@BeforeEach
fun setUp() {
mockkConstructor(
JSONObject::class,
JSONArray::class,
)
every {
anyConstructed<JSONObject>().put("credentialTypes", any<JSONArray>())
} returns mockk()
every {
anyConstructed<JSONObject>().put("knownExtensions", any<JSONArray>())
} returns mockk()
}
@AfterEach
fun tearDown() {
unmockkConstructor(
JSONObject::class,
JSONArray::class,
)
}
@Test
fun `importCredentials should construct request correctly and return a success result`() =
fun `importCredentials should return Success when provider returns valid response`() =
runTest {
val mockCallingAppInfo = mockk<CallingAppInfo>()
val capturedRequestJson = mutableListOf<ImportCredentialsRequest>()
val expectedRequestJson = """
{
"version": {
"major":0,
"minor":0
},
"mode": ["direct"],
"importerRpId": "mockPackageName",
"importerDisplayName": "null",
"credentialTypes": [
"basic-auth"
]
}
"""
.trimIndent()
coEvery {
mockProviderEventsManager.importCredentials(
context = mockActivity,
request = capture(capturedRequestJson),
context = any(),
request = any(),
)
} returns ProviderImportCredentialsResponse(
response = ImportCredentialsResponse(
@ -60,10 +74,7 @@ class CredentialExchangeImporterTest {
)
val result = importer.importCredentials(listOf("basic-auth"))
assertEquals(
expectedRequestJson,
capturedRequestJson.firstOrNull()?.requestJson,
)
assertEquals(
ImportCredentialsSelectionResult.Success(
response = "mockResponse",
@ -75,38 +86,35 @@ class CredentialExchangeImporterTest {
@Suppress("MaxLineLength")
@Test
fun `importCredentials should return ImportCredentialsSelectionResult Cancelled when ImportCredentialsCancellationException is thrown`() =
fun `importCredentials should return Cancelled when ImportCredentialsCancellationException is thrown`() =
runTest {
coEvery {
mockProviderEventsManager.importCredentials(
context = mockActivity,
context = any(),
request = any(),
)
} throws ImportCredentialsCancellationException()
assertEquals(
ImportCredentialsSelectionResult.Cancelled,
importer.importCredentials(listOf("basic-auth")),
)
val result = importer.importCredentials(listOf("basic-auth"))
assertEquals(ImportCredentialsSelectionResult.Cancelled, result)
}
@Suppress("MaxLineLength")
@Test
fun `importCredentials should return ImportCredentialsSelectionResult Failure when ImportCredentialsException is thrown`() =
fun `importCredentials should return Failure with UnknownErrorException when generic Exception is thrown`() =
runTest {
val importException = mockk<ImportCredentialsException>()
coEvery {
mockProviderEventsManager.importCredentials(
context = mockActivity,
context = any(),
request = any(),
)
} throws importException
} throws RuntimeException("Test exception")
val result = importer.importCredentials(listOf("basic-auth"))
assertEquals(
ImportCredentialsSelectionResult.Failure(error = importException),
result,
)
assertTrue(result is ImportCredentialsSelectionResult.Failure)
val failure = result as ImportCredentialsSelectionResult.Failure
assertTrue(failure.error is ImportCredentialsUnknownErrorException)
}
}

View File

@ -0,0 +1,197 @@
package com.bitwarden.cxf.parser
import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.cxf.model.CredentialExchangeExportResponse
import com.bitwarden.cxf.model.CredentialExchangePayload
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class CredentialExchangePayloadParserTest {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
private val parser = CredentialExchangePayloadParserImpl(json = json)
@Nested
inner class PayloadParsing {
@Test
fun `parse should return Importable when payload is valid with accounts`() {
val result = parser.parse(VALID_PAYLOAD)
assertTrue(result is CredentialExchangePayload.Importable)
val importable = result as CredentialExchangePayload.Importable
assertTrue(importable.accountsJson.isNotEmpty())
}
@Test
fun `parse should return NoItems when payload has empty accounts`() {
val result = parser.parse(VALID_PAYLOAD_EMPTY_ACCOUNTS)
assertEquals(CredentialExchangePayload.NoItems, result)
}
@Test
fun `parse should return Error when payload has unsupported major version`() {
val result = parser.parse(PAYLOAD_UNSUPPORTED_MAJOR_VERSION)
assertTrue(result is CredentialExchangePayload.Error)
val error = (result as CredentialExchangePayload.Error).throwable
assertTrue(error is ImportCredentialsInvalidJsonException)
assertTrue(error.message?.contains("Unsupported CXF version") == true)
}
@Test
fun `parse should return Error when payload has unsupported minor version`() {
val result = parser.parse(PAYLOAD_UNSUPPORTED_MINOR_VERSION)
assertTrue(result is CredentialExchangePayload.Error)
val error = (result as CredentialExchangePayload.Error).throwable
assertTrue(error is ImportCredentialsInvalidJsonException)
assertTrue(error.message?.contains("Unsupported CXF version") == true)
}
}
@Nested
inner class InvalidPayloadParsing {
@Test
fun `parse should return Error when payload is completely invalid JSON`() {
val result = parser.parse("not valid json")
assertTrue(result is CredentialExchangePayload.Error)
val error = (result as CredentialExchangePayload.Error).throwable
assertTrue(error is ImportCredentialsInvalidJsonException)
assertTrue(error.message?.contains("Invalid Credential Exchange JSON") == true)
}
@Test
fun `parse should return Error when payload is empty string`() {
val result = parser.parse("")
assertTrue(result is CredentialExchangePayload.Error)
val error = (result as CredentialExchangePayload.Error).throwable
assertTrue(error is ImportCredentialsInvalidJsonException)
}
@Test
fun `parse should return Error when payload is valid JSON but wrong structure`() {
val result = parser.parse("""{"foo": "bar"}""")
assertTrue(result is CredentialExchangePayload.Error)
val error = (result as CredentialExchangePayload.Error).throwable
assertTrue(error is ImportCredentialsInvalidJsonException)
}
}
@Nested
inner class SerializationErrorHandling {
@Suppress("MaxLineLength")
@Test
fun `parse should return Error when account serialization fails`() {
val mockJson = mockk<Json> {
every {
decodeFromStringOrNull<CredentialExchangeExportResponse>(VALID_PAYLOAD)
} returns MOCK_EXPORT_RESPONSE
every {
encodeToString<CredentialExchangeExportResponse.Account?>(any(), any())
} throws SerializationException("Mock serialization failure")
}
val parserWithMockJson = CredentialExchangePayloadParserImpl(json = mockJson)
val result = parserWithMockJson.parse(VALID_PAYLOAD)
assertTrue(result is CredentialExchangePayload.Error)
val error = (result as CredentialExchangePayload.Error).throwable
assertTrue(error is ImportCredentialsInvalidJsonException)
assertTrue(error.message?.contains("Unable to serialize accounts") == true)
}
}
}
/**
* Valid CXF payload (direct format) with version 1.0.
*/
private val VALID_PAYLOAD = """
{
"version": {"major": 1, "minor": 0},
"exporterRpId": "com.example.exporter",
"exporterDisplayName": "Example Exporter",
"timestamp": 1704067200,
"accounts": [
{
"id": "account-123",
"username": "user@example.com",
"email": "user@example.com",
"collections": [],
"items": []
}
]
}
""".trimIndent()
/**
* Valid CXF payload with empty accounts list.
*/
private val VALID_PAYLOAD_EMPTY_ACCOUNTS = """
{
"version": {"major": 1, "minor": 0},
"exporterRpId": "com.example.exporter",
"exporterDisplayName": "Example Exporter",
"timestamp": 1704067200,
"accounts": []
}
""".trimIndent()
/**
* CXF payload with unsupported major version (2.0).
*/
private val PAYLOAD_UNSUPPORTED_MAJOR_VERSION = """
{
"version": {"major": 2, "minor": 0},
"exporterRpId": "com.example.exporter",
"exporterDisplayName": "Example Exporter",
"timestamp": 1704067200,
"accounts": []
}
""".trimIndent()
/**
* CXF payload with unsupported minor version (1.1).
*/
private val PAYLOAD_UNSUPPORTED_MINOR_VERSION = """
{
"version": {"major": 1, "minor": 1},
"exporterRpId": "com.example.exporter",
"exporterDisplayName": "Example Exporter",
"timestamp": 1704067200,
"accounts": []
}
""".trimIndent()
/**
* Mock export response for testing serialization failures.
*/
private val MOCK_EXPORT_RESPONSE = CredentialExchangeExportResponse(
version = com.bitwarden.cxf.model.CredentialExchangeVersion(major = 1, minor = 0),
exporterRpId = "com.example.exporter",
exporterDisplayName = "Example Exporter",
timestamp = 1704067200,
accounts = listOf(
CredentialExchangeExportResponse.Account(
id = "account-123",
username = "user@example.com",
email = "user@example.com",
collections = JsonArray(emptyList()),
items = JsonArray(emptyList()),
),
),
)

View File

@ -22,7 +22,7 @@ androidxCamera = "1.5.2"
androidxComposeBom = "2026.01.00"
androidxCore = "1.17.0"
androidxCredentials = "1.6.0-beta03"
androidxCredentialsProviderEvents = "1.0.0-alpha03"
androidxCredentialsProviderEvents = "1.0.0-alpha04"
androidxHiltNavigationCompose = "1.3.0"
androidxLifecycle = "2.10.0"
androidxNavigation = "2.9.6"