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
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
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.network.model.ImportCiphersJsonRequest
import com.bitwarden.network.model.ImportCiphersResponseJson
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.util.base64UrlDecodeOrNull
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].
Expand All @@ -19,58 +34,109 @@ class CredentialExchangeImportManagerImpl(
private val vaultSdkSource: VaultSdkSource,
private val ciphersService: CiphersService,
private val vaultSyncManager: VaultSyncManager,
private val json: Json,
) : CredentialExchangeImportManager {

@Suppress("LongMethod")
override suspend fun importCxfPayload(
userId: String,
payload: String,
): ImportCxfPayloadResult = vaultSdkSource
.importCxf(
userId = userId,
payload = payload,
)
.flatMap { cipherList ->
if (cipherList.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 = cipherList.map {
it.toEncryptedNetworkCipher(
encryptedFor = userId,
)
},
folders = emptyList(),
folderRelationships = emptyList(),
),
)
.flatMap { importCiphersResponseJson ->
when (importCiphersResponseJson) {
is ImportCiphersResponseJson.Invalid -> {
ImportCredentialsUnknownErrorException().asFailure()
}
): ImportCxfPayloadResult {
val credentialExchangeExportResult = json
.decodeFromStringOrNull<CredentialExchangeProtocolMessage>(payload)
?: return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Invalid CXP JSON."),
)

if (SUPPORTED_CXP_FORMAT_VERSIONS[credentialExchangeExportResult.version.major]
Copy link
Collaborator

Choose a reason for hiding this comment

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

How does this work exactly, the major version contains a set of supported minor versions?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Correct. This may change later, once we determine how much of the validation and payload mutation we can offload to the SDK. IMO, the SDK should be handling all of this, but it only accepts the account JSON at the moment. ๐Ÿ˜ž

Copy link
Collaborator

Choose a reason for hiding this comment

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

Gotcha

?.contains(credentialExchangeExportResult.version.minor) != true
) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException(
"Unsupported CXF version: ${credentialExchangeExportResult.version}.",
),
)
}

ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult
.Success(itemCount = cipherList.size)
.asSuccess()
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(exportResponse.accounts.firstOrNull())
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is it OK to encode null here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could check it's not empty and exit early. This effectively achieves the same because we're catching SerializationExceptions and propagating it to the caller.

Copy link
Collaborator

@david-livefront david-livefront Oct 7, 2025

Choose a reason for hiding this comment

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

Parsing null will not throw, it will make the string null

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah. True. I'll check if it's empty first, in that case.

} catch (_: SerializationException) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unable to re-encode accounts."),
)
}
return vaultSdkSource
.importCxf(
userId = userId,
payload = accountsJson,
)
.flatMap { cipherList ->
if (cipherList.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 = cipherList.map {
it.toEncryptedNetworkCipher(
encryptedFor = userId,
)
},
folders = emptyList(),
folderRelationships = emptyList(),
),
)
.flatMap { importCiphersResponseJson ->
when (importCiphersResponseJson) {
is ImportCiphersResponseJson.Invalid -> {
ImportCredentialsUnknownErrorException().asFailure()
}

ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult
.Success(itemCount = cipherList.size)
.asSuccess()
}
}
}
}
}
.map {
when (val syncResult = vaultSyncManager.syncForResult(forced = true)) {
is SyncVaultDataResult.Success -> it
is SyncVaultDataResult.Error -> {
ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable)
}
.map {
when (val syncResult = vaultSyncManager.syncForResult(forced = true)) {
is SyncVaultDataResult.Success -> it
is SyncVaultDataResult.Error -> {
ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable)
}
}
}
}
.fold(
onSuccess = { it },
onFailure = { ImportCxfPayloadResult.Error(error = it) },
)
.fold(
onSuccess = { it },
onFailure = { ImportCxfPayloadResult.Error(error = it) },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ 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 @@ -203,9 +204,11 @@ object VaultManagerModule {
vaultSdkSource: VaultSdkSource,
ciphersService: CiphersService,
vaultSyncManager: VaultSyncManager,
json: Json,
): CredentialExchangeImportManager = CredentialExchangeImportManagerImpl(
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
vaultSyncManager = vaultSyncManager,
json = json,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManagerImpl
import com.x8bit.bitwarden.ui.credentials.manager.CredentialProviderCompletionManager
Expand Down Expand Up @@ -68,7 +69,10 @@ fun LocalManagerProvider(
credentialExchangeImporter: CredentialExchangeImporter =
credentialExchangeImporter(activity = activity),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
credentialExchangeCompletionManager(activity = activity),
credentialExchangeCompletionManager(activity = activity, clock = clock) {
exporterRpId = activity.packageName
exporterDisplayName = activity.getString(R.string.app_name)
},
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
Expand Down
Loading
Loading