BITAU-97 Add AuthenticatorBridgeManager (#3987)

This commit is contained in:
Andrew Haisting 2024-10-01 16:27:12 -05:00 committed by GitHub
parent 757baf0290
commit 9e4119fe32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 965 additions and 0 deletions

Binary file not shown.

View file

@ -54,6 +54,21 @@ The following is a list of all third-party dependencies required by the SDK.
> [!IMPORTANT] > [!IMPORTANT]
> The SDK does not come packaged with these dependencies, so consumers of the SDK must provide them. > The SDK does not come packaged with these dependencies, so consumers of the SDK must provide them.
- **AndroidX Appcompat**
- https://developer.android.com/jetpack/androidx/releases/appcompat
- Purpose: Allows access to new APIs on older API versions.
- License: Apache 2.0
- **AndroidX Lifecycle**
- https://developer.android.com/jetpack/androidx/releases/lifecycle
- Purpose: Lifecycle aware components and tooling.
- License: Apache 2.0
- **kotlinx.coroutines**
- https://github.com/Kotlin/kotlinx.coroutines
- Purpose: Kotlin coroutines library for asynchronous and reactive code.
- License: Apache 2.0
- **kotlinx.serialization** - **kotlinx.serialization**
- https://github.com/Kotlin/kotlinx.serialization/ - https://github.com/Kotlin/kotlinx.serialization/
- Purpose: JSON serialization library for Kotlin. - Purpose: JSON serialization library for Kotlin.

View file

@ -60,7 +60,10 @@ kotlin {
dependencies { dependencies {
// SDK dependencies: // SDK dependencies:
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.process)
implementation(libs.kotlinx.serialization) implementation(libs.kotlinx.serialization)
implementation(libs.kotlinx.coroutines.core)
// Test environment dependencies: // Test environment dependencies:
testImplementation(libs.junit.junit5) testImplementation(libs.junit.junit5)

View file

@ -0,0 +1,33 @@
package com.bitwarden.authenticatorbridge.factory
import android.content.Context
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManagerImpl
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider
/**
* Factory for supplying implementation instances of Authenticator Bridge SDK interfaces.
*/
class AuthenticatorBridgeFactory(
context: Context,
) {
private val applicationContext = context.applicationContext
/**
* Gets a new instance of [AuthenticatorBridgeManager].
*
* @param connectionType Specifies which build variant to connect to.
* @param symmetricKeyStorageProvider Provides access to local storage of the symmetric
* encryption key.
*/
fun getAuthenticatorBridgeManager(
connectionType: AuthenticatorBridgeConnectionType,
symmetricKeyStorageProvider: SymmetricKeyStorageProvider,
): AuthenticatorBridgeManager = AuthenticatorBridgeManagerImpl(
context = applicationContext,
connectionType = connectionType,
symmetricKeyStorageProvider = symmetricKeyStorageProvider,
)
}

View file

@ -0,0 +1,17 @@
package com.bitwarden.authenticatorbridge.manager
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API to make it simpler for consuming applications to
* query [IAuthenticatorBridgeService].
*/
interface AuthenticatorBridgeManager {
/**
* State flow representing the current [AccountSyncState].
*/
val accountSyncStateFlow: StateFlow<AccountSyncState>
}

View file

@ -0,0 +1,229 @@
package com.bitwarden.authenticatorbridge.manager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager.NameNotFoundException
import android.os.IBinder
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
import com.bitwarden.authenticatorbridge.manager.util.toPackageName
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
import com.bitwarden.authenticatorbridge.provider.AuthenticatorBridgeCallbackProvider
import com.bitwarden.authenticatorbridge.provider.StubAuthenticatorBridgeCallbackProvider
import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider
import com.bitwarden.authenticatorbridge.util.decrypt
import com.bitwarden.authenticatorbridge.util.toFingerprint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
private const val AUTHENTICATOR_BRIDGE_SERVICE_CLASS =
"com.x8bit.bitwarden.data.platform.service.AuthenticatorBridgeService"
/**
* Default implementation of [AuthenticatorBridgeManager].
*
* @param context The Context that will be used to bind to AuthenticatorBridgeService.
* @param connectionType Specifies which build variant to connect to.
* @param symmetricKeyStorageProvider Provides access to local storage of the symmetric encryption
* key.
* @param callbackProvider Provides a way to construct a service callback that can be mocked in
* tests.
* @param processLifecycleOwner Lifecycle owner that is used to listen for start/stop
* lifecycle events.
*/
internal class AuthenticatorBridgeManagerImpl(
private val connectionType: AuthenticatorBridgeConnectionType,
private val symmetricKeyStorageProvider: SymmetricKeyStorageProvider,
callbackProvider: AuthenticatorBridgeCallbackProvider = StubAuthenticatorBridgeCallbackProvider(),
context: Context,
processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(),
) : AuthenticatorBridgeManager {
private val applicationContext = context.applicationContext
/**
* Main AuthenticatorBridgeService access point.
*/
private var bridgeService: IAuthenticatorBridgeService? = null
/**
* Internal state of [accountSyncStateFlow].
*/
private val mutableSharedAccountsStateFlow: MutableStateFlow<AccountSyncState> =
MutableStateFlow(
if (isBitwardenAppInstalled()) {
AccountSyncState.Loading
} else {
AccountSyncState.AppNotInstalled
}
)
/**
* Callback registered with AuthenticatorBridgeService.
*/
private val authenticatorBridgeCallback = callbackProvider.getCallback(::onAccountsSync)
/**
* Service connection that listens for connected and disconnected service events.
*/
private val bridgeServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
onServiceConnected(service)
}
override fun onServiceDisconnected(name: ComponentName) {
onServiceDisconnected()
}
}
override val accountSyncStateFlow: StateFlow<AccountSyncState> =
mutableSharedAccountsStateFlow.asStateFlow()
init {
// Listen for lifecycle events
processLifecycleOwner.lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
bindService()
}
override fun onStop(owner: LifecycleOwner) {
unbindService()
}
},
)
}
private fun bindService() {
if (!isBitwardenAppInstalled()) {
mutableSharedAccountsStateFlow.value = AccountSyncState.AppNotInstalled
return
}
val intent = Intent().apply {
component = ComponentName(
connectionType.toPackageName(),
AUTHENTICATOR_BRIDGE_SERVICE_CLASS,
)
}
val isBound = try {
applicationContext.bindService(
intent,
bridgeServiceConnection,
Context.BIND_AUTO_CREATE,
)
} catch (e: SecurityException) {
unbindService()
false
}
if (!isBound) {
mutableSharedAccountsStateFlow.value = AccountSyncState.Error
}
}
private fun onAccountsSync(data: EncryptedSharedAccountData) {
// Received account sync update. Decrypt with local symmetric key and update StateFlow:
mutableSharedAccountsStateFlow.value = symmetricKeyStorageProvider.symmetricKey
?.let { data.decrypt(it) }
?.getOrNull()
?.let { AccountSyncState.Success(it.accounts) }
?: AccountSyncState.Error
}
private fun onServiceConnected(binder: IBinder) {
val service = IAuthenticatorBridgeService.Stub
.asInterface(binder)
.also { bridgeService = it }
// TODO: Add check for version mismatch between client and server SDKs: BITAU-72
// Ensure we are using the correct symmetric key:
val localKeyFingerprint =
symmetricKeyStorageProvider.symmetricKey?.toFingerprint()?.getOrNull()
// Query bridge service to see if we have a matching symmetric key:
val haveCorrectKey = service
.safeCall { checkSymmetricEncryptionKeyFingerprint(localKeyFingerprint) }
.fold(
onSuccess = { it },
onFailure = { false },
)
if (!haveCorrectKey) {
// If we don't have the correct key, query for key:
service
.safeCall { symmetricEncryptionKeyData }
.fold(
onSuccess = {
symmetricKeyStorageProvider.symmetricKey = it
},
onFailure = {
mutableSharedAccountsStateFlow.value = AccountSyncState.Error
unbindService()
return
},
)
}
if (symmetricKeyStorageProvider.symmetricKey == null) {
// This means bridgeService returned a null key, which means we can make
// no valid operations. We should disconnect form the service and expose to the
// calling application that authenticator sync is not enabled.
mutableSharedAccountsStateFlow.value = AccountSyncState.SyncNotEnabled
unbindService()
return
}
// Register callback:
service.safeCall { registerBridgeServiceCallback(authenticatorBridgeCallback) }
// Sync data:
service.safeCall { syncAccounts() }
}
private fun onServiceDisconnected() {
bridgeService = null
}
private fun unbindService() {
bridgeService?.safeCall { unregisterBridgeServiceCallback(authenticatorBridgeCallback) }
bridgeService = null
@Suppress("TooGenericExceptionCaught")
try {
applicationContext.unbindService(bridgeServiceConnection)
} catch (_: Exception) {
// We want to be super safe when unbinding to assure no crashes.
}
}
private fun isBitwardenAppInstalled(): Boolean =
// Check to see if correct Bitwarden app is installed:
try {
applicationContext.packageManager.getPackageInfo(connectionType.toPackageName(), 0)
true
} catch (e: NameNotFoundException) {
false
}
}
/**
* Helper function for wrapping all calls to [IAuthenticatorBridgeService] around try catch.
*
* This is important because all calls to [IAuthenticatorBridgeService] can throw
* DeadObjectExceptions as well as RemoteExceptions.
*/
private fun <T> IAuthenticatorBridgeService.safeCall(
action: IAuthenticatorBridgeService.() -> T,
): Result<T> =
runCatching {
this.let { action.invoke(it) }
}

View file

@ -0,0 +1,34 @@
package com.bitwarden.authenticatorbridge.manager.model
import com.bitwarden.authenticatorbridge.model.SharedAccountData
/**
* Models various states of account syncing.
*/
sealed class AccountSyncState {
/**
* The Bitwarden app is not installed and therefore accounts cannot be synced.
*/
data object AppNotInstalled : AccountSyncState()
/**
* Something went wrong syncing accounts.
*/
data object Error : AccountSyncState()
/**
* The user needs to enable authenticator syncing from the bitwarden app.
*/
data object SyncNotEnabled : AccountSyncState()
/**
* Accounts are being synced.
*/
data object Loading : AccountSyncState()
/**
* Accounts successfully synced.
*/
data class Success(val accounts: List<SharedAccountData.Account>) : AccountSyncState()
}

View file

@ -0,0 +1,19 @@
package com.bitwarden.authenticatorbridge.manager.model
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
/**
* Models different connection types for [AuthenticatorBridgeManager].
*/
enum class AuthenticatorBridgeConnectionType {
/**
* Connect to release build variant.
*/
RELEASE,
/**
* Connect to dev build variant.
*/
DEV,
}

View file

@ -0,0 +1,12 @@
package com.bitwarden.authenticatorbridge.manager.util
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
/**
* Convert a [AuthenticatorBridgeConnectionType] to raw package name for connection.
*/
internal fun AuthenticatorBridgeConnectionType.toPackageName() =
when (this) {
AuthenticatorBridgeConnectionType.RELEASE -> "com.x8bit.bitwarden"
AuthenticatorBridgeConnectionType.DEV -> "com.x8bit.bitwarden.dev"
}

View file

@ -0,0 +1,21 @@
package com.bitwarden.authenticatorbridge.provider
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
/**
* Provides an implementation of [IAuthenticatorBridgeServiceCallback]. This is useful
* for writing unit tests that don't touch Binder logic.
*/
interface AuthenticatorBridgeCallbackProvider {
/**
* Get a [IAuthenticatorBridgeServiceCallback] that will call delegate [onAccountsSync] call
* to the given lambda.
*
* @param onAccountsSync Lambda that will be invoked when [onAccountsSync] calls back.
*/
fun getCallback(
onAccountsSync: (EncryptedSharedAccountData) -> Unit,
): IAuthenticatorBridgeServiceCallback
}

View file

@ -0,0 +1,18 @@
package com.bitwarden.authenticatorbridge.provider
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
/**
* Default implementation of [AuthenticatorBridgeCallbackProvider] that provides a live
* [IAuthenticatorBridgeServiceCallback.Stub] implementation.
*/
class StubAuthenticatorBridgeCallbackProvider : AuthenticatorBridgeCallbackProvider {
override fun getCallback(
onAccountsSync: (EncryptedSharedAccountData) -> Unit,
): IAuthenticatorBridgeServiceCallback = object : IAuthenticatorBridgeServiceCallback.Stub() {
override fun onAccountsSync(data: EncryptedSharedAccountData) = onAccountsSync.invoke(data)
}
}

View file

@ -0,0 +1,14 @@
package com.bitwarden.authenticatorbridge.provider
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData
/**
* Provides a way for calling Applications to implement symmetric key storage.
*/
interface SymmetricKeyStorageProvider {
/**
* Stored symmetric encryption key.
*/
var symmetricKey: SymmetricEncryptionKeyData?
}

View file

@ -0,0 +1,440 @@
package com.bitwarden.authenticatorbridge.manager
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager.NameNotFoundException
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
import com.bitwarden.authenticatorbridge.model.SharedAccountData
import com.bitwarden.authenticatorbridge.util.FakeLifecycleOwner
import com.bitwarden.authenticatorbridge.util.FakeSymmetricKeyStorageProvider
import com.bitwarden.authenticatorbridge.util.TestAuthenticatorBridgeCallbackProvider
import com.bitwarden.authenticatorbridge.util.decrypt
import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.authenticatorbridge.util.toFingerprint
import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AuthenticatorBridgeManagerTest {
private val context = mockk<Context> {
every { applicationContext } returns this
every {
packageManager.getPackageInfo("com.x8bit.bitwarden.dev", 0)
} returns mockk()
}
private val mockBridgeService: IAuthenticatorBridgeService = mockk()
private val fakeLifecycleOwner = FakeLifecycleOwner()
private val fakeSymmetricKeyStorageProvider = FakeSymmetricKeyStorageProvider()
private val testAuthenticatorBridgeCallbackProvider = TestAuthenticatorBridgeCallbackProvider()
private val manager: AuthenticatorBridgeManagerImpl = AuthenticatorBridgeManagerImpl(
context = context,
connectionType = AuthenticatorBridgeConnectionType.DEV,
symmetricKeyStorageProvider = fakeSymmetricKeyStorageProvider,
callbackProvider = testAuthenticatorBridgeCallbackProvider,
processLifecycleOwner = fakeLifecycleOwner,
)
@BeforeEach
fun setup() {
mockkConstructor(Intent::class)
mockkStatic(IAuthenticatorBridgeService.Stub::class)
mockkStatic(EncryptedSharedAccountData::decrypt)
}
@AfterEach
fun teardown() {
unmockkConstructor(Intent::class)
unmockkStatic(IAuthenticatorBridgeService.Stub::class)
unmockkStatic(EncryptedSharedAccountData::decrypt)
}
@Test
fun `initial AccountSyncState should be Loading when Bitwarden app is present`() {
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
}
@Test
fun `initial AccountSyncState should be AppNotInstalled when Bitwarden app is not present`() {
every {
context.packageManager.getPackageInfo("com.x8bit.bitwarden.dev", 0)
} throws NameNotFoundException()
val manager = AuthenticatorBridgeManagerImpl(
context = context,
connectionType = AuthenticatorBridgeConnectionType.DEV,
symmetricKeyStorageProvider = fakeSymmetricKeyStorageProvider,
callbackProvider = testAuthenticatorBridgeCallbackProvider,
processLifecycleOwner = fakeLifecycleOwner,
)
assertEquals(AccountSyncState.AppNotInstalled, manager.accountSyncStateFlow.value)
}
@Test
fun `onStart when bindService fails should set state to error`() {
val mockIntent: Intent = mockk()
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) } returns false
fakeLifecycleOwner.lifecycle.dispatchOnStart()
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
}
@Test
fun `onStart when Bitwarden app is not present should set state to AppNotInstalled`() {
val mockIntent: Intent = mockk()
every {
context.packageManager.getPackageInfo("com.x8bit.bitwarden.dev", 0)
} throws NameNotFoundException()
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every {
context.bindService(any(), any(), Context.BIND_AUTO_CREATE)
} throws SecurityException()
fakeLifecycleOwner.lifecycle.dispatchOnStart()
assertEquals(AccountSyncState.AppNotInstalled, manager.accountSyncStateFlow.value)
}
@Test
fun `onStart when bindService throws security exception should set state to error`() {
val mockIntent: Intent = mockk()
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every {
context.bindService(any(), any(), Context.BIND_AUTO_CREATE)
} throws SecurityException()
fakeLifecycleOwner.lifecycle.dispatchOnStart()
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
}
@Test
fun `onStart when Bitwarden app is present and bindService succeeds should set state to Loading before service calls back`() {
val mockIntent: Intent = mockk()
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) } returns true
fakeLifecycleOwner.lifecycle.dispatchOnStart()
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
}
@Test
@Suppress("MaxLineLength")
fun `onServiceConnected when symmetric key is not present and service returns null symmetric key state should be SyncNotEnabled and should unbind service`() {
val serviceConnection = slot<ServiceConnection>()
val mockIntent: Intent = mockk()
fakeSymmetricKeyStorageProvider.symmetricKey = null
every { mockBridgeService.symmetricEncryptionKeyData } returns null
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.unbindService(any()) } just runs
every {
context.bindService(
any(),
capture(serviceConnection),
Context.BIND_AUTO_CREATE
)
} returns true
fakeLifecycleOwner.lifecycle.dispatchOnStart()
serviceConnection.captured.onServiceConnected(mockk(), mockk())
assertEquals(AccountSyncState.SyncNotEnabled, manager.accountSyncStateFlow.value)
verify { mockBridgeService.symmetricEncryptionKeyData }
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
verify { context.unbindService(any()) }
}
@Test
@Suppress("MaxLineLength")
fun `onServiceConnected when symmetric key does not match should set symmetric key from service`() {
fakeSymmetricKeyStorageProvider.symmetricKey = null
every { mockBridgeService.symmetricEncryptionKeyData } returns SYMMETRIC_KEY
val serviceConnection = slot<ServiceConnection>()
val mockIntent: Intent = mockk()
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.unbindService(any()) } just runs
every {
context.bindService(
any(),
capture(serviceConnection),
Context.BIND_AUTO_CREATE
)
} returns true
fakeLifecycleOwner.lifecycle.dispatchOnStart()
serviceConnection.captured.onServiceConnected(mockk(), mockk())
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
verify { mockBridgeService.symmetricEncryptionKeyData }
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
}
@Test
@Suppress("MaxLineLength")
fun `onServiceConnected when symmetric key does match should not query for symmetric key`() {
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
every {
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
SYMMETRIC_KEY.toFingerprint().getOrNull()
)
} returns true
val serviceConnection = slot<ServiceConnection>()
val mockIntent: Intent = mockk()
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.unbindService(any()) } just runs
every {
context.bindService(
any(),
capture(serviceConnection),
Context.BIND_AUTO_CREATE
)
} returns true
fakeLifecycleOwner.lifecycle.dispatchOnStart()
serviceConnection.captured.onServiceConnected(mockk(), mockk())
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
verify(exactly = 0) { mockBridgeService.symmetricEncryptionKeyData }
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
verify { mockBridgeService.registerBridgeServiceCallback(any()) }
verify { mockBridgeService.syncAccounts() }
}
@Test
@Suppress("MaxLineLength")
fun `onServiceConnected when symmetric key does not match and query for key fails state should be error`() {
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
every {
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
SYMMETRIC_KEY.toFingerprint().getOrNull()
)
} returns false
every {
mockBridgeService.symmetricEncryptionKeyData
} throws RuntimeException()
val serviceConnection = slot<ServiceConnection>()
val mockIntent: Intent = mockk()
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.unbindService(any()) } just runs
every {
context.bindService(
any(),
capture(serviceConnection),
Context.BIND_AUTO_CREATE
)
} returns true
fakeLifecycleOwner.lifecycle.dispatchOnStart()
serviceConnection.captured.onServiceConnected(mockk(), mockk())
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
verify { context.unbindService(any()) }
verify { mockBridgeService.symmetricEncryptionKeyData }
}
@Test
@Suppress("MaxLineLength")
fun `onAccountsSync should set AccountSyncState to decrypted response`() {
val expectedAccounts = listOf<SharedAccountData.Account>(
mockk()
)
val encryptedAccounts: EncryptedSharedAccountData = mockk()
val decryptedAccounts: SharedAccountData = mockk {
every { accounts } returns expectedAccounts
}
mockkStatic(EncryptedSharedAccountData::decrypt)
every { encryptedAccounts.decrypt(SYMMETRIC_KEY) } returns Result.success(decryptedAccounts)
every {
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
SYMMETRIC_KEY.toFingerprint().getOrNull()
)
} returns true
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
val serviceConnection = slot<ServiceConnection>()
val callback = slot<IAuthenticatorBridgeServiceCallback>()
val mockIntent: Intent = mockk()
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
every { mockBridgeService.registerBridgeServiceCallback(capture(callback)) } just runs
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.unbindService(any()) } just runs
every {
context.bindService(
any(),
capture(serviceConnection),
Context.BIND_AUTO_CREATE
)
} returns true
fakeLifecycleOwner.lifecycle.dispatchOnStart()
serviceConnection.captured.onServiceConnected(mockk(), mockk())
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
callback.captured.onAccountsSync(encryptedAccounts)
assertEquals(AccountSyncState.Success(expectedAccounts), manager.accountSyncStateFlow.value)
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
verify(exactly = 0) { mockBridgeService.symmetricEncryptionKeyData }
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
verify { mockBridgeService.registerBridgeServiceCallback(any()) }
verify { mockBridgeService.syncAccounts() }
}
@Test
fun `onAccountsSync when symmetric key is missing should not set state to Success`() {
val encryptedAccounts: EncryptedSharedAccountData = mockk()
every {
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
SYMMETRIC_KEY.toFingerprint().getOrNull()
)
} returns true
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
val serviceConnection = slot<ServiceConnection>()
val callback = slot<IAuthenticatorBridgeServiceCallback>()
val mockIntent: Intent = mockk()
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
every { mockBridgeService.registerBridgeServiceCallback(capture(callback)) } just runs
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.unbindService(any()) } just runs
every {
context.bindService(
any(),
capture(serviceConnection),
Context.BIND_AUTO_CREATE
)
} returns true
fakeLifecycleOwner.lifecycle.dispatchOnStart()
serviceConnection.captured.onServiceConnected(mockk(), mockk())
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
fakeSymmetricKeyStorageProvider.symmetricKey = null
callback.captured.onAccountsSync(encryptedAccounts)
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
verify(exactly = 0) { mockBridgeService.symmetricEncryptionKeyData }
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
verify { mockBridgeService.registerBridgeServiceCallback(any()) }
verify { mockBridgeService.syncAccounts() }
}
@Test
@Suppress("MaxLineLength")
fun `onAccountsSync when decryption fails state should be error`() {
val encryptedAccounts: EncryptedSharedAccountData = mockk()
mockkStatic(EncryptedSharedAccountData::decrypt)
every { encryptedAccounts.decrypt(SYMMETRIC_KEY) } returns Result.failure(RuntimeException())
every {
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
SYMMETRIC_KEY.toFingerprint().getOrNull()
)
} returns true
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
val serviceConnection = slot<ServiceConnection>()
val callback = slot<IAuthenticatorBridgeServiceCallback>()
val mockIntent: Intent = mockk()
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
every { mockBridgeService.registerBridgeServiceCallback(capture(callback)) } just runs
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.unbindService(any()) } just runs
every {
context.bindService(
any(),
capture(serviceConnection),
Context.BIND_AUTO_CREATE
)
} returns true
fakeLifecycleOwner.lifecycle.dispatchOnStart()
serviceConnection.captured.onServiceConnected(mockk(), mockk())
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
callback.captured.onAccountsSync(encryptedAccounts)
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
verify(exactly = 0) { mockBridgeService.symmetricEncryptionKeyData }
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
verify { mockBridgeService.registerBridgeServiceCallback(any()) }
verify { mockBridgeService.syncAccounts() }
}
@Test
fun `onStop when service has been started should unbind service`() {
val mockIntent: Intent = mockk()
every {
anyConstructed<Intent>().setComponent(any())
} returns mockIntent
every { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) } returns true
every { context.unbindService(any()) } just runs
fakeLifecycleOwner.lifecycle.dispatchOnStart()
fakeLifecycleOwner.lifecycle.dispatchOnStop()
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
verify { context.unbindService(any()) }
}
}
private val SYMMETRIC_KEY = generateSecretKey()
.getOrThrow()
.encoded
.toSymmetricEncryptionKeyData()

View file

@ -0,0 +1,24 @@
package com.bitwarden.authenticatorbridge.manager.util
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class AuthenticatorBridgeConnectionTypeExtensionsTest {
@Test
fun `toPackageName RELEASE should map to correct release package`() {
assertEquals(
"com.x8bit.bitwarden",
AuthenticatorBridgeConnectionType.RELEASE.toPackageName()
)
}
@Test
fun `toPackageName DEV should map to correct dev package`() {
assertEquals(
"com.x8bit.bitwarden.dev",
AuthenticatorBridgeConnectionType.DEV.toPackageName()
)
}
}

View file

@ -0,0 +1,45 @@
package com.bitwarden.authenticatorbridge.util
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
/**
* A fake implementation of [LifecycleOwner] and [Lifecycle] for testing purposes.
*/
class FakeLifecycle(
private val lifecycleOwner: LifecycleOwner,
) : Lifecycle() {
private val observers = mutableSetOf<DefaultLifecycleObserver>()
override var currentState: State = State.INITIALIZED
override fun addObserver(observer: LifecycleObserver) {
observers += (observer as DefaultLifecycleObserver)
}
override fun removeObserver(observer: LifecycleObserver) {
observers -= (observer as DefaultLifecycleObserver)
}
/**
* Triggers [DefaultLifecycleObserver.onStart] calls for each registered observer.
*/
fun dispatchOnStart() {
currentState = State.STARTED
observers.forEach { observer ->
observer.onStart(lifecycleOwner)
}
}
/**
* Triggers [DefaultLifecycleObserver.onStop] calls for each registered observer.
*/
fun dispatchOnStop() {
currentState = State.CREATED
observers.forEach { observer ->
observer.onStop(lifecycleOwner)
}
}
}

View file

@ -0,0 +1,10 @@
package com.bitwarden.authenticatorbridge.util
import androidx.lifecycle.LifecycleOwner
/**
* A fake implementation of [LifecycleOwner] for testing purposes.
*/
class FakeLifecycleOwner : LifecycleOwner {
override val lifecycle: FakeLifecycle = FakeLifecycle(lifecycleOwner = this)
}

View file

@ -0,0 +1,11 @@
package com.bitwarden.authenticatorbridge.util
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData
import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider
/**
* A fake implementation of [SymmetricKeyStorageProvider] for testing purposes.
*/
class FakeSymmetricKeyStorageProvider : SymmetricKeyStorageProvider {
override var symmetricKey: SymmetricEncryptionKeyData? = null
}

View file

@ -0,0 +1,19 @@
package com.bitwarden.authenticatorbridge.util
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
import com.bitwarden.authenticatorbridge.provider.AuthenticatorBridgeCallbackProvider
/**
* Test implementation of [AuthenticatorBridgeCallbackProvider] that provides a testable
* [IAuthenticatorBridgeServiceCallback.Default] implementation.
*/
class TestAuthenticatorBridgeCallbackProvider : AuthenticatorBridgeCallbackProvider {
override fun getCallback(
onAccountsSync: (EncryptedSharedAccountData) -> Unit,
): IAuthenticatorBridgeServiceCallback = object : IAuthenticatorBridgeServiceCallback.Default() {
override fun onAccountsSync(data: EncryptedSharedAccountData) = onAccountsSync.invoke(data)
}
}

View file

@ -97,6 +97,7 @@ junit-junit5 = { module = "org.junit.jupiter:junit-jupiter", version.ref = "juni
junit-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } junit-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
mockk-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-mockk = { module = "io.mockk:mockk", version.ref = "mockk" }