diff --git a/app/libs/bridge-0.1.0-SNAPSHOT-release.aar b/app/libs/bridge-0.1.0-SNAPSHOT-release.aar index d55770b5c..45cde408a 100644 Binary files a/app/libs/bridge-0.1.0-SNAPSHOT-release.aar and b/app/libs/bridge-0.1.0-SNAPSHOT-release.aar differ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index 1f56c22b1..59f055e73 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -51,6 +51,7 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl +import com.x8bit.bitwarden.data.platform.repository.BridgeRepository import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository @@ -80,8 +81,12 @@ object PlatformManagerModule { @Provides @Singleton fun provideBridgeServiceProcessor( + bridgeRepository: BridgeRepository, + dispatcherManager: DispatcherManager, featureFlagManager: FeatureFlagManager, ): BridgeServiceProcessor = BridgeServiceProcessorImpl( + bridgeRepository = bridgeRepository, + dispatcherManager = dispatcherManager, featureFlagManager = featureFlagManager, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/processor/BridgeServiceProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/processor/BridgeServiceProcessorImpl.kt index 415cd4ad6..e5dff3945 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/processor/BridgeServiceProcessorImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/processor/BridgeServiceProcessorImpl.kt @@ -2,22 +2,37 @@ package com.x8bit.bitwarden.data.platform.processor import android.content.Intent import android.os.Build +import android.os.IInterface +import android.os.RemoteCallbackList import com.bitwarden.bridge.IBridgeService import com.bitwarden.bridge.IBridgeServiceCallback import com.bitwarden.bridge.model.EncryptedAddTotpLoginItemData import com.bitwarden.bridge.model.SymmetricEncryptionKeyData import com.bitwarden.bridge.model.SymmetricEncryptionKeyFingerprintData +import com.bitwarden.bridge.util.NATIVE_BRIDGE_SDK_VERSION +import com.bitwarden.bridge.util.encrypt +import com.bitwarden.bridge.util.toFingerprint +import com.bitwarden.bridge.util.toSymmetricEncryptionKeyData import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.data.platform.repository.BridgeRepository import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Default implementation of [BridgeServiceProcessor]. */ class BridgeServiceProcessorImpl( + private val bridgeRepository: BridgeRepository, private val featureFlagManager: FeatureFlagManager, + dispatcherManager: DispatcherManager, ) : BridgeServiceProcessor { + private val callbacks by lazy { RemoteCallbackList() } + private val scope by lazy { CoroutineScope(dispatcherManager.default) } + override val binder: IBridgeService.Stub? get() { return if ( @@ -37,42 +52,75 @@ class BridgeServiceProcessorImpl( * Default implementation of the bridge service binder. */ private val defaultBinder = object : IBridgeService.Stub() { - override fun getVersionNumber(): String { - // TODO: BITAU-104 - return "" - } + + override fun getVersionNumber(): String = NATIVE_BRIDGE_SDK_VERSION override fun checkSymmetricEncryptionKeyFingerprint( - data: SymmetricEncryptionKeyFingerprintData?, + symmetricKeyFingerprint: SymmetricEncryptionKeyFingerprintData?, ): Boolean { - // TODO: BITAU-104 - return false + if (symmetricKeyFingerprint == null) return false + val localSymmetricKeyFingerprint = + bridgeRepository.authenticatorSyncSymmetricKey + ?.toSymmetricEncryptionKeyData() + ?.toFingerprint() + ?.getOrNull() + return symmetricKeyFingerprint == localSymmetricKeyFingerprint } - override fun getSymmetricEncryptionKeyData(): SymmetricEncryptionKeyData? { - // TODO: BITAU-104 - return null - } + override fun getSymmetricEncryptionKeyData(): SymmetricEncryptionKeyData? = + bridgeRepository.authenticatorSyncSymmetricKey?.toSymmetricEncryptionKeyData() override fun registerBridgeServiceCallback(callback: IBridgeServiceCallback?) { - // TODO: BITAU-104 + if (callback == null) return + callbacks.register(callback) } override fun unregisterBridgeServiceCallback(callback: IBridgeServiceCallback?) { - // TODO: BITAU-104 + if (callback == null) return + callbacks.unregister(callback) } override fun syncAccounts() { - // TODO: BITAU-104 + val symmetricEncryptionKey = symmetricEncryptionKeyData ?: return + scope.launch { + // Encrypt the shared account data with the symmetric key: + val encryptedSharedAccountData = bridgeRepository + .getSharedAccounts() + .encrypt(symmetricEncryptionKey) + .getOrNull() + ?: return@launch + + // Report results to callback: + callbacks.forEach { callback -> + callback.onAccountsSync(encryptedSharedAccountData) + } + } } override fun createAddTotpLoginItemIntent(): Intent { - // TODO: BITAU-104 + // TODO: BITAU-112 return Intent() } override fun setPendingAddTotpLoginItemData(data: EncryptedAddTotpLoginItemData?) { - // TODO: BITAU-104 + // TODO: BITAU-112 } } } + +/** + * This function mirrors the hidden "RemoteCallbackList.broadcast" function. + */ +@Suppress("TooGenericExceptionCaught") +private fun RemoteCallbackList.forEach(action: (T) -> Unit) { + val count = this.beginBroadcast() + try { + for (index in 0..count) { + action(this.getBroadcastItem(index)) + } + } catch (e: Exception) { + // Broadcast failed + } finally { + this.finishBroadcast() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/BridgeRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/BridgeRepository.kt new file mode 100644 index 000000000..0e118933b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/BridgeRepository.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.data.platform.repository + +import com.bitwarden.bridge.model.SharedAccountData + +/** + * Provides an API for querying disk sources required by Bridge service implementation. + */ +interface BridgeRepository { + + /** + * The currently persisted authenticator sync symmetric key. This key is used for + * encrypting IPC traffic. + */ + val authenticatorSyncSymmetricKey: ByteArray? + + /** + * Get a list of shared account data. This function will go through all accounts and for each + * one, check to see if the user has Authenticator account syncing enabled and if they + * do, it will query and decrypt the user's shared account data. + * + * Users who do not have authenticator sync enabled or otherwise cannot have their ciphers + * accessed will be omitted from the list. + */ + suspend fun getSharedAccounts(): SharedAccountData +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/BridgeRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/BridgeRepositoryImpl.kt new file mode 100644 index 000000000..2892cda74 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/BridgeRepositoryImpl.kt @@ -0,0 +1,119 @@ +package com.x8bit.bitwarden.data.platform.repository + +import com.bitwarden.bridge.model.SharedAccountData +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +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.repository.util.statusFor +import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher +import kotlinx.coroutines.flow.first + +/** + * Default implementation of [BridgeRepository]. + */ +class BridgeRepositoryImpl( + private val authRepository: AuthRepository, + private val authDiskSource: AuthDiskSource, + private val vaultRepository: VaultRepository, + private val vaultDiskSource: VaultDiskSource, + private val vaultSdkSource: VaultSdkSource, + private val settingsDiskSource: SettingsDiskSource, +) : BridgeRepository { + + override val authenticatorSyncSymmetricKey: ByteArray? + get() = authDiskSource.authenticatorSyncSymmetricKey + + @Suppress("LongMethod") + override suspend fun getSharedAccounts(): SharedAccountData { + val allAccounts = authRepository.userStateFlow.value?.accounts ?: emptyList() + + return allAccounts + .mapNotNull { account -> + val userId = account.userId + + // Grab the user's authenticator sync unlock key. If it is null, + // the user has not enabled authenticator sync. + 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( + userId = userId, + decryptedUserKey = decryptedUserKey, + ) + + when (unlockResult) { + is VaultUnlockResult.AuthenticationError, + VaultUnlockResult.GenericError, + 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 + } + } + + // Vault is unlocked, query vault disk source for totp logins: + val totpUris = vaultDiskSource + .getCiphers(userId) + .first() + // Filter out any ciphers without a totp item: + .filter { it.login?.totp != null } + .mapNotNull { + // Decrypt each cipher and take just totp codes: + vaultSdkSource + .decryptCipher( + userId = userId, + cipher = it.toEncryptedSdkCipher(), + ) + .getOrNull() + ?.login + ?.totp + } + + val lastSyncTime = + settingsDiskSource.getLastSyncTime(userId) ?: return@mapNotNull null + + // Lock the user's vault if we unlocked it for this operation: + if (!isVaultAlreadyUnlocked) { + vaultRepository.lockVault(userId) + } + + SharedAccountData.Account( + userId = account.userId, + name = account.name, + email = account.email, + environmentLabel = account.environment.label, + lastSyncTime = lastSyncTime, + totpUris = totpUris, + ) + } + .let { + SharedAccountData(it) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt index 23a566d13..2273aff0a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.repository.di import android.view.autofill.AutofillManager import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource @@ -12,6 +13,8 @@ import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigServic import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.repository.BridgeRepository +import com.x8bit.bitwarden.data.platform.repository.BridgeRepositoryImpl import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepositoryImpl import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository @@ -20,7 +23,9 @@ import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepositoryImpl 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.VaultSdkSource +import com.x8bit.bitwarden.data.vault.repository.VaultRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -35,6 +40,24 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object PlatformRepositoryModule { + @Provides + @Singleton + fun providesBridgeRepository( + authRepository: AuthRepository, + authDiskSource: AuthDiskSource, + vaultRepository: VaultRepository, + vaultDiskSource: VaultDiskSource, + vaultSdkSource: VaultSdkSource, + settingsDiskSource: SettingsDiskSource, + ): BridgeRepository = BridgeRepositoryImpl( + authRepository = authRepository, + authDiskSource = authDiskSource, + vaultRepository = vaultRepository, + vaultDiskSource = vaultDiskSource, + vaultSdkSource = vaultSdkSource, + settingsDiskSource = settingsDiskSource, + ) + @Provides @Singleton fun provideServerConfigRepository( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/processor/BridgeServiceProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/processor/BridgeServiceProcessorTest.kt index 3c1d1e6e6..441887c57 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/processor/BridgeServiceProcessorTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/processor/BridgeServiceProcessorTest.kt @@ -1,52 +1,221 @@ package com.x8bit.bitwarden.data.platform.processor import android.os.Build +import android.os.RemoteCallbackList +import com.bitwarden.bridge.IBridgeService +import com.bitwarden.bridge.IBridgeServiceCallback +import com.bitwarden.bridge.model.EncryptedSharedAccountData +import com.bitwarden.bridge.model.SharedAccountData +import com.bitwarden.bridge.util.NATIVE_BRIDGE_SDK_VERSION +import com.bitwarden.bridge.util.encrypt +import com.bitwarden.bridge.util.generateSecretKey +import com.bitwarden.bridge.util.toFingerprint +import com.bitwarden.bridge.util.toSymmetricEncryptionKeyData +import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.data.platform.repository.BridgeRepository +import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.mockkConstructor import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test class BridgeServiceProcessorTest { - private val featureFlagManager: FeatureFlagManager = mockk() + private val featureFlagManager = mockk() + private val bridgeRepository = mockk() - private val bridgeServiceManager = BridgeServiceProcessorImpl( - featureFlagManager = featureFlagManager, - ) + private lateinit var bridgeServiceProcessor: BridgeServiceProcessorImpl @BeforeEach fun setup() { - mockkStatic(::isBuildVersionBelow) + bridgeServiceProcessor = BridgeServiceProcessorImpl( + bridgeRepository = bridgeRepository, + featureFlagManager = featureFlagManager, + dispatcherManager = FakeDispatcherManager(), + ) + } + + @AfterEach + fun teardown() { + unmockkStatic(::isBuildVersionBelow) + unmockkStatic(SharedAccountData::encrypt) } @Test fun `when AuthenticatorSync feature flag is off, should return null binder`() { + mockkStatic(::isBuildVersionBelow) every { isBuildVersionBelow(Build.VERSION_CODES.S) } returns false every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns false - assertNull(bridgeServiceManager.binder) + assertNull(bridgeServiceProcessor.binder) } @Test @Suppress("MaxLineLength") fun `when AuthenticatorSync feature flag is on and running Android level greater than S, should return non-null binder`() { + mockkStatic(::isBuildVersionBelow) every { isBuildVersionBelow(Build.VERSION_CODES.S) } returns false every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns true - assertNotNull(bridgeServiceManager.binder) + assertNotNull(bridgeServiceProcessor.binder) } @Test fun `when below Android level S, should never return a binder regardless of feature flag`() { + mockkStatic(::isBuildVersionBelow) every { isBuildVersionBelow(Build.VERSION_CODES.S) } returns true every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns false - assertNull(bridgeServiceManager.binder) + assertNull(bridgeServiceProcessor.binder) every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns true - assertNull(bridgeServiceManager.binder) + assertNull(bridgeServiceProcessor.binder) + } + + @Test + fun `versionNumber should match version of compiled bridge sdk`() { + val binder = getDefaultBinder() + assertEquals( + NATIVE_BRIDGE_SDK_VERSION, + binder.versionNumber, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `checkSymmetricEncryptionKeyFingerprint should return false when given fingerprint is null`() { + val binder = getDefaultBinder() + // Set disk symmetric key to null so that it is technically equal to given null fingerprint: + every { bridgeRepository.authenticatorSyncSymmetricKey } returns null + // Binder should still return false in this case: + assertFalse(binder.checkSymmetricEncryptionKeyFingerprint(null)) + } + + @Test + @Suppress("MaxLineLength") + fun `checkSymmetricEncryptionKeyFingerprint should return false if fingerprint doesn't match`() { + val binder = getDefaultBinder() + every { bridgeRepository.authenticatorSyncSymmetricKey } returns ByteArray(1) + assertFalse(binder.checkSymmetricEncryptionKeyFingerprint(SYMMETRIC_KEY_FINGERPRINT)) + } + + @Test + @Suppress("MaxLineLength") + fun `checkSymmetricEncryptionKeyFingerprint should return true if fingerprint does match`() { + val binder = getDefaultBinder() + every { + bridgeRepository.authenticatorSyncSymmetricKey + } returns SYMMETRIC_KEY.symmetricEncryptionKey.byteArray + assertTrue(binder.checkSymmetricEncryptionKeyFingerprint(SYMMETRIC_KEY_FINGERPRINT)) + } + + @Test + @Suppress("MaxLineLength") + fun `getSymmetricEncryptionKeyData should return null when there is no symmetric key stored on disk`() { + val binder = getDefaultBinder() + every { bridgeRepository.authenticatorSyncSymmetricKey } returns null + assertNull(binder.symmetricEncryptionKeyData) + } + + @Test + @Suppress("MaxLineLength") + fun `getSymmetricEncryptionKeyData should return the symmetric key stored on disk`() { + val binder = getDefaultBinder() + every { + bridgeRepository.authenticatorSyncSymmetricKey + } returns SYMMETRIC_KEY.symmetricEncryptionKey.byteArray + assertEquals(SYMMETRIC_KEY, binder.symmetricEncryptionKeyData) + } + + @Nested + inner class SyncAccountsTest { + + private var lastAccountsSync: EncryptedSharedAccountData? = null + + private val serviceCallback = object : IBridgeServiceCallback.Stub() { + override fun onAccountsSync(data: EncryptedSharedAccountData?) { + lastAccountsSync = data + } + } + + @BeforeEach + fun setup() { + // Setup RemoteCallbackList to call back to serviceCallback: + mockkConstructor(RemoteCallbackList::class) + every { + anyConstructed>() + .register(serviceCallback) + } returns true + every { + anyConstructed>() + .beginBroadcast() + } returns 1 + every { + anyConstructed>() + .getBroadcastItem(0) + } returns serviceCallback + lastAccountsSync = null + } + + @Test + fun `syncAccounts when symmetricEncryptionKeyData is null should do nothing`() { + every { bridgeRepository.authenticatorSyncSymmetricKey } returns null + getDefaultBinder().syncAccounts() + assertNull(lastAccountsSync) + } + + @Test + fun `syncAccounts should encrypt result from BridgeRepository`() { + val sharedAccountData = mockk() + val expected = mockk() + every { + bridgeRepository.authenticatorSyncSymmetricKey + } returns SYMMETRIC_KEY.symmetricEncryptionKey.byteArray + coEvery { bridgeRepository.getSharedAccounts() } returns sharedAccountData + mockkStatic(SharedAccountData::encrypt) + every { sharedAccountData.encrypt(SYMMETRIC_KEY) } returns expected.asSuccess() + + getDefaultBinder().syncAccounts() + + assertEquals(expected, lastAccountsSync) + coVerify { bridgeRepository.getSharedAccounts() } + } + } + + /** + * Helper function for accessing the default implementation of [IBridgeService.Stub]. This + * is particularly useful because the binder is nullable on [BridgeServiceProcessor] behind + * a feature flag. + */ + private fun getDefaultBinder(): IBridgeService.Stub { + mockkStatic(::isBuildVersionBelow) + every { isBuildVersionBelow(Build.VERSION_CODES.S) } returns false + every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns true + return bridgeServiceProcessor.binder!! } } + +/** + * Symmetric encryption key that can be used for test. + */ +private val SYMMETRIC_KEY = generateSecretKey() + .getOrThrow() + .encoded + .toSymmetricEncryptionKeyData() + +/** + * Fingerprint of [SYMMETRIC_KEY]. + */ +private val SYMMETRIC_KEY_FINGERPRINT = SYMMETRIC_KEY.toFingerprint().getOrThrow() diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/BridgeRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/BridgeRepositoryTest.kt new file mode 100644 index 000000000..64964b50e --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/BridgeRepositoryTest.kt @@ -0,0 +1,438 @@ +package com.x8bit.bitwarden.data.platform.repository + +import com.bitwarden.bridge.model.SharedAccountData +import com.bitwarden.bridge.util.generateSecretKey +import com.bitwarden.bridge.util.toSymmetricEncryptionKeyData +import com.bitwarden.vault.Cipher +import com.bitwarden.vault.CipherView +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.datasource.disk.util.FakeSettingsDiskSource +import com.x8bit.bitwarden.data.platform.util.asSuccess +import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +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.repository.util.toEncryptedSdkCipher +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Instant + +class BridgeRepositoryTest { + + private val authRepository = mockk() + private val vaultSdkSource = mockk() + private val vaultDiskSource = mockk() + private val vaultRepository = mockk() + private val fakeAuthDiskSource = FakeAuthDiskSource() + private val fakeSettingsDiskSource = FakeSettingsDiskSource() + + private val bridgeRepository = BridgeRepositoryImpl( + authRepository = authRepository, + authDiskSource = fakeAuthDiskSource, + vaultRepository = vaultRepository, + vaultDiskSource = vaultDiskSource, + vaultSdkSource = vaultSdkSource, + settingsDiskSource = fakeSettingsDiskSource, + ) + + @BeforeEach + fun setup() { + // Because there is so much setup for the happy path, we set that all up here and + // then adjust accordingly in each test. The "base" setup here is that + // there are two users, USER_1, which we will often manipulate in tests, + // and USER_2, which will remain usually not manipulated to demonstrate that a single + // account failing shouldn't impact other accounts. + + // Store symmetric encryption key on disk: + fakeAuthDiskSource.authenticatorSyncSymmetricKey = + SYMMETRIC_KEY.symmetricEncryptionKey.byteArray + + // Setup authRepository to return default USER_STATE: + every { authRepository.userStateFlow } returns MutableStateFlow(USER_STATE) + + // Setup authDiskSource to have each user's authenticator sync unlock key: + fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey( + userId = USER_1_ID, + authenticatorSyncUnlockKey = USER_1_UNLOCK_KEY, + ) + fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey( + 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) } returns Unit + coEvery { + vaultRepository.unlockVaultWithDecryptedUserKey( + userId = USER_2_ID, + decryptedUserKey = USER_2_UNLOCK_KEY, + ) + } returns VaultUnlockResult.Success + + // Setup settingDiskSource to have lastSyncTime set: + fakeSettingsDiskSource.storeLastSyncTime(USER_1_ID, LAST_SYNC_TIME) + fakeSettingsDiskSource.storeLastSyncTime(USER_2_ID, LAST_SYNC_TIME) + + // Add some ciphers to vaultDiskSource for each user, + // and setup mock decryption for them: + every { vaultDiskSource.getCiphers(USER_1_ID) } returns flowOf(USER_1_CIPHERS) + every { vaultDiskSource.getCiphers(USER_2_ID) } returns flowOf(USER_2_CIPHERS) + mockkStatic(SyncResponseJson.Cipher::toEncryptedSdkCipher) + every { + USER_1_TOTP_CIPHER.toEncryptedSdkCipher() + } returns USER_1_ENCRYPTED_SDK_TOTP_CIPHER + every { + USER_2_TOTP_CIPHER.toEncryptedSdkCipher() + } returns USER_2_ENCRYPTED_SDK_TOTP_CIPHER + coEvery { + vaultSdkSource.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) + } returns USER_2_DECRYPTED_TOTP_CIPHER.asSuccess() + } + + @AfterEach + fun teardown() { + confirmVerified(authRepository, vaultSdkSource, vaultRepository, vaultDiskSource) + } + + @Test + @Suppress("MaxLineLength") + fun `syncAccounts with user 1 vault unlocked and all data present should send expected shared accounts data`() = + runTest { + val sharedAccounts = bridgeRepository.getSharedAccounts() + assertEquals( + BOTH_ACCOUNT_SUCCESS, + sharedAccounts, + ) + verify { authRepository.userStateFlow } + verify { vaultRepository.vaultUnlockDataStateFlow } + verify { vaultDiskSource.getCiphers(USER_1_ID) } + verify { vaultDiskSource.getCiphers(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) } + 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 = bridgeRepository.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`() = + runTest { + fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey( + userId = USER_1_ID, + authenticatorSyncUnlockKey = null, + ) + + assertEquals( + SharedAccountData(listOf(USER_2_SHARED_ACCOUNT)), + bridgeRepository.getSharedAccounts(), + ) + + verify { authRepository.userStateFlow } + 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) } + verify { vaultDiskSource.getCiphers(USER_2_ID) } + coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) } + } + + @Test + @Suppress("MaxLineLength") + fun `syncAccounts when vault is locked for both users should unlock and re-lock vault for both users`() = + 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) } returns Unit + + val sharedAccounts = bridgeRepository.getSharedAccounts() + assertEquals( + BOTH_ACCOUNT_SUCCESS, + sharedAccounts, + ) + verify { vaultRepository.vaultUnlockDataStateFlow } + verify { vaultDiskSource.getCiphers(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( + userId = USER_1_ID, + decryptedUserKey = USER_1_UNLOCK_KEY, + ) + } + verify { vaultRepository.lockVault(USER_1_ID) } + 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) } + verify { vaultDiskSource.getCiphers(USER_2_ID) } + coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) } + } + + @Test + fun `syncAccounts when getLastSyncTime is null should omit account from list`() = runTest { + fakeSettingsDiskSource.storeLastSyncTime(USER_1_ID, null) + val sharedAccounts = bridgeRepository.getSharedAccounts() + assertEquals(SharedAccountData(listOf(USER_2_SHARED_ACCOUNT)), sharedAccounts) + verify { vaultRepository.vaultUnlockDataStateFlow } + verify { vaultDiskSource.getCiphers(USER_1_ID) } + verify { vaultRepository.isVaultUnlocked(USER_1_ID) } + coVerify { vaultSdkSource.decryptCipher(USER_1_ID, USER_1_ENCRYPTED_SDK_TOTP_CIPHER) } + verify { authRepository.userStateFlow } + 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) } + verify { vaultDiskSource.getCiphers(USER_2_ID) } + coVerify { vaultSdkSource.decryptCipher(USER_2_ID, USER_2_ENCRYPTED_SDK_TOTP_CIPHER) } + } + + @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`() = + runTest { + every { vaultRepository.isVaultUnlocked(USER_1_ID) } returns false + coEvery { + vaultRepository.unlockVaultWithDecryptedUserKey(USER_1_ID, USER_1_UNLOCK_KEY) + } returns VaultUnlockResult.InvalidStateError + + val sharedAccounts = bridgeRepository.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, + decryptedUserKey = USER_1_UNLOCK_KEY, + ) + } + 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) } + verify { vaultDiskSource.getCiphers(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 = bridgeRepository.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) + vaultDiskSource.getCiphers(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() + + verify { authRepository.userStateFlow } + verify { vaultDiskSource.getCiphers(USER_1_ID) } + verify { vaultDiskSource.getCiphers(USER_2_ID) } + verify { vaultRepository.isVaultUnlocked(USER_1_ID) } + verify { vaultRepository.isVaultUnlocked(USER_2_ID) } + verify { vaultRepository.vaultUnlockDataStateFlow } + coVerify { + vaultRepository.unlockVaultWithDecryptedUserKey( + userId = USER_2_ID, + decryptedUserKey = USER_2_UNLOCK_KEY, + ) + } + verify { vaultRepository.lockVault(USER_2_ID) } + 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 `authenticatorSyncSymmetricKey should read from authDiskSource`() { + fakeAuthDiskSource.authenticatorSyncSymmetricKey = null + assertNull(bridgeRepository.authenticatorSyncSymmetricKey) + + val syncKey = generateSecretKey().getOrThrow().encoded + fakeAuthDiskSource.authenticatorSyncSymmetricKey = syncKey + + assertEquals(syncKey, bridgeRepository.authenticatorSyncSymmetricKey) + } +} + +/** + * Symmetric encryption key that can be used for test. + */ +private val SYMMETRIC_KEY = generateSecretKey() + .getOrThrow() + .encoded + .toSymmetricEncryptionKeyData() + +private const val USER_1_ID = "user1Id" +private const val USER_2_ID = "user2Id" + +private const val USER_1_UNLOCK_KEY = "user1UnlockKey" +private const val USER_2_UNLOCK_KEY = "user2UnlockKey" + +private val ACCOUNT_1 = mockk { + every { userId } returns USER_1_ID + every { name } returns "John Doe" + every { email } returns "john@doe.com" + 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 { environment.label } returns "bitwarden.com" +} + +private val USER_STATE = UserState( + activeUserId = USER_1_ID, + accounts = listOf( + ACCOUNT_1, + ACCOUNT_2, + ), +) + +private val USER_1_TOTP_CIPHER = mockk { + every { login?.totp } returns "encryptedTotp1" +} + +private val USER_2_TOTP_CIPHER = mockk { + every { login?.totp } returns "encryptedTotp2" +} + +private val USER_1_ENCRYPTED_SDK_TOTP_CIPHER = mockk() +private val USER_2_ENCRYPTED_SDK_TOTP_CIPHER = mockk() + +private val USER_1_DECRYPTED_TOTP_CIPHER = mockk { + every { login?.totp } returns "totp1" +} +private val USER_2_DECRYPTED_TOTP_CIPHER = mockk { + every { login?.totp } returns "totp2" +} + +private val USER_1_EXPECTED_TOTP_LIST = listOf("totp1") +private val USER_2_EXPECTED_TOTP_LIST = listOf("totp2") + +private val LAST_SYNC_TIME = Instant.now() + +private val USER_1_SHARED_ACCOUNT = SharedAccountData.Account( + userId = ACCOUNT_1.userId, + name = ACCOUNT_1.name, + email = ACCOUNT_1.email, + environmentLabel = ACCOUNT_1.environment.label, + totpUris = USER_1_EXPECTED_TOTP_LIST, + lastSyncTime = LAST_SYNC_TIME, +) + +private val USER_2_SHARED_ACCOUNT = SharedAccountData.Account( + userId = ACCOUNT_2.userId, + name = ACCOUNT_2.name, + email = ACCOUNT_2.email, + environmentLabel = ACCOUNT_2.environment.label, + totpUris = USER_2_EXPECTED_TOTP_LIST, + lastSyncTime = LAST_SYNC_TIME, +) + +private val USER_1_CIPHERS = listOf( + USER_1_TOTP_CIPHER, +) + +private val USER_2_CIPHERS = listOf( + USER_2_TOTP_CIPHER, +) + +private val BOTH_ACCOUNT_SUCCESS = SharedAccountData( + listOf( + USER_1_SHARED_ACCOUNT, + USER_2_SHARED_ACCOUNT, + ), +) diff --git a/bridge/src/main/aidl/com/bitwarden/bridge/IBridgeService.aidl b/bridge/src/main/aidl/com/bitwarden/bridge/IBridgeService.aidl index 02256f3a5..a954da747 100644 --- a/bridge/src/main/aidl/com/bitwarden/bridge/IBridgeService.aidl +++ b/bridge/src/main/aidl/com/bitwarden/bridge/IBridgeService.aidl @@ -18,7 +18,7 @@ interface IBridgeService { String getVersionNumber(); // Returns true when the given symmetric fingerprint data matches that contained by the SDK. - boolean checkSymmetricEncryptionKeyFingerprint(in SymmetricEncryptionKeyFingerprintData data); + boolean checkSymmetricEncryptionKeyFingerprint(in SymmetricEncryptionKeyFingerprintData symmetricKeyFingerprint); // Returns a symmetric key that will be used for encypting all IPC traffic. // diff --git a/bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt b/bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt index 707b58fd5..4110b0c8a 100644 --- a/bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt +++ b/bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt @@ -9,6 +9,7 @@ import com.bitwarden.bridge.model.EncryptedSharedAccountData import com.bitwarden.bridge.model.SharedAccountData import com.bitwarden.bridge.model.SharedAccountDataJson import com.bitwarden.bridge.model.SymmetricEncryptionKeyData +import com.bitwarden.bridge.model.SymmetricEncryptionKeyFingerprintData import com.bitwarden.bridge.model.toByteArrayContainer import kotlinx.serialization.encodeToString import java.security.MessageDigest @@ -36,12 +37,13 @@ fun generateSecretKey(): Result = runCatching { * [IBridgeService.checkSymmetricEncryptionKeyFingerprint], which allows callers of the service * to verify that they have the correct symmetric key without actually having to send the key. */ -fun SymmetricEncryptionKeyData.toFingerprint(): Result = runCatching { - val messageDigest = MessageDigest.getInstance(KeyProperties.DIGEST_SHA256) - messageDigest.reset() - messageDigest.update(this.symmetricEncryptionKey.byteArray) - messageDigest.digest() -} +fun SymmetricEncryptionKeyData.toFingerprint(): Result = + runCatching { + val messageDigest = MessageDigest.getInstance(KeyProperties.DIGEST_SHA256) + messageDigest.reset() + messageDigest.update(this.symmetricEncryptionKey.byteArray) + SymmetricEncryptionKeyFingerprintData(messageDigest.digest().toByteArrayContainer()) + } /** * Encrypt [SharedAccountData].