mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 08:35:05 -06:00
Improve CXF message handling (#5982)
This commit is contained in:
parent
202dd65229
commit
9fee973563
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
################################################################################
|
||||
# AndroidX Credentials ProviderEvents
|
||||
################################################################################
|
||||
-keep class androidx.credentials.providerevents.** { *; }
|
||||
@ -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\"" }}
|
||||
]
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
@ -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"
|
||||
]
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user