BIT-1033: Store user key and private key to disc (#197)

This commit is contained in:
Ramsey Smith 2023-11-02 10:41:21 -06:00 committed by Álison Fernandes
parent ad44263028
commit ea5908d2ca
11 changed files with 390 additions and 98 deletions

View file

@ -21,4 +21,24 @@ interface AuthDiskSource {
* Emits updates that track [userState]. This will replay the last known value, if any.
*/
val userStateFlow: Flow<UserStateJson?>
/**
* Retrieves a user key using a [userId].
*/
fun getUserKey(userId: String): String?
/**
* Stores a user key using a [userId].
*/
fun storeUserKey(userId: String, userKey: String?)
/**
* Retrieves a private key using a [userId].
*/
fun getPrivateKey(userId: String): String?
/**
* Stores a private key using a [userId].
*/
fun storePrivateKey(userId: String, privateKey: String?)
}

View file

@ -12,6 +12,8 @@ import kotlinx.serialization.json.Json
private const val REMEMBERED_EMAIL_ADDRESS_KEY = "$BASE_KEY:rememberedEmail"
private const val STATE_KEY = "$BASE_KEY:state"
private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey"
private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey"
/**
* Primary implementation of [AuthDiskSource].
@ -56,4 +58,24 @@ class AuthDiskSourceImpl(
STATE_KEY -> mutableUserStateFlow.tryEmit(userState)
}
}
override fun getUserKey(userId: String): String? =
getString(key = "${MASTER_KEY_ENCRYPTION_USER_KEY}_$userId")
override fun storeUserKey(userId: String, userKey: String?) {
putString(
key = "${MASTER_KEY_ENCRYPTION_USER_KEY}_$userId",
value = userKey,
)
}
override fun getPrivateKey(userId: String): String? =
getString(key = "${MASTER_KEY_ENCRYPTION_PRIVATE_KEY}_$userId")
override fun storePrivateKey(userId: String, privateKey: String?) {
putString(
key = "${MASTER_KEY_ENCRYPTION_PRIVATE_KEY}_$userId",
value = privateKey,
)
}
}

View file

@ -112,6 +112,16 @@ class AuthRepositoryImpl @Inject constructor(
.toUserState(
previousUserState = authDiskSource.userState,
)
.also { userState ->
authDiskSource.storeUserKey(
userId = userState.activeUserId,
userKey = it.key,
)
authDiskSource.storePrivateKey(
userId = userState.activeUserId,
privateKey = it.privateKey,
)
}
LoginResult.Success
}
@ -131,7 +141,8 @@ class AuthRepositoryImpl @Inject constructor(
val updatedAccounts = currentUserState
.accounts
.filterKeys { it != activeUserId }
authDiskSource.storeUserKey(userId = activeUserId, userKey = null)
authDiskSource.storePrivateKey(userId = activeUserId, privateKey = null)
// Check if there is a new active user
if (updatedAccounts.isNotEmpty()) {
val (updatedActiveUserId, updatedActiveAccount) =

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.repository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@ -15,6 +16,7 @@ import kotlinx.coroutines.launch
class VaultRepositoryImpl constructor(
private val syncService: SyncService,
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
dispatcherManager: DispatcherManager,
) : VaultRepository {
@ -29,8 +31,11 @@ class VaultRepositoryImpl constructor(
.sync()
.fold(
onSuccess = { syncResponse ->
storeUserKeyAndPrivateKey(
userKey = syncResponse.profile?.key,
privateKey = syncResponse.profile?.privateKey,
)
// TODO transform into domain object consumable by VaultViewModel BIT-205.
// TODO initialize crypto in BIT-990
syncResponse.ciphers?.let { networkCiphers ->
vaultSdkSource.decryptCipherList(
@ -49,4 +54,22 @@ class VaultRepositoryImpl constructor(
)
}
}
private fun storeUserKeyAndPrivateKey(
userKey: String?,
privateKey: String?,
) {
val userId = authDiskSource.userState?.activeUserId ?: return
if (userKey == null || privateKey == null) return
authDiskSource.apply {
storeUserKey(
userId = userId,
userKey = userKey,
)
storePrivateKey(
userId = userId,
privateKey = privateKey,
)
}
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.repository.di
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@ -23,10 +24,12 @@ class VaultRepositoryModule {
fun providesVaultRepository(
syncService: SyncService,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
dispatcherManager: DispatcherManager,
): VaultRepository = VaultRepositoryImpl(
syncService = syncService,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
dispatcherManager = dispatcherManager,
)
}

View file

@ -87,6 +87,79 @@ class AuthDiskSourceTest {
assertEquals(USER_STATE, awaitItem())
}
}
@Test
fun `getUserKey should pull from SharedPreferences`() {
val mockUserId = "mockUserId"
val mockUserKey = "mockUserKey"
fakeSharedPreferences
.edit()
.putString(
"masterKeyEncryptedUserKey_$mockUserId",
mockUserKey,
)
.apply()
val actual = authDiskSource.getUserKey(userId = mockUserId)
assertEquals(
mockUserKey,
actual,
)
}
@Test
fun `storeUserKey should update SharedPreferences`() {
val mockUserId = "mockUserId"
val mockUserKey = "mockUserKey"
authDiskSource.storeUserKey(
userId = mockUserId,
userKey = mockUserKey,
)
val actual = fakeSharedPreferences
.getString(
"masterKeyEncryptedUserKey_$mockUserId",
null,
)
assertEquals(
mockUserKey,
actual,
)
}
@Test
fun `getPrivateKey should pull from SharedPreferences`() {
val mockUserId = "mockUserId"
val mockPrivateKey = "mockPrivateKey"
fakeSharedPreferences
.edit()
.putString(
"encPrivateKey_$mockUserId",
mockPrivateKey,
)
.apply()
val actual = authDiskSource.getPrivateKey(userId = mockUserId)
assertEquals(
mockPrivateKey,
actual,
)
}
@Test
fun `storePrivateKey should update SharedPreferences`() {
val mockUserId = "mockUserId"
val mockPrivateKey = "mockPrivateKey"
authDiskSource.storePrivateKey(
userId = mockUserId,
privateKey = mockPrivateKey,
)
val actual = fakeSharedPreferences.getString(
"encPrivateKey_$mockUserId",
null,
)
assertEquals(
mockPrivateKey,
actual,
)
}
}
private const val USER_STATE_JSON = """

View file

@ -0,0 +1,57 @@
package com.x8bit.bitwarden.data.auth.datasource.disk.util
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import org.junit.Assert.assertEquals
class FakeAuthDiskSource : AuthDiskSource {
override var rememberedEmailAddress: String? = null
override var userState: UserStateJson? = null
set(value) {
field = value
mutableUserStateFlow.tryEmit(value)
}
override val userStateFlow: Flow<UserStateJson?>
get() = mutableUserStateFlow.onSubscription { emit(userState) }
override fun getUserKey(userId: String): String? = storedUserKeys[userId]
override fun storeUserKey(userId: String, userKey: String?) {
storedUserKeys[userId] = userKey
}
override fun getPrivateKey(userId: String): String? = storedPrivateKeys[userId]
override fun storePrivateKey(userId: String, privateKey: String?) {
storedPrivateKeys[userId] = privateKey
}
private val mutableUserStateFlow =
MutableSharedFlow<UserStateJson?>(
replay = 1,
extraBufferCapacity = Int.MAX_VALUE,
)
private val storedUserKeys = mutableMapOf<String, String?>()
private val storedPrivateKeys = mutableMapOf<String, String?>()
/**
* Assert that the [userKey] was stored successfully using the [userId].
*/
fun assertUserKey(userId: String, userKey: String?) {
assertEquals(userKey, storedUserKeys[userId])
}
/**
* Assert that the [privateKey] was stored successfully using the [userId].
*/
fun assertPrivateKey(userId: String, privateKey: String?) {
assertEquals(privateKey, storedPrivateKeys[userId])
}
}

View file

@ -4,9 +4,9 @@ import app.cash.turbine.test
import com.bitwarden.core.Kdf
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RsaKeyPair
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
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.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.PBKDF2_SHA256
@ -38,9 +38,6 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@ -186,34 +183,43 @@ class AuthRepositoryTest {
}
@Test
fun `login get token succeeds should return Success and update AuthState`() = runTest {
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
coEvery {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
fun `login get token succeeds should return Success and update AuthState and stored keys`() =
runTest {
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
coEvery {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
)
}
.returns(Result.success(successResponse))
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(previousUserState = null)
} returns SINGLE_USER_STATE_1
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
coVerify { accountsService.preLogin(email = EMAIL) }
fakeAuthDiskSource.assertPrivateKey(
userId = USER_ID_1,
privateKey = "privateKey",
)
}
.returns(Result.success(successResponse))
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(previousUserState = null)
} returns SINGLE_USER_STATE_1
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
coVerify { accountsService.preLogin(email = EMAIL) }
coVerify {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
fakeAuthDiskSource.assertUserKey(
userId = USER_ID_1,
userKey = "key",
)
coVerify {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
)
}
}
}
@Test
fun `login get token returns captcha request should return CaptchaRequired`() = runTest {
@ -578,7 +584,7 @@ class AuthRepositoryTest {
}
@Test
fun `logout for single account should clear the access token`() = runTest {
fun `logout for single account should clear the access toke and stored keys`() = runTest {
// First login:
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
@ -608,45 +614,62 @@ class AuthRepositoryTest {
assertEquals(AuthState.Unauthenticated, awaitItem())
assertNull(fakeAuthDiskSource.userState)
fakeAuthDiskSource.assertPrivateKey(
userId = USER_ID_1,
privateKey = null,
)
fakeAuthDiskSource.assertUserKey(
userId = USER_ID_1,
userKey = null,
)
}
}
@Test
fun `logout for multiple accounts should update current access token`() = runTest {
// First populate multiple user accounts
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
fun `logout for multiple accounts should update current access token and stored keys`() =
runTest {
// First populate multiple user accounts
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
// Then login:
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
coEvery {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
)
} returns Result.success(successResponse)
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(previousUserState = SINGLE_USER_STATE_2)
} returns MULTI_USER_STATE
// Then login:
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
coEvery {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
)
} returns Result.success(successResponse)
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(previousUserState = SINGLE_USER_STATE_2)
} returns MULTI_USER_STATE
repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
assertEquals(MULTI_USER_STATE, fakeAuthDiskSource.userState)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
assertEquals(MULTI_USER_STATE, fakeAuthDiskSource.userState)
// Then call logout:
repository.authStateFlow.test {
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), awaitItem())
// Then call logout:
repository.authStateFlow.test {
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), awaitItem())
repository.logout()
repository.logout()
assertEquals(AuthState.Authenticated(ACCESS_TOKEN_2), awaitItem())
assertEquals(SINGLE_USER_STATE_2, fakeAuthDiskSource.userState)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN_2), awaitItem())
assertEquals(SINGLE_USER_STATE_2, fakeAuthDiskSource.userState)
fakeAuthDiskSource.assertPrivateKey(
userId = USER_ID_1,
privateKey = null,
)
fakeAuthDiskSource.assertUserKey(
userId = USER_ID_1,
userKey = null,
)
}
}
}
@Test
fun `getPasswordStrength should be based on password length`() = runTest {
@ -774,22 +797,3 @@ class AuthRepositoryTest {
)
}
}
private class FakeAuthDiskSource : AuthDiskSource {
override var rememberedEmailAddress: String? = null
override var userState: UserStateJson? = null
set(value) {
field = value
mutableUserStateFlow.tryEmit(value)
}
override val userStateFlow: Flow<UserStateJson?>
get() = mutableUserStateFlow.onSubscription { emit(userState) }
private val mutableUserStateFlow =
MutableSharedFlow<UserStateJson?>(
replay = 1,
extraBufferCapacity = Int.MAX_VALUE,
)
}

View file

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
fun createMockSyncResponse(number: Int): SyncResponseJson =
SyncResponseJson(
folders = listOf(createMockFolder(number = number)),
collections = listOf(createMockCollection(number = number)),
profile = createMockProfile(number = number),
ciphers = listOf(createMockCipher(number = number)),
policies = listOf(createMockPolicy(number = number)),
domains = createMockDomains(number = number),
sends = listOf(createMockSend(number = number)),
)

View file

@ -2,14 +2,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.service
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import com.x8bit.bitwarden.data.vault.datasource.network.api.SyncApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCollection
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockDomains
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockFolder
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockProfile
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSend
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSyncResponse
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.Test
@ -27,7 +20,7 @@ class SyncServiceTest : BaseServiceTest() {
fun `sync should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(SYNC_SUCCESS_JSON))
val result = syncService.sync()
assertEquals(SYNC_SUCCESS, result.getOrThrow())
assertEquals(createMockSyncResponse(number = 1), result.getOrThrow())
}
}
@ -363,13 +356,3 @@ private const val SYNC_SUCCESS_JSON = """
]
}
"""
private val SYNC_SUCCESS = SyncResponseJson(
folders = listOf(createMockFolder(number = 1)),
collections = listOf(createMockCollection(number = 1)),
profile = createMockProfile(number = 1),
ciphers = listOf(createMockCipher(number = 1)),
policies = listOf(createMockPolicy(number = 1)),
domains = createMockDomains(number = 1),
sends = listOf(createMockSend(number = 1)),
)

View file

@ -0,0 +1,84 @@
package com.x8bit.bitwarden.data.vault.repository
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
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.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSyncResponse
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkCipher
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkFolder
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class VaultRepositoryTest {
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val syncService: SyncService = mockk()
private val vaultSdkSource: VaultSdkSource = mockk()
private val vaultRepository = VaultRepositoryImpl(
syncService = syncService,
vaultSdkSource = vaultSdkSource,
authDiskSource = fakeAuthDiskSource,
dispatcherManager = dispatcherManager,
)
@Test
fun `sync when syncService Success should update AuthDiskSource with keys`() = runTest {
coEvery { syncService.sync() } returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns mockk()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns mockk()
fakeAuthDiskSource.userState = MOCK_USER_STATE
vaultRepository.sync()
fakeAuthDiskSource.assertUserKey(
userId = "mockUserId",
userKey = "mockKey-1",
)
fakeAuthDiskSource.assertPrivateKey(
userId = "mockUserId",
privateKey = "mockPrivateKey-1",
)
}
}
private val MOCK_USER_STATE = UserStateJson(
activeUserId = "mockUserId",
accounts = mapOf(
"mockUserId" to AccountJson(
profile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = null,
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremium = true,
forcePasswordResetReason = null,
kdfType = null,
kdfIterations = null,
kdfMemory = null,
kdfParallelism = null,
userDecryptionOptions = null,
),
tokens = AccountJson.Tokens(
accessToken = "accessToken",
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = null,
),
),
),
)