diff --git a/app/libs/authenticatorbridge-0.1.0-SNAPSHOT-release.aar b/app/libs/authenticatorbridge-0.1.0-SNAPSHOT-release.aar index aa6f2e076..9436cdc4e 100644 Binary files a/app/libs/authenticatorbridge-0.1.0-SNAPSHOT-release.aar and b/app/libs/authenticatorbridge-0.1.0-SNAPSHOT-release.aar differ diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 6d8429bc1..f16b9fd97 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AddTotpItemFromAuthenticatorManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AddTotpItemFromAuthenticatorManager.kt new file mode 100644 index 000000000..522fe0ad5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AddTotpItemFromAuthenticatorManager.kt @@ -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? +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AddTotpItemFromAuthenticatorManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AddTotpItemFromAuthenticatorManagerImpl.kt new file mode 100644 index 000000000..a8c4eeba9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AddTotpItemFromAuthenticatorManagerImpl.kt @@ -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 +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt index 1c2fff83d..0b84837ee 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt @@ -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() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index 62ab14ea7..bdd34075e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -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, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/processor/AuthenticatorBridgeProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/processor/AuthenticatorBridgeProcessorImpl.kt index 0b70ca04c..29df0ed42 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/processor/AuthenticatorBridgeProcessorImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/processor/AuthenticatorBridgeProcessorImpl.kt @@ -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() } 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 } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/AddTotpIntentFromAuthenticatorUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/AddTotpIntentFromAuthenticatorUtils.kt new file mode 100644 index 000000000..52b725c04 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/AddTotpIntentFromAuthenticatorUtils.kt @@ -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) diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index d55434390..053ae47a9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -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(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() + 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() + 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 { 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( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/AddTotpItemFromAuthenticatorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/AddTotpItemFromAuthenticatorTest.kt new file mode 100644 index 000000000..a60233c23 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/AddTotpItemFromAuthenticatorTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/processor/AuthenticatorBridgeProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/processor/AuthenticatorBridgeProcessorTest.kt index 483de21d6..4eae793d8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/processor/AuthenticatorBridgeProcessorTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/processor/AuthenticatorBridgeProcessorTest.kt @@ -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() + private val addTotpItemFromAuthenticatorManager = AddTotpItemFromAuthenticatorManagerImpl() private val authenticatorBridgeRepository = mockk() + private val context = mockk { + 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 { diff --git a/authenticatorbridge/src/main/aidl/com/bitwarden/authenticatorbridge/IAuthenticatorBridgeService.aidl b/authenticatorbridge/src/main/aidl/com/bitwarden/authenticatorbridge/IAuthenticatorBridgeService.aidl index 431cb9937..9ab0b703d 100644 --- a/authenticatorbridge/src/main/aidl/com/bitwarden/authenticatorbridge/IAuthenticatorBridgeService.aidl +++ b/authenticatorbridge/src/main/aidl/com/bitwarden/authenticatorbridge/IAuthenticatorBridgeService.aidl @@ -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); } diff --git a/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManager.kt b/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManager.kt index 4871017d7..abec491da 100644 --- a/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManager.kt +++ b/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManager.kt @@ -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 + + /** + * 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 } diff --git a/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerImpl.kt b/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerImpl.kt index 678b6ebda..263506cfc 100644 --- a/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerImpl.kt +++ b/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerImpl.kt @@ -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() diff --git a/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/util/EncryptionUtils.kt b/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/util/EncryptionUtils.kt index c54061c1c..6bfb2d480 100644 --- a/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/util/EncryptionUtils.kt +++ b/authenticatorbridge/src/main/java/com/bitwarden/authenticatorbridge/util/EncryptionUtils.kt @@ -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 = runCatching { val encodedKey = symmetricEncryptionKeyData