🍒 PM-23666: Construct unique SDK client for Authentocator Sync feature (#5528)

This commit is contained in:
David Perez 2025-07-14 16:39:56 -05:00 committed by GitHub
parent 37af6a1773
commit a721744a6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 431 additions and 233 deletions

View File

@ -1,17 +1,24 @@
package com.x8bit.bitwarden.data.platform.repository
import com.bitwarden.authenticatorbridge.model.SharedAccountData
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
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.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.repository.util.sanitizeTotpUri
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import kotlinx.coroutines.flow.first
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
/**
* Default implementation of [AuthenticatorBridgeRepository].
@ -19,9 +26,8 @@ import kotlinx.coroutines.flow.first
class AuthenticatorBridgeRepositoryImpl(
private val authRepository: AuthRepository,
private val authDiskSource: AuthDiskSource,
private val vaultRepository: VaultRepository,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val scopedVaultSdkSource: ScopedVaultSdkSource,
) : AuthenticatorBridgeRepository {
override val authenticatorSyncSymmetricKey: ByteArray?
@ -45,52 +51,41 @@ class AuthenticatorBridgeRepositoryImpl(
@Suppress("LongMethod")
override suspend fun getSharedAccounts(): SharedAccountData {
val allAccounts = authRepository.userStateFlow.value?.accounts ?: emptyList()
val allAccounts = authDiskSource.userState?.accounts.orEmpty()
return allAccounts
.mapNotNull { account ->
val userId = account.userId
.mapNotNull { (userId, account) ->
// Grab the user's authenticator sync unlock key. If it is null,
// the user has not enabled authenticator sync.
// the user has not enabled authenticator sync and we skip the account.
val decryptedUserKey = authDiskSource.getAuthenticatorSyncUnlockKey(userId)
?: return@mapNotNull null
// Wait for any unlocking actions to finish:
vaultRepository.vaultUnlockDataStateFlow.first {
it.statusFor(userId) != VaultUnlockData.Status.UNLOCKING
}
// Unlock vault if necessary:
val isVaultAlreadyUnlocked = vaultRepository.isVaultUnlocked(userId = userId)
if (!isVaultAlreadyUnlocked) {
val unlockResult = vaultRepository
.unlockVaultWithDecryptedUserKey(
val vaultUnlockResult = unlockClient(
userId = userId,
account = account,
decryptedUserKey = decryptedUserKey,
)
when (vaultUnlockResult) {
is VaultUnlockResult.AuthenticationError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> {
// Not being able to unlock the user's vault with the
// decrypted unlock key is an unexpected case, but if it does
// happen we omit the account from list of shared accounts
// and remove that user's authenticator sync unlock key.
// This gives the user a way to potentially re-enable syncing
// (going to Account Security and re-enabling the toggle)
authDiskSource.storeAuthenticatorSyncUnlockKey(
userId = userId,
decryptedUserKey = decryptedUserKey,
authenticatorSyncUnlockKey = null,
)
when (unlockResult) {
is VaultUnlockResult.AuthenticationError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> {
// Not being able to unlock the user's vault with the
// decrypted unlock key is an unexpected case, but if it does
// happen we omit the account from list of shared accounts
// and remove that user's authenticator sync unlock key.
// This gives the user a way to potentially re-enable syncing
// (going to Account Security and re-enabling the toggle)
authDiskSource.storeAuthenticatorSyncUnlockKey(
userId = userId,
authenticatorSyncUnlockKey = null,
)
return@mapNotNull null
}
// Proceed
VaultUnlockResult.Success -> Unit
// Destroy our stand-alone instance of the vault.
scopedVaultSdkSource.clearCrypto(userId = userId)
return@mapNotNull null
}
// Proceed
VaultUnlockResult.Success -> Unit
}
// Vault is unlocked, query vault disk source for totp logins:
@ -99,7 +94,7 @@ class AuthenticatorBridgeRepositoryImpl(
// Filter out any deleted ciphers.
.filter { it.deletedDate == null }
.mapNotNull {
val decryptedCipher = vaultSdkSource
val decryptedCipher = scopedVaultSdkSource
.decryptCipher(
userId = userId,
cipher = it.toEncryptedSdkCipher(),
@ -113,19 +108,18 @@ class AuthenticatorBridgeRepositoryImpl(
rawTotp.sanitizeTotpUri(cipherName, username)
}
// Lock the user's vault if we unlocked it for this operation:
if (!isVaultAlreadyUnlocked) {
vaultRepository.lockVault(
userId = userId,
isUserInitiated = false,
)
}
// Lock and destroy our stand-alone instance of the vault:
scopedVaultSdkSource.clearCrypto(userId = userId)
SharedAccountData.Account(
userId = account.userId,
name = account.name,
email = account.email,
environmentLabel = account.environment.label,
userId = userId,
name = account.profile.name,
email = account.profile.email,
environmentLabel = account
.settings
.environmentUrlData
.toEnvironmentUrlsOrDefault()
.label,
totpUris = totpUris,
)
}
@ -133,4 +127,44 @@ class AuthenticatorBridgeRepositoryImpl(
SharedAccountData(it)
}
}
private suspend fun unlockClient(
userId: String,
account: AccountJson,
decryptedUserKey: String,
): VaultUnlockResult {
val privateKey = authDiskSource
.getPrivateKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError(MissingPropertyException("Private key"))
return scopedVaultSdkSource
.initializeCrypto(
userId = userId,
request = InitUserCryptoRequest(
userId = userId,
kdfParams = account.profile.toSdkParams(),
email = account.profile.email,
privateKey = privateKey,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = decryptedUserKey,
),
signingKey = null,
),
)
.flatMap { result ->
// Initialize the SDK for organizations if necessary
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
if (organizationKeys != null && result is InitializeCryptoResult.Success) {
scopedVaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(organizationKeys = organizationKeys),
)
} else {
result.asSuccess()
}
}
.fold(
onFailure = { VaultUnlockResult.GenericError(error = it) },
onSuccess = { it.toVaultUnlockResult() },
)
}
}

View File

@ -21,8 +21,8 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepositoryImpl
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -41,15 +41,13 @@ object PlatformRepositoryModule {
fun providesAuthenticatorBridgeRepository(
authRepository: AuthRepository,
authDiskSource: AuthDiskSource,
vaultRepository: VaultRepository,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
scopedVaultSdkSource: ScopedVaultSdkSource,
): AuthenticatorBridgeRepository = AuthenticatorBridgeRepositoryImpl(
authRepository = authRepository,
authDiskSource = authDiskSource,
vaultRepository = vaultRepository,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
scopedVaultSdkSource = scopedVaultSdkSource,
)
@Provides

View File

@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
/**
* This is a non-singleton instance of the [VaultSdkSource] that is intentionally separate; this
* allows you to temporarily unlock vaults for a given user within its own scope without affecting
* the foreground behavior of the app.
*
* Users of this class must always call [ScopedVaultSdkSource.clearCrypto] when they are done using
* the unlocked vault in order to ensure that this instance of the vault is re-locked.
*/
interface ScopedVaultSdkSource : VaultSdkSource

View File

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.data.manager.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.NativeLibraryManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManagerImpl
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
/**
* The default instance of the [ScopedVaultSdkSource]. This uses its own instance of the
* [SdkClientManagerImpl] to keep it separate from the rest of the app.
*/
@OmitFromCoverage
class ScopedVaultSdkSourceImpl(
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
sdkRepositoryFactory: SdkRepositoryFactory,
vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl(
sdkClientManager = SdkClientManagerImpl(
// We do not want to have the real NativeLibraryManager used here to avoid
// initializing the library twice.
nativeLibraryManager = object : NativeLibraryManager {
override fun loadLibrary(libraryName: String): Result<Unit> = Unit.asSuccess()
},
sdkRepoFactory = sdkRepositoryFactory,
featureFlagManager = featureFlagManager,
),
dispatcherManager = dispatcherManager,
),
) : ScopedVaultSdkSource, VaultSdkSource by vaultSdkSource

View File

@ -3,7 +3,11 @@ package com.x8bit.bitwarden.data.vault.datasource.sdk.di
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSourceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSourceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialStoreImpl
@ -32,6 +36,18 @@ object VaultSdkModule {
dispatcherManager = dispatcherManager,
)
@Provides
fun providesScopedVaultSdkSource(
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
sdkRepositoryFactory: SdkRepositoryFactory,
): ScopedVaultSdkSource =
ScopedVaultSdkSourceImpl(
dispatcherManager = dispatcherManager,
featureFlagManager = featureFlagManager,
sdkRepositoryFactory = sdkRepositoryFactory,
)
@Provides
@Singleton
fun providesFido2CredentialStore(

View File

@ -3,29 +3,38 @@ package com.x8bit.bitwarden.data.platform.repository
import com.bitwarden.authenticatorbridge.model.SharedAccountData
import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.crypto.Kdf
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
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.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.util.sanitizeTotpUri
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
@ -39,18 +48,17 @@ import java.time.ZonedDateTime
class AuthenticatorBridgeRepositoryTest {
private val authRepository = mockk<AuthRepository>()
private val vaultSdkSource = mockk<VaultSdkSource>()
private val scopedVaultSdkSource = mockk<ScopedVaultSdkSource>()
private val vaultDiskSource = mockk<VaultDiskSource>()
private val vaultRepository = mockk<VaultRepository>()
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val authenticatorBridgeRepository = AuthenticatorBridgeRepositoryImpl(
authRepository = authRepository,
authDiskSource = fakeAuthDiskSource,
vaultRepository = vaultRepository,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
)
private val authenticatorBridgeRepository: AuthenticatorBridgeRepository =
AuthenticatorBridgeRepositoryImpl(
authRepository = authRepository,
authDiskSource = fakeAuthDiskSource,
vaultDiskSource = vaultDiskSource,
scopedVaultSdkSource = scopedVaultSdkSource,
)
@BeforeEach
fun setup() {
@ -66,6 +74,7 @@ class AuthenticatorBridgeRepositoryTest {
// Setup authRepository to return default USER_STATE:
every { authRepository.userStateFlow } returns MutableStateFlow(USER_STATE)
fakeAuthDiskSource.userState = USER_STATE_JSON
// Setup authDiskSource to have each user's authenticator sync unlock key:
fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey(
@ -76,30 +85,69 @@ class AuthenticatorBridgeRepositoryTest {
userId = USER_2_ID,
authenticatorSyncUnlockKey = USER_2_UNLOCK_KEY,
)
// Setup vaultRepository to not be stuck unlocking:
every { vaultRepository.vaultUnlockDataStateFlow } returns MutableStateFlow(
listOf(
VaultUnlockData(USER_1_ID, VaultUnlockData.Status.UNLOCKED),
VaultUnlockData(USER_2_ID, VaultUnlockData.Status.UNLOCKED),
),
)
// Setup vaultRepository to be unlocked for user 1:
every { vaultRepository.isVaultUnlocked(USER_1_ID) } returns true
// But locked for user 2:
every { vaultRepository.isVaultUnlocked(USER_2_ID) } returns false
every { vaultRepository.lockVault(USER_2_ID, isUserInitiated = false) } returns Unit
fakeAuthDiskSource.storePrivateKey(userId = USER_1_ID, privateKey = USER_1_PRIVATE_KEY)
fakeAuthDiskSource.storePrivateKey(userId = USER_2_ID, privateKey = USER_2_PRIVATE_KEY)
coEvery {
vaultRepository.unlockVaultWithDecryptedUserKey(
userId = USER_2_ID,
decryptedUserKey = USER_2_UNLOCK_KEY,
scopedVaultSdkSource.initializeCrypto(
userId = USER_1_ID,
request = InitUserCryptoRequest(
userId = USER_1_ID,
kdfParams = Kdf.Argon2id(iterations = 0U, memory = 0U, parallelism = 0U),
email = USER_1_EMAIL,
privateKey = USER_1_PRIVATE_KEY,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = USER_1_UNLOCK_KEY,
),
signingKey = null,
),
)
} returns VaultUnlockResult.Success
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
scopedVaultSdkSource.initializeCrypto(
userId = USER_2_ID,
request = InitUserCryptoRequest(
userId = USER_2_ID,
kdfParams = Kdf.Argon2id(iterations = 0U, memory = 0U, parallelism = 0U),
email = USER_2_EMAIL,
privateKey = USER_2_PRIVATE_KEY,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = USER_2_UNLOCK_KEY,
),
signingKey = null,
),
)
} returns InitializeCryptoResult.Success.asSuccess()
fakeAuthDiskSource.storeOrganizationKeys(
userId = USER_1_ID,
organizationKeys = USER_1_ORG_KEYS,
)
fakeAuthDiskSource.storeOrganizationKeys(
userId = USER_2_ID,
organizationKeys = USER_2_ORG_KEYS,
)
coEvery {
scopedVaultSdkSource.initializeOrganizationCrypto(
userId = USER_1_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_1_ORG_KEYS),
)
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
scopedVaultSdkSource.initializeOrganizationCrypto(
userId = USER_2_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_2_ORG_KEYS),
)
} returns InitializeCryptoResult.Success.asSuccess()
every { scopedVaultSdkSource.clearCrypto(userId = USER_1_ID) } just runs
every { scopedVaultSdkSource.clearCrypto(userId = USER_2_ID) } just runs
// Add some ciphers to vaultDiskSource for each user,
// and setup mock decryption for them:
coEvery { vaultDiskSource.getTotpCiphers(USER_1_ID) } returns USER_1_CIPHERS
coEvery { vaultDiskSource.getTotpCiphers(USER_2_ID) } returns USER_2_CIPHERS
mockkStatic(SyncResponseJson.Cipher::toEncryptedSdkCipher)
mockkStatic(
SyncResponseJson.Cipher::toEncryptedSdkCipher,
EnvironmentUrlDataJson::toEnvironmentUrlsOrDefault,
)
every {
USER_1_TOTP_CIPHER.toEncryptedSdkCipher()
} returns USER_1_ENCRYPTED_SDK_TOTP_CIPHER
@ -107,10 +155,10 @@ class AuthenticatorBridgeRepositoryTest {
USER_2_TOTP_CIPHER.toEncryptedSdkCipher()
} returns USER_2_ENCRYPTED_SDK_TOTP_CIPHER
coEvery {
vaultSdkSource.decryptCipher(USER_1_ID, USER_1_ENCRYPTED_SDK_TOTP_CIPHER)
scopedVaultSdkSource.decryptCipher(USER_1_ID, USER_1_ENCRYPTED_SDK_TOTP_CIPHER)
} returns USER_1_DECRYPTED_TOTP_CIPHER.asSuccess()
coEvery {
vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER)
scopedVaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER)
} returns USER_2_DECRYPTED_TOTP_CIPHER.asSuccess()
mockkStatic(String::sanitizeTotpUri)
every {
@ -120,50 +168,26 @@ class AuthenticatorBridgeRepositoryTest {
@AfterEach
fun teardown() {
confirmVerified(authRepository, vaultSdkSource, vaultRepository, vaultDiskSource)
unmockkStatic(SyncResponseJson.Cipher::toEncryptedSdkCipher)
unmockkStatic(String::sanitizeTotpUri)
confirmVerified(authRepository, scopedVaultSdkSource, vaultDiskSource)
unmockkStatic(
SyncResponseJson.Cipher::toEncryptedSdkCipher,
EnvironmentUrlDataJson::toEnvironmentUrlsOrDefault,
String::sanitizeTotpUri,
)
}
@Test
@Suppress("MaxLineLength")
fun `syncAccounts with user 1 vault unlocked and all data present should send expected shared accounts data`() =
runTest {
val sharedAccounts = authenticatorBridgeRepository.getSharedAccounts()
assertEquals(
BOTH_ACCOUNT_SUCCESS,
sharedAccounts,
)
verify { authRepository.userStateFlow }
verify { vaultRepository.vaultUnlockDataStateFlow }
coVerify { vaultDiskSource.getTotpCiphers(USER_1_ID) }
coVerify { vaultDiskSource.getTotpCiphers(USER_2_ID) }
verify { vaultRepository.isVaultUnlocked(USER_1_ID) }
verify { vaultRepository.isVaultUnlocked(USER_2_ID) }
coVerify {
vaultRepository.unlockVaultWithDecryptedUserKey(
userId = USER_2_ID,
decryptedUserKey = USER_2_UNLOCK_KEY,
)
}
verify { vaultRepository.lockVault(USER_2_ID, isUserInitiated = false) }
coVerify { vaultSdkSource.decryptCipher(USER_1_ID, USER_1_ENCRYPTED_SDK_TOTP_CIPHER) }
coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) }
}
@Test
fun `syncAccounts when userStateFlow is null should return an empty list`() = runTest {
every { authRepository.userStateFlow } returns MutableStateFlow(null)
fun `getSharedAccounts when userStateFlow is null should return an empty list`() = runTest {
fakeAuthDiskSource.userState = null
val sharedData = authenticatorBridgeRepository.getSharedAccounts()
assertTrue(sharedData.accounts.isEmpty())
verify { authRepository.userStateFlow }
}
@Test
@Suppress("MaxLineLength")
fun `syncAccounts when there is no authenticator sync unlock key for user 1 should omit user 1 from list`() =
fun `getSharedAccounts when there is no authenticator sync unlock key for user 1 should omit user 1 from list`() =
runTest {
fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey(
userId = USER_1_ID,
@ -175,138 +199,162 @@ class AuthenticatorBridgeRepositoryTest {
authenticatorBridgeRepository.getSharedAccounts(),
)
verify { authRepository.userStateFlow }
verify { vaultRepository.isVaultUnlocked(USER_2_ID) }
coVerify {
vaultRepository.unlockVaultWithDecryptedUserKey(
coVerify(exactly = 1) {
scopedVaultSdkSource.initializeCrypto(
userId = USER_2_ID,
decryptedUserKey = USER_2_UNLOCK_KEY,
request = InitUserCryptoRequest(
userId = USER_2_ID,
kdfParams = Kdf.Argon2id(iterations = 0U, memory = 0U, parallelism = 0U),
email = USER_2_EMAIL,
privateKey = USER_2_PRIVATE_KEY,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = USER_2_UNLOCK_KEY,
),
signingKey = null,
),
)
scopedVaultSdkSource.initializeOrganizationCrypto(
userId = USER_2_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_2_ORG_KEYS),
)
vaultDiskSource.getTotpCiphers(userId = USER_2_ID)
scopedVaultSdkSource.decryptCipher(
userId = USER_2_ID,
cipher = USER_2_ENCRYPTED_SDK_TOTP_CIPHER,
)
}
verify { vaultRepository.vaultUnlockDataStateFlow }
verify { vaultRepository.lockVault(USER_2_ID, isUserInitiated = false) }
coVerify { vaultDiskSource.getTotpCiphers(USER_2_ID) }
coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) }
verify(exactly = 1) {
scopedVaultSdkSource.clearCrypto(userId = USER_2_ID)
}
}
@Test
@Suppress("MaxLineLength")
fun `syncAccounts when vault is locked for both users should unlock and re-lock vault for both users and filter out deleted ciphers`() =
fun `getSharedAccounts should unlock and re-lock vault for both users and filter out deleted ciphers`() =
runTest {
every { vaultRepository.isVaultUnlocked(USER_1_ID) } returns false
coEvery {
vaultRepository.unlockVaultWithDecryptedUserKey(USER_1_ID, USER_1_UNLOCK_KEY)
} returns VaultUnlockResult.Success
every { vaultRepository.lockVault(USER_1_ID, isUserInitiated = false) } returns Unit
val sharedAccounts = authenticatorBridgeRepository.getSharedAccounts()
assertEquals(
BOTH_ACCOUNT_SUCCESS,
sharedAccounts,
authenticatorBridgeRepository.getSharedAccounts(),
)
verify { vaultRepository.vaultUnlockDataStateFlow }
coVerify { vaultDiskSource.getTotpCiphers(USER_1_ID) }
verify { vaultRepository.isVaultUnlocked(USER_1_ID) }
coVerify { vaultSdkSource.decryptCipher(USER_1_ID, USER_1_ENCRYPTED_SDK_TOTP_CIPHER) }
verify { authRepository.userStateFlow }
coVerify {
vaultRepository.unlockVaultWithDecryptedUserKey(
coVerify(exactly = 1) {
scopedVaultSdkSource.initializeCrypto(
userId = USER_1_ID,
decryptedUserKey = USER_1_UNLOCK_KEY,
request = InitUserCryptoRequest(
userId = USER_1_ID,
kdfParams = Kdf.Argon2id(iterations = 0U, memory = 0U, parallelism = 0U),
email = USER_1_EMAIL,
privateKey = USER_1_PRIVATE_KEY,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = USER_1_UNLOCK_KEY,
),
signingKey = null,
),
)
}
verify { vaultRepository.lockVault(USER_1_ID, isUserInitiated = false) }
verify { vaultRepository.isVaultUnlocked(USER_2_ID) }
coVerify {
vaultRepository.unlockVaultWithDecryptedUserKey(
scopedVaultSdkSource.initializeOrganizationCrypto(
userId = USER_1_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_1_ORG_KEYS),
)
vaultDiskSource.getTotpCiphers(userId = USER_1_ID)
scopedVaultSdkSource.decryptCipher(
userId = USER_1_ID,
cipher = USER_1_ENCRYPTED_SDK_TOTP_CIPHER,
)
scopedVaultSdkSource.initializeCrypto(
userId = USER_2_ID,
decryptedUserKey = USER_2_UNLOCK_KEY,
request = InitUserCryptoRequest(
userId = USER_2_ID,
kdfParams = Kdf.Argon2id(iterations = 0U, memory = 0U, parallelism = 0U),
email = USER_2_EMAIL,
privateKey = USER_2_PRIVATE_KEY,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = USER_2_UNLOCK_KEY,
),
signingKey = null,
),
)
scopedVaultSdkSource.initializeOrganizationCrypto(
userId = USER_2_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_2_ORG_KEYS),
)
vaultDiskSource.getTotpCiphers(userId = USER_2_ID)
scopedVaultSdkSource.decryptCipher(
userId = USER_2_ID,
cipher = USER_2_ENCRYPTED_SDK_TOTP_CIPHER,
)
}
verify { vaultRepository.vaultUnlockDataStateFlow }
verify { vaultRepository.lockVault(USER_2_ID, isUserInitiated = false) }
coVerify { vaultDiskSource.getTotpCiphers(USER_2_ID) }
coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) }
verify(exactly = 1) {
scopedVaultSdkSource.clearCrypto(userId = USER_1_ID)
scopedVaultSdkSource.clearCrypto(userId = USER_2_ID)
}
}
@Test
@Suppress("MaxLineLength")
fun `syncAccounts when for user 1 vault is locked and unlock fails should reset authenticator sync unlock key and omit user from the list`() =
fun `getSharedAccounts when for user 1 vault fails to unlock should reset authenticator sync unlock key and omit user from the list`() =
runTest {
every { vaultRepository.isVaultUnlocked(USER_1_ID) } returns false
val error = Throwable("Fail")
coEvery {
vaultRepository.unlockVaultWithDecryptedUserKey(USER_1_ID, USER_1_UNLOCK_KEY)
} returns VaultUnlockResult.InvalidStateError(error = error)
val sharedAccounts = authenticatorBridgeRepository.getSharedAccounts()
assertEquals(SharedAccountData(listOf(USER_2_SHARED_ACCOUNT)), sharedAccounts)
assertNull(fakeAuthDiskSource.getAuthenticatorSyncUnlockKey(USER_1_ID))
verify { vaultRepository.vaultUnlockDataStateFlow }
verify { vaultRepository.isVaultUnlocked(USER_1_ID) }
verify { authRepository.userStateFlow }
coVerify {
vaultRepository.unlockVaultWithDecryptedUserKey(
scopedVaultSdkSource.initializeCrypto(
userId = USER_1_ID,
decryptedUserKey = USER_1_UNLOCK_KEY,
request = InitUserCryptoRequest(
userId = USER_1_ID,
kdfParams = Kdf.Argon2id(iterations = 0U, memory = 0U, parallelism = 0U),
email = USER_1_EMAIL,
privateKey = USER_1_PRIVATE_KEY,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = USER_1_UNLOCK_KEY,
),
signingKey = null,
),
)
}
verify { vaultRepository.isVaultUnlocked(USER_2_ID) }
coVerify {
vaultRepository.unlockVaultWithDecryptedUserKey(
userId = USER_2_ID,
decryptedUserKey = USER_2_UNLOCK_KEY,
)
}
verify { vaultRepository.vaultUnlockDataStateFlow }
verify { vaultRepository.lockVault(USER_2_ID, isUserInitiated = false) }
coVerify { vaultDiskSource.getTotpCiphers(USER_2_ID) }
coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) }
}
@Test
@Suppress("MaxLineLength")
fun `syncAccounts when when the vault repository never leaves unlocking state should never callback`() =
runTest {
val vaultUnlockStateFlow = MutableStateFlow(
listOf(
VaultUnlockData(USER_1_ID, VaultUnlockData.Status.UNLOCKING),
VaultUnlockData(USER_2_ID, VaultUnlockData.Status.UNLOCKED),
),
)
every { vaultRepository.vaultUnlockDataStateFlow } returns vaultUnlockStateFlow
val deferred = async {
val sharedAccounts = authenticatorBridgeRepository.getSharedAccounts()
assertEquals(BOTH_ACCOUNT_SUCCESS, sharedAccounts)
}
// None of these calls should happen until after user 1's vault state is not UNLOCKING:
verify(exactly = 0) { vaultRepository.isVaultUnlocked(userId = USER_1_ID) }
coVerify(exactly = 0) { vaultDiskSource.getTotpCiphers(USER_1_ID) }
// Then move out of UNLOCKING state, and things should proceed as normal:
vaultUnlockStateFlow.value = listOf(
VaultUnlockData(USER_1_ID, VaultUnlockData.Status.UNLOCKED),
VaultUnlockData(USER_2_ID, VaultUnlockData.Status.UNLOCKED),
} returns InitializeCryptoResult.AuthenticationError(error = Throwable()).asSuccess()
assertEquals(
SharedAccountData(listOf(USER_2_SHARED_ACCOUNT)),
authenticatorBridgeRepository.getSharedAccounts(),
)
deferred.await()
verify { authRepository.userStateFlow }
coVerify { vaultDiskSource.getTotpCiphers(USER_1_ID) }
coVerify { vaultDiskSource.getTotpCiphers(USER_2_ID) }
verify { vaultRepository.isVaultUnlocked(USER_1_ID) }
verify { vaultRepository.isVaultUnlocked(USER_2_ID) }
verify { vaultRepository.vaultUnlockDataStateFlow }
coVerify {
vaultRepository.unlockVaultWithDecryptedUserKey(
assertNull(fakeAuthDiskSource.getAuthenticatorSyncUnlockKey(USER_1_ID))
coVerify(exactly = 1) {
scopedVaultSdkSource.initializeCrypto(
userId = USER_1_ID,
request = InitUserCryptoRequest(
userId = USER_1_ID,
kdfParams = Kdf.Argon2id(iterations = 0U, memory = 0U, parallelism = 0U),
email = USER_1_EMAIL,
privateKey = USER_1_PRIVATE_KEY,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = USER_1_UNLOCK_KEY,
),
signingKey = null,
),
)
scopedVaultSdkSource.initializeCrypto(
userId = USER_2_ID,
decryptedUserKey = USER_2_UNLOCK_KEY,
request = InitUserCryptoRequest(
userId = USER_2_ID,
kdfParams = Kdf.Argon2id(iterations = 0U, memory = 0U, parallelism = 0U),
email = USER_2_EMAIL,
privateKey = USER_2_PRIVATE_KEY,
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = USER_2_UNLOCK_KEY,
),
signingKey = null,
),
)
scopedVaultSdkSource.initializeOrganizationCrypto(
userId = USER_2_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_2_ORG_KEYS),
)
vaultDiskSource.getTotpCiphers(userId = USER_2_ID)
scopedVaultSdkSource.decryptCipher(
userId = USER_2_ID,
cipher = USER_2_ENCRYPTED_SDK_TOTP_CIPHER,
)
}
verify { vaultRepository.lockVault(USER_2_ID, isUserInitiated = false) }
coVerify { vaultSdkSource.decryptCipher(USER_1_ID, USER_1_ENCRYPTED_SDK_TOTP_CIPHER) }
coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) }
verify(exactly = 1) {
scopedVaultSdkSource.clearCrypto(userId = USER_1_ID)
scopedVaultSdkSource.clearCrypto(userId = USER_2_ID)
}
}
@Test
@ -362,20 +410,79 @@ private val SYMMETRIC_KEY = generateSecretKey()
private const val USER_1_ID = "user1Id"
private const val USER_2_ID = "user2Id"
private const val USER_1_EMAIL = "john@doe.com"
private const val USER_2_EMAIL = "jane@doe.com"
private const val USER_1_PRIVATE_KEY = "user1PrivateKey"
private const val USER_2_PRIVATE_KEY = "user2PrivateKey"
private const val USER_1_UNLOCK_KEY = "user1UnlockKey"
private const val USER_2_UNLOCK_KEY = "user2UnlockKey"
private val USER_1_ORG_KEYS = mapOf("test_1" to "test_1_data")
private val USER_2_ORG_KEYS = mapOf("test_2" to "test_2_data")
private val ACCOUNT_JSON_1 = AccountJson(
profile = mockk {
every { userId } returns USER_1_ID
every { name } returns "John Doe"
every { email } returns USER_1_EMAIL
every { kdfType } returns KdfTypeJson.ARGON2_ID
every { kdfIterations } returns 0
every { kdfMemory } returns 0
every { kdfParallelism } returns 0
},
tokens = AccountTokensJson(
accessToken = "accessToken1",
refreshToken = "refreshToken1",
),
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson(
base = "https://vault.bitwarden.com",
),
),
)
private val ACCOUNT_JSON_2 = AccountJson(
profile = mockk {
every { userId } returns USER_2_ID
every { name } returns "Jane Doe"
every { email } returns USER_2_EMAIL
every { kdfType } returns KdfTypeJson.ARGON2_ID
every { kdfIterations } returns 0
every { kdfMemory } returns 0
every { kdfParallelism } returns 0
},
tokens = AccountTokensJson(
accessToken = "accessToken2",
refreshToken = "refreshToken2",
),
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson(
base = "https://vault.bitwarden.com",
),
),
)
private val USER_STATE_JSON = UserStateJson(
activeUserId = USER_1_ID,
accounts = mapOf(
USER_1_ID to ACCOUNT_JSON_1,
USER_2_ID to ACCOUNT_JSON_2,
),
)
private val ACCOUNT_1 = mockk<UserState.Account> {
every { userId } returns USER_1_ID
every { name } returns "John Doe"
every { email } returns "john@doe.com"
every { email } returns USER_1_EMAIL
every { environment.label } returns "bitwarden.com"
}
private val ACCOUNT_2 = mockk<UserState.Account> {
every { userId } returns USER_2_ID
every { name } returns "Jane Doe"
every { email } returns "Jane@doe.com"
every { email } returns USER_2_EMAIL
every { environment.label } returns "bitwarden.com"
}