BITAU-104 Implement BridgeService (#3954)

This commit is contained in:
Andrew Haisting 2024-09-24 09:16:59 -05:00 committed by GitHub
parent 190f92ec67
commit 87e223bc59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 861 additions and 32 deletions

View file

@ -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,
)

View file

@ -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<IBridgeServiceCallback>() }
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 <T : IInterface> RemoteCallbackList<T>.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()
}
}

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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(

View file

@ -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<FeatureFlagManager>()
private val bridgeRepository = mockk<BridgeRepository>()
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<RemoteCallbackList<IBridgeServiceCallback>>()
.register(serviceCallback)
} returns true
every {
anyConstructed<RemoteCallbackList<IBridgeServiceCallback>>()
.beginBroadcast()
} returns 1
every {
anyConstructed<RemoteCallbackList<IBridgeServiceCallback>>()
.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<SharedAccountData>()
val expected = mockk<EncryptedSharedAccountData>()
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()

View file

@ -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<AuthRepository>()
private val vaultSdkSource = mockk<VaultSdkSource>()
private val vaultDiskSource = mockk<VaultDiskSource>()
private val vaultRepository = mockk<VaultRepository>()
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<UserState.Account> {
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<UserState.Account> {
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<SyncResponseJson.Cipher> {
every { login?.totp } returns "encryptedTotp1"
}
private val USER_2_TOTP_CIPHER = mockk<SyncResponseJson.Cipher> {
every { login?.totp } returns "encryptedTotp2"
}
private val USER_1_ENCRYPTED_SDK_TOTP_CIPHER = mockk<Cipher>()
private val USER_2_ENCRYPTED_SDK_TOTP_CIPHER = mockk<Cipher>()
private val USER_1_DECRYPTED_TOTP_CIPHER = mockk<CipherView> {
every { login?.totp } returns "totp1"
}
private val USER_2_DECRYPTED_TOTP_CIPHER = mockk<CipherView> {
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,
),
)

View file

@ -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.
//

View file

@ -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<SecretKey> = 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<ByteArray> = runCatching {
val messageDigest = MessageDigest.getInstance(KeyProperties.DIGEST_SHA256)
messageDigest.reset()
messageDigest.update(this.symmetricEncryptionKey.byteArray)
messageDigest.digest()
}
fun SymmetricEncryptionKeyData.toFingerprint(): Result<SymmetricEncryptionKeyFingerprintData> =
runCatching {
val messageDigest = MessageDigest.getInstance(KeyProperties.DIGEST_SHA256)
messageDigest.reset()
messageDigest.update(this.symmetricEncryptionKey.byteArray)
SymmetricEncryptionKeyFingerprintData(messageDigest.digest().toByteArrayContainer())
}
/**
* Encrypt [SharedAccountData].