BITAU-112 Support deep link into add item flow from Authenticator app (#4128)

This commit is contained in:
Andrew Haisting 2024-10-23 11:17:31 -05:00 committed by GitHub
parent f1d7d1a530
commit fa248243b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 365 additions and 21 deletions

View file

@ -5,6 +5,7 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
@ -23,6 +24,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@ -33,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
@ -58,6 +61,7 @@ private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val fido2CredentialManager: Fido2CredentialManager,
@ -234,7 +238,20 @@ class MainViewModel @Inject constructor(
val autofillSaveItem = intent.getAutofillSaveItemOrNull()
val autofillSelectionData = intent.getAutofillSelectionDataOrNull()
val shareData = intentManager.getShareDataFromIntent(intent)
val totpData = intent.getTotpDataOrNull()
val totpData: TotpData? =
// First grab TOTP URI directly from the intent data:
intent.getTotpDataOrNull()
?: run {
// Then check to see if the intent is coming from the Authenticator app:
if (intent.isAddTotpLoginItemFromAuthenticator()) {
addTotpItemFromAuthenticatorManager.pendingAddTotpLoginItemData.also {
// Clear pending add TOTP data so it is only handled once:
addTotpItemFromAuthenticatorManager.pendingAddTotpLoginItemData = null
}
} else {
null
}
}
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.auth.manager
import com.x8bit.bitwarden.ui.vault.model.TotpData
/**
* Manager for keeping track of requests from the Bitwarden Authenticator app to add a TOTP
* item.
*/
interface AddTotpItemFromAuthenticatorManager {
/**
* Current pending [TotpData] to be added from the Authenticator app.
*/
var pendingAddTotpLoginItemData: TotpData?
}

View file

@ -0,0 +1,11 @@
package com.x8bit.bitwarden.data.auth.manager
import com.x8bit.bitwarden.ui.vault.model.TotpData
/**
* Default in memory implementation for [AddTotpItemFromAuthenticatorManager].
*/
class AddTotpItemFromAuthenticatorManagerImpl : AddTotpItemFromAuthenticatorManager {
override var pendingAddTotpLoginItemData: TotpData? = null
}

View file

@ -7,6 +7,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsServ
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManagerImpl
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManagerImpl
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
@ -124,4 +126,9 @@ object AuthManagerModule {
vaultSdkSource = vaultSdkSource,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun providesAddTotpItemFromAuthenticatorManager(): AddTotpItemFromAuthenticatorManager =
AddTotpItemFromAuthenticatorManagerImpl()
}

View file

@ -4,6 +4,7 @@ import android.app.Application
import android.content.Context
import androidx.core.content.getSystemService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
@ -84,10 +85,14 @@ object PlatformManagerModule {
@Singleton
fun provideAuthenticatorBridgeProcessor(
authenticatorBridgeRepository: AuthenticatorBridgeRepository,
addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
@ApplicationContext context: Context,
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
): AuthenticatorBridgeProcessor = AuthenticatorBridgeProcessorImpl(
authenticatorBridgeRepository = authenticatorBridgeRepository,
addTotpItemFromAuthenticatorManager = addTotpItemFromAuthenticatorManager,
context = context,
dispatcherManager = dispatcherManager,
featureFlagManager = featureFlagManager,
)

View file

@ -1,23 +1,28 @@
package com.x8bit.bitwarden.data.platform.processor
import android.content.Intent
import android.content.Context
import android.os.Build
import android.os.IInterface
import android.os.RemoteCallbackList
import androidx.core.net.toUri
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
import com.bitwarden.authenticatorbridge.model.EncryptedAddTotpLoginItemData
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyFingerprintData
import com.bitwarden.authenticatorbridge.util.AUTHENTICATOR_BRIDGE_SDK_VERSION
import com.bitwarden.authenticatorbridge.util.decrypt
import com.bitwarden.authenticatorbridge.util.encrypt
import com.bitwarden.authenticatorbridge.util.toFingerprint
import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
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.AuthenticatorBridgeRepository
import com.x8bit.bitwarden.data.platform.util.createAddTotpItemFromAuthenticatorIntent
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -26,10 +31,13 @@ import kotlinx.coroutines.launch
*/
class AuthenticatorBridgeProcessorImpl(
private val authenticatorBridgeRepository: AuthenticatorBridgeRepository,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager,
context: Context,
) : AuthenticatorBridgeProcessor {
private val applicationContext = context.applicationContext
private val callbacks by lazy { RemoteCallbackList<IAuthenticatorBridgeServiceCallback>() }
private val scope by lazy { CoroutineScope(dispatcherManager.default) }
@ -101,13 +109,18 @@ class AuthenticatorBridgeProcessorImpl(
}
}
override fun createAddTotpLoginItemIntent(): Intent {
// TODO: BITAU-112
return Intent()
}
override fun setPendingAddTotpLoginItemData(data: EncryptedAddTotpLoginItemData?) {
// TODO: BITAU-112
override fun startAddTotpLoginItemFlow(data: EncryptedAddTotpLoginItemData): Boolean {
val symmetricEncryptionKey = symmetricEncryptionKeyData ?: return false
val intent = createAddTotpItemFromAuthenticatorIntent(context = applicationContext)
val totpData = data.decrypt(symmetricEncryptionKey)
.getOrNull()
?.totpUri
?.toUri()
?.getTotpDataOrNull()
?: return false
addTotpItemFromAuthenticatorManager.pendingAddTotpLoginItemData = totpData
applicationContext.startActivity(intent)
return true
}
}
}

View file

@ -0,0 +1,41 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.data.platform.util
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
import android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
private const val ADD_TOTP_ITEM_FROM_AUTHENTICATOR_KEY = "add-totp-item-from-authenticator-key"
/**
* Creates an intent for launching add TOTP item flow from the Authenticator app.
*/
fun createAddTotpItemFromAuthenticatorIntent(
context: Context,
): Intent =
Intent(
context,
MainActivity::class.java,
)
.apply {
putExtra(
ADD_TOTP_ITEM_FROM_AUTHENTICATOR_KEY,
true,
)
addFlags(FLAG_ACTIVITY_NEW_TASK)
addFlags(FLAG_ACTIVITY_SINGLE_TOP)
addFlags(FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
}
/**
* Returns true if the Intent was started by the Authenticator app to add a TOTP item. The TOTP
* item can be found in [AddTotpItemFromAuthenticatorManager].
*/
fun Intent.isAddTotpLoginItemFromAuthenticator(): Boolean =
getBooleanExtra(ADD_TOTP_ITEM_FROM_AUTHENTICATOR_KEY, false)

View file

@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManagerImpl
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
@ -43,6 +44,7 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -79,6 +81,7 @@ class MainViewModelTest : BaseViewModelTest() {
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
private val accessibilitySelectionManager: AccessibilitySelectionManager =
AccessibilitySelectionManagerImpl()
private val addTotpItemAuthenticatorManager = AddTotpItemFromAuthenticatorManagerImpl()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
@ -131,6 +134,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getFido2AssertionRequestOrNull,
Intent::getFido2CredentialRequestOrNull,
Intent::getFido2GetCredentialsRequestOrNull,
Intent::isAddTotpLoginItemFromAuthenticator,
)
mockkStatic(
Intent::isMyVaultShortcut,
@ -150,6 +154,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getFido2AssertionRequestOrNull,
Intent::getFido2CredentialRequestOrNull,
Intent::getFido2GetCredentialsRequestOrNull,
Intent::isAddTotpLoginItemFromAuthenticator,
)
unmockkStatic(
Intent::isMyVaultShortcut,
@ -321,6 +326,38 @@ class MainViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveFirstIntent with TOTP data from Authenticator app should set the special circumstance to AddTotpLoginItem and clear pendingAddTotpLoginItemData`() {
val viewModel = createViewModel()
val totpData = mockk<TotpData>()
val mockIntent = createMockIntent(
mockIsAddTotpLoginItemFromAuthenticator = true,
)
addTotpItemAuthenticatorManager.pendingAddTotpLoginItemData = totpData
viewModel.trySendAction(MainAction.ReceiveFirstIntent(intent = mockIntent))
assertEquals(
SpecialCircumstance.AddTotpLoginItem(data = totpData),
specialCircumstanceManager.specialCircumstance,
)
assertNull(addTotpItemAuthenticatorManager.pendingAddTotpLoginItemData)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveFirstIntent when intent is from Authenticator app but pending item is null should not set special circumstance`() {
val viewModel = createViewModel()
val mockIntent = createMockIntent(
mockIsAddTotpLoginItemFromAuthenticator = true,
)
addTotpItemAuthenticatorManager.pendingAddTotpLoginItemData = null
viewModel.trySendAction(MainAction.ReceiveFirstIntent(intent = mockIntent))
assertNull(specialCircumstanceManager.specialCircumstance)
assertNull(addTotpItemAuthenticatorManager.pendingAddTotpLoginItemData)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveFirstIntent with share data should set the special circumstance to ShareNewSend`() {
@ -748,6 +785,38 @@ class MainViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent with TOTP data from Authenticator app should set the special circumstance to AddTotpLoginItem and clear pendingAddTotpLoginItemData`() {
val viewModel = createViewModel()
val totpData = mockk<TotpData>()
val mockIntent = createMockIntent(
mockIsAddTotpLoginItemFromAuthenticator = true,
)
addTotpItemAuthenticatorManager.pendingAddTotpLoginItemData = totpData
viewModel.trySendAction(MainAction.ReceiveNewIntent(intent = mockIntent))
assertEquals(
SpecialCircumstance.AddTotpLoginItem(data = totpData),
specialCircumstanceManager.specialCircumstance,
)
assertNull(addTotpItemAuthenticatorManager.pendingAddTotpLoginItemData)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent when intent is from Authenticator app but pending item is null should not set special circumstance`() {
val viewModel = createViewModel()
val mockIntent = createMockIntent(
mockIsAddTotpLoginItemFromAuthenticator = true,
)
addTotpItemAuthenticatorManager.pendingAddTotpLoginItemData = null
viewModel.trySendAction(MainAction.ReceiveNewIntent(intent = mockIntent))
assertNull(specialCircumstanceManager.specialCircumstance)
assertNull(addTotpItemAuthenticatorManager.pendingAddTotpLoginItemData)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent with autofill data should set the special circumstance to AutofillSelection`() {
@ -943,6 +1012,7 @@ class MainViewModelTest : BaseViewModelTest() {
initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel(
accessibilitySelectionManager = accessibilitySelectionManager,
addTotpItemFromAuthenticatorManager = addTotpItemAuthenticatorManager,
autofillSelectionManager = autofillSelectionManager,
specialCircumstanceManager = specialCircumstanceManager,
garbageCollectionManager = garbageCollectionManager,
@ -1013,6 +1083,7 @@ private fun createMockIntent(
mockIsMyVaultShortcut: Boolean = false,
mockIsPasswordGeneratorShortcut: Boolean = false,
mockIsAccountSecurityShortcut: Boolean = false,
mockIsAddTotpLoginItemFromAuthenticator: Boolean = false,
): Intent = mockk<Intent> {
every { getTotpDataOrNull() } returns mockTotpData
every { getPasswordlessRequestDataIntentOrNull() } returns mockPasswordlessRequestData
@ -1025,6 +1096,7 @@ private fun createMockIntent(
every { isMyVaultShortcut } returns mockIsMyVaultShortcut
every { isPasswordGeneratorShortcut } returns mockIsPasswordGeneratorShortcut
every { isAccountSecurityShortcut } returns mockIsAccountSecurityShortcut
every { isAddTotpLoginItemFromAuthenticator() } returns mockIsAddTotpLoginItemFromAuthenticator
}
private val FIXED_CLOCK: Clock = Clock.fixed(

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.auth.manager
import com.x8bit.bitwarden.ui.vault.model.TotpData
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
class AddTotpItemFromAuthenticatorTest {
@Test
fun `pendingAddTotpLoginItemData should start as null and keep value when set`() {
val manager = AddTotpItemFromAuthenticatorManagerImpl()
assertNull(manager.pendingAddTotpLoginItemData)
val totpData: TotpData = mockk()
manager.pendingAddTotpLoginItemData = totpData
assertEquals(
totpData,
manager.pendingAddTotpLoginItemData,
)
manager.pendingAddTotpLoginItemData = null
assertNull(manager.pendingAddTotpLoginItemData)
}
}

View file

@ -1,29 +1,42 @@
package com.x8bit.bitwarden.data.platform.processor
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.RemoteCallbackList
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
import com.bitwarden.authenticatorbridge.model.AddTotpLoginItemData
import com.bitwarden.authenticatorbridge.model.EncryptedAddTotpLoginItemData
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
import com.bitwarden.authenticatorbridge.model.SharedAccountData
import com.bitwarden.authenticatorbridge.util.AUTHENTICATOR_BRIDGE_SDK_VERSION
import com.bitwarden.authenticatorbridge.util.decrypt
import com.bitwarden.authenticatorbridge.util.encrypt
import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.authenticatorbridge.util.toFingerprint
import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManagerImpl
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.AuthenticatorBridgeRepository
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.createAddTotpItemFromAuthenticatorIntent
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import io.mockk.coEvery
import io.mockk.coVerify
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.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@ -37,23 +50,44 @@ import org.junit.jupiter.api.Test
class AuthenticatorBridgeProcessorTest {
private val featureFlagManager = mockk<FeatureFlagManager>()
private val addTotpItemFromAuthenticatorManager = AddTotpItemFromAuthenticatorManagerImpl()
private val authenticatorBridgeRepository = mockk<AuthenticatorBridgeRepository>()
private val context = mockk<Context> {
every { applicationContext } returns this@mockk
}
private lateinit var bridgeServiceProcessor: AuthenticatorBridgeProcessorImpl
@BeforeEach
fun setup() {
bridgeServiceProcessor = AuthenticatorBridgeProcessorImpl(
addTotpItemFromAuthenticatorManager = addTotpItemFromAuthenticatorManager,
authenticatorBridgeRepository = authenticatorBridgeRepository,
context = context,
featureFlagManager = featureFlagManager,
dispatcherManager = FakeDispatcherManager(),
)
mockkStatic(::createAddTotpItemFromAuthenticatorIntent)
mockkStatic(
SharedAccountData::encrypt,
EncryptedAddTotpLoginItemData::decrypt,
Uri::parse,
Uri::getTotpDataOrNull,
)
}
@AfterEach
fun teardown() {
unmockkStatic(::isBuildVersionBelow)
unmockkStatic(SharedAccountData::encrypt)
unmockkStatic(
::createAddTotpItemFromAuthenticatorIntent,
::isBuildVersionBelow,
)
unmockkStatic(
SharedAccountData::encrypt,
EncryptedAddTotpLoginItemData::decrypt,
Uri::parse,
Uri::getTotpDataOrNull,
)
}
@Test
@ -139,6 +173,80 @@ class AuthenticatorBridgeProcessorTest {
assertEquals(SYMMETRIC_KEY, binder.symmetricEncryptionKeyData)
}
@Test
@Suppress("MaxLineLength")
fun `startAddTotpLoginItemFlow should return false when symmetricEncryptionKeyData is null`() {
val binder = getDefaultBinder()
every { authenticatorBridgeRepository.authenticatorSyncSymmetricKey } returns null
val data: EncryptedAddTotpLoginItemData = mockk()
assertFalse(binder.startAddTotpLoginItemFlow(data))
verify { authenticatorBridgeRepository.authenticatorSyncSymmetricKey }
}
@Test
@Suppress("MaxLineLength")
fun `startAddTotpLoginItemFlow should return false when decryption fails`() {
val binder = getDefaultBinder()
val intent: Intent = mockk()
val data: EncryptedAddTotpLoginItemData = mockk()
every {
authenticatorBridgeRepository.authenticatorSyncSymmetricKey
} returns SYMMETRIC_KEY.symmetricEncryptionKey.byteArray
every { createAddTotpItemFromAuthenticatorIntent(context) } returns intent
every { data.decrypt(SYMMETRIC_KEY) } returns Result.failure(RuntimeException())
assertFalse(binder.startAddTotpLoginItemFlow(data))
verify { authenticatorBridgeRepository.authenticatorSyncSymmetricKey }
}
@Test
@Suppress("MaxLineLength")
fun `startAddTotpLoginItemFlow should return false when getTotpDataOrNull returns null`() {
val binder = getDefaultBinder()
val intent: Intent = mockk()
val totpUri = "totpUri"
val uri: Uri = mockk()
every { Uri.parse(totpUri) } returns uri
val data: EncryptedAddTotpLoginItemData = mockk()
val decryptedData: AddTotpLoginItemData = mockk()
every {
authenticatorBridgeRepository.authenticatorSyncSymmetricKey
} returns SYMMETRIC_KEY.symmetricEncryptionKey.byteArray
every { createAddTotpItemFromAuthenticatorIntent(context) } returns intent
every { data.decrypt(SYMMETRIC_KEY) } returns Result.success(decryptedData)
every { decryptedData.totpUri } returns totpUri
every { uri.getTotpDataOrNull() } returns null
assertFalse(binder.startAddTotpLoginItemFlow(data))
verify { authenticatorBridgeRepository.authenticatorSyncSymmetricKey }
}
@Test
@Suppress("MaxLineLength")
fun `startAddTotpLoginItemFlow should return true and set pendingAddTotpLoginItemData when getTotpDataOrNull succeeds`() {
val binder = getDefaultBinder()
val intent: Intent = mockk()
val totpUri = "totpUri"
val uri: Uri = mockk()
every { Uri.parse(totpUri) } returns uri
val expectedPendingData: TotpData = mockk()
val data: EncryptedAddTotpLoginItemData = mockk()
val decryptedData: AddTotpLoginItemData = mockk()
every {
authenticatorBridgeRepository.authenticatorSyncSymmetricKey
} returns SYMMETRIC_KEY.symmetricEncryptionKey.byteArray
every { createAddTotpItemFromAuthenticatorIntent(context) } returns intent
every { data.decrypt(SYMMETRIC_KEY) } returns Result.success(decryptedData)
every { decryptedData.totpUri } returns totpUri
every { uri.getTotpDataOrNull() } returns expectedPendingData
every { context.startActivity(intent) } just runs
assertTrue(binder.startAddTotpLoginItemFlow(data))
assertEquals(
expectedPendingData,
addTotpItemFromAuthenticatorManager.pendingAddTotpLoginItemData,
)
verify { context.startActivity(intent) }
verify { authenticatorBridgeRepository.authenticatorSyncSymmetricKey }
}
@Nested
inner class SyncAccountsTest {

View file

@ -49,13 +49,8 @@ interface IAuthenticatorBridgeService {
// Add TOTP Item
// ==============
// Returns an intent that can be launched to navigate the user to the add Totp item flow
// in the main password manager app.
Intent createAddTotpLoginItemIntent();
// Give the given TOTP item data to the main Bitwarden app before launching the add TOTP
// item flow. This should be called before launching the intent returned from
// createAddTotpLoginItemIntent().
void setPendingAddTotpLoginItemData(in EncryptedAddTotpLoginItemData data);
// Start the add TOTP item flow in the main Bitwarden app with the given data.
// Returns true if the flow was successfully launched and false otherwise.
boolean startAddTotpLoginItemFlow(in EncryptedAddTotpLoginItemData data);
}

View file

@ -1,5 +1,7 @@
package com.bitwarden.authenticatorbridge.manager
import android.content.Intent
import android.net.Uri
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import kotlinx.coroutines.flow.StateFlow
@ -14,4 +16,12 @@ interface AuthenticatorBridgeManager {
* State flow representing the current [AccountSyncState].
*/
val accountSyncStateFlow: StateFlow<AccountSyncState>
/**
* Start the add TOTP item flow in the main Bitwarden app with the given data.
*
* @param totpUri TOTP URI to add to the main Bitwarden app.
* @return true if the flow was successfully launched, false otherwise.
*/
fun startAddTotpLoginItemFlow(totpUri: String): Boolean
}

View file

@ -7,6 +7,7 @@ import android.content.ServiceConnection
import android.content.pm.PackageManager.NameNotFoundException
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
@ -14,11 +15,13 @@ 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.AddTotpLoginItemData
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.encrypt
import com.bitwarden.authenticatorbridge.util.isBuildVersionBelow
import com.bitwarden.authenticatorbridge.util.toFingerprint
import kotlinx.coroutines.flow.MutableStateFlow
@ -103,6 +106,21 @@ internal class AuthenticatorBridgeManagerImpl(
)
}
override fun startAddTotpLoginItemFlow(totpUri: String): Boolean =
bridgeService
?.safeCall {
// Grab symmetric key data from local storage:
val symmetricKey = symmetricKeyStorageProvider.symmetricKey ?: return@safeCall false
// Encrypt the given URI:
val addTotpData = AddTotpLoginItemData(totpUri).encrypt(symmetricKey).getOrThrow()
return@safeCall this.startAddTotpLoginItemFlow(addTotpData)
}
?.fold(
onFailure = { false },
onSuccess = { true }
)
?: false
private fun bindService() {
if (isBuildVersionBelow(Build.VERSION_CODES.S)) {
mutableSharedAccountsStateFlow.value = AccountSyncState.OsVersionNotSupported
@ -119,11 +137,17 @@ internal class AuthenticatorBridgeManagerImpl(
)
}
val flags = if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
Context.BIND_AUTO_CREATE
} else {
Context.BIND_AUTO_CREATE or Context.BIND_ALLOW_ACTIVITY_STARTS
}
val isBound = try {
applicationContext.bindService(
intent,
bridgeServiceConnection,
Context.BIND_AUTO_CREATE,
flags,
)
} catch (e: SecurityException) {
unbindService()

View file

@ -118,7 +118,7 @@ internal fun AddTotpLoginItemData.encrypt(
*
* @param symmetricEncryptionKeyData Symmetric key used for decryption.
*/
internal fun EncryptedAddTotpLoginItemData.decrypt(
fun EncryptedAddTotpLoginItemData.decrypt(
symmetricEncryptionKeyData: SymmetricEncryptionKeyData,
): Result<AddTotpLoginItemData> = runCatching {
val encodedKey = symmetricEncryptionKeyData