Improve CXF message handling (#5982)

This commit is contained in:
Patrick Honkonen 2025-10-07 14:28:07 -04:00 committed by GitHub
parent 202dd65229
commit 9fee973563
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 604 additions and 76 deletions

View File

@ -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].
@ -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."),
)
ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult
.Success(itemCount = cipherList.size)
.asSuccess()
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(exportResponse.accounts.firstOrNull())
} 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) },
)
}
}

View File

@ -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
@ -203,9 +204,11 @@ object VaultManagerModule {
vaultSdkSource: VaultSdkSource,
ciphersService: CiphersService,
vaultSyncManager: VaultSyncManager,
json: Json,
): CredentialExchangeImportManager = CredentialExchangeImportManagerImpl(
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
vaultSyncManager = vaultSyncManager,
json = json,
)
}

View File

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

View File

@ -3,9 +3,14 @@ package com.x8bit.bitwarden.data.vault.manager
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.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.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipher
@ -14,12 +19,20 @@ import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import io.mockk.awaits
import io.mockk.coEvery
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.Test
import org.junit.jupiter.api.assertNotNull
@ -28,20 +41,45 @@ class CredentialExchangeImportManagerTest {
private val vaultSdkSource: VaultSdkSource = mockk()
private val ciphersService: CiphersService = mockk(relaxed = true)
private val vaultSyncManager: VaultSyncManager = mockk()
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 importManager = CredentialExchangeImportManagerImpl(
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
vaultSyncManager = vaultSyncManager,
json = json,
)
@BeforeEach
fun setUp() {
mockkStatic(String::base64UrlDecodeOrNull)
every {
DEFAULT_PAYLOAD.base64UrlDecodeOrNull()
} returns DEFAULT_PAYLOAD
}
@AfterEach
fun tearDown() {
unmockkStatic(String::base64UrlDecodeOrNull)
}
@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_PAYLOAD,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns exception.asFailure()
@ -53,7 +91,7 @@ class CredentialExchangeImportManagerTest {
assertEquals(ImportCxfPayloadResult.Error(exception), result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
}
coVerify(exactly = 0) {
ciphersService.importCiphers(any())
@ -65,7 +103,7 @@ class CredentialExchangeImportManagerTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_PAYLOAD,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
@ -80,7 +118,7 @@ class CredentialExchangeImportManagerTest {
assertEquals(ImportCxfPayloadResult.Error(exception), result)
assertEquals(1, capturedRequest.captured.ciphers.size)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
}
}
@ -90,7 +128,7 @@ class CredentialExchangeImportManagerTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_PAYLOAD,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
@ -100,13 +138,13 @@ class CredentialExchangeImportManagerTest {
.Invalid(validationErrors = emptyMap())
.asSuccess()
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
val error = (result as? ImportCxfPayloadResult.Error)?.error
assertNotNull(error)
assertTrue(error is ImportCredentialsUnknownErrorException)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
}
}
@ -118,7 +156,7 @@ class CredentialExchangeImportManagerTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_PAYLOAD,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
@ -130,14 +168,14 @@ class CredentialExchangeImportManagerTest {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Error(throwable)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
assertEquals(
ImportCxfPayloadResult.SyncFailed(throwable),
result,
)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
}
@ -148,7 +186,7 @@ class CredentialExchangeImportManagerTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_PAYLOAD,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns DEFAULT_CIPHER_LIST.asSuccess()
@ -159,11 +197,11 @@ class CredentialExchangeImportManagerTest {
vaultSyncManager.syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
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_PAYLOAD)
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
ciphersService.importCiphers(any())
vaultSyncManager.syncForResult(forced = true)
}
@ -176,26 +214,206 @@ class CredentialExchangeImportManagerTest {
coEvery {
vaultSdkSource.importCxf(
userId = DEFAULT_USER_ID,
payload = DEFAULT_PAYLOAD,
payload = DEFAULT_ACCOUNT_JSON,
)
} returns emptyList<Cipher>().asSuccess()
coEvery {
ciphersService.importCiphers(any())
} just awaits
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON)
assertEquals(ImportCxfPayloadResult.NoItems, result)
coVerify(exactly = 1) {
vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_PAYLOAD)
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)
}
}
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 = """
{
"id": "$DEFAULT_USER_ID",
"username": "username-1",
"email": "mockEmail-1",
"fullName": "fullName-1",
"collections": [],
"items": [
{
"id": "mockId-1",
"creationAt": 1759783057,
"modifiedAt": 1759783057,
"title": "mockTitle-1",
"favorite": false,
"scope": {
"urls": [
"mockUrl-1"
],
"androidApps": []
},
"credentials": [
{
"type": "mockType-1",
"username": {
"fieldType": "mockUsernameFieldType-1",
"value": "mockUsernameValue-1"
},
"password": {
"fieldType": "mockPasswordFieldType-1",
"value": "mockPasswordValue-1"
}
}
]
}
]
}
"""
.trimIndent()

View File

@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.google.services)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
android {
@ -52,6 +53,7 @@ dependencies {
implementation(libs.androidx.credentials.providerevents)
implementation(libs.androidx.credentials.providerevents.play.services)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization)
implementation(libs.timber)
testImplementation(platform(libs.junit.bom))

View File

@ -0,0 +1,4 @@
################################################################################
# AndroidX Credentials ProviderEvents
################################################################################
-keep class androidx.credentials.providerevents.** { *; }

View File

@ -10,6 +10,9 @@ import androidx.credentials.providerevents.transfer.ImportCredentialsRequest
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].
*
@ -32,10 +35,17 @@ internal class CredentialExchangeImporterImpl(
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 = """
{
"importer": "${activity.packageName}",
"version": {
"major":$CXP_FORMAT_VERSION_MAJOR,
"minor":$CXP_FORMAT_VERSION_MINOR
},
"mode": ["direct"],
"importerRpId": "${activity.packageName}",
"importerDisplayName": "${activity.applicationInfo.name}",
"credentialTypes": [
${credentialTypes.joinToString { "\"$it\"" }}
]

View File

@ -3,33 +3,79 @@ package com.bitwarden.cxf.manager
import android.app.Activity
import android.content.Intent
import androidx.credentials.providerevents.IntentHandler
import androidx.credentials.providerevents.exception.ImportCredentialsException
import androidx.credentials.providerevents.transfer.ImportCredentialsResponse
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
import timber.log.Timber
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
/**
* Primary implementation of [CredentialExchangeCompletionManager].
*
* @param activity The [Activity] that initiated the credential exchange operation.
*/
internal class CredentialExchangeCompletionManagerImpl(
private val activity: Activity,
private val clock: Clock,
) : CredentialExchangeCompletionManager {
override fun completeCredentialExport(exportResult: ExportCredentialsResult) {
val intent = Intent()
when (exportResult) {
is ExportCredentialsResult.Failure -> {
IntentHandler.setImportCredentialsException(
intent = intent,
exception = exportResult.error,
)
finishWithError(intent = intent, error = exportResult.error)
}
is ExportCredentialsResult.Success -> {
val exporterRpId = activity.packageName
val exporterDisplayName = activity.applicationInfo.name
val headerJson = """
{
"version": {
"major": $CXF_FORMAT_VERSION_MAJOR,
"minor": $CXF_FORMAT_VERSION_MINOR
},
"exporterRpId": "${activity.packageName}",
"exporterDisplayName": "${activity.applicationInfo.name}",
"timestamp": ${clock.instant().epochSecond},
"accounts": [${exportResult.payload}]
}
"""
.trimIndent()
Timber.d("completeCredentialExport set headerJson:\n$headerJson")
val encodedPayload = Base64.UrlSafe
.withPadding(Base64.PaddingOption.ABSENT)
.encode(headerJson.toByteArray())
Timber.d("completeCredentialExport set encodedPayload: $encodedPayload")
val responseJson = """
{
"version": {
"major": $CXP_FORMAT_VERSION_MAJOR,
"minor": $CXP_FORMAT_VERSION_MINOR
},
"exporterRpId": "$exporterRpId",
"exporterDisplayName": "$exporterDisplayName",
"payload": "$encodedPayload"
}
"""
.trimIndent()
Timber.d("completeCredentialExport set responseJson: $responseJson")
IntentHandler.setImportCredentialsResponse(
context = activity,
intent = intent,
uri = exportResult.uri,
response = ImportCredentialsResponse(
responseJson = exportResult.payload,
responseJson = responseJson,
),
)
}
@ -39,4 +85,15 @@ internal class CredentialExchangeCompletionManagerImpl(
finish()
}
}
private fun finishWithError(error: ImportCredentialsException, intent: Intent) {
IntentHandler.setImportCredentialsException(
intent = intent,
exception = error,
)
activity.apply {
setResult(Activity.RESULT_OK, intent)
finish()
}
}
}

View File

@ -6,6 +6,7 @@ import android.app.Activity
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManagerImpl
import java.time.Clock
/**
* A DSL for building a [CredentialExchangeCompletionManager].
@ -18,20 +19,43 @@ import com.bitwarden.cxf.manager.CredentialExchangeCompletionManagerImpl
*/
@OmitFromCoverage
class CredentialExchangeCompletionManagerBuilder internal constructor() {
internal fun build(activity: Activity): CredentialExchangeCompletionManager =
CredentialExchangeCompletionManagerImpl(activity = activity)
/**
* The relying party ID of the credential exporter.
*/
lateinit var exporterRpId: String
/**
* The display name of the credential exporter.
*/
lateinit var exporterDisplayName: String
/**
* Constructs a [CredentialExchangeCompletionManager] instance with the configured properties.
*
* This function is internal and called by the [credentialExchangeCompletionManager] builder
* after the [CredentialExchangeCompletionManagerBuilder] has been configured.
*
* @param activity The [Activity] that initiated the credential exchange operation.
* @return An initialized [CredentialExchangeCompletionManager] ready to complete the flow.
*/
internal fun build(activity: Activity, clock: Clock): CredentialExchangeCompletionManager =
CredentialExchangeCompletionManagerImpl(
activity = activity,
clock = clock,
)
}
/**
* Creates an instance of [CredentialExchangeCompletionManager] using a DSL-style builder.
*
* This function is the entry point for handling the completion of a credential exchange flow,
* such as after a user has successfully created or selected a passkey.
* such as after a user has successfully selected items to export.
*
* Example usage:
* ```
* val completionManager = credentialExchangeCompletionManager(activity) {
* // Configuration options can be added here if the DSL is extended in the future.
* exporterRpId = "example.com"
* exporterDisplayName = "Example"
* }
*
* // Use the completionManager to finish the credential exchange.
@ -47,8 +71,12 @@ class CredentialExchangeCompletionManagerBuilder internal constructor() {
*/
fun credentialExchangeCompletionManager(
activity: Activity,
config: CredentialExchangeCompletionManagerBuilder.() -> Unit = {},
clock: Clock,
config: CredentialExchangeCompletionManagerBuilder.() -> Unit,
): CredentialExchangeCompletionManager =
CredentialExchangeCompletionManagerBuilder()
.apply(config)
.build(activity = activity)
.build(
activity = activity,
clock = clock,
)

View File

@ -10,6 +10,10 @@ sealed class ExportCredentialsResult {
/**
* Represents a successful export.
*
* @param payload The payload of the export, formatted as a FIDO 2
* [Account Entity](https://fidoalliance.org/specs/cx/cxf-v1.0-ps-20250814.html#entity-account)
* JSON string.
*/
data class Success(val payload: String, val uri: Uri) : ExportCredentialsResult()

View File

@ -0,0 +1,55 @@
package com.bitwarden.cxf.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
/**
* Represents the header of a credential exchange export file.
* This data class contains metadata about the export, such as the file version,
* information about the exporting relying party (RP), the export timestamp, and
* a list of accounts included in the export.
*
* See the FIDO Alliance CXF specification for more details:
* https://fidoalliance.org/specs/cx/cxf-v1.0-rd-20250313.html#sctn-data-structure-specifications
*
* @property version The version of the credential exchange file format.
* @property exporterRpId The relying party ID of the application that exported the credentials.
* @property exporterDisplayName The display name of the application that exported the credentials.
* @property timestamp The Unix timestamp (in seconds) when the export was created.
* @property accounts A list of [Account]s whose credentials are included in this export.
*/
@Serializable
data class CredentialExchangeExportResponse(
val version: CredentialExchangeVersion,
val exporterRpId: String,
val exporterDisplayName: String,
val timestamp: Long,
val accounts: List<Account>,
) {
/**
* Represents a single account included in the credential exchange export.
* Each account object contains user identification information, references to collections,
* and the actual credential items belonging to that user.
*
* See the FIDO Alliance CXF specification for more details:
* https://fidoalliance.org/specs/cx/cxf-v1.0-rd-20250313.html#entity-collection
*
* @property id A unique, stable identifier for the account, such as a UUID.
* @property username The username associated with the account.
* @property email The email address associated with the account.
* @property collections A JSON array of
* [Collection](https://fidoalliance.org/specs/cx/cxf-v1.0-rd-20250313.html#entity-collection)
* objects associated with this account.
* @property items A JSON array of credential
* [Item](https://fidoalliance.org/specs/cx/cxf-v1.0-rd-20250313.html#entity-item) objects
* associated with this account.
*/
@Serializable
data class Account(
val id: String,
val username: String,
val email: String,
val collections: JsonArray,
val items: JsonArray,
)
}

View File

@ -0,0 +1,30 @@
package com.bitwarden.cxf.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the top-level structure of a message used in the credential exchange protocol.
*
* This data class is designed to be serialized and deserialized for importing or exporting
* credentials between applications.
*
* @property version The version of the credential exchange protocol being used.
* @property exporterRpId The relying party identifier (e.g., website domain) of the application
* that exported the credentials.
* @property exporterDisplayName The user-friendly display name of the application that exported
* the credentials.
* @property payload A base64-encoded string containing the actual credential data, typically
* serialized and encrypted.
*/
@Serializable
data class CredentialExchangeProtocolMessage(
@SerialName("version")
val version: CredentialExchangeVersion,
@SerialName("exporterRpId")
val exporterRpId: String,
@SerialName("exporterDisplayName")
val exporterDisplayName: String,
@SerialName("payload")
val payload: String,
)

View File

@ -0,0 +1,25 @@
package com.bitwarden.cxf.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the version of the credential exchange protocol.
* This is used to ensure compatibility between the client and the server during the
* credential exchange process.
*
* See the FIDO Alliance CXF specification for more details:
* https://fidoalliance.org/specs/cx/cxf-v1.0-rd-20250313.html#dict-version
*
* @property major The major version number. A change in this number indicates a
* breaking change in the protocol.
* @property minor The minor version number. A change in this number indicates a
* non-breaking change, such as an addition or enhancement.
*/
@Serializable
data class CredentialExchangeVersion(
@SerialName("major")
val major: Int,
@SerialName("minor")
val minor: Int,
)

View File

@ -34,7 +34,13 @@ class CredentialExchangeImporterTest {
val capturedRequestJson = mutableListOf<ImportCredentialsRequest>()
val expectedRequestJson = """
{
"importer": "mockPackageName",
"version": {
"major":0,
"minor":0
},
"mode": ["direct"],
"importerRpId": "mockPackageName",
"importerDisplayName": "null",
"credentialTypes": [
"basic-auth"
]

View File

@ -1,6 +1,7 @@
package com.bitwarden.cxf.manager
import android.app.Activity
import android.content.pm.ApplicationInfo
import android.net.Uri
import androidx.credentials.providerevents.IntentHandler
import androidx.credentials.providerevents.exception.ImportCredentialsException
@ -14,15 +15,25 @@ import io.mockk.runs
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class CredentialExchangeCompletionManagerTest {
private val mockActivity = mockk<Activity>()
private val completionManager = CredentialExchangeCompletionManagerImpl(mockActivity)
private val mockActivity = mockk<Activity>(relaxed = true, relaxUnitFun = true)
private val mockApplicationInfo = mockk<ApplicationInfo>(relaxed = true)
private val completionManager = CredentialExchangeCompletionManagerImpl(
activity = mockActivity,
clock = FIXED_CLOCK,
)
@BeforeEach
fun setUp() {
mockkObject(IntentHandler)
every { mockActivity.packageName } returns "mockPackageName-1"
every { mockActivity.applicationInfo } returns mockApplicationInfo
every {
IntentHandler.setImportCredentialsResponse(
context = any(),
@ -82,3 +93,8 @@ class CredentialExchangeCompletionManagerTest {
}
}
}
private val FIXED_CLOCK = Clock.fixed(
Instant.parse("2024-01-25T10:15:30.00Z"),
ZoneOffset.UTC,
)