diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index 361e3b9d2..91dafdc2c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -27,6 +27,11 @@ interface AuthDiskSource { */ var rememberedOrgIdentifier: String? + /** + * The currently persisted state indicating that the user has trusted this device. + */ + var shouldTrustDevice: Boolean + /** * The currently persisted user state information (or `null` if not set). */ @@ -106,6 +111,16 @@ interface AuthDiskSource { */ fun storeUserAutoUnlockKey(userId: String, userAutoUnlockKey: String?) + /** + * Gets the device key for the given [userId]. + */ + fun getDeviceKey(userId: String): String? + + /** + * Stores the device key for the given [userId]. + */ + fun storeDeviceKey(userId: String, deviceKey: String?) + /** * Gets the biometrics key for the given [userId]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index 5187312d5..5c2a127e1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -20,6 +20,8 @@ import java.util.UUID private const val ACCOUNT_TOKENS_KEY = "$ENCRYPTED_BASE_KEY:accountTokens" private const val BIOMETRICS_UNLOCK_KEY = "$ENCRYPTED_BASE_KEY:userKeyBiometricUnlock" private const val USER_AUTO_UNLOCK_KEY_KEY = "$ENCRYPTED_BASE_KEY:userKeyAutoUnlock" +private const val DEVICE_KEY_KEY = "$ENCRYPTED_BASE_KEY:deviceKey" + private const val UNIQUE_APP_ID_KEY = "$BASE_KEY:appId" private const val REMEMBERED_EMAIL_ADDRESS_KEY = "$BASE_KEY:rememberedEmail" private const val REMEMBERED_ORG_IDENTIFIER_KEY = "$BASE_KEY:rememberedOrgIdentifier" @@ -35,6 +37,7 @@ private const val ORGANIZATION_KEYS_KEY = "$BASE_KEY:encOrgKeys" private const val TWO_FACTOR_TOKEN_KEY = "$BASE_KEY:twoFactorToken" private const val MASTER_PASSWORD_HASH_KEY = "$BASE_KEY:keyHash" private const val POLICIES_KEY = "$BASE_KEY:policies" +private const val SHOULD_TRUST_DEVICE_KEY = "$BASE_KEY:shouldTrustDevice" /** * Primary implementation of [AuthDiskSource]. @@ -101,6 +104,12 @@ class AuthDiskSourceImpl( ) } + override var shouldTrustDevice: Boolean + get() = requireNotNull(getBoolean(key = SHOULD_TRUST_DEVICE_KEY, default = false)) + set(value) { + putBoolean(key = SHOULD_TRUST_DEVICE_KEY, value = value) + } + override val userStateFlow: Flow get() = mutableUserStateFlow .onSubscription { emit(userState) } @@ -115,6 +124,7 @@ class AuthDiskSourceImpl( storePrivateKey(userId = userId, privateKey = null) storeOrganizationKeys(userId = userId, organizationKeys = null) storeOrganizations(userId = userId, organizations = null) + storeDeviceKey(userId = userId, deviceKey = null) storeUserBiometricUnlockKey(userId = userId, biometricsKey = null) storeMasterPasswordHash(userId = userId, passwordHash = null) storePolicies(userId = userId, policies = null) @@ -183,6 +193,14 @@ class AuthDiskSourceImpl( ) } + override fun getDeviceKey( + userId: String, + ): String? = getEncryptedString(key = "${DEVICE_KEY_KEY}_$userId") + + override fun storeDeviceKey(userId: String, deviceKey: String?) { + putEncryptedString(key = "${DEVICE_KEY_KEY}_$userId", value = deviceKey) + } + override fun getUserBiometricUnlockKey(userId: String): String? = getEncryptedString(key = "${BIOMETRICS_UNLOCK_KEY}_$userId") diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManager.kt new file mode 100644 index 000000000..b13bff911 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManager.kt @@ -0,0 +1,11 @@ +package com.x8bit.bitwarden.data.auth.manager + +/** + * Manager used to establish trust with this device. + */ +interface TrustedDeviceManager { + /** + * Establishes trust with this device if necessary. + */ + suspend fun trustThisDeviceIfNecessary(userId: String): Result +} 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 new file mode 100644 index 000000000..c81060f2c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerImpl.kt @@ -0,0 +1,41 @@ +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.platform.util.asSuccess +import com.x8bit.bitwarden.data.platform.util.flatMap +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource + +/** + * Default implementation of the [TrustedDeviceManager] used to establish trust with this device. + */ +class TrustedDeviceManagerImpl( + private val authDiskSource: AuthDiskSource, + private val vaultSdkSource: VaultSdkSource, + private val devicesService: DevicesService, +) : TrustedDeviceManager { + override suspend fun trustThisDeviceIfNecessary(userId: String): Result = + if (!authDiskSource.shouldTrustDevice) { + false.asSuccess() + } else { + vaultSdkSource + .getTrustDevice(userId = userId) + .flatMap { trustedDevice -> + devicesService + .trustDevice( + appId = authDiskSource.uniqueAppId, + encryptedDevicePrivateKey = trustedDevice.protectedDevicePrivateKey, + encryptedDevicePublicKey = trustedDevice.protectedDevicePublicKey, + encryptedUserKey = trustedDevice.protectedUserKey, + ) + .onSuccess { + authDiskSource.storeDeviceKey( + userId = userId, + deviceKey = trustedDevice.deviceKey, + ) + } + } + .also { authDiskSource.shouldTrustDevice = false } + .map { true } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt index 698a4abc2..1b1f7e94e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt @@ -3,12 +3,15 @@ package com.x8bit.bitwarden.data.auth.manager.di import android.content.Context import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager import com.x8bit.bitwarden.data.auth.manager.AuthRequestManagerImpl import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManagerImpl +import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager +import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManagerImpl import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManagerImpl import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource @@ -68,6 +71,19 @@ object AuthManagerModule { authDiskSource = authDiskSource, ) + @Provides + @Singleton + fun provideTrustedDeviceManager( + authDiskSource: AuthDiskSource, + vaultSdkSource: VaultSdkSource, + devicesService: DevicesService, + ): TrustedDeviceManager = + TrustedDeviceManagerImpl( + authDiskSource = authDiskSource, + vaultSdkSource = vaultSdkSource, + devicesService = devicesService, + ) + @Provides @Singleton fun provideUserLogoutManager( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index 59eea66db..240b8d868 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -120,6 +120,23 @@ class AuthDiskSourceTest { assertNull(authDiskSource.rememberedOrgIdentifier) } + @Test + fun `shouldTrustDevice should pull from and update SharedPreferences`() { + val shouldTrustDeviceKey = "bwPreferencesStorage:shouldTrustDevice" + + // Shared preferences and the disk source start with the same value. + assertFalse(authDiskSource.shouldTrustDevice) + assertFalse(fakeSharedPreferences.getBoolean(shouldTrustDeviceKey, false)) + + // Updating the disk source updates shared preferences + authDiskSource.shouldTrustDevice = true + assertTrue(fakeSharedPreferences.getBoolean(shouldTrustDeviceKey, false)) + + // Update SharedPreferences updates the disk source + fakeSharedPreferences.edit { putBoolean(shouldTrustDeviceKey, false) } + assertFalse(authDiskSource.shouldTrustDevice) + } + @Test fun `userState should pull from and update SharedPreferences`() { val userStateKey = "bwPreferencesStorage:state" @@ -164,6 +181,10 @@ class AuthDiskSourceTest { fun `clearData should clear all necessary data for the given user`() { val userId = "userId" + authDiskSource.storeDeviceKey( + userId = userId, + deviceKey = "9876-5432-1234", + ) authDiskSource.storeUserBiometricUnlockKey( userId = userId, biometricsKey = "1234-9876-0192", @@ -204,6 +225,7 @@ class AuthDiskSourceTest { authDiskSource.clearData(userId = userId) + assertNull(authDiskSource.getDeviceKey(userId = userId)) assertNull(authDiskSource.getUserBiometricUnlockKey(userId = userId)) assertNull(authDiskSource.getLastActiveTimeMillis(userId = userId)) assertNull(authDiskSource.getInvalidUnlockAttempts(userId = userId)) @@ -484,6 +506,42 @@ class AuthDiskSourceTest { ) } + @Test + fun `getDeviceKey should pull from SharedPreferences`() { + val deviceKeyBaseKey = "bwSecureStorage:deviceKey" + val mockUserId = "mockUserId" + val deviceKeyKey = "${deviceKeyBaseKey}_$mockUserId" + val devicesKey = "1234" + fakeEncryptedSharedPreferences.edit { putString(deviceKeyKey, devicesKey) } + val actual = authDiskSource.getDeviceKey(userId = mockUserId) + assertEquals(devicesKey, actual) + } + + @Test + fun `storeDeviceKey for non-null values should update SharedPreferences`() { + val deviceKeyBaseKey = "bwSecureStorage:deviceKey" + val mockUserId = "mockUserId" + val deviceKeyKey = "${deviceKeyBaseKey}_$mockUserId" + val devicesKey = "1234" + authDiskSource.storeDeviceKey(userId = mockUserId, deviceKey = devicesKey) + val actual = fakeEncryptedSharedPreferences.getString( + key = deviceKeyKey, + defaultValue = null, + ) + assertEquals(devicesKey, actual) + } + + @Test + fun `storeDeviceKey for null values should clear SharedPreferences`() { + val deviceKeyBaseKey = "bwSecureStorage:deviceKey" + val mockUserId = "mockUserId" + val deviceKeyKey = "${deviceKeyBaseKey}_$mockUserId" + val deviceKey = "1234" + fakeEncryptedSharedPreferences.edit { putString(deviceKeyKey, deviceKey) } + authDiskSource.storeDeviceKey(userId = mockUserId, deviceKey = null) + assertFalse(fakeEncryptedSharedPreferences.contains(deviceKeyKey)) + } + @Test fun `getUserBiometricUnlockKey should pull from SharedPreferences`() { val biometricsKeyBaseKey = "bwSecureStorage:userKeyBiometricUnlock" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 1ac38c533..63b225fb9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -37,10 +37,13 @@ class FakeAuthDiskSource : AuthDiskSource { mutableMapOf?>() private val storedOrganizationKeys = mutableMapOf?>() private val storedAccountTokens = mutableMapOf() + private val storedDeviceKey = mutableMapOf() private val storedBiometricKeys = mutableMapOf() private val storedMasterPasswordHashes = mutableMapOf() private val storedPolicies = mutableMapOf?>() + override var shouldTrustDevice: Boolean = false + override var userState: UserStateJson? = null set(value) { field = value @@ -161,6 +164,12 @@ class FakeAuthDiskSource : AuthDiskSource { getMutableOrganizationsFlow(userId = userId).tryEmit(organizations) } + override fun getDeviceKey(userId: String): String? = storedDeviceKey[userId] + + override fun storeDeviceKey(userId: String, deviceKey: String?) { + storedDeviceKey[userId] = deviceKey + } + override fun getUserBiometricUnlockKey(userId: String): String? = storedBiometricKeys[userId] @@ -273,6 +282,13 @@ class FakeAuthDiskSource : AuthDiskSource { assertEquals(organizationKeys, storedOrganizationKeys[userId]) } + /** + * Assert that the [deviceKey] was stored successfully using the [userId]. + */ + fun assertDeviceKey(userId: String, deviceKey: String?) { + assertEquals(deviceKey, storedDeviceKey[userId]) + } + /** * Assert that the [biometricsKey] was stored successfully using the [userId]. */ 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 new file mode 100644 index 000000000..edced7fc9 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/TrustedDeviceManagerTests.kt @@ -0,0 +1,167 @@ +package com.x8bit.bitwarden.data.auth.manager + +import com.bitwarden.crypto.TrustDeviceResponse +import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceKeysResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService +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.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import java.time.ZonedDateTime + +class TrustedDeviceManagerTests { + private val fakeAuthDiskSource = FakeAuthDiskSource() + private val vaultSdkSource: VaultSdkSource = mockk() + private val devicesService: DevicesService = mockk() + + private val manager: TrustedDeviceManager = TrustedDeviceManagerImpl( + authDiskSource = fakeAuthDiskSource, + vaultSdkSource = vaultSdkSource, + devicesService = devicesService, + ) + + @Suppress("MaxLineLength") + @Test + fun `trustThisDeviceIfNecessary when shouldTrustDevice false should return success with false`() = + runTest { + val userId = "userId" + fakeAuthDiskSource.shouldTrustDevice = false + + val result = manager.trustThisDeviceIfNecessary(userId = userId) + + assertEquals(false.asSuccess(), result) + coVerify(exactly = 0) { + vaultSdkSource.getTrustDevice(userId = userId) + devicesService.trustDevice( + appId = any(), + encryptedUserKey = any(), + encryptedDevicePublicKey = any(), + encryptedDevicePrivateKey = any(), + ) + } + } + + @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) + } returns error.asFailure() + + val result = manager.trustThisDeviceIfNecessary(userId = userId) + + assertEquals(error.asFailure(), result) + assertFalse(fakeAuthDiskSource.shouldTrustDevice) + coVerify(exactly = 1) { + vaultSdkSource.getTrustDevice(userId = userId) + } + coVerify(exactly = 0) { + devicesService.trustDevice( + appId = any(), + encryptedUserKey = any(), + encryptedDevicePublicKey = any(), + encryptedDevicePrivateKey = any(), + ) + } + } + + @Test + fun `trustThisDeviceIfNecessary when trustDevice fails should return failure`() = runTest { + val userId = "userId" + val deviceKey = "deviceKey" + val protectedUserKey = "protectedUserKey" + val protectedDevicePrivateKey = "protectedDevicePrivateKey" + val protectedDevicePublicKey = "protectedDevicePublicKey" + val trustedDeviceResponse = TrustDeviceResponse( + deviceKey = deviceKey, + protectedUserKey = protectedUserKey, + protectedDevicePrivateKey = protectedDevicePrivateKey, + protectedDevicePublicKey = protectedDevicePublicKey, + ) + val error = Throwable("Fail") + fakeAuthDiskSource.shouldTrustDevice = true + coEvery { + vaultSdkSource.getTrustDevice(userId = userId) + } returns trustedDeviceResponse.asSuccess() + coEvery { + devicesService.trustDevice( + appId = "testUniqueAppId", + encryptedUserKey = protectedUserKey, + encryptedDevicePublicKey = protectedDevicePublicKey, + encryptedDevicePrivateKey = protectedDevicePrivateKey, + ) + } returns error.asFailure() + + val result = manager.trustThisDeviceIfNecessary(userId = userId) + + assertEquals(error.asFailure(), result) + assertFalse(fakeAuthDiskSource.shouldTrustDevice) + coVerify(exactly = 1) { + vaultSdkSource.getTrustDevice(userId = userId) + devicesService.trustDevice( + appId = "testUniqueAppId", + encryptedUserKey = protectedUserKey, + encryptedDevicePublicKey = protectedDevicePublicKey, + encryptedDevicePrivateKey = protectedDevicePrivateKey, + ) + } + } + + @Test + fun `trustThisDeviceIfNecessary when success should return success with true`() = runTest { + val userId = "userId" + val deviceKey = "deviceKey" + val protectedUserKey = "protectedUserKey" + val protectedDevicePrivateKey = "protectedDevicePrivateKey" + val protectedDevicePublicKey = "protectedDevicePublicKey" + val trustedDeviceResponse = TrustDeviceResponse( + deviceKey = deviceKey, + protectedUserKey = protectedUserKey, + protectedDevicePrivateKey = protectedDevicePrivateKey, + protectedDevicePublicKey = protectedDevicePublicKey, + ) + val trustedDeviceKeysResponseJson = TrustedDeviceKeysResponseJson( + id = "id", + name = "name", + identifier = "identifier", + type = 0, + creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), + ) + fakeAuthDiskSource.shouldTrustDevice = true + coEvery { + vaultSdkSource.getTrustDevice(userId = userId) + } returns trustedDeviceResponse.asSuccess() + coEvery { + devicesService.trustDevice( + appId = "testUniqueAppId", + encryptedUserKey = protectedUserKey, + encryptedDevicePublicKey = protectedDevicePublicKey, + encryptedDevicePrivateKey = protectedDevicePrivateKey, + ) + } returns trustedDeviceKeysResponseJson.asSuccess() + + val result = manager.trustThisDeviceIfNecessary(userId = userId) + + assertEquals(true.asSuccess(), result) + fakeAuthDiskSource.assertDeviceKey(userId = userId, deviceKey = deviceKey) + assertFalse(fakeAuthDiskSource.shouldTrustDevice) + coVerify(exactly = 1) { + vaultSdkSource.getTrustDevice(userId = userId) + devicesService.trustDevice( + appId = "testUniqueAppId", + encryptedUserKey = protectedUserKey, + encryptedDevicePublicKey = protectedDevicePublicKey, + encryptedDevicePrivateKey = protectedDevicePrivateKey, + ) + } + } +}