mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BITAU-104 Implement BridgeService (#3954)
This commit is contained in:
parent
190f92ec67
commit
87e223bc59
10 changed files with 861 additions and 32 deletions
Binary file not shown.
|
@ -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.garbage.GarbageCollectionManagerImpl
|
||||||
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
|
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
|
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.DebugMenuRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
|
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
|
||||||
|
@ -80,8 +81,12 @@ object PlatformManagerModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideBridgeServiceProcessor(
|
fun provideBridgeServiceProcessor(
|
||||||
|
bridgeRepository: BridgeRepository,
|
||||||
|
dispatcherManager: DispatcherManager,
|
||||||
featureFlagManager: FeatureFlagManager,
|
featureFlagManager: FeatureFlagManager,
|
||||||
): BridgeServiceProcessor = BridgeServiceProcessorImpl(
|
): BridgeServiceProcessor = BridgeServiceProcessorImpl(
|
||||||
|
bridgeRepository = bridgeRepository,
|
||||||
|
dispatcherManager = dispatcherManager,
|
||||||
featureFlagManager = featureFlagManager,
|
featureFlagManager = featureFlagManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -2,22 +2,37 @@ package com.x8bit.bitwarden.data.platform.processor
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.IInterface
|
||||||
|
import android.os.RemoteCallbackList
|
||||||
import com.bitwarden.bridge.IBridgeService
|
import com.bitwarden.bridge.IBridgeService
|
||||||
import com.bitwarden.bridge.IBridgeServiceCallback
|
import com.bitwarden.bridge.IBridgeServiceCallback
|
||||||
import com.bitwarden.bridge.model.EncryptedAddTotpLoginItemData
|
import com.bitwarden.bridge.model.EncryptedAddTotpLoginItemData
|
||||||
import com.bitwarden.bridge.model.SymmetricEncryptionKeyData
|
import com.bitwarden.bridge.model.SymmetricEncryptionKeyData
|
||||||
import com.bitwarden.bridge.model.SymmetricEncryptionKeyFingerprintData
|
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.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.manager.model.FlagKey
|
||||||
|
import com.x8bit.bitwarden.data.platform.repository.BridgeRepository
|
||||||
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
|
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default implementation of [BridgeServiceProcessor].
|
* Default implementation of [BridgeServiceProcessor].
|
||||||
*/
|
*/
|
||||||
class BridgeServiceProcessorImpl(
|
class BridgeServiceProcessorImpl(
|
||||||
|
private val bridgeRepository: BridgeRepository,
|
||||||
private val featureFlagManager: FeatureFlagManager,
|
private val featureFlagManager: FeatureFlagManager,
|
||||||
|
dispatcherManager: DispatcherManager,
|
||||||
) : BridgeServiceProcessor {
|
) : BridgeServiceProcessor {
|
||||||
|
|
||||||
|
private val callbacks by lazy { RemoteCallbackList<IBridgeServiceCallback>() }
|
||||||
|
private val scope by lazy { CoroutineScope(dispatcherManager.default) }
|
||||||
|
|
||||||
override val binder: IBridgeService.Stub?
|
override val binder: IBridgeService.Stub?
|
||||||
get() {
|
get() {
|
||||||
return if (
|
return if (
|
||||||
|
@ -37,42 +52,75 @@ class BridgeServiceProcessorImpl(
|
||||||
* Default implementation of the bridge service binder.
|
* Default implementation of the bridge service binder.
|
||||||
*/
|
*/
|
||||||
private val defaultBinder = object : IBridgeService.Stub() {
|
private val defaultBinder = object : IBridgeService.Stub() {
|
||||||
override fun getVersionNumber(): String {
|
|
||||||
// TODO: BITAU-104
|
override fun getVersionNumber(): String = NATIVE_BRIDGE_SDK_VERSION
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkSymmetricEncryptionKeyFingerprint(
|
override fun checkSymmetricEncryptionKeyFingerprint(
|
||||||
data: SymmetricEncryptionKeyFingerprintData?,
|
symmetricKeyFingerprint: SymmetricEncryptionKeyFingerprintData?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// TODO: BITAU-104
|
if (symmetricKeyFingerprint == null) return false
|
||||||
return false
|
val localSymmetricKeyFingerprint =
|
||||||
|
bridgeRepository.authenticatorSyncSymmetricKey
|
||||||
|
?.toSymmetricEncryptionKeyData()
|
||||||
|
?.toFingerprint()
|
||||||
|
?.getOrNull()
|
||||||
|
return symmetricKeyFingerprint == localSymmetricKeyFingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSymmetricEncryptionKeyData(): SymmetricEncryptionKeyData? {
|
override fun getSymmetricEncryptionKeyData(): SymmetricEncryptionKeyData? =
|
||||||
// TODO: BITAU-104
|
bridgeRepository.authenticatorSyncSymmetricKey?.toSymmetricEncryptionKeyData()
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun registerBridgeServiceCallback(callback: IBridgeServiceCallback?) {
|
override fun registerBridgeServiceCallback(callback: IBridgeServiceCallback?) {
|
||||||
// TODO: BITAU-104
|
if (callback == null) return
|
||||||
|
callbacks.register(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unregisterBridgeServiceCallback(callback: IBridgeServiceCallback?) {
|
override fun unregisterBridgeServiceCallback(callback: IBridgeServiceCallback?) {
|
||||||
// TODO: BITAU-104
|
if (callback == null) return
|
||||||
|
callbacks.unregister(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun syncAccounts() {
|
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 {
|
override fun createAddTotpLoginItemIntent(): Intent {
|
||||||
// TODO: BITAU-104
|
// TODO: BITAU-112
|
||||||
return Intent()
|
return Intent()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setPendingAddTotpLoginItemData(data: EncryptedAddTotpLoginItemData?) {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.repository.di
|
||||||
|
|
||||||
import android.view.autofill.AutofillManager
|
import android.view.autofill.AutofillManager
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
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.accessibility.manager.AccessibilityEnabledManager
|
||||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
|
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
|
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.BiometricsEncryptionManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
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.DebugMenuRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepositoryImpl
|
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepositoryImpl
|
||||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
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.ServerConfigRepositoryImpl
|
||||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepositoryImpl
|
import com.x8bit.bitwarden.data.platform.repository.SettingsRepositoryImpl
|
||||||
|
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
@ -35,6 +40,24 @@ import javax.inject.Singleton
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object PlatformRepositoryModule {
|
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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideServerConfigRepository(
|
fun provideServerConfigRepository(
|
||||||
|
|
|
@ -1,52 +1,221 @@
|
||||||
package com.x8bit.bitwarden.data.platform.processor
|
package com.x8bit.bitwarden.data.platform.processor
|
||||||
|
|
||||||
import android.os.Build
|
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.FeatureFlagManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
|
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 com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkConstructor
|
||||||
import io.mockk.mockkStatic
|
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.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
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.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Nested
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class BridgeServiceProcessorTest {
|
class BridgeServiceProcessorTest {
|
||||||
|
|
||||||
private val featureFlagManager: FeatureFlagManager = mockk()
|
private val featureFlagManager = mockk<FeatureFlagManager>()
|
||||||
|
private val bridgeRepository = mockk<BridgeRepository>()
|
||||||
|
|
||||||
private val bridgeServiceManager = BridgeServiceProcessorImpl(
|
private lateinit var bridgeServiceProcessor: BridgeServiceProcessorImpl
|
||||||
featureFlagManager = featureFlagManager,
|
|
||||||
)
|
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun setup() {
|
fun setup() {
|
||||||
mockkStatic(::isBuildVersionBelow)
|
bridgeServiceProcessor = BridgeServiceProcessorImpl(
|
||||||
|
bridgeRepository = bridgeRepository,
|
||||||
|
featureFlagManager = featureFlagManager,
|
||||||
|
dispatcherManager = FakeDispatcherManager(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun teardown() {
|
||||||
|
unmockkStatic(::isBuildVersionBelow)
|
||||||
|
unmockkStatic(SharedAccountData::encrypt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when AuthenticatorSync feature flag is off, should return null binder`() {
|
fun `when AuthenticatorSync feature flag is off, should return null binder`() {
|
||||||
|
mockkStatic(::isBuildVersionBelow)
|
||||||
every { isBuildVersionBelow(Build.VERSION_CODES.S) } returns false
|
every { isBuildVersionBelow(Build.VERSION_CODES.S) } returns false
|
||||||
every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns false
|
every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns false
|
||||||
assertNull(bridgeServiceManager.binder)
|
assertNull(bridgeServiceProcessor.binder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
fun `when AuthenticatorSync feature flag is on and running Android level greater than S, should return non-null binder`() {
|
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 { isBuildVersionBelow(Build.VERSION_CODES.S) } returns false
|
||||||
every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns true
|
every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns true
|
||||||
assertNotNull(bridgeServiceManager.binder)
|
assertNotNull(bridgeServiceProcessor.binder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when below Android level S, should never return a binder regardless of feature flag`() {
|
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 { isBuildVersionBelow(Build.VERSION_CODES.S) } returns true
|
||||||
every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns false
|
every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns false
|
||||||
assertNull(bridgeServiceManager.binder)
|
assertNull(bridgeServiceProcessor.binder)
|
||||||
|
|
||||||
every { featureFlagManager.getFeatureFlag(FlagKey.AuthenticatorSync) } returns true
|
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()
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
)
|
|
@ -18,7 +18,7 @@ interface IBridgeService {
|
||||||
String getVersionNumber();
|
String getVersionNumber();
|
||||||
|
|
||||||
// Returns true when the given symmetric fingerprint data matches that contained by the SDK.
|
// 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.
|
// Returns a symmetric key that will be used for encypting all IPC traffic.
|
||||||
//
|
//
|
||||||
|
|
|
@ -9,6 +9,7 @@ import com.bitwarden.bridge.model.EncryptedSharedAccountData
|
||||||
import com.bitwarden.bridge.model.SharedAccountData
|
import com.bitwarden.bridge.model.SharedAccountData
|
||||||
import com.bitwarden.bridge.model.SharedAccountDataJson
|
import com.bitwarden.bridge.model.SharedAccountDataJson
|
||||||
import com.bitwarden.bridge.model.SymmetricEncryptionKeyData
|
import com.bitwarden.bridge.model.SymmetricEncryptionKeyData
|
||||||
|
import com.bitwarden.bridge.model.SymmetricEncryptionKeyFingerprintData
|
||||||
import com.bitwarden.bridge.model.toByteArrayContainer
|
import com.bitwarden.bridge.model.toByteArrayContainer
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
@ -36,12 +37,13 @@ fun generateSecretKey(): Result<SecretKey> = runCatching {
|
||||||
* [IBridgeService.checkSymmetricEncryptionKeyFingerprint], which allows callers of the service
|
* [IBridgeService.checkSymmetricEncryptionKeyFingerprint], which allows callers of the service
|
||||||
* to verify that they have the correct symmetric key without actually having to send the key.
|
* to verify that they have the correct symmetric key without actually having to send the key.
|
||||||
*/
|
*/
|
||||||
fun SymmetricEncryptionKeyData.toFingerprint(): Result<ByteArray> = runCatching {
|
fun SymmetricEncryptionKeyData.toFingerprint(): Result<SymmetricEncryptionKeyFingerprintData> =
|
||||||
|
runCatching {
|
||||||
val messageDigest = MessageDigest.getInstance(KeyProperties.DIGEST_SHA256)
|
val messageDigest = MessageDigest.getInstance(KeyProperties.DIGEST_SHA256)
|
||||||
messageDigest.reset()
|
messageDigest.reset()
|
||||||
messageDigest.update(this.symmetricEncryptionKey.byteArray)
|
messageDigest.update(this.symmetricEncryptionKey.byteArray)
|
||||||
messageDigest.digest()
|
SymmetricEncryptionKeyFingerprintData(messageDigest.digest().toByteArrayContainer())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt [SharedAccountData].
|
* Encrypt [SharedAccountData].
|
||||||
|
|
Loading…
Reference in a new issue