diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerImpl.kt index df5f922c7..44daee84d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerImpl.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.manager import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService +import com.x8bit.bitwarden.data.auth.manager.util.toUserStateJson import com.x8bit.bitwarden.data.platform.util.flatMap import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource @@ -44,6 +45,10 @@ class TrustedDeviceManagerImpl( userId = userId, deviceKey = trustedDevice.deviceKey, ) + authDiskSource.userState = trustedDevice.toUserStateJson( + userId = userId, + previousUserState = requireNotNull(authDiskSource.userState), + ) } } .also { authDiskSource.shouldTrustDevice = false } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/util/TrustDeviceResponseExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/util/TrustDeviceResponseExtensions.kt new file mode 100644 index 000000000..f1692b941 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/util/TrustDeviceResponseExtensions.kt @@ -0,0 +1,58 @@ +package com.x8bit.bitwarden.data.auth.manager.util + +import com.bitwarden.crypto.TrustDeviceResponse +import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson + +/** + * Converts the given [TrustDeviceResponse] to an updated [UserStateJson], given the following + * additional information: + * + * - the [userId] + * - the [previousUserState] + */ +fun TrustDeviceResponse.toUserStateJson( + userId: String, + previousUserState: UserStateJson, +): UserStateJson { + val trustedAccount = requireNotNull(previousUserState.accounts[userId]) + val profile = trustedAccount.profile + // The UserDecryptionOptionsJson and TrustedDeviceUserDecryptionOptionsJson + // should be present at this time, but we have fallbacks just in case. + val decryptionOptions = profile + .userDecryptionOptions + ?: UserDecryptionOptionsJson( + hasMasterPassword = false, + trustedDeviceUserDecryptionOptions = null, + keyConnectorUserDecryptionOptions = null, + ) + val deviceOptions = decryptionOptions + .trustedDeviceUserDecryptionOptions + ?.copy( + encryptedPrivateKey = this.protectedDevicePrivateKey, + encryptedUserKey = this.protectedUserKey, + ) + ?: TrustedDeviceUserDecryptionOptionsJson( + encryptedPrivateKey = this.protectedDevicePrivateKey, + encryptedUserKey = this.protectedUserKey, + hasAdminApproval = false, + hasLoginApprovingDevice = false, + hasManageResetPasswordPermission = false, + ) + val account = trustedAccount.copy( + profile = profile.copy( + userDecryptionOptions = decryptionOptions.copy( + trustedDeviceUserDecryptionOptions = deviceOptions, + ), + ), + ) + + // Update the existing UserState. + return previousUserState.copy( + accounts = previousUserState + .accounts + .toMutableMap() + .apply { put(userId, account) }, + ) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerTests.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerTests.kt index a69e61980..add0bc439 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerTests.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerTests.kt @@ -1,18 +1,30 @@ package com.x8bit.bitwarden.data.auth.manager import com.bitwarden.crypto.TrustDeviceResponse +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceKeysResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService +import com.x8bit.bitwarden.data.auth.manager.util.toUserStateJson import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.ZonedDateTime @@ -27,11 +39,20 @@ class TrustedDeviceManagerTests { devicesService = devicesService, ) + @BeforeEach + fun setup() { + mockkStatic(TrustDeviceResponse::toUserStateJson) + } + + @AfterEach + fun tearDown() { + unmockkStatic(TrustDeviceResponse::toUserStateJson) + } + @Suppress("MaxLineLength") @Test fun `trustThisDeviceIfNecessary when shouldTrustDevice false should return success with false`() = runTest { - val userId = "userId" val deviceKey = "deviceKey" val trustedDeviceResponse = TrustDeviceResponse( deviceKey = deviceKey, @@ -41,19 +62,19 @@ class TrustedDeviceManagerTests { ) fakeAuthDiskSource.shouldTrustDevice = false coEvery { - vaultSdkSource.getTrustDevice(userId = userId) + vaultSdkSource.getTrustDevice(userId = USER_ID) } returns trustedDeviceResponse.asSuccess() - val result = manager.trustThisDeviceIfNecessary(userId = userId) + val result = manager.trustThisDeviceIfNecessary(userId = USER_ID) assertEquals(false.asSuccess(), result) fakeAuthDiskSource.assertDeviceKey( - userId = userId, + userId = USER_ID, deviceKey = deviceKey, inMemoryOnly = true, ) coVerify(exactly = 1) { - vaultSdkSource.getTrustDevice(userId = userId) + vaultSdkSource.getTrustDevice(userId = USER_ID) } coVerify(exactly = 0) { devicesService.trustDevice( @@ -67,19 +88,18 @@ class TrustedDeviceManagerTests { @Test fun `trustThisDeviceIfNecessary when getTrustDevice fails should return failure`() = runTest { - val userId = "userId" fakeAuthDiskSource.shouldTrustDevice = true val error = Throwable("Fail") coEvery { - vaultSdkSource.getTrustDevice(userId = userId) + vaultSdkSource.getTrustDevice(userId = USER_ID) } returns error.asFailure() - val result = manager.trustThisDeviceIfNecessary(userId = userId) + val result = manager.trustThisDeviceIfNecessary(userId = USER_ID) assertEquals(error.asFailure(), result) assertFalse(fakeAuthDiskSource.shouldTrustDevice) coVerify(exactly = 1) { - vaultSdkSource.getTrustDevice(userId = userId) + vaultSdkSource.getTrustDevice(userId = USER_ID) } coVerify(exactly = 0) { devicesService.trustDevice( @@ -93,7 +113,6 @@ class TrustedDeviceManagerTests { @Test fun `trustThisDeviceIfNecessary when trustDevice fails should return failure`() = runTest { - val userId = "userId" val deviceKey = "deviceKey" val protectedUserKey = "protectedUserKey" val protectedDevicePrivateKey = "protectedDevicePrivateKey" @@ -107,7 +126,7 @@ class TrustedDeviceManagerTests { val error = Throwable("Fail") fakeAuthDiskSource.shouldTrustDevice = true coEvery { - vaultSdkSource.getTrustDevice(userId = userId) + vaultSdkSource.getTrustDevice(userId = USER_ID) } returns trustedDeviceResponse.asSuccess() coEvery { devicesService.trustDevice( @@ -118,12 +137,12 @@ class TrustedDeviceManagerTests { ) } returns error.asFailure() - val result = manager.trustThisDeviceIfNecessary(userId = userId) + val result = manager.trustThisDeviceIfNecessary(userId = USER_ID) assertEquals(error.asFailure(), result) assertFalse(fakeAuthDiskSource.shouldTrustDevice) coVerify(exactly = 1) { - vaultSdkSource.getTrustDevice(userId = userId) + vaultSdkSource.getTrustDevice(userId = USER_ID) devicesService.trustDevice( appId = "testUniqueAppId", encryptedUserKey = protectedUserKey, @@ -135,7 +154,6 @@ class TrustedDeviceManagerTests { @Test fun `trustThisDeviceIfNecessary when success should return success with true`() = runTest { - val userId = "userId" val deviceKey = "deviceKey" val protectedUserKey = "protectedUserKey" val protectedDevicePrivateKey = "protectedDevicePrivateKey" @@ -153,9 +171,10 @@ class TrustedDeviceManagerTests { type = 0, creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), ) + fakeAuthDiskSource.userState = DEFAULT_USER_STATE fakeAuthDiskSource.shouldTrustDevice = true coEvery { - vaultSdkSource.getTrustDevice(userId = userId) + vaultSdkSource.getTrustDevice(userId = USER_ID) } returns trustedDeviceResponse.asSuccess() coEvery { devicesService.trustDevice( @@ -165,14 +184,21 @@ class TrustedDeviceManagerTests { encryptedDevicePrivateKey = protectedDevicePrivateKey, ) } returns trustedDeviceKeysResponseJson.asSuccess() + every { + trustedDeviceResponse.toUserStateJson( + userId = USER_ID, + previousUserState = DEFAULT_USER_STATE, + ) + } returns UPDATED_USER_STATE - val result = manager.trustThisDeviceIfNecessary(userId = userId) + val result = manager.trustThisDeviceIfNecessary(userId = USER_ID) assertEquals(true.asSuccess(), result) - fakeAuthDiskSource.assertDeviceKey(userId = userId, deviceKey = deviceKey) + fakeAuthDiskSource.assertDeviceKey(userId = USER_ID, deviceKey = deviceKey) assertFalse(fakeAuthDiskSource.shouldTrustDevice) + fakeAuthDiskSource.assertUserState(UPDATED_USER_STATE) coVerify(exactly = 1) { - vaultSdkSource.getTrustDevice(userId = userId) + vaultSdkSource.getTrustDevice(userId = USER_ID) devicesService.trustDevice( appId = "testUniqueAppId", encryptedUserKey = protectedUserKey, @@ -182,3 +208,87 @@ class TrustedDeviceManagerTests { } } } + +private const val USER_ID: String = "userId" + +private val DEFAULT_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS = TrustedDeviceUserDecryptionOptionsJson( + encryptedPrivateKey = null, + encryptedUserKey = null, + hasAdminApproval = false, + hasLoginApprovingDevice = false, + hasManageResetPasswordPermission = false, +) + +private val UPDATED_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS = TrustedDeviceUserDecryptionOptionsJson( + encryptedPrivateKey = "encryptedPrivateKey", + encryptedUserKey = "encryptedUserKey", + hasAdminApproval = false, + hasLoginApprovingDevice = false, + hasManageResetPasswordPermission = false, +) + +private val DEFAULT_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDecryptionOptionsJson( + hasMasterPassword = false, + trustedDeviceUserDecryptionOptions = DEFAULT_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS, + keyConnectorUserDecryptionOptions = null, +) + +private val UPDATED_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDecryptionOptionsJson( + hasMasterPassword = false, + trustedDeviceUserDecryptionOptions = UPDATED_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS, + keyConnectorUserDecryptionOptions = null, +) + +private val DEFAULT_ACCOUNT = AccountJson( + profile = AccountJson.Profile( + userId = USER_ID, + email = "test@bitwarden.com", + isEmailVerified = true, + name = "Bitwarden Tester", + hasPremium = false, + stamp = null, + organizationId = null, + avatarColorHex = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = DEFAULT_USER_DECRYPTION_OPTIONS, + ), + settings = AccountJson.Settings( + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ), +) + +private val UPDATED_ACCOUNT = AccountJson( + profile = AccountJson.Profile( + userId = USER_ID, + email = "test@bitwarden.com", + isEmailVerified = true, + name = "Bitwarden Tester", + hasPremium = false, + stamp = null, + organizationId = null, + avatarColorHex = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = UPDATED_USER_DECRYPTION_OPTIONS, + ), + settings = AccountJson.Settings( + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ), +) + +private val DEFAULT_USER_STATE = UserStateJson( + activeUserId = USER_ID, + accounts = mapOf(USER_ID to DEFAULT_ACCOUNT), +) + +private val UPDATED_USER_STATE = UserStateJson( + activeUserId = USER_ID, + accounts = mapOf(USER_ID to UPDATED_ACCOUNT), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/util/TrustDeviceResponseExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/util/TrustDeviceResponseExtensionsTest.kt new file mode 100644 index 000000000..d847bf980 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/util/TrustDeviceResponseExtensionsTest.kt @@ -0,0 +1,117 @@ +package com.x8bit.bitwarden.data.auth.manager.util + +import com.bitwarden.crypto.TrustDeviceResponse +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TrustDeviceResponseExtensionsTest { + @Test + fun `toUserState updates the previous state`() { + assertEquals( + UPDATED_USER_STATE, + DEFAULT_TRUST_DEVICE_RESPONSE.toUserStateJson( + userId = USER_ID, + previousUserState = DEFAULT_USER_STATE, + ), + ) + } +} + +private const val USER_ID: String = "userId" +private const val USER_KEY: String = "protectedUserKey" +private const val PRIVATE_KEY: String = "protectedDevicePrivateKey" + +private val DEFAULT_TRUST_DEVICE_RESPONSE: TrustDeviceResponse = TrustDeviceResponse( + deviceKey = "deviceKey", + protectedUserKey = USER_KEY, + protectedDevicePrivateKey = PRIVATE_KEY, + protectedDevicePublicKey = "protectedDevicePublicKey", +) + +private val DEFAULT_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS = TrustedDeviceUserDecryptionOptionsJson( + encryptedPrivateKey = null, + encryptedUserKey = null, + hasAdminApproval = false, + hasLoginApprovingDevice = false, + hasManageResetPasswordPermission = false, +) + +private val UPDATED_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS = TrustedDeviceUserDecryptionOptionsJson( + encryptedPrivateKey = PRIVATE_KEY, + encryptedUserKey = USER_KEY, + hasAdminApproval = false, + hasLoginApprovingDevice = false, + hasManageResetPasswordPermission = false, +) + +private val DEFAULT_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDecryptionOptionsJson( + hasMasterPassword = false, + trustedDeviceUserDecryptionOptions = DEFAULT_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS, + keyConnectorUserDecryptionOptions = null, +) + +private val UPDATED_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDecryptionOptionsJson( + hasMasterPassword = false, + trustedDeviceUserDecryptionOptions = UPDATED_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS, + keyConnectorUserDecryptionOptions = null, +) + +private val DEFAULT_ACCOUNT = AccountJson( + profile = AccountJson.Profile( + userId = USER_ID, + email = "test@bitwarden.com", + isEmailVerified = true, + name = "Bitwarden Tester", + hasPremium = false, + stamp = null, + organizationId = null, + avatarColorHex = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = DEFAULT_USER_DECRYPTION_OPTIONS, + ), + settings = AccountJson.Settings( + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ), +) + +private val UPDATED_ACCOUNT = AccountJson( + profile = AccountJson.Profile( + userId = USER_ID, + email = "test@bitwarden.com", + isEmailVerified = true, + name = "Bitwarden Tester", + hasPremium = false, + stamp = null, + organizationId = null, + avatarColorHex = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = UPDATED_USER_DECRYPTION_OPTIONS, + ), + settings = AccountJson.Settings( + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ), +) + +private val DEFAULT_USER_STATE = UserStateJson( + activeUserId = USER_ID, + accounts = mapOf(USER_ID to DEFAULT_ACCOUNT), +) + +private val UPDATED_USER_STATE = UserStateJson( + activeUserId = USER_ID, + accounts = mapOf(USER_ID to UPDATED_ACCOUNT), +)