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.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView 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.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull 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.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository 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.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel 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.isAccountSecurityShortcut
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut 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 com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -58,6 +61,7 @@ private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager, accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager, autofillSelectionManager: AutofillSelectionManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager, private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager, private val garbageCollectionManager: GarbageCollectionManager,
private val fido2CredentialManager: Fido2CredentialManager, private val fido2CredentialManager: Fido2CredentialManager,
@ -234,7 +238,20 @@ class MainViewModel @Inject constructor(
val autofillSaveItem = intent.getAutofillSaveItemOrNull() val autofillSaveItem = intent.getAutofillSaveItemOrNull()
val autofillSelectionData = intent.getAutofillSelectionDataOrNull() val autofillSelectionData = intent.getAutofillSelectionDataOrNull()
val shareData = intentManager.getShareDataFromIntent(intent) 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 hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut val hasVaultShortcut = intent.isMyVaultShortcut
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut 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.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService 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.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.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManagerImpl import com.x8bit.bitwarden.data.auth.manager.AuthRequestManagerImpl
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
@ -124,4 +126,9 @@ object AuthManagerModule {
vaultSdkSource = vaultSdkSource, vaultSdkSource = vaultSdkSource,
dispatcherManager = dispatcherManager, dispatcherManager = dispatcherManager,
) )
@Provides
@Singleton
fun providesAddTotpItemFromAuthenticatorManager(): AddTotpItemFromAuthenticatorManager =
AddTotpItemFromAuthenticatorManagerImpl()
} }

View file

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

View file

@ -1,23 +1,28 @@
package com.x8bit.bitwarden.data.platform.processor package com.x8bit.bitwarden.data.platform.processor
import android.content.Intent import android.content.Context
import android.os.Build import android.os.Build
import android.os.IInterface import android.os.IInterface
import android.os.RemoteCallbackList import android.os.RemoteCallbackList
import androidx.core.net.toUri
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
import com.bitwarden.authenticatorbridge.model.EncryptedAddTotpLoginItemData import com.bitwarden.authenticatorbridge.model.EncryptedAddTotpLoginItemData
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyFingerprintData import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyFingerprintData
import com.bitwarden.authenticatorbridge.util.AUTHENTICATOR_BRIDGE_SDK_VERSION 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.encrypt
import com.bitwarden.authenticatorbridge.util.toFingerprint import com.bitwarden.authenticatorbridge.util.toFingerprint
import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData 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.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager 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.AuthenticatorBridgeRepository 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.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -26,10 +31,13 @@ import kotlinx.coroutines.launch
*/ */
class AuthenticatorBridgeProcessorImpl( class AuthenticatorBridgeProcessorImpl(
private val authenticatorBridgeRepository: AuthenticatorBridgeRepository, private val authenticatorBridgeRepository: AuthenticatorBridgeRepository,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val featureFlagManager: FeatureFlagManager, private val featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager, dispatcherManager: DispatcherManager,
context: Context,
) : AuthenticatorBridgeProcessor { ) : AuthenticatorBridgeProcessor {
private val applicationContext = context.applicationContext
private val callbacks by lazy { RemoteCallbackList<IAuthenticatorBridgeServiceCallback>() } private val callbacks by lazy { RemoteCallbackList<IAuthenticatorBridgeServiceCallback>() }
private val scope by lazy { CoroutineScope(dispatcherManager.default) } private val scope by lazy { CoroutineScope(dispatcherManager.default) }
@ -101,13 +109,18 @@ class AuthenticatorBridgeProcessorImpl(
} }
} }
override fun createAddTotpLoginItemIntent(): Intent { override fun startAddTotpLoginItemFlow(data: EncryptedAddTotpLoginItemData): Boolean {
// TODO: BITAU-112 val symmetricEncryptionKey = symmetricEncryptionKeyData ?: return false
return Intent() val intent = createAddTotpItemFromAuthenticatorIntent(context = applicationContext)
} val totpData = data.decrypt(symmetricEncryptionKey)
.getOrNull()
override fun setPendingAddTotpLoginItemData(data: EncryptedAddTotpLoginItemData?) { ?.totpUri
// TODO: BITAU-112 ?.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 app.cash.turbine.test
import com.bitwarden.vault.CipherView import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus 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.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult 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.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow 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.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -79,6 +81,7 @@ class MainViewModelTest : BaseViewModelTest() {
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl() private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
private val accessibilitySelectionManager: AccessibilitySelectionManager = private val accessibilitySelectionManager: AccessibilitySelectionManager =
AccessibilitySelectionManagerImpl() AccessibilitySelectionManagerImpl()
private val addTotpItemAuthenticatorManager = AddTotpItemFromAuthenticatorManagerImpl()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null) private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT) private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true) private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
@ -131,6 +134,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getFido2AssertionRequestOrNull, Intent::getFido2AssertionRequestOrNull,
Intent::getFido2CredentialRequestOrNull, Intent::getFido2CredentialRequestOrNull,
Intent::getFido2GetCredentialsRequestOrNull, Intent::getFido2GetCredentialsRequestOrNull,
Intent::isAddTotpLoginItemFromAuthenticator,
) )
mockkStatic( mockkStatic(
Intent::isMyVaultShortcut, Intent::isMyVaultShortcut,
@ -150,6 +154,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getFido2AssertionRequestOrNull, Intent::getFido2AssertionRequestOrNull,
Intent::getFido2CredentialRequestOrNull, Intent::getFido2CredentialRequestOrNull,
Intent::getFido2GetCredentialsRequestOrNull, Intent::getFido2GetCredentialsRequestOrNull,
Intent::isAddTotpLoginItemFromAuthenticator,
) )
unmockkStatic( unmockkStatic(
Intent::isMyVaultShortcut, 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") @Suppress("MaxLineLength")
@Test @Test
fun `on ReceiveFirstIntent with share data should set the special circumstance to ShareNewSend`() { 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") @Suppress("MaxLineLength")
@Test @Test
fun `on ReceiveNewIntent with autofill data should set the special circumstance to AutofillSelection`() { fun `on ReceiveNewIntent with autofill data should set the special circumstance to AutofillSelection`() {
@ -943,6 +1012,7 @@ class MainViewModelTest : BaseViewModelTest() {
initialSpecialCircumstance: SpecialCircumstance? = null, initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel( ) = MainViewModel(
accessibilitySelectionManager = accessibilitySelectionManager, accessibilitySelectionManager = accessibilitySelectionManager,
addTotpItemFromAuthenticatorManager = addTotpItemAuthenticatorManager,
autofillSelectionManager = autofillSelectionManager, autofillSelectionManager = autofillSelectionManager,
specialCircumstanceManager = specialCircumstanceManager, specialCircumstanceManager = specialCircumstanceManager,
garbageCollectionManager = garbageCollectionManager, garbageCollectionManager = garbageCollectionManager,
@ -1013,6 +1083,7 @@ private fun createMockIntent(
mockIsMyVaultShortcut: Boolean = false, mockIsMyVaultShortcut: Boolean = false,
mockIsPasswordGeneratorShortcut: Boolean = false, mockIsPasswordGeneratorShortcut: Boolean = false,
mockIsAccountSecurityShortcut: Boolean = false, mockIsAccountSecurityShortcut: Boolean = false,
mockIsAddTotpLoginItemFromAuthenticator: Boolean = false,
): Intent = mockk<Intent> { ): Intent = mockk<Intent> {
every { getTotpDataOrNull() } returns mockTotpData every { getTotpDataOrNull() } returns mockTotpData
every { getPasswordlessRequestDataIntentOrNull() } returns mockPasswordlessRequestData every { getPasswordlessRequestDataIntentOrNull() } returns mockPasswordlessRequestData
@ -1025,6 +1096,7 @@ private fun createMockIntent(
every { isMyVaultShortcut } returns mockIsMyVaultShortcut every { isMyVaultShortcut } returns mockIsMyVaultShortcut
every { isPasswordGeneratorShortcut } returns mockIsPasswordGeneratorShortcut every { isPasswordGeneratorShortcut } returns mockIsPasswordGeneratorShortcut
every { isAccountSecurityShortcut } returns mockIsAccountSecurityShortcut every { isAccountSecurityShortcut } returns mockIsAccountSecurityShortcut
every { isAddTotpLoginItemFromAuthenticator() } returns mockIsAddTotpLoginItemFromAuthenticator
} }
private val FIXED_CLOCK: Clock = Clock.fixed( 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 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.Build
import android.os.RemoteCallbackList import android.os.RemoteCallbackList
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback 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.EncryptedSharedAccountData
import com.bitwarden.authenticatorbridge.model.SharedAccountData import com.bitwarden.authenticatorbridge.model.SharedAccountData
import com.bitwarden.authenticatorbridge.util.AUTHENTICATOR_BRIDGE_SDK_VERSION 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.encrypt
import com.bitwarden.authenticatorbridge.util.generateSecretKey import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.authenticatorbridge.util.toFingerprint import com.bitwarden.authenticatorbridge.util.toFingerprint
import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData 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.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.AuthenticatorBridgeRepository import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepository
import com.x8bit.bitwarden.data.platform.util.asSuccess 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.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.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkConstructor import io.mockk.mockkConstructor
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
@ -37,23 +50,44 @@ import org.junit.jupiter.api.Test
class AuthenticatorBridgeProcessorTest { class AuthenticatorBridgeProcessorTest {
private val featureFlagManager = mockk<FeatureFlagManager>() private val featureFlagManager = mockk<FeatureFlagManager>()
private val addTotpItemFromAuthenticatorManager = AddTotpItemFromAuthenticatorManagerImpl()
private val authenticatorBridgeRepository = mockk<AuthenticatorBridgeRepository>() private val authenticatorBridgeRepository = mockk<AuthenticatorBridgeRepository>()
private val context = mockk<Context> {
every { applicationContext } returns this@mockk
}
private lateinit var bridgeServiceProcessor: AuthenticatorBridgeProcessorImpl private lateinit var bridgeServiceProcessor: AuthenticatorBridgeProcessorImpl
@BeforeEach @BeforeEach
fun setup() { fun setup() {
bridgeServiceProcessor = AuthenticatorBridgeProcessorImpl( bridgeServiceProcessor = AuthenticatorBridgeProcessorImpl(
addTotpItemFromAuthenticatorManager = addTotpItemFromAuthenticatorManager,
authenticatorBridgeRepository = authenticatorBridgeRepository, authenticatorBridgeRepository = authenticatorBridgeRepository,
context = context,
featureFlagManager = featureFlagManager, featureFlagManager = featureFlagManager,
dispatcherManager = FakeDispatcherManager(), dispatcherManager = FakeDispatcherManager(),
) )
mockkStatic(::createAddTotpItemFromAuthenticatorIntent)
mockkStatic(
SharedAccountData::encrypt,
EncryptedAddTotpLoginItemData::decrypt,
Uri::parse,
Uri::getTotpDataOrNull,
)
} }
@AfterEach @AfterEach
fun teardown() { fun teardown() {
unmockkStatic(::isBuildVersionBelow) unmockkStatic(
unmockkStatic(SharedAccountData::encrypt) ::createAddTotpItemFromAuthenticatorIntent,
::isBuildVersionBelow,
)
unmockkStatic(
SharedAccountData::encrypt,
EncryptedAddTotpLoginItemData::decrypt,
Uri::parse,
Uri::getTotpDataOrNull,
)
} }
@Test @Test
@ -139,6 +173,80 @@ class AuthenticatorBridgeProcessorTest {
assertEquals(SYMMETRIC_KEY, binder.symmetricEncryptionKeyData) 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 @Nested
inner class SyncAccountsTest { inner class SyncAccountsTest {

View file

@ -49,13 +49,8 @@ interface IAuthenticatorBridgeService {
// Add TOTP Item // Add TOTP Item
// ============== // ==============
// Returns an intent that can be launched to navigate the user to the add Totp item flow // Start the add TOTP item flow in the main Bitwarden app with the given data.
// in the main password manager app. // Returns true if the flow was successfully launched and false otherwise.
Intent createAddTotpLoginItemIntent(); boolean startAddTotpLoginItemFlow(in EncryptedAddTotpLoginItemData data);
// 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);
} }

View file

@ -1,5 +1,7 @@
package com.bitwarden.authenticatorbridge.manager package com.bitwarden.authenticatorbridge.manager
import android.content.Intent
import android.net.Uri
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -14,4 +16,12 @@ interface AuthenticatorBridgeManager {
* State flow representing the current [AccountSyncState]. * State flow representing the current [AccountSyncState].
*/ */
val accountSyncStateFlow: StateFlow<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.content.pm.PackageManager.NameNotFoundException
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner 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.AccountSyncState
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
import com.bitwarden.authenticatorbridge.manager.util.toPackageName import com.bitwarden.authenticatorbridge.manager.util.toPackageName
import com.bitwarden.authenticatorbridge.model.AddTotpLoginItemData
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
import com.bitwarden.authenticatorbridge.provider.AuthenticatorBridgeCallbackProvider import com.bitwarden.authenticatorbridge.provider.AuthenticatorBridgeCallbackProvider
import com.bitwarden.authenticatorbridge.provider.StubAuthenticatorBridgeCallbackProvider import com.bitwarden.authenticatorbridge.provider.StubAuthenticatorBridgeCallbackProvider
import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider
import com.bitwarden.authenticatorbridge.util.decrypt import com.bitwarden.authenticatorbridge.util.decrypt
import com.bitwarden.authenticatorbridge.util.encrypt
import com.bitwarden.authenticatorbridge.util.isBuildVersionBelow import com.bitwarden.authenticatorbridge.util.isBuildVersionBelow
import com.bitwarden.authenticatorbridge.util.toFingerprint import com.bitwarden.authenticatorbridge.util.toFingerprint
import kotlinx.coroutines.flow.MutableStateFlow 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() { private fun bindService() {
if (isBuildVersionBelow(Build.VERSION_CODES.S)) { if (isBuildVersionBelow(Build.VERSION_CODES.S)) {
mutableSharedAccountsStateFlow.value = AccountSyncState.OsVersionNotSupported 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 { val isBound = try {
applicationContext.bindService( applicationContext.bindService(
intent, intent,
bridgeServiceConnection, bridgeServiceConnection,
Context.BIND_AUTO_CREATE, flags,
) )
} catch (e: SecurityException) { } catch (e: SecurityException) {
unbindService() unbindService()

View file

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