From 61b917016b2d3ea9d6429b0744e89343cc4bce4b Mon Sep 17 00:00:00 2001 From: romain Date: Tue, 17 Dec 2024 22:04:46 +0100 Subject: [PATCH] #4416 add possibility to select a client certificate for mTLS --- .../java/com/x8bit/bitwarden/MainActivity.kt | 11 +++ .../disk/model/EnvironmentUrlDataJson.kt | 6 ++ .../network/di/PlatformNetworkModule.kt | 16 ++++ .../network/retrofit/RetrofitsImpl.kt | 4 +- .../datasource/network/util/TLSHelper.kt | 75 +++++++++++++++++++ .../ChoosePrivateKeyAliasCallback.kt | 7 ++ .../ChoosePrivateKeyAliasCallbackImpl.kt | 12 +++ .../platform/repository/KeyChainRepository.kt | 15 ++++ .../repository/KeyChainRepositoryImpl.kt | 65 ++++++++++++++++ .../ui/auth/feature/auth/AuthNavigation.kt | 3 + .../environment/EnvironmentNavigation.kt | 4 +- .../feature/environment/EnvironmentScreen.kt | 34 +++++++++ .../environment/EnvironmentViewModel.kt | 21 ++++++ .../platform/feature/rootnav/RootNavScreen.kt | 4 +- app/src/main/res/values-de-rDE/strings.xml | 3 + app/src/main/res/values-fr-rFR/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../network/retrofit/RetrofitsTest.kt | 7 ++ .../environment/EnvironmentScreenTest.kt | 46 ++++++++++++ .../environment/EnvironmentViewModelTest.kt | 3 + 20 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/TLSHelper.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/repository/ChoosePrivateKeyAliasCallback.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/repository/ChoosePrivateKeyAliasCallbackImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/repository/KeyChainRepository.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/repository/KeyChainRepositoryImpl.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index 175487965b2..6d5d7d3c041 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -19,6 +19,8 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityComp import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.data.platform.repository.ChoosePrivateKeyAliasCallback +import com.x8bit.bitwarden.data.platform.repository.KeyChainRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider @@ -53,6 +55,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var debugLaunchManager: DebugMenuLaunchManager + @Inject + lateinit var keyChainRepository: KeyChainRepository + override fun onCreate(savedInstanceState: Bundle?) { var shouldShowSplashScreen = true installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen } @@ -66,6 +71,9 @@ class MainActivity : AppCompatActivity() { ) } + // inject current activity in keychain repository for later use + keyChainRepository.configure(this) + // Within the app the language will change dynamically and will be managed // by the OS, but we need to ensure we properly set the language when // upgrading from older versions that handle this differently. @@ -102,6 +110,9 @@ class MainActivity : AppCompatActivity() { RootNavScreen( onSplashScreenRemoved = { shouldShowSplashScreen = false }, navController = navController, + choosePrivateKeyAlias = { callback: ChoosePrivateKeyAliasCallback -> + keyChainRepository.choosePrivateKeyAlias(callback) + } ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/EnvironmentUrlDataJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/EnvironmentUrlDataJson.kt index 738091f49bd..d6ddaeeab51 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/EnvironmentUrlDataJson.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/EnvironmentUrlDataJson.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.Serializable * Represents URLs for various Bitwarden domains. * * @property base The overall base URL. + * @property keyAlias A key alias to use for connections with the server. * @property api Separate base URL for the "/api" domain (if applicable). * @property identity Separate base URL for the "/identity" domain (if applicable). * @property icon Separate base URL for the icon domain (if applicable). @@ -19,6 +20,9 @@ data class EnvironmentUrlDataJson( @SerialName("base") val base: String, + @SerialName("keyAlias") + val keyAlias: String? = null, + @SerialName("api") val api: String? = null, @@ -51,6 +55,7 @@ data class EnvironmentUrlDataJson( */ val DEFAULT_LEGACY_US: EnvironmentUrlDataJson = EnvironmentUrlDataJson( base = "https://vault.bitwarden.com", + keyAlias = null, api = "https://api.bitwarden.com", identity = "https://identity.bitwarden.com", icon = "https://icons.bitwarden.net", @@ -71,6 +76,7 @@ data class EnvironmentUrlDataJson( */ val DEFAULT_LEGACY_EU: EnvironmentUrlDataJson = EnvironmentUrlDataJson( base = "https://vault.bitwarden.eu", + keyAlias = null, api = "https://api.bitwarden.eu", identity = "https://identity.bitwarden.eu", icon = "https://icons.bitwarden.eu", diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt index ff476177341..8bc4c43bdd6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.platform.datasource.network.di import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors @@ -14,6 +15,9 @@ import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService import com.x8bit.bitwarden.data.platform.datasource.network.service.EventServiceImpl import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService import com.x8bit.bitwarden.data.platform.datasource.network.service.PushServiceImpl +import com.x8bit.bitwarden.data.platform.datasource.network.util.TLSHelper +import com.x8bit.bitwarden.data.platform.repository.KeyChainRepository +import com.x8bit.bitwarden.data.platform.repository.KeyChainRepositoryImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -70,6 +74,16 @@ object PlatformNetworkModule { @Singleton fun providesRefreshAuthenticator(): RefreshAuthenticator = RefreshAuthenticator() + @Provides + @Singleton + fun providesKeyChainRepository(environmentDiskSource: EnvironmentDiskSource): KeyChainRepository = + KeyChainRepositoryImpl(environmentDiskSource = environmentDiskSource) + + @Provides + @Singleton + fun providesTlsHelper(keyChainRepository: KeyChainRepository): TLSHelper = + TLSHelper(keyChainRepository = keyChainRepository) + @Provides @Singleton fun provideRetrofits( @@ -78,6 +92,7 @@ object PlatformNetworkModule { headersInterceptor: HeadersInterceptor, refreshAuthenticator: RefreshAuthenticator, json: Json, + tlsHelper: TLSHelper, ): Retrofits = RetrofitsImpl( authTokenInterceptor = authTokenInterceptor, @@ -85,6 +100,7 @@ object PlatformNetworkModule { headersInterceptor = headersInterceptor, refreshAuthenticator = refreshAuthenticator, json = json, + tlsHelper = tlsHelper ) @Provides diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt index 7ee544457c6..268c015ccdd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlI import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.HeadersInterceptor import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_KEY_AUTHORIZATION +import com.x8bit.bitwarden.data.platform.datasource.network.util.TLSHelper import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -24,6 +25,7 @@ class RetrofitsImpl( headersInterceptor: HeadersInterceptor, refreshAuthenticator: RefreshAuthenticator, json: Json, + tlsHelper: TLSHelper ) : Retrofits { //region Authenticated Retrofits @@ -84,7 +86,7 @@ class RetrofitsImpl( } private val baseOkHttpClient: OkHttpClient = - OkHttpClient.Builder() + tlsHelper.setupOkHttpClientSSLSocketFactory(OkHttpClient.Builder()) .addInterceptor(headersInterceptor) .build() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/TLSHelper.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/TLSHelper.kt new file mode 100644 index 00000000000..e3426ba45e4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/TLSHelper.kt @@ -0,0 +1,75 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.util + +import com.x8bit.bitwarden.data.platform.repository.KeyChainRepository +import java.net.Socket +import java.security.KeyStore +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import javax.inject.Inject +import javax.inject.Named +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509ExtendedKeyManager +import javax.net.ssl.X509TrustManager +import okhttp3.OkHttpClient + +class TLSHelper @Inject constructor( + @Named("keyChainRepository") private val keyChainRepository: KeyChainRepository, +) { + fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder): OkHttpClient.Builder { + val trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + val trustManagers = trustManagerFactory.trustManagers + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(arrayOf(getMTLSKeyManagerForOKHTTP()), trustManagers, null) + + builder.sslSocketFactory(sslContext.socketFactory, trustManagers[0] as X509TrustManager) + + return builder + } + + private fun getMTLSKeyManagerForOKHTTP(): X509ExtendedKeyManager { + return object : X509ExtendedKeyManager() { + override fun getClientAliases( + p0: String?, + p1: Array?, + ): Array { + return emptyArray() + } + + override fun chooseClientAlias( + p0: Array?, + p1: Array?, + p2: Socket?, + ): String { + return "" + } + + override fun getServerAliases( + p0: String?, + p1: Array?, + ): Array { + return arrayOf() + } + + override fun chooseServerAlias( + p0: String?, + p1: Array?, + p2: Socket?, + ): String { + return "" + } + + override fun getCertificateChain(p0: String?): Array? { + return keyChainRepository.getCertificateChain() + } + + override fun getPrivateKey(p0: String?): PrivateKey? { + return keyChainRepository.getPrivateKey() + } + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/ChoosePrivateKeyAliasCallback.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/ChoosePrivateKeyAliasCallback.kt new file mode 100644 index 00000000000..1d2654d3e02 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/ChoosePrivateKeyAliasCallback.kt @@ -0,0 +1,7 @@ +package com.x8bit.bitwarden.data.platform.repository + +import android.security.KeyChainAliasCallback + +interface ChoosePrivateKeyAliasCallback { + fun getCallback(): KeyChainAliasCallback +} \ No newline at end of file diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/ChoosePrivateKeyAliasCallbackImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/ChoosePrivateKeyAliasCallbackImpl.kt new file mode 100644 index 00000000000..8ceefc74982 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/ChoosePrivateKeyAliasCallbackImpl.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.data.platform.repository + +import android.security.KeyChainAliasCallback + +class ChoosePrivateKeyAliasCallbackImpl constructor(val callback: (String?) -> Unit) : + ChoosePrivateKeyAliasCallback { + override fun getCallback(): KeyChainAliasCallback { + return KeyChainAliasCallback { + callback(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/KeyChainRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/KeyChainRepository.kt new file mode 100644 index 00000000000..81433df1b94 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/KeyChainRepository.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.data.platform.repository + +import android.app.Activity +import java.security.PrivateKey +import java.security.cert.X509Certificate + +interface KeyChainRepository { + fun configure(activity: Activity) + + fun choosePrivateKeyAlias(callback: ChoosePrivateKeyAliasCallback) + + fun getPrivateKey(): PrivateKey? + + fun getCertificateChain(): Array? +} \ No newline at end of file diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/KeyChainRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/KeyChainRepositoryImpl.kt new file mode 100644 index 00000000000..17301aa6537 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/KeyChainRepositoryImpl.kt @@ -0,0 +1,65 @@ +package com.x8bit.bitwarden.data.platform.repository + +import android.app.Activity +import android.security.KeyChain +import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource +import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrlsOrDefault +import timber.log.Timber +import java.security.PrivateKey +import java.security.cert.X509Certificate +import javax.inject.Inject + +class KeyChainRepositoryImpl @Inject constructor( + environmentDiskSource: EnvironmentDiskSource, +) : KeyChainRepository { + private var activity: Activity? = null + private var alias: String? = null + private var key: PrivateKey? = null + private var chain: Array? = null + + init { + alias = + environmentDiskSource.preAuthEnvironmentUrlData.toEnvironmentUrlsOrDefault().environmentUrlData.keyAlias + } + + override fun configure(activity: Activity) { + this.activity = activity + } + + override fun choosePrivateKeyAlias(callback: ChoosePrivateKeyAliasCallback) { + if (activity == null) { + Timber.tag(this.javaClass.name) + .d("activity is not set yet -- trying to choose private key alias") + return + } + + KeyChain.choosePrivateKeyAlias(activity!!, { a -> + callback.getCallback().alias(a) + alias = a + }, null, null, null, alias) + } + + override fun getPrivateKey(): PrivateKey? { + if (key == null && activity != null && !alias.isNullOrEmpty()) { + key = try { + KeyChain.getPrivateKey(activity!!, alias!!) + } catch (e: Exception) { + null + } + } + + return key + } + + override fun getCertificateChain(): Array? { + if (chain == null && activity != null && !alias.isNullOrEmpty()) { + chain = try { + KeyChain.getCertificateChain(activity!!, alias!!) + } catch (e: Exception) { + null + } + } + + return chain + } +} \ No newline at end of file diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 7df1ecfa3e7..d8a4ae9ceed 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -6,6 +6,7 @@ import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.navOptions import androidx.navigation.navigation +import com.x8bit.bitwarden.data.platform.repository.ChoosePrivateKeyAliasCallback import com.x8bit.bitwarden.ui.auth.feature.checkemail.checkEmailDestination import com.x8bit.bitwarden.ui.auth.feature.checkemail.navigateToCheckEmail import com.x8bit.bitwarden.ui.auth.feature.completeregistration.completeRegistrationDestination @@ -50,6 +51,7 @@ const val AUTH_GRAPH_ROUTE: String = "auth_graph" @Suppress("LongMethod") fun NavGraphBuilder.authGraph( navController: NavHostController, + choosePrivateKeyAlias: (ChoosePrivateKeyAliasCallback) -> Unit, ) { navigation( startDestination = LANDING_ROUTE, @@ -173,6 +175,7 @@ fun NavGraphBuilder.authGraph( ) environmentDestination( onNavigateBack = { navController.popBackStack() }, + choosePrivateKeyAlias = choosePrivateKeyAlias, ) masterPasswordHintDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentNavigation.kt index abd0e27fa36..e3c6b6ef443 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentNavigation.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.auth.feature.environment import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import com.x8bit.bitwarden.data.platform.repository.ChoosePrivateKeyAliasCallback import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions private const val ENVIRONMENT_ROUTE = "environment" @@ -12,11 +13,12 @@ private const val ENVIRONMENT_ROUTE = "environment" */ fun NavGraphBuilder.environmentDestination( onNavigateBack: () -> Unit, + choosePrivateKeyAlias: (ChoosePrivateKeyAliasCallback) -> Unit, ) { composableWithSlideTransitions( route = ENVIRONMENT_ROUTE, ) { - EnvironmentScreen(onNavigateBack = onNavigateBack) + EnvironmentScreen(onNavigateBack = onNavigateBack, choosePrivateKeyAlias = choosePrivateKeyAlias) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt index e7985551a93..2d6f2e4fef7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt @@ -28,6 +28,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.ChoosePrivateKeyAliasCallback +import com.x8bit.bitwarden.data.platform.repository.ChoosePrivateKeyAliasCallbackImpl import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton @@ -47,6 +49,7 @@ import kotlinx.collections.immutable.persistentListOf fun EnvironmentScreen( onNavigateBack: () -> Unit, viewModel: EnvironmentViewModel = hiltViewModel(), + choosePrivateKeyAlias: (ChoosePrivateKeyAliasCallback) -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current @@ -133,6 +136,37 @@ fun EnvironmentScreen( .padding(horizontal = 16.dp), ) + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenListHeaderText( + label = stringResource(id = R.string.client_certificate), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + BitwardenTextField( + label = stringResource(id = R.string.client_certificate), + value = state.keyAlias, + hint = stringResource(id = R.string.client_certificate_footer), + onValueChange = { + }, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .testTag("KeyAliasEntry") + .padding(horizontal = 16.dp), + ) + + BitwardenTextButton( + label = stringResource(id = R.string.choose_client_certificate), + onClick = { + choosePrivateKeyAlias(ChoosePrivateKeyAliasCallbackImpl { + viewModel.trySendAction(EnvironmentAction.KeyAliasChange(it.orEmpty())) + }) + } + ) + Spacer(modifier = Modifier.height(24.dp)) BitwardenListHeaderText( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt index 4c93113f898..03faaa9b49f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.KeyChainRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text @@ -39,6 +40,7 @@ class EnvironmentViewModel @Inject constructor( } EnvironmentState( serverUrl = environmentUrlData.base, + keyAlias = environmentUrlData.keyAlias.orEmpty(), webVaultServerUrl = environmentUrlData.webVault.orEmpty(), apiServerUrl = environmentUrlData.api.orEmpty(), identityServerUrl = environmentUrlData.identity.orEmpty(), @@ -61,6 +63,7 @@ class EnvironmentViewModel @Inject constructor( is EnvironmentAction.SaveClick -> handleSaveClickAction() is EnvironmentAction.ErrorDialogDismiss -> handleErrorDialogDismiss() is EnvironmentAction.ServerUrlChange -> handleServerUrlChangeAction(action) + is EnvironmentAction.KeyAliasChange -> handleKeyAliasChangeAction(action) is EnvironmentAction.WebVaultServerUrlChange -> handleWebVaultServerUrlChangeAction(action) is EnvironmentAction.ApiServerUrlChange -> handleApiServerUrlChangeAction(action) is EnvironmentAction.IdentityServerUrlChange -> handleIdentityServerUrlChangeAction(action) @@ -91,6 +94,7 @@ class EnvironmentViewModel @Inject constructor( // Ensure all non-null/non-empty values have "http(s)://" prefixed. val updatedServerUrl = state.serverUrl.prefixHttpsIfNecessaryOrNull() ?: "" + val updatedKeyAlias = state.keyAlias val updatedWebVaultServerUrl = state.webVaultServerUrl.prefixHttpsIfNecessaryOrNull() val updatedApiServerUrl = state.apiServerUrl.prefixHttpsIfNecessaryOrNull() val updatedIdentityServerUrl = state.identityServerUrl.prefixHttpsIfNecessaryOrNull() @@ -99,6 +103,7 @@ class EnvironmentViewModel @Inject constructor( environmentRepository.environment = Environment.SelfHosted( environmentUrlData = EnvironmentUrlDataJson( base = updatedServerUrl, + keyAlias = updatedKeyAlias, api = updatedApiServerUrl, identity = updatedIdentityServerUrl, icon = updatedIconsServerUrl, @@ -122,6 +127,14 @@ class EnvironmentViewModel @Inject constructor( } } + private fun handleKeyAliasChangeAction( + action: EnvironmentAction.KeyAliasChange, + ) { + mutableStateFlow.update { + it.copy(keyAlias = action.keyAlias) + } + } + private fun handleWebVaultServerUrlChangeAction( action: EnvironmentAction.WebVaultServerUrlChange, ) { @@ -161,6 +174,7 @@ class EnvironmentViewModel @Inject constructor( @Parcelize data class EnvironmentState( val serverUrl: String, + val keyAlias: String, val webVaultServerUrl: String, val apiServerUrl: String, val identityServerUrl: String, @@ -211,6 +225,13 @@ sealed class EnvironmentAction { val serverUrl: String, ) : EnvironmentAction() + /** + * Indicates that the key alias has changed. + */ + data class KeyAliasChange( + val keyAlias: String, + ) : EnvironmentAction() + /** * Indicates that the web vault server URL has changed. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 4bb22e3fa86..1ded297f7cf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -15,6 +15,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.x8bit.bitwarden.data.platform.repository.ChoosePrivateKeyAliasCallback import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SETUP_AUTO_FILL_AS_ROOT_ROUTE import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SETUP_COMPLETE_ROUTE import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SETUP_UNLOCK_AS_ROOT_ROUTE @@ -74,6 +75,7 @@ fun RootNavScreen( viewModel: RootNavViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), onSplashScreenRemoved: () -> Unit = {}, + choosePrivateKeyAlias: (ChoosePrivateKeyAliasCallback) -> Unit = { _ -> }, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val previousStateReference = remember { AtomicReference(state) } @@ -92,7 +94,7 @@ fun RootNavScreen( popExitTransition = { toExitTransition()(this) }, ) { splashDestination() - authGraph(navController) + authGraph(navController = navController, choosePrivateKeyAlias = choosePrivateKeyAlias) removePasswordDestination() resetPasswordDestination() trustedDeviceGraph(navController) diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 357444a9cd7..534106a58b7 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -262,6 +262,9 @@ Das Scannen erfolgt automatisch. Verschlüsselungscode-Migration erforderlich. Bitte melde dich über den Web-Tresor an, um deinen Verschlüsselungscode zu aktualisieren. Mehr erfahren API Server-URL + Clientzertifikat + Clientzertifikat, welches benutzt wird, um sich mit dem Server zu verbinden. Falls kein Clientzertifikat installiert ist, passiert beim Klicken auf „Auswählen“ nichts. + Auswählen Benutzerdefinierte Umgebung Für fortgeschrittene Benutzer. Du kannst die Basis-URL der jeweiligen Dienste unabhängig voneinander festlegen. Die URLs der Umgebung wurden gespeichert. diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 9fbe4f63397..3013d9ee9c3 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -262,6 +262,9 @@ La numérisation se fera automatiquement. Migration de la clé de chiffrement nécessaire. Veuillez vous connecter sur le coffre web pour mettre à jour votre clé de chiffrement. En savoir plus URL du serveur de l\'API + Certificat client + Certificat client qui sera envoyé au serveur. Si aucun certificat client n\'est installé, cliquer sur le bouton « Choisir » n\'aura aucun effet. + Choisir Environnement personnalisé Pour les utilisateurs avancés. Vous pouvez spécifier l\'URL de base de chaque service indépendamment. Les URLs d\'environnement ont été enregistrées. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 921161d7659..cab05359e08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -262,6 +262,9 @@ Scanning will happen automatically. Encryption key migration required. Please login through the web vault to update your encryption key. Learn more API server URL + Client certificate + Client certificate that will be used for connections to the server. In case no user certificate is installed, clicking "Choose" will have no effect. + Choose Custom environment For advanced users. You can specify the base URL of each service independently. The environment URLs have been saved. diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsTest.kt index fe9cbb4b077..53481defc7a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsTest.kt @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthToke import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.HeadersInterceptor import com.x8bit.bitwarden.data.platform.datasource.network.model.NetworkResult +import com.x8bit.bitwarden.data.platform.datasource.network.util.TLSHelper import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -13,6 +14,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import okhttp3.Authenticator import okhttp3.Interceptor +import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After @@ -47,6 +49,10 @@ class RetrofitsTest { } private val json = Json private val server = MockWebServer() + private val tlsHelper = mockk { + val builderSlot = slot() + every { setupOkHttpClientSSLSocketFactory(capture(builderSlot)) } answers { builderSlot.captured } + } private val retrofits = RetrofitsImpl( authTokenInterceptor = authTokenInterceptor, @@ -54,6 +60,7 @@ class RetrofitsTest { headersInterceptor = headersInterceptors, refreshAuthenticator = refreshAuthenticator, json = json, + tlsHelper = tlsHelper, ) private var isAuthInterceptorCalled = false diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt index 9e9ddd3c824..d755990cd59 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt @@ -9,8 +9,11 @@ import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onParent import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.requestFocus +import com.x8bit.bitwarden.data.platform.repository.ChoosePrivateKeyAliasCallback import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import io.mockk.every @@ -30,6 +33,9 @@ class EnvironmentScreenTest : BaseComposeTest() { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow } + private val choosePrivateKeyAlias = { callback: ChoosePrivateKeyAliasCallback -> + callback.getCallback().alias("alias") + } @Before fun setUp() { @@ -37,6 +43,7 @@ class EnvironmentScreenTest : BaseComposeTest() { EnvironmentScreen( onNavigateBack = { onNavigateBackCalled = true }, viewModel = viewModel, + choosePrivateKeyAlias = choosePrivateKeyAlias, ) } } @@ -140,6 +147,44 @@ class EnvironmentScreenTest : BaseComposeTest() { } } + @Test + fun `key alias should change according to the state`() { + composeTestRule + .onNodeWithText("Client certificate") + // Click to focus to see placeholder + .performClick() + .assertTextEquals( + "Client certificate", + "Client certificate that will be used for connections to the server", + "", + "", + ) + + mutableStateFlow.update { it.copy(keyAlias = "alias") } + + composeTestRule + .onNodeWithText("Client certificate") + .assertTextEquals( + "Client certificate", + "Client certificate that will be used for connections to the server", + "alias", + ) + } + + @Test + fun `key alias change should send KeyAliasChange`() { + composeTestRule + .onNodeWithText("Choose", useUnmergedTree = true) + .onParent() + .requestFocus() + .performClick() + verify { + viewModel.trySendAction( + EnvironmentAction.KeyAliasChange(keyAlias = "alias"), + ) + } + } + @Test fun `web vault URL should change according to the state`() { composeTestRule @@ -255,6 +300,7 @@ class EnvironmentScreenTest : BaseComposeTest() { companion object { val DEFAULT_STATE = EnvironmentState( serverUrl = "", + keyAlias = "", webVaultServerUrl = "", apiServerUrl = "", identityServerUrl = "", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt index 8d7c3a3ed06..f018109e82a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt @@ -173,6 +173,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() { Environment.SelfHosted( environmentUrlData = EnvironmentUrlDataJson( base = "https://server-url", + keyAlias = "", api = "https://api-url", identity = "https://identity-url", icon = "https://icons-url", @@ -220,6 +221,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() { Environment.SelfHosted( environmentUrlData = EnvironmentUrlDataJson( base = "", + keyAlias = "", api = null, identity = null, icon = null, @@ -308,6 +310,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() { companion object { private val DEFAULT_STATE = EnvironmentState( serverUrl = "", + keyAlias = "", webVaultServerUrl = "", apiServerUrl = "", identityServerUrl = "",