diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt index 8b3961bcfc..b350077f69 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt @@ -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(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(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) }, + ) + } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index 892aa89977..56e4d184cc 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -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, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index c3cbefe01b..3fa26bb9d1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt index 89646cca80..9bd6436f54 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt @@ -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 { + every { + decodeFromStringOrNull(any()) + } returns DEFAULT_CXP_MESSAGE + every { + decodeFromStringOrNull(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().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(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(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(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(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(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(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(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 = 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() diff --git a/cxf/build.gradle.kts b/cxf/build.gradle.kts index 75acd2faa7..b15dd5f390 100644 --- a/cxf/build.gradle.kts +++ b/cxf/build.gradle.kts @@ -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)) diff --git a/cxf/consumer-rules.pro b/cxf/consumer-rules.pro index e69de29bb2..90f4b39b1f 100644 --- a/cxf/consumer-rules.pro +++ b/cxf/consumer-rules.pro @@ -0,0 +1,4 @@ +################################################################################ +# AndroidX Credentials ProviderEvents +################################################################################ +-keep class androidx.credentials.providerevents.** { *; } diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt index bf95e9dc62..130d87f19e 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt @@ -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\"" }} ] diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt index 12c5144d3f..2f0f17465d 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt @@ -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() + } + } } diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/dsl/CredentialExchangeCompletionManagerBuilder.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/dsl/CredentialExchangeCompletionManagerBuilder.kt index aaafe917f3..75053233bf 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/dsl/CredentialExchangeCompletionManagerBuilder.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/dsl/CredentialExchangeCompletionManagerBuilder.kt @@ -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, + ) diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/model/ExportCredentialsResult.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/model/ExportCredentialsResult.kt index c90e3b721b..e2d91e3db6 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/model/ExportCredentialsResult.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/model/ExportCredentialsResult.kt @@ -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() diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangeExportResponse.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangeExportResponse.kt new file mode 100644 index 0000000000..95b298f92f --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangeExportResponse.kt @@ -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, +) { + /** + * 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, + ) +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangeProtocolMessage.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangeProtocolMessage.kt new file mode 100644 index 0000000000..0c063dec08 --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangeProtocolMessage.kt @@ -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, +) diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangeVersion.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangeVersion.kt new file mode 100644 index 0000000000..648aee7eee --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangeVersion.kt @@ -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, +) diff --git a/cxf/src/test/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterTest.kt b/cxf/src/test/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterTest.kt index 6cf9ab6665..5fe3ec61f8 100644 --- a/cxf/src/test/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterTest.kt +++ b/cxf/src/test/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterTest.kt @@ -34,7 +34,13 @@ class CredentialExchangeImporterTest { val capturedRequestJson = mutableListOf() val expectedRequestJson = """ { - "importer": "mockPackageName", + "version": { + "major":0, + "minor":0 + }, + "mode": ["direct"], + "importerRpId": "mockPackageName", + "importerDisplayName": "null", "credentialTypes": [ "basic-auth" ] diff --git a/cxf/src/test/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerTest.kt b/cxf/src/test/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerTest.kt index 70c76c6f57..9d3ddce2e0 100644 --- a/cxf/src/test/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerTest.kt +++ b/cxf/src/test/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerTest.kt @@ -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() - private val completionManager = CredentialExchangeCompletionManagerImpl(mockActivity) + private val mockActivity = mockk(relaxed = true, relaxUnitFun = true) + private val mockApplicationInfo = mockk(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, +)