Create trusted device service (#1185)

This commit is contained in:
David Perez 2024-03-28 16:43:35 -05:00 committed by Álison Fernandes
parent b13c89b688
commit 0a65b37a65
8 changed files with 342 additions and 0 deletions

View file

@ -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].
*/

View file

@ -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<UserStateJson?>
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")

View file

@ -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<Boolean>
}

View file

@ -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<Boolean> =
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 }
}
}

View file

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

View file

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

View file

@ -37,10 +37,13 @@ class FakeAuthDiskSource : AuthDiskSource {
mutableMapOf<String, List<SyncResponseJson.Profile.Organization>?>()
private val storedOrganizationKeys = mutableMapOf<String, Map<String, String>?>()
private val storedAccountTokens = mutableMapOf<String, AccountTokensJson?>()
private val storedDeviceKey = mutableMapOf<String, String?>()
private val storedBiometricKeys = mutableMapOf<String, String?>()
private val storedMasterPasswordHashes = mutableMapOf<String, String?>()
private val storedPolicies = mutableMapOf<String, List<SyncResponseJson.Policy>?>()
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].
*/

View file

@ -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,
)
}
}
}