🍒 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 package com.x8bit.bitwarden.data.platform.repository
import com.bitwarden.authenticatorbridge.model.SharedAccountData 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.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.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.platform.repository.util.sanitizeTotpUri
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult 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 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]. * Default implementation of [AuthenticatorBridgeRepository].
@ -19,9 +26,8 @@ import kotlinx.coroutines.flow.first
class AuthenticatorBridgeRepositoryImpl( class AuthenticatorBridgeRepositoryImpl(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val authDiskSource: AuthDiskSource, private val authDiskSource: AuthDiskSource,
private val vaultRepository: VaultRepository,
private val vaultDiskSource: VaultDiskSource, private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource, private val scopedVaultSdkSource: ScopedVaultSdkSource,
) : AuthenticatorBridgeRepository { ) : AuthenticatorBridgeRepository {
override val authenticatorSyncSymmetricKey: ByteArray? override val authenticatorSyncSymmetricKey: ByteArray?
@ -45,52 +51,41 @@ class AuthenticatorBridgeRepositoryImpl(
@Suppress("LongMethod") @Suppress("LongMethod")
override suspend fun getSharedAccounts(): SharedAccountData { override suspend fun getSharedAccounts(): SharedAccountData {
val allAccounts = authRepository.userStateFlow.value?.accounts ?: emptyList() val allAccounts = authDiskSource.userState?.accounts.orEmpty()
return allAccounts return allAccounts
.mapNotNull { account -> .mapNotNull { (userId, account) ->
val userId = account.userId
// Grab the user's authenticator sync unlock key. If it is null, // 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) val decryptedUserKey = authDiskSource.getAuthenticatorSyncUnlockKey(userId)
?: return@mapNotNull null ?: return@mapNotNull null
val vaultUnlockResult = unlockClient(
// Wait for any unlocking actions to finish: userId = userId,
vaultRepository.vaultUnlockDataStateFlow.first { account = account,
it.statusFor(userId) != VaultUnlockData.Status.UNLOCKING decryptedUserKey = decryptedUserKey,
} )
when (vaultUnlockResult) {
// Unlock vault if necessary: is VaultUnlockResult.AuthenticationError,
val isVaultAlreadyUnlocked = vaultRepository.isVaultUnlocked(userId = userId) is VaultUnlockResult.BiometricDecodingError,
if (!isVaultAlreadyUnlocked) { is VaultUnlockResult.GenericError,
val unlockResult = vaultRepository is VaultUnlockResult.InvalidStateError,
.unlockVaultWithDecryptedUserKey( -> {
// 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, userId = userId,
decryptedUserKey = decryptedUserKey, authenticatorSyncUnlockKey = null,
) )
// Destroy our stand-alone instance of the vault.
when (unlockResult) { scopedVaultSdkSource.clearCrypto(userId = userId)
is VaultUnlockResult.AuthenticationError, return@mapNotNull null
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
} }
// Proceed
VaultUnlockResult.Success -> Unit
} }
// Vault is unlocked, query vault disk source for totp logins: // Vault is unlocked, query vault disk source for totp logins:
@ -99,7 +94,7 @@ class AuthenticatorBridgeRepositoryImpl(
// Filter out any deleted ciphers. // Filter out any deleted ciphers.
.filter { it.deletedDate == null } .filter { it.deletedDate == null }
.mapNotNull { .mapNotNull {
val decryptedCipher = vaultSdkSource val decryptedCipher = scopedVaultSdkSource
.decryptCipher( .decryptCipher(
userId = userId, userId = userId,
cipher = it.toEncryptedSdkCipher(), cipher = it.toEncryptedSdkCipher(),
@ -113,19 +108,18 @@ class AuthenticatorBridgeRepositoryImpl(
rawTotp.sanitizeTotpUri(cipherName, username) rawTotp.sanitizeTotpUri(cipherName, username)
} }
// Lock the user's vault if we unlocked it for this operation: // Lock and destroy our stand-alone instance of the vault:
if (!isVaultAlreadyUnlocked) { scopedVaultSdkSource.clearCrypto(userId = userId)
vaultRepository.lockVault(
userId = userId,
isUserInitiated = false,
)
}
SharedAccountData.Account( SharedAccountData.Account(
userId = account.userId, userId = userId,
name = account.name, name = account.profile.name,
email = account.email, email = account.profile.email,
environmentLabel = account.environment.label, environmentLabel = account
.settings
.environmentUrlData
.toEnvironmentUrlsOrDefault()
.label,
totpUris = totpUris, totpUris = totpUris,
) )
} }
@ -133,4 +127,44 @@ class AuthenticatorBridgeRepositoryImpl(
SharedAccountData(it) 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.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepositoryImpl import com.x8bit.bitwarden.data.platform.repository.SettingsRepositoryImpl
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource 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.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -41,15 +41,13 @@ object PlatformRepositoryModule {
fun providesAuthenticatorBridgeRepository( fun providesAuthenticatorBridgeRepository(
authRepository: AuthRepository, authRepository: AuthRepository,
authDiskSource: AuthDiskSource, authDiskSource: AuthDiskSource,
vaultRepository: VaultRepository,
vaultDiskSource: VaultDiskSource, vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource, scopedVaultSdkSource: ScopedVaultSdkSource,
): AuthenticatorBridgeRepository = AuthenticatorBridgeRepositoryImpl( ): AuthenticatorBridgeRepository = AuthenticatorBridgeRepositoryImpl(
authRepository = authRepository, authRepository = authRepository,
authDiskSource = authDiskSource, authDiskSource = authDiskSource,
vaultRepository = vaultRepository,
vaultDiskSource = vaultDiskSource, vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource, scopedVaultSdkSource = scopedVaultSdkSource,
) )
@Provides @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.data.manager.DispatcherManager
import com.bitwarden.sdk.Fido2CredentialStore import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.data.auth.repository.AuthRepository 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.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.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSourceImpl import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSourceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialStoreImpl import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialStoreImpl
@ -32,6 +36,18 @@ object VaultSdkModule {
dispatcherManager = dispatcherManager, dispatcherManager = dispatcherManager,
) )
@Provides
fun providesScopedVaultSdkSource(
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
sdkRepositoryFactory: SdkRepositoryFactory,
): ScopedVaultSdkSource =
ScopedVaultSdkSourceImpl(
dispatcherManager = dispatcherManager,
featureFlagManager = featureFlagManager,
sdkRepositoryFactory = sdkRepositoryFactory,
)
@Provides @Provides
@Singleton @Singleton
fun providesFido2CredentialStore( 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.model.SharedAccountData
import com.bitwarden.authenticatorbridge.util.generateSecretKey import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData 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.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.network.model.SyncResponseJson
import com.bitwarden.vault.Cipher import com.bitwarden.vault.Cipher
import com.bitwarden.vault.CipherView 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.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.util.sanitizeTotpUri 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.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.confirmVerified import io.mockk.confirmVerified
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic import io.mockk.unmockkStatic
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
@ -39,18 +48,17 @@ import java.time.ZonedDateTime
class AuthenticatorBridgeRepositoryTest { class AuthenticatorBridgeRepositoryTest {
private val authRepository = mockk<AuthRepository>() private val authRepository = mockk<AuthRepository>()
private val vaultSdkSource = mockk<VaultSdkSource>() private val scopedVaultSdkSource = mockk<ScopedVaultSdkSource>()
private val vaultDiskSource = mockk<VaultDiskSource>() private val vaultDiskSource = mockk<VaultDiskSource>()
private val vaultRepository = mockk<VaultRepository>()
private val fakeAuthDiskSource = FakeAuthDiskSource() private val fakeAuthDiskSource = FakeAuthDiskSource()
private val authenticatorBridgeRepository = AuthenticatorBridgeRepositoryImpl( private val authenticatorBridgeRepository: AuthenticatorBridgeRepository =
authRepository = authRepository, AuthenticatorBridgeRepositoryImpl(
authDiskSource = fakeAuthDiskSource, authRepository = authRepository,
vaultRepository = vaultRepository, authDiskSource = fakeAuthDiskSource,
vaultDiskSource = vaultDiskSource, vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource, scopedVaultSdkSource = scopedVaultSdkSource,
) )
@BeforeEach @BeforeEach
fun setup() { fun setup() {
@ -66,6 +74,7 @@ class AuthenticatorBridgeRepositoryTest {
// Setup authRepository to return default USER_STATE: // Setup authRepository to return default USER_STATE:
every { authRepository.userStateFlow } returns MutableStateFlow(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: // Setup authDiskSource to have each user's authenticator sync unlock key:
fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey( fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey(
@ -76,30 +85,69 @@ class AuthenticatorBridgeRepositoryTest {
userId = USER_2_ID, userId = USER_2_ID,
authenticatorSyncUnlockKey = USER_2_UNLOCK_KEY, authenticatorSyncUnlockKey = USER_2_UNLOCK_KEY,
) )
// Setup vaultRepository to not be stuck unlocking: fakeAuthDiskSource.storePrivateKey(userId = USER_1_ID, privateKey = USER_1_PRIVATE_KEY)
every { vaultRepository.vaultUnlockDataStateFlow } returns MutableStateFlow( fakeAuthDiskSource.storePrivateKey(userId = USER_2_ID, privateKey = USER_2_PRIVATE_KEY)
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
coEvery { coEvery {
vaultRepository.unlockVaultWithDecryptedUserKey( scopedVaultSdkSource.initializeCrypto(
userId = USER_2_ID, userId = USER_1_ID,
decryptedUserKey = USER_2_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,
),
) )
} 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, // Add some ciphers to vaultDiskSource for each user,
// and setup mock decryption for them: // and setup mock decryption for them:
coEvery { vaultDiskSource.getTotpCiphers(USER_1_ID) } returns USER_1_CIPHERS coEvery { vaultDiskSource.getTotpCiphers(USER_1_ID) } returns USER_1_CIPHERS
coEvery { vaultDiskSource.getTotpCiphers(USER_2_ID) } returns USER_2_CIPHERS coEvery { vaultDiskSource.getTotpCiphers(USER_2_ID) } returns USER_2_CIPHERS
mockkStatic(SyncResponseJson.Cipher::toEncryptedSdkCipher) mockkStatic(
SyncResponseJson.Cipher::toEncryptedSdkCipher,
EnvironmentUrlDataJson::toEnvironmentUrlsOrDefault,
)
every { every {
USER_1_TOTP_CIPHER.toEncryptedSdkCipher() USER_1_TOTP_CIPHER.toEncryptedSdkCipher()
} returns USER_1_ENCRYPTED_SDK_TOTP_CIPHER } returns USER_1_ENCRYPTED_SDK_TOTP_CIPHER
@ -107,10 +155,10 @@ class AuthenticatorBridgeRepositoryTest {
USER_2_TOTP_CIPHER.toEncryptedSdkCipher() USER_2_TOTP_CIPHER.toEncryptedSdkCipher()
} returns USER_2_ENCRYPTED_SDK_TOTP_CIPHER } returns USER_2_ENCRYPTED_SDK_TOTP_CIPHER
coEvery { 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() } returns USER_1_DECRYPTED_TOTP_CIPHER.asSuccess()
coEvery { 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() } returns USER_2_DECRYPTED_TOTP_CIPHER.asSuccess()
mockkStatic(String::sanitizeTotpUri) mockkStatic(String::sanitizeTotpUri)
every { every {
@ -120,50 +168,26 @@ class AuthenticatorBridgeRepositoryTest {
@AfterEach @AfterEach
fun teardown() { fun teardown() {
confirmVerified(authRepository, vaultSdkSource, vaultRepository, vaultDiskSource) confirmVerified(authRepository, scopedVaultSdkSource, vaultDiskSource)
unmockkStatic(SyncResponseJson.Cipher::toEncryptedSdkCipher) unmockkStatic(
unmockkStatic(String::sanitizeTotpUri) SyncResponseJson.Cipher::toEncryptedSdkCipher,
EnvironmentUrlDataJson::toEnvironmentUrlsOrDefault,
String::sanitizeTotpUri,
)
} }
@Test @Test
@Suppress("MaxLineLength") fun `getSharedAccounts when userStateFlow is null should return an empty list`() = runTest {
fun `syncAccounts with user 1 vault unlocked and all data present should send expected shared accounts data`() = fakeAuthDiskSource.userState = null
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)
val sharedData = authenticatorBridgeRepository.getSharedAccounts() val sharedData = authenticatorBridgeRepository.getSharedAccounts()
assertTrue(sharedData.accounts.isEmpty()) assertTrue(sharedData.accounts.isEmpty())
verify { authRepository.userStateFlow }
} }
@Test @Test
@Suppress("MaxLineLength") @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 { runTest {
fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey( fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey(
userId = USER_1_ID, userId = USER_1_ID,
@ -175,138 +199,162 @@ class AuthenticatorBridgeRepositoryTest {
authenticatorBridgeRepository.getSharedAccounts(), authenticatorBridgeRepository.getSharedAccounts(),
) )
verify { authRepository.userStateFlow } coVerify(exactly = 1) {
verify { vaultRepository.isVaultUnlocked(USER_2_ID) } scopedVaultSdkSource.initializeCrypto(
coVerify {
vaultRepository.unlockVaultWithDecryptedUserKey(
userId = USER_2_ID, 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(exactly = 1) {
verify { vaultRepository.lockVault(USER_2_ID, isUserInitiated = false) } scopedVaultSdkSource.clearCrypto(userId = USER_2_ID)
coVerify { vaultDiskSource.getTotpCiphers(USER_2_ID) } }
coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) }
} }
@Test @Test
@Suppress("MaxLineLength") @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 { 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( assertEquals(
BOTH_ACCOUNT_SUCCESS, BOTH_ACCOUNT_SUCCESS,
sharedAccounts, authenticatorBridgeRepository.getSharedAccounts(),
) )
verify { vaultRepository.vaultUnlockDataStateFlow }
coVerify { vaultDiskSource.getTotpCiphers(USER_1_ID) } coVerify(exactly = 1) {
verify { vaultRepository.isVaultUnlocked(USER_1_ID) } scopedVaultSdkSource.initializeCrypto(
coVerify { vaultSdkSource.decryptCipher(USER_1_ID, USER_1_ENCRYPTED_SDK_TOTP_CIPHER) }
verify { authRepository.userStateFlow }
coVerify {
vaultRepository.unlockVaultWithDecryptedUserKey(
userId = USER_1_ID, 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,
),
) )
} scopedVaultSdkSource.initializeOrganizationCrypto(
verify { vaultRepository.lockVault(USER_1_ID, isUserInitiated = false) } userId = USER_1_ID,
verify { vaultRepository.isVaultUnlocked(USER_2_ID) } request = InitOrgCryptoRequest(organizationKeys = USER_1_ORG_KEYS),
coVerify { )
vaultRepository.unlockVaultWithDecryptedUserKey( 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, 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(exactly = 1) {
verify { vaultRepository.lockVault(USER_2_ID, isUserInitiated = false) } scopedVaultSdkSource.clearCrypto(userId = USER_1_ID)
coVerify { vaultDiskSource.getTotpCiphers(USER_2_ID) } scopedVaultSdkSource.clearCrypto(userId = USER_2_ID)
coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) } }
} }
@Test @Test
@Suppress("MaxLineLength") @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 { runTest {
every { vaultRepository.isVaultUnlocked(USER_1_ID) } returns false
val error = Throwable("Fail")
coEvery { coEvery {
vaultRepository.unlockVaultWithDecryptedUserKey(USER_1_ID, USER_1_UNLOCK_KEY) scopedVaultSdkSource.initializeCrypto(
} 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(
userId = USER_1_ID, 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,
),
) )
} } returns InitializeCryptoResult.AuthenticationError(error = Throwable()).asSuccess()
verify { vaultRepository.isVaultUnlocked(USER_2_ID) } assertEquals(
coVerify { SharedAccountData(listOf(USER_2_SHARED_ACCOUNT)),
vaultRepository.unlockVaultWithDecryptedUserKey( authenticatorBridgeRepository.getSharedAccounts(),
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),
) )
deferred.await() assertNull(fakeAuthDiskSource.getAuthenticatorSyncUnlockKey(USER_1_ID))
coVerify(exactly = 1) {
verify { authRepository.userStateFlow } scopedVaultSdkSource.initializeCrypto(
coVerify { vaultDiskSource.getTotpCiphers(USER_1_ID) } userId = USER_1_ID,
coVerify { vaultDiskSource.getTotpCiphers(USER_2_ID) } request = InitUserCryptoRequest(
verify { vaultRepository.isVaultUnlocked(USER_1_ID) } userId = USER_1_ID,
verify { vaultRepository.isVaultUnlocked(USER_2_ID) } kdfParams = Kdf.Argon2id(iterations = 0U, memory = 0U, parallelism = 0U),
verify { vaultRepository.vaultUnlockDataStateFlow } email = USER_1_EMAIL,
coVerify { privateKey = USER_1_PRIVATE_KEY,
vaultRepository.unlockVaultWithDecryptedUserKey( method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = USER_1_UNLOCK_KEY,
),
signingKey = null,
),
)
scopedVaultSdkSource.initializeCrypto(
userId = USER_2_ID, 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) } verify(exactly = 1) {
coVerify { vaultSdkSource.decryptCipher(USER_1_ID, USER_1_ENCRYPTED_SDK_TOTP_CIPHER) } scopedVaultSdkSource.clearCrypto(userId = USER_1_ID)
coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) } scopedVaultSdkSource.clearCrypto(userId = USER_2_ID)
}
} }
@Test @Test
@ -362,20 +410,79 @@ private val SYMMETRIC_KEY = generateSecretKey()
private const val USER_1_ID = "user1Id" private const val USER_1_ID = "user1Id"
private const val USER_2_ID = "user2Id" 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_1_UNLOCK_KEY = "user1UnlockKey"
private const val USER_2_UNLOCK_KEY = "user2UnlockKey" 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> { private val ACCOUNT_1 = mockk<UserState.Account> {
every { userId } returns USER_1_ID every { userId } returns USER_1_ID
every { name } returns "John Doe" every { name } returns "John Doe"
every { email } returns "john@doe.com" every { email } returns USER_1_EMAIL
every { environment.label } returns "bitwarden.com" every { environment.label } returns "bitwarden.com"
} }
private val ACCOUNT_2 = mockk<UserState.Account> { private val ACCOUNT_2 = mockk<UserState.Account> {
every { userId } returns USER_2_ID every { userId } returns USER_2_ID
every { name } returns "Jane Doe" every { name } returns "Jane Doe"
every { email } returns "Jane@doe.com" every { email } returns USER_2_EMAIL
every { environment.label } returns "bitwarden.com" every { environment.label } returns "bitwarden.com"
} }