Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,8 @@ class MainViewModel @Inject constructor(
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = importCredentialsRequest.uri,
requestJson = importCredentialsRequest.request.requestJson,
credentialTypes = importCredentialsRequest.request.credentialTypes,
knownExtensions = importCredentialsRequest.request.knownExtensions,
),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,23 @@
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
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),
)
import timber.log.Timber

/**
* Default implementation of [CredentialExchangeImportManager].
Expand All @@ -38,118 +27,92 @@ 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."),
)
): ImportCxfPayloadResult =
when (val exportResponse = credentialExchangePayloadParser.parse(payload)) {
is CredentialExchangePayload.Importable -> {
import(
userId = userId,
accountsJsonList = exportResponse.accountsJsonList,
)
}

if (SUPPORTED_CXP_FORMAT_VERSIONS[credentialExchangeExportResult.version.major]
?.contains(credentialExchangeExportResult.version.minor) != true
) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException(
"Unsupported CXF version: ${credentialExchangeExportResult.version}.",
),
)
}
CredentialExchangePayload.NoItems -> {
ImportCxfPayloadResult.NoItems
}

val decodedPayload = credentialExchangeExportResult.payload
.base64UrlDecodeOrNull()
?: return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unable to decode payload."),
)
is CredentialExchangePayload.Error -> {
ImportCxfPayloadResult.Error(exportResponse.throwable)
}
}

val exportResponse = json
.decodeFromStringOrNull<CredentialExchangeExportResponse>(decodedPayload)
?: return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unable to decode header."),
)
private suspend fun import(
userId: String,
accountsJsonList: List<String>,
): ImportCxfPayloadResult {
val allCiphers = accountsJsonList.flatMap { accountJson ->
vaultSdkSource
.importCxf(userId = userId, payload = accountJson)
.getOrElse { return ImportCxfPayloadResult.Error(error = it) }
}

if (SUPPORTED_CXF_FORMAT_VERSIONS[exportResponse.version.major]
?.contains(exportResponse.version.minor) != true
) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unsupported CXF version."),
)
// Filter out card ciphers if RESTRICT_ITEM_TYPES policy is active
val filteredCipherList = if (policyManager.hasRestrictItemTypes()) {
allCiphers.filter { cipher -> cipher.type != CipherType.CARD }
} else {
allCiphers
}

if (exportResponse.accounts.isEmpty()) {
if (filteredCipherList.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,
return uploadCiphers(userId = userId, ciphers = filteredCipherList)
.map { syncVault(it) }
.fold(
onSuccess = { it },
onFailure = { ImportCxfPayloadResult.Error(error = it) },
)
.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()
}
}
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 -> {
Timber.w(
"Import ciphers validation failed: %s",
response.validationErrors,
)
ImportCredentialsUnknownErrorException().asFailure()
}
}
.map {
when (val syncResult = vaultSyncManager.syncForResult(forced = true)) {
is SyncVaultDataResult.Success -> it
is SyncVaultDataResult.Error -> {
ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable)

is ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult.Success(itemCount = ciphers.size).asSuccess()
}
}
}
.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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ‘

)
}
17 changes: 15 additions & 2 deletions app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,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
Expand Down Expand Up @@ -225,6 +229,7 @@ class MainViewModelTest : BaseViewModelTest() {
ProviderCreateCredentialRequest.Companion,
ProviderGetCredentialRequest.Companion,
)
unmockkConstructor(JSONObject::class)
}

@Suppress("MaxLineLength")
Expand Down Expand Up @@ -945,9 +950,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",
Expand All @@ -966,7 +978,8 @@ class MainViewModelTest : BaseViewModelTest() {
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = importCredentialsRequestData.uri,
requestJson = importCredentialsRequestData.request.requestJson,
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
),
),
specialCircumstanceManager.specialCircumstance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading