PM-33160: Instantiate SDK client with Repositories class (#6681)

This commit is contained in:
David Perez
2026-03-17 20:28:14 +00:00
committed by GitHub
parent 9d5a82e9ea
commit 183255cbff
14 changed files with 305 additions and 21 deletions
@@ -124,6 +124,16 @@ interface AuthDiskSource : AppIdProvider {
*/
fun storeUserKey(userId: String, userKey: String?)
/**
* Retrieves the local user data key for the given [userId].
*/
fun getLocalUserDataKey(userId: String): String?
/**
* Stores the local user data key for a given [userId].
*/
fun storeLocalUserDataKey(userId: String, wrappedKey: String?)
/**
* Retrieves a private key using a [userId].
*/
@@ -35,6 +35,7 @@ private const val REMEMBERED_ORG_IDENTIFIER_KEY = "rememberedOrgIdentifier"
private const val STATE_KEY = "state"
private const val INVALID_UNLOCK_ATTEMPTS_KEY = "invalidUnlockAttempts"
private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey"
private const val LOCAL_USER_DATA_KEY = "localUserDataKey"
private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey"
private const val PIN_PROTECTED_USER_KEY_KEY = "pinKeyEncryptedUserKey"
private const val PIN_PROTECTED_USER_KEY_KEY_ENVELOPE = "pinKeyEncryptedUserKeyEnvelope"
@@ -144,6 +145,7 @@ class AuthDiskSourceImpl(
override fun clearData(userId: String) {
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
storeUserKey(userId = userId, userKey = null)
storeLocalUserDataKey(userId = userId, wrappedKey = null)
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
storePrivateKey(userId = userId, privateKey = null)
storeAccountKeys(userId = userId, accountKeys = null)
@@ -237,6 +239,13 @@ class AuthDiskSourceImpl(
)
}
override fun getLocalUserDataKey(userId: String): String? =
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
override fun storeLocalUserDataKey(userId: String, wrappedKey: String?) {
putString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId), value = wrappedKey)
}
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
override fun getPrivateKey(userId: String): String? =
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))
@@ -26,11 +26,9 @@ class SdkClientManagerImpl(
repository = sdkRepoFactory.getServerCommunicationConfigRepository(),
platformApi = sdkPlatformApiFactory.getServerCommunicationConfigPlatformApi(),
)
userId?.let {
platform().state().apply {
registerCipherRepository(sdkRepoFactory.getCipherRepository(userId = it))
}
}
platform().state().registerClientManagedRepositories(
repositories = sdkRepoFactory.getRepositories(userId = userId),
)
}
},
) : SdkClientManager {
@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.sdk.CipherRepository
import com.bitwarden.sdk.Repositories
import com.bitwarden.sdk.ServerCommunicationConfigRepository
/**
@@ -9,9 +9,9 @@ import com.bitwarden.sdk.ServerCommunicationConfigRepository
*/
interface SdkRepositoryFactory {
/**
* Retrieves or creates a [CipherRepository] for use with the Bitwarden SDK.
* Retrieves or creates a [Repositories] for use with the Bitwarden SDK.
*/
fun getCipherRepository(userId: String): CipherRepository
fun getRepositories(userId: String?): Repositories
/**
* Retrieves or creates a [ClientManagedTokens] for use with the Bitwarden SDK.
@@ -2,11 +2,12 @@ package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.sdk.CipherRepository
import com.bitwarden.sdk.Repositories
import com.bitwarden.sdk.ServerCommunicationConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkCipherRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkLocalUserDataKeyStateRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkTokenRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.ServerCommunicationConfigRepositoryImpl
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
@@ -20,12 +21,15 @@ class SdkRepositoryFactoryImpl(
private val configDiskSource: ConfigDiskSource,
private val authDiskSource: AuthDiskSource,
) : SdkRepositoryFactory {
override fun getCipherRepository(
userId: String,
): CipherRepository =
SdkCipherRepository(
userId = userId,
vaultDiskSource = vaultDiskSource,
override fun getRepositories(userId: String?): Repositories =
Repositories(
cipher = getSdkRepository(userId = userId),
folder = null,
userKeyState = null,
localUserDataKeyState = SdkLocalUserDataKeyStateRepository(
authDiskSource = authDiskSource,
),
ephemeralPinEnvelopeState = null,
)
override fun getClientManagedTokens(
@@ -41,4 +45,10 @@ class SdkRepositoryFactoryImpl(
cookieDiskSource = cookieDiskSource,
configDiskSource = configDiskSource,
)
private fun getSdkRepository(
userId: String?,
): SdkCipherRepository? = userId?.let {
SdkCipherRepository(userId = it, vaultDiskSource = vaultDiskSource)
}
}
@@ -0,0 +1,49 @@
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
import com.bitwarden.core.LocalUserDataKeyState
import com.bitwarden.sdk.LocalUserDataKeyStateRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
/**
* An implementation of a Bitwarden SDK [LocalUserDataKeyStateRepository].
*/
class SdkLocalUserDataKeyStateRepository(
private val authDiskSource: AuthDiskSource,
) : LocalUserDataKeyStateRepository {
override suspend fun get(id: String): LocalUserDataKeyState? {
return authDiskSource
.getLocalUserDataKey(userId = id)
?.let { LocalUserDataKeyState(wrappedKey = it) }
}
override suspend fun has(
id: String,
): Boolean = authDiskSource.getLocalUserDataKey(userId = id) != null
override suspend fun list(): List<LocalUserDataKeyState> =
authDiskSource
.userState
?.accounts
?.mapNotNull { get(id = it.key) }
.orEmpty()
override suspend fun remove(id: String) {
authDiskSource.storeLocalUserDataKey(userId = id, wrappedKey = null)
}
override suspend fun removeAll() {
removeBulk(keys = authDiskSource.userState?.accounts.orEmpty().keys.toList())
}
override suspend fun removeBulk(keys: List<String>) {
keys.forEach { remove(id = it) }
}
override suspend fun set(id: String, value: LocalUserDataKeyState) {
authDiskSource.storeLocalUserDataKey(userId = id, value.wrappedKey)
}
override suspend fun setBulk(values: Map<String, LocalUserDataKeyState>) {
values.forEach { (id, value) -> set(id = id, value = value) }
}
}
@@ -711,6 +711,7 @@ class VaultLockManagerImpl(
is InitUserCryptoMethod.DecryptedKey,
is InitUserCryptoMethod.DeviceKey,
is InitUserCryptoMethod.KeyConnector,
is InitUserCryptoMethod.KeyConnectorUrl,
is InitUserCryptoMethod.Pin,
is InitUserCryptoMethod.PinEnvelope,
-> return
@@ -14,5 +14,6 @@ val InitUserCryptoMethod.logTag: String
is InitUserCryptoMethod.KeyConnector -> "Key Connector"
is InitUserCryptoMethod.Pin -> "Pin"
is InitUserCryptoMethod.PinEnvelope -> "Pin Envelope"
is InitUserCryptoMethod.KeyConnectorUrl -> "Key Connector Url"
is InitUserCryptoMethod.MasterPasswordUnlock -> "Master Password Unlock"
}
@@ -448,6 +448,28 @@ class AuthDiskSourceTest {
)
}
@Test
fun `getLocalUserDataKey should pull from SharedPreferences`() {
val userKeyBaseKey = "bwPreferencesStorage:localUserDataKey"
val mockUserId = "mockUserId"
val mockLocalUserKey = "mockLocalUserDataKey"
fakeSharedPreferences.edit {
putString("${userKeyBaseKey}_$mockUserId", mockLocalUserKey)
}
val actual = authDiskSource.getLocalUserDataKey(userId = mockUserId)
assertEquals(mockLocalUserKey, actual)
}
@Test
fun `storeLocalUserDataKey should update SharedPreferences`() {
val userKeyBaseKey = "bwPreferencesStorage:localUserDataKey"
val mockUserId = "mockUserId"
val mockLocalUserKey = "mockLocalUserDataKey"
authDiskSource.storeLocalUserDataKey(userId = mockUserId, wrappedKey = mockLocalUserKey)
val actual = fakeSharedPreferences.getString("${userKeyBaseKey}_$mockUserId", null)
assertEquals(mockLocalUserKey, actual)
}
@Test
fun `getPrivateKey should pull from SharedPreferences`() {
val privateKeyBaseKey = "bwPreferencesStorage:encPrivateKey"
@@ -45,6 +45,7 @@ class FakeAuthDiskSource : AuthDiskSource {
private val storedShouldTrustDevice = mutableMapOf<String, Boolean?>()
private val storedInvalidUnlockAttempts = mutableMapOf<String, Int?>()
private val storedUserKeys = mutableMapOf<String, String?>()
private val storedLocalUserDataKeys = mutableMapOf<String, String?>()
private val storedPrivateKeys = mutableMapOf<String, String?>()
private val storedTwoFactorTokens = mutableMapOf<String, String?>()
private val storedUserAutoUnlockKeys = mutableMapOf<String, String?>()
@@ -81,6 +82,7 @@ class FakeAuthDiskSource : AuthDiskSource {
override fun clearData(userId: String) {
storedInvalidUnlockAttempts.remove(userId)
storedUserKeys.remove(userId)
storedLocalUserDataKeys.remove(userId)
storedPrivateKeys.remove(userId)
storedTwoFactorTokens.clear()
storedUserAutoUnlockKeys.remove(userId)
@@ -150,6 +152,12 @@ class FakeAuthDiskSource : AuthDiskSource {
storedUserKeys[userId] = userKey
}
override fun getLocalUserDataKey(userId: String): String? = storedLocalUserDataKeys[userId]
override fun storeLocalUserDataKey(userId: String, wrappedKey: String?) {
storedLocalUserDataKeys[userId] = wrappedKey
}
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
override fun getPrivateKey(userId: String): String? = storedPrivateKeys[userId]
@@ -22,7 +22,7 @@ class SdkClientManagerTest {
every { loadLibrary(any()) } returns Result.success(Unit)
}
private val sdkRepoFactory: SdkRepositoryFactory = mockk {
every { getCipherRepository(userId = any()) } returns mockk()
every { getRepositories(userId = any()) } returns mockk()
}
private val sdkPlatformApiFactory: SdkPlatformApiFactory = mockk {
every { getServerCommunicationConfigPlatformApi() } returns mockk()
@@ -23,18 +23,23 @@ class SdkRepositoryFactoryTests {
)
@Test
fun `getCipherRepository should create a new client`() {
fun `getRepositories should create a new client`() {
val userId = "userId"
val firstClient = sdkRepoFactory.getCipherRepository(userId = userId)
val firstClient = sdkRepoFactory.getRepositories(userId = userId)
// Additional calls for the same userId should create a repo
val secondClient = sdkRepoFactory.getCipherRepository(userId = userId)
val secondClient = sdkRepoFactory.getRepositories(userId = userId)
assertNotEquals(firstClient, secondClient)
// Additional calls for different userIds should return a different repo
val otherUserId = "otherUserId"
val thirdClient = sdkRepoFactory.getCipherRepository(userId = otherUserId)
val thirdClient = sdkRepoFactory.getRepositories(userId = otherUserId)
assertNotEquals(firstClient, thirdClient)
// Null should get its own client too
val fourthClient = sdkRepoFactory.getRepositories(userId = null)
assertNotEquals(firstClient, fourthClient)
assertNotEquals(thirdClient, fourthClient)
}
@Test
@@ -0,0 +1,171 @@
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
import com.bitwarden.core.LocalUserDataKeyState
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
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.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SdkLocalUserDataKeyStateRepositoryTest {
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val repository = SdkLocalUserDataKeyStateRepository(
authDiskSource = fakeAuthDiskSource,
)
@Test
fun `get should return null when no key is stored for the given id`() = runTest {
assertNull(repository.get(id = USER_ID))
}
@Test
fun `get should return LocalUserDataKeyState when key is stored for the given id`() = runTest {
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID, wrappedKey = WRAPPED_KEY)
assertEquals(
LocalUserDataKeyState(wrappedKey = WRAPPED_KEY),
repository.get(id = USER_ID),
)
}
@Test
fun `has should return false when no key is stored for the given id`() = runTest {
assertFalse(repository.has(id = USER_ID))
}
@Test
fun `has should return true when a key is stored for the given id`() = runTest {
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID, wrappedKey = WRAPPED_KEY)
assertTrue(repository.has(id = USER_ID))
}
@Test
fun `list should return empty list when userState is null`() = runTest {
fakeAuthDiskSource.userState = null
assertEquals(emptyList<LocalUserDataKeyState>(), repository.list())
}
@Test
fun `list should return empty list when no keys are stored for any account`() = runTest {
fakeAuthDiskSource.userState = UserStateJson(
activeUserId = USER_ID,
accounts = mapOf(USER_ID to mockk()),
)
assertEquals(emptyList<LocalUserDataKeyState>(), repository.list())
}
@Test
fun `list should return LocalUserDataKeyState for each account that has a stored key`() =
runTest {
fakeAuthDiskSource.userState = UserStateJson(
activeUserId = USER_ID,
accounts = mapOf(
USER_ID to mockk(),
USER_ID_2 to mockk(),
),
)
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID, wrappedKey = WRAPPED_KEY)
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID_2, wrappedKey = WRAPPED_KEY_2)
assertEquals(
listOf(
LocalUserDataKeyState(wrappedKey = WRAPPED_KEY),
LocalUserDataKeyState(wrappedKey = WRAPPED_KEY_2),
),
repository.list(),
)
}
@Test
fun `list should omit accounts that have no stored key`() = runTest {
fakeAuthDiskSource.userState = UserStateJson(
activeUserId = USER_ID,
accounts = mapOf(USER_ID to mockk(), USER_ID_2 to mockk()),
)
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID, wrappedKey = WRAPPED_KEY)
assertEquals(
listOf(LocalUserDataKeyState(wrappedKey = WRAPPED_KEY)),
repository.list(),
)
}
@Test
fun `remove should clear the stored key for the given id`() = runTest {
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID, wrappedKey = WRAPPED_KEY)
repository.remove(id = USER_ID)
assertNull(fakeAuthDiskSource.getLocalUserDataKey(userId = USER_ID))
}
@Test
fun `removeAll should clear the stored key for all accounts`() = runTest {
fakeAuthDiskSource.userState = UserStateJson(
activeUserId = USER_ID,
accounts = mapOf(
USER_ID to mockk(),
USER_ID_2 to mockk(),
),
)
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID, wrappedKey = WRAPPED_KEY)
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID_2, wrappedKey = WRAPPED_KEY_2)
repository.removeAll()
assertNull(fakeAuthDiskSource.getLocalUserDataKey(userId = USER_ID))
assertNull(fakeAuthDiskSource.getLocalUserDataKey(userId = USER_ID_2))
}
@Test
fun `removeAll should do nothing when userState is null`() = runTest {
fakeAuthDiskSource.userState = null
repository.removeAll()
}
@Test
fun `removeBulk should clear the stored key for each given id`() = runTest {
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID, wrappedKey = WRAPPED_KEY)
fakeAuthDiskSource.storeLocalUserDataKey(userId = USER_ID_2, wrappedKey = WRAPPED_KEY_2)
repository.removeBulk(keys = listOf(USER_ID, USER_ID_2))
assertNull(fakeAuthDiskSource.getLocalUserDataKey(userId = USER_ID))
assertNull(fakeAuthDiskSource.getLocalUserDataKey(userId = USER_ID_2))
}
@Test
fun `set should store the wrapped key for the given id`() = runTest {
repository.set(id = USER_ID, value = LocalUserDataKeyState(wrappedKey = WRAPPED_KEY))
assertEquals(WRAPPED_KEY, fakeAuthDiskSource.getLocalUserDataKey(userId = USER_ID))
}
@Test
fun `setBulk should store the wrapped key for each given id`() = runTest {
repository.setBulk(
values = mapOf(
USER_ID to LocalUserDataKeyState(wrappedKey = WRAPPED_KEY),
USER_ID_2 to LocalUserDataKeyState(wrappedKey = WRAPPED_KEY_2),
),
)
assertEquals(WRAPPED_KEY, fakeAuthDiskSource.getLocalUserDataKey(userId = USER_ID))
assertEquals(WRAPPED_KEY_2, fakeAuthDiskSource.getLocalUserDataKey(userId = USER_ID_2))
}
}
private const val USER_ID: String = "userId"
private const val USER_ID_2: String = "userId2"
private const val WRAPPED_KEY: String = "wrappedKey"
private const val WRAPPED_KEY_2: String = "wrappedKey2"
+1 -1
View File
@@ -30,7 +30,7 @@ androidxRoom = "2.8.4"
androidxSecurityCrypto = "1.1.0"
androidxSplash = "1.2.0"
androidxWork = "2.11.1"
bitwardenSdk = "2.0.0-5451-c73f9161"
bitwardenSdk = "2.0.0-5676-14521973"
crashlytics = "3.0.6"
detekt = "1.23.8"
firebaseBom = "34.10.0"