From a721744a6b9db732a55ae660376d0fc7bc61cfd9 Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 14 Jul 2025 16:39:56 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=92=20PM-23666:=20Construct=20unique?= =?UTF-8?q?=20SDK=20client=20for=20Authentocator=20Sync=20feature=20(#5528?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuthenticatorBridgeRepositoryImpl.kt | 148 +++--- .../repository/di/PlatformRepositoryModule.kt | 8 +- .../datasource/sdk/ScopedVaultSdkSource.kt | 11 + .../sdk/ScopedVaultSdkSourceImpl.kt | 32 ++ .../vault/datasource/sdk/di/VaultSdkModule.kt | 16 + .../AuthenticatorBridgeRepositoryTest.kt | 449 +++++++++++------- 6 files changed, 431 insertions(+), 233 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/ScopedVaultSdkSource.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/ScopedVaultSdkSourceImpl.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryImpl.kt index cb35ca25d2..58ad93f2a0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryImpl.kt @@ -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() }, + ) + } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt index 9eef728923..1a6ce950fd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/ScopedVaultSdkSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/ScopedVaultSdkSource.kt new file mode 100644 index 0000000000..7150b22f95 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/ScopedVaultSdkSource.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/ScopedVaultSdkSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/ScopedVaultSdkSourceImpl.kt new file mode 100644 index 0000000000..475894f760 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/ScopedVaultSdkSourceImpl.kt @@ -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.asSuccess() + }, + sdkRepoFactory = sdkRepositoryFactory, + featureFlagManager = featureFlagManager, + ), + dispatcherManager = dispatcherManager, + ), +) : ScopedVaultSdkSource, VaultSdkSource by vaultSdkSource diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/di/VaultSdkModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/di/VaultSdkModule.kt index 80994af1f1..9b55b02d09 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/di/VaultSdkModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/di/VaultSdkModule.kt @@ -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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryTest.kt index ed5f5bdb5c..6b8b4b7eca 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryTest.kt @@ -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() - private val vaultSdkSource = mockk() + private val scopedVaultSdkSource = mockk() private val vaultDiskSource = mockk() - private val vaultRepository = mockk() 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 { 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 { 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" }