diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5655e474ce5..c201ce6b6db 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -188,6 +188,9 @@ dependencies { // Development dependencies debugImplementation(libs.leakCanary) + // oauth dependencies + implementation(libs.openIdAppOauth) + // Internal, dev, beta and staging only tracking & logging devImplementation(libs.dataDog.core) internalImplementation(libs.dataDog.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 455a27e1535..ae6166b0b40 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -144,6 +144,12 @@ + + + + + + @@ -199,6 +205,34 @@ + + + + + + + + + + + + + + + + + + + ) -> Unit + + operator fun invoke(context: Context, enrollmentResultHandler: (Either) -> Unit) { + this.enrollmentResultHandler = enrollmentResultHandler + scope.launch { + enrollE2EI.initialEnrollment().fold({ + enrollmentResultHandler(Either.Left(it)) + }, { + if (it is E2EIEnrollmentResult.Initialized) { + initialEnrollmentResult = it + OAuthUseCase(context, it.target).launch( + context.getActivity()!!.activityResultRegistry, + ::oAuthResultHandler + ) + } else enrollmentResultHandler(Either.Right(it)) + }) + } + } + + private fun oAuthResultHandler(oAuthResult: OAuthUseCase.OAuthResult) { + scope.launch { + when (oAuthResult) { + is OAuthUseCase.OAuthResult.Success -> { + enrollE2EI.finalizeEnrollment( + oAuthResult.idToken, + initialEnrollmentResult + ).map { enrollmentResultHandler(Either.Right(it)) } + } + + is OAuthUseCase.OAuthResult.Failed -> { + enrollmentResultHandler(Either.Left(E2EIFailure.FailedOAuth(oAuthResult.reason))) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt new file mode 100644 index 00000000000..7bd95f4f25b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt @@ -0,0 +1,207 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.e2ei + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContracts +import com.wire.android.appLogger +import com.wire.android.util.deeplink.DeepLinkProcessor +import net.openid.appauth.AppAuthConfiguration +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ClientAuthentication +import net.openid.appauth.ClientSecretBasic +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.browser.BrowserAllowList +import net.openid.appauth.browser.VersionedBrowserMatcher +import net.openid.appauth.connectivity.ConnectionBuilder +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +class OAuthUseCase(context: Context, private val authUrl: String) { + private var authState: AuthState = AuthState() + private var authorizationService: AuthorizationService + private lateinit var authServiceConfig: AuthorizationServiceConfiguration + + // todo: this is a temporary code to ignore ssl issues on the test environment, will be removed after the preparation of the environment + // region Ignore SSL for OAuth + val naiveTrustManager = object : X509TrustManager { + override fun getAcceptedIssuers(): Array = arrayOf() + override fun checkClientTrusted(certs: Array, authType: String) = Unit + override fun checkServerTrusted(certs: Array, authType: String) = Unit + } + val insecureSocketFactory = SSLContext.getInstance("SSL").apply { + val trustAllCerts = arrayOf(naiveTrustManager) + init(null, trustAllCerts, SecureRandom()) + }.socketFactory + + private var insecureConnection = ConnectionBuilder() { uri -> + val url = URL(uri.toString()) + val connection = url.openConnection() as HttpURLConnection + if (connection is HttpsURLConnection) { + connection.hostnameVerifier = HostnameVerifier { _, _ -> true } + connection.sslSocketFactory = insecureSocketFactory + } + connection + } + // endregion + + private var appAuthConfiguration: AppAuthConfiguration = AppAuthConfiguration.Builder() + .setBrowserMatcher( + BrowserAllowList( + VersionedBrowserMatcher.CHROME_CUSTOM_TAB, VersionedBrowserMatcher.SAMSUNG_CUSTOM_TAB + ) + ) + .setConnectionBuilder(insecureConnection) + .setSkipIssuerHttpsCheck(true) + .build() + + init { + authorizationService = AuthorizationService(context, appAuthConfiguration) + } + + private fun getAuthorizationRequestIntent(): Intent = authorizationService.getAuthorizationRequestIntent(getAuthorizationRequest()) + + fun launch(activityResultRegistry: ActivityResultRegistry, resultHandler: (OAuthResult) -> Unit) { + val resultLauncher = activityResultRegistry.register( + OAUTH_ACTIVITY_RESULT_KEY, ActivityResultContracts.StartActivityForResult() + ) { result -> + handleActivityResult(result, resultHandler) + } + AuthorizationServiceConfiguration.fetchFromUrl( + Uri.parse(authUrl.plus(IDP_CONFIGURATION_PATH)), + { configuration, ex -> + if (ex == null) { + authServiceConfig = configuration!! + resultLauncher.launch(getAuthorizationRequestIntent()) + } else { + resultHandler(OAuthResult.Failed.InvalidActivityResult("Fetching the configurations failed! $ex")) + } + }, insecureConnection + ) + } + + private fun handleActivityResult(result: ActivityResult, resultHandler: (OAuthResult) -> Unit) { + if (result.resultCode == Activity.RESULT_OK) { + handleAuthorizationResponse(result.data!!, resultHandler) + } else { + resultHandler(OAuthResult.Failed.InvalidActivityResult(result.toString())) + } + } + + private fun handleAuthorizationResponse(intent: Intent, resultHandler: (OAuthResult) -> Unit) { + val authorizationResponse: AuthorizationResponse? = AuthorizationResponse.fromIntent(intent) + val clientAuth: ClientAuthentication = ClientSecretBasic(CLIENT_SECRET) + + val error = AuthorizationException.fromIntent(intent) + + authState = AuthState(authorizationResponse, error) + + val tokenExchangeRequest = authorizationResponse?.createTokenExchangeRequest() + + tokenExchangeRequest?.let { request -> + authorizationService.performTokenRequest(request, clientAuth) { response, exception -> + if (exception != null) { + authState = AuthState() + resultHandler(OAuthResult.Failed(exception.toString())) + } else { + if (response != null) { + authState.update(response, exception) + appLogger.i("OAuth idToken: ${response.idToken}") + resultHandler(OAuthResult.Success(response.idToken.toString())) + } else { + resultHandler(OAuthResult.Failed.EmptyResponse) + } + } + } + } ?: resultHandler(OAuthResult.Failed.Unknown) + } + + private fun getAuthorizationRequest() = AuthorizationRequest.Builder( + authServiceConfig, CLIENT_ID, ResponseTypeValues.CODE, URL_AUTH_REDIRECT + ).setCodeVerifier().setScopes(SCOPE_OPENID, SCOPE_EMAIL, SCOPE_PROFILE).build() + + private fun AuthorizationRequest.Builder.setCodeVerifier(): AuthorizationRequest.Builder { + val codeVerifier = getCodeVerifier() + setCodeVerifier( + codeVerifier, getCodeChallenge(codeVerifier), CODE_VERIFIER_CHALLENGE_METHOD + ) + return this + } + + @Suppress("MagicNumber") + private fun getCodeVerifier(): String { + val secureRandom = SecureRandom() + val bytes = ByteArray(64) + secureRandom.nextBytes(bytes) + return Base64.encodeToString(bytes, ENCODING) + } + + private fun getCodeChallenge(codeVerifier: String): String { + val hash = MESSAGE_DIGEST.digest(codeVerifier.toByteArray()) + return Base64.encodeToString(hash, ENCODING) + } + + sealed class OAuthResult { + data class Success(val idToken: String) : OAuthResult() + open class Failed(val reason: String) : OAuthResult() { + object Unknown : Failed("Unknown") + class InvalidActivityResult(reason: String) : Failed(reason) + object EmptyResponse : Failed("Empty Response") + } + } + + companion object { + const val OAUTH_ACTIVITY_RESULT_KEY = "OAuthActivityResult" + + const val SCOPE_PROFILE = "profile" + const val SCOPE_EMAIL = "email" + const val SCOPE_OPENID = "openid" + + // todo: clientId and the clientSecret will be replaced with the values from the BE once the BE provides them + const val CLIENT_ID = "wireapp" + const val CLIENT_SECRET = "dUpVSGx2dVdFdGQ0dmsxWGhDalQ0SldU" + const val CODE_VERIFIER_CHALLENGE_METHOD = "S256" + const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" + val MESSAGE_DIGEST = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + const val ENCODING = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + val URL_AUTH_REDIRECT: Uri = Uri.Builder().scheme(DeepLinkProcessor.DEEP_LINK_SCHEME) + .authority(DeepLinkProcessor.E2EI_DEEPLINK_HOST) + .appendPath(DeepLinkProcessor.E2EI_DEEPLINK_OAUTH_REDIRECT_PATH).build() + + const val IDP_CONFIGURATION_PATH = "/.well-known/openid-configuration" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt index 65f57dbeb98..9095e883ea6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt @@ -18,6 +18,8 @@ * */ +@file:Suppress("MultiLineIfElse") + package com.wire.android.ui.common import androidx.compose.foundation.layout.Box diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index 343cad91d17..61b1b98b7cd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel @@ -37,9 +38,13 @@ import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.CurrentAccount +import com.wire.android.feature.e2ei.GetE2EICertificateUseCase import com.wire.android.migration.failure.UserMigrationStatus import com.wire.android.model.Clickable import com.wire.android.ui.common.RowItemTemplate +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.WireSwitch import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dimensions @@ -51,10 +56,13 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.E2EIFailure import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.debug.DisableEventProcessingUseCase +import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase +import com.wire.kalium.logic.functional.fold import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -72,7 +80,9 @@ data class DebugDataOptionsState( val mlsErrorMessage: String = "null", val isManualMigrationAllowed: Boolean = false, val debugId: String = "null", - val commitish: String = "null" + val commitish: String = "null", + val certificate: String = "null", + val showCertificate: Boolean = false ) @Suppress("LongParameterList") @@ -85,7 +95,8 @@ class DebugDataOptionsViewModel private val updateApiVersions: UpdateApiVersionsScheduler, private val mlsKeyPackageCountUseCase: MLSKeyPackageCountUseCase, private val restartSlowSyncProcessForRecovery: RestartSlowSyncProcessForRecoveryUseCase, - private val disableEventProcessingUseCase: DisableEventProcessingUseCase + private val disableEventProcessingUseCase: DisableEventProcessingUseCase, + private val e2eiCertificateUseCase: GetE2EICertificateUseCase ) : ViewModel() { var state by mutableStateOf( @@ -116,6 +127,28 @@ class DebugDataOptionsViewModel } } + fun enrollE2EICertificate(context: Context) { + e2eiCertificateUseCase(context) { result -> + result.fold({ + state = state.copy( + certificate = (it as E2EIFailure.FailedOAuth).reason, showCertificate = true + ) + }, { + if (it is E2EIEnrollmentResult.Finalized) { + state = state.copy( + certificate = it.certificate, showCertificate = true + ) + } + }) + } + } + + fun dismissCertificateDialog() { + state = state.copy( + showCertificate = false, + ) + } + fun forceUpdateApiVersions() { updateApiVersions.scheduleImmediateApiVersionUpdate() } @@ -199,7 +232,9 @@ fun DebugDataOptions( onRestartSlowSyncForRecovery = viewModel::restartSlowSyncForRecovery, onForceUpdateApiVersions = viewModel::forceUpdateApiVersions, onManualMigrationPressed = { onManualMigrationPressed(viewModel.currentAccount) }, - onDisableEventProcessingChange = viewModel::disableEventProcessing + onDisableEventProcessingChange = viewModel::disableEventProcessing, + enrollE2EICertificate = viewModel::enrollE2EICertificate, + dismissCertificateDialog = viewModel::dismissCertificateDialog ) } @@ -214,7 +249,9 @@ fun DebugDataOptionsContent( onDisableEventProcessingChange: (Boolean) -> Unit, onRestartSlowSyncForRecovery: () -> Unit, onForceUpdateApiVersions: () -> Unit, - onManualMigrationPressed: () -> Unit + onManualMigrationPressed: () -> Unit, + enrollE2EICertificate: (Context) -> Unit, + dismissCertificateDialog: () -> Unit ) { Column { @@ -249,7 +286,6 @@ fun DebugDataOptionsContent( onClick = { onCopyText(state.commitish) } ) ) - if (BuildConfig.PRIVATE_BUILD) { SettingsItem( @@ -261,19 +297,41 @@ fun DebugDataOptionsContent( onClick = { onCopyText(state.debugId) } ) ) + if (BuildConfig.DEBUG) { + GetE2EICertificateSwitch( + enrollE2EI = enrollE2EICertificate + ) + if (state.showCertificate) { + WireDialog( + title = stringResource(R.string.end_to_end_identity_ceritifcate), + text = state.certificate, + onDismiss = { + dismissCertificateDialog() + }, + optionButton1Properties = WireDialogButtonProperties( + onClick = { + dismissCertificateDialog() + }, + text = stringResource(R.string.label_ok), + type = WireDialogButtonType.Primary, + ) + ) + } + } ProteusOptions( isEncryptedStorageEnabled = state.isEncryptedProteusStorageEnabled, onEncryptedStorageEnabledChange = onEnableEncryptedProteusStorageChange ) - - MLSOptions( - keyPackagesCount = state.keyPackagesCount, - mlsClientId = state.mslClientId, - mlsErrorMessage = state.mlsErrorMessage, - restartSlowSyncForRecovery = onRestartSlowSyncForRecovery, - onCopyText = onCopyText - ) + if (BuildConfig.DEBUG) { + MLSOptions( + keyPackagesCount = state.keyPackagesCount, + mlsClientId = state.mslClientId, + mlsErrorMessage = state.mlsErrorMessage, + restartSlowSyncForRecovery = onRestartSlowSyncForRecovery, + onCopyText = onCopyText + ) + } DebugToolsOptions( isEventProcessingEnabled = state.isEventProcessingDisabled, @@ -292,6 +350,35 @@ fun DebugDataOptionsContent( } } +@Composable +private fun GetE2EICertificateSwitch( + enrollE2EI: (context: Context) -> Unit +) { + val context = LocalContext.current + Column { + FolderHeader(stringResource(R.string.debug_settings_e2ei_enrollment_title)) + RowItemTemplate(modifier = Modifier.wrapContentWidth(), + title = { + Text( + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground, + text = stringResource(R.string.label_get_e2ei_cetificate), + modifier = Modifier.padding(start = dimensions().spacing8x) + ) + }, + actions = { + WirePrimaryButton( + onClick = { + enrollE2EI(context) + }, + text = stringResource(R.string.label_get_e2ei_cetificate), + fillMaxWidth = false + ) + } + ) + } +} + //region Scala Migration Options @Composable private fun ManualMigrationOptions( @@ -513,6 +600,8 @@ fun PreviewOtherDebugOptions() { onForceUpdateApiVersions = {}, onDisableEventProcessingChange = {}, onRestartSlowSyncForRecovery = {}, - onManualMigrationPressed = {} + onManualMigrationPressed = {}, + enrollE2EICertificate = {}, + dismissCertificateDialog = {}, ) } diff --git a/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt b/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt index 4fa6d2a96f3..106909c5979 100644 --- a/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt +++ b/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt @@ -153,6 +153,8 @@ class DeepLinkProcessor @Inject constructor( companion object { const val DEEP_LINK_SCHEME = "wire" + const val E2EI_DEEPLINK_HOST = "e2ei" + const val E2EI_DEEPLINK_OAUTH_REDIRECT_PATH = "oauth2redirect" const val ACCESS_DEEPLINK_HOST = "access" const val SERVER_CONFIG_PARAM = "config" const val SSO_LOGIN_DEEPLINK_HOST = "sso-login" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9bfe2131f3e..7c410d0c78c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -197,6 +197,8 @@ Give Feedback Report Bug Debug Settings + API VERSIONING + E2EI Manual Enrollment Force API versioning update Update Support @@ -986,6 +988,7 @@ Delete All Logs Restart slow sync Restart + Get E2EI Certificate MLS Options Enable Logging Proteus Options @@ -1234,6 +1237,7 @@ Certificate updated The certificate is updated and your device is verified. Certificate Details + Certificate Details Start Recording Recording Audio… diff --git a/buildSrc/src/main/kotlin/scripts/variants.gradle.kts b/buildSrc/src/main/kotlin/scripts/variants.gradle.kts index cc281e09be0..509442734b6 100644 --- a/buildSrc/src/main/kotlin/scripts/variants.gradle.kts +++ b/buildSrc/src/main/kotlin/scripts/variants.gradle.kts @@ -59,6 +59,7 @@ fun NamedDomainObjectContainer.createAppFlavour( versionNameSuffix = "-${flavour.buildName}" resValue("string", "app_name", flavour.appName) manifestPlaceholders["sharedUserId"] = sharedUserId + manifestPlaceholders["appAuthRedirectScheme"] = flavorApplicationId } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bfbb234fb9e..84d15b81518 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,6 +61,9 @@ rss-parser = "6.0.1" # Logging dataDog = "1.19.3" +#OAuth +openIdAppAuth = "0.11.1" + # Other Tools aboutLibraries = "10.8.0" leakCanary = "2.7" @@ -198,6 +201,9 @@ rss-parser = { module = "com.prof18.rssparser:rssparser", version.ref = "rss-par dataDog-core = { module = "com.datadoghq:dd-sdk-android", version.ref = "dataDog" } dataDog-compose = { module = "com.datadoghq:dd-sdk-android-compose", version.ref = "dataDog" } +# OAuth +openIdAppOauth = { module = "net.openid:appauth", version.ref = "openIdAppAuth" } + # Material material = { module = "com.google.android.material:material", version.ref = "material" } diff --git a/kalium b/kalium index b0c7e3707a1..b80ce0f89bc 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit b0c7e3707a1b696797e7529ccbb0fee032bbdbe6 +Subproject commit b80ce0f89bcef621bda6f2b234b2750b4d15d913