From ea80b3c9d978738cc7146c5a2c7b37106a0c8484 Mon Sep 17 00:00:00 2001 From: Mario Danic Date: Tue, 10 Apr 2018 16:34:03 +0200 Subject: [PATCH] Initial support for client certificate auth Signed-off-by: Mario Danic --- .../talk/activities/CallActivity.java | 2 +- .../AccountVerificationController.java | 7 +- .../MagicBottomNavigationController.java | 2 +- .../ServerSelectionController.java | 55 ++++- .../talk/controllers/SettingsController.java | 49 ++++- .../controllers/SwitchAccountController.java | 2 +- .../controllers/WebViewLoginController.java | 80 ++++--- .../talk/controllers/base/BaseController.java | 32 +++ .../BottomNavigationController.java | 98 ++++----- .../bottomsheet/OperationsMenuController.java | 1 + .../talk/dagger/modules/DatabaseModule.java | 2 +- .../talk/dagger/modules/RestModule.java | 42 +++- .../nextcloud/talk/jobs/CapabilitiesJob.java | 2 +- .../nextcloud/talk/models/database/User.java | 2 + .../com/nextcloud/talk/utils/PushUtils.java | 2 +- .../talk/utils/database/user/UserUtils.java | 12 +- .../utils/preferences/AppPreferences.java | 13 +- .../talk/utils/ssl/MagicKeyManager.java | 202 ++++++++++++++++++ .../talk/utils/ssl/SSLSocketFactoryCompat.kt | 13 +- .../layout/controller_server_selection.xml | 15 ++ .../main/res/layout/controller_settings.xml | 9 +- app/src/main/res/values/strings.xml | 4 + 22 files changed, 546 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/ssl/MagicKeyManager.java diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java index eea93eacd..071a633a1 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java @@ -260,7 +260,7 @@ public class CallActivity extends AppCompatActivity { if (!userEntity.getCurrent()) { userUtils.createOrUpdateUser(null, null, null, null, - null, true, null, userEntity.getId(), null) + null, true, null, userEntity.getId(), null, null) .subscribe(new Observer() { @Override public void onSubscribe(Disposable d) { diff --git a/app/src/main/java/com/nextcloud/talk/controllers/AccountVerificationController.java b/app/src/main/java/com/nextcloud/talk/controllers/AccountVerificationController.java index 39c0ee0d8..d6998d475 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/AccountVerificationController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/AccountVerificationController.java @@ -45,6 +45,7 @@ import com.nextcloud.talk.utils.ApiUtils; import com.nextcloud.talk.utils.ApplicationWideMessageHolder; import com.nextcloud.talk.utils.bundle.BundleKeys; import com.nextcloud.talk.utils.database.user.UserUtils; +import com.nextcloud.talk.utils.preferences.AppPreferences; import java.net.CookieManager; @@ -72,6 +73,9 @@ public class AccountVerificationController extends BaseController { @Inject CookieManager cookieManager; + @Inject + AppPreferences appPreferences; + @BindView(R.id.progress_text) TextView progressText; @@ -198,7 +202,8 @@ public class AccountVerificationController extends BaseController { if (!TextUtils.isEmpty(displayName)) { dbQueryDisposable = userUtils.createOrUpdateUser(username, token, baseUrl, displayName, null, true, - userProfileOverall.getOcs().getData().getUserId(), null, null) + userProfileOverall.getOcs().getData().getUserId(), null, null, + appPreferences.getTemporaryClientCertAlias()) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(userEntity -> { diff --git a/app/src/main/java/com/nextcloud/talk/controllers/MagicBottomNavigationController.java b/app/src/main/java/com/nextcloud/talk/controllers/MagicBottomNavigationController.java index 50d03bf3f..f966d0445 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/MagicBottomNavigationController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/MagicBottomNavigationController.java @@ -51,7 +51,7 @@ public class MagicBottomNavigationController extends BottomNavigationController BottomNavigationMenuItem.getEnum(itemId).getControllerClass().getConstructors(); Controller controller = null; try { - /* Determine default or Bundle constructor */ + /* Determine default or Bundle constructor */ for (Constructor constructor : constructors) { if (constructor.getParameterTypes().length == 0) { controller = (Controller) constructor.newInstance(); diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ServerSelectionController.java b/app/src/main/java/com/nextcloud/talk/controllers/ServerSelectionController.java index e0129da33..2e88b75e9 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ServerSelectionController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ServerSelectionController.java @@ -20,10 +20,12 @@ package com.nextcloud.talk.controllers; +import android.annotation.SuppressLint; import android.content.Intent; import android.content.pm.ActivityInfo; import android.net.Uri; import android.os.Bundle; +import android.security.KeyChain; import android.support.annotation.NonNull; import android.text.Editable; import android.text.TextUtils; @@ -46,6 +48,7 @@ import com.nextcloud.talk.utils.ApiUtils; import com.nextcloud.talk.utils.ApplicationWideMessageHolder; import com.nextcloud.talk.utils.bundle.BundleKeys; import com.nextcloud.talk.utils.database.user.UserUtils; +import com.nextcloud.talk.utils.preferences.AppPreferences; import java.security.cert.CertificateException; @@ -53,6 +56,7 @@ import javax.inject.Inject; import autodagger.AutoInjector; import butterknife.BindView; +import butterknife.OnClick; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; @@ -72,6 +76,8 @@ public class ServerSelectionController extends BaseController { ProgressBar progressBar; @BindView(R.id.helper_text_view) TextView providersTextView; + @BindView(R.id.cert_text_view) + TextView certTextView; @Inject NcApi ncApi; @@ -79,6 +85,9 @@ public class ServerSelectionController extends BaseController { @Inject UserUtils userUtils; + @Inject + AppPreferences appPreferences; + private Disposable statusQueryDisposable; @Override @@ -86,6 +95,22 @@ public class ServerSelectionController extends BaseController { return inflater.inflate(R.layout.controller_server_selection, container, false); } + @SuppressLint("LongLogTag") + @OnClick(R.id.cert_text_view) + public void onCertClick() { + if (getActivity() != null) { + KeyChain.choosePrivateKeyAlias(getActivity(), alias -> { + if (alias != null) { + appPreferences.setTemporaryClientCertAlias(alias); + } else { + appPreferences.removeTemporaryClientCertAlias(); + } + + setCertTextView(); + }, new String[]{"RSA", "EC"}, null, null, -1, null); + } + } + @Override protected void onViewBound(@NonNull View view) { super.onViewBound(view); @@ -108,7 +133,7 @@ public class ServerSelectionController extends BaseController { if (TextUtils.isEmpty(getResources().getString(R.string.nc_providers_url)) && (TextUtils.isEmpty(getResources ().getString(R.string.nc_import_account_type)))) { - providersTextView.setVisibility(View.GONE); + providersTextView.setVisibility(View.INVISIBLE); } else { if ((TextUtils.isEmpty(getResources ().getString(R.string.nc_import_account_type)) || @@ -151,7 +176,7 @@ public class ServerSelectionController extends BaseController { .popChangeHandler(new HorizontalChangeHandler())); }); } else { - providersTextView.setVisibility(View.GONE); + providersTextView.setVisibility(View.INVISIBLE); } } @@ -204,8 +229,9 @@ public class ServerSelectionController extends BaseController { serverEntry.setEnabled(false); progressBar.setVisibility(View.VISIBLE); - if (providersTextView.getVisibility() != View.GONE) { + if (providersTextView.getVisibility() != View.INVISIBLE) { providersTextView.setVisibility(View.INVISIBLE); + certTextView.setVisibility(View.INVISIBLE); } if (url.endsWith("/")) { @@ -279,8 +305,9 @@ public class ServerSelectionController extends BaseController { } progressBar.setVisibility(View.INVISIBLE); - if (providersTextView.getVisibility() != View.GONE) { + if (providersTextView.getVisibility() != View.INVISIBLE) { providersTextView.setVisibility(View.VISIBLE); + certTextView.setVisibility(View.VISIBLE); } toggleProceedButton(false); @@ -288,8 +315,9 @@ public class ServerSelectionController extends BaseController { } }, () -> { progressBar.setVisibility(View.INVISIBLE); - if (providersTextView.getVisibility() != View.GONE) { + if (providersTextView.getVisibility() != View.INVISIBLE) { providersTextView.setVisibility(View.VISIBLE); + certTextView.setVisibility(View.VISIBLE); } dispose(); }); @@ -315,6 +343,23 @@ public class ServerSelectionController extends BaseController { } ApplicationWideMessageHolder.getInstance().setMessageType(null); } + + setCertTextView(); + } + + private void setCertTextView() { + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + if (!TextUtils.isEmpty(appPreferences.getTemporaryClientCertAlias())) { + certTextView.setText(R.string.nc_change_cert_auth); + } else { + certTextView.setText(R.string.nc_configure_cert_auth); + } + + textFieldBoxes.setError("", true); + toggleProceedButton(true); + }); + } } @Override diff --git a/app/src/main/java/com/nextcloud/talk/controllers/SettingsController.java b/app/src/main/java/com/nextcloud/talk/controllers/SettingsController.java index 7ed7af628..c621a48c7 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/SettingsController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/SettingsController.java @@ -24,9 +24,11 @@ import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.content.Intent; import android.net.Uri; +import android.security.KeyChain; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextUtils; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -64,9 +66,12 @@ import net.orange_box.storebox.listeners.OnPreferenceValueChangedListener; import org.greenrobot.eventbus.EventBus; import java.net.CookieManager; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import javax.inject.Inject; @@ -126,6 +131,9 @@ public class SettingsController extends BaseController { @BindView(R.id.message_view) MaterialPreferenceCategory messageView; + @BindView(R.id.settings_client_cert) + MaterialStandardPreference certificateSetup; + @BindView(R.id.message_text) TextView messageText; @@ -221,6 +229,45 @@ public class SettingsController extends BaseController { SwitchAccountController()).pushChangeHandler(new VerticalChangeHandler()) .popChangeHandler(new VerticalChangeHandler())); }); + + if (userEntity.getClientCertificate() != null) { + certificateSetup.setTitle(R.string.nc_client_cert_change); + } + + + String host = null; + int port = -1; + + URI uri; + try { + uri = new URI(userEntity.getBaseUrl()); + host = uri.getHost(); + port = uri.getPort(); + } catch (URISyntaxException e) { + Log.e(TAG, "Failed to create uri"); + } + + String finalHost = host; + int finalPort = port; + certificateSetup.addPreferenceClickListener(v -> KeyChain.choosePrivateKeyAlias(Objects.requireNonNull(getActivity()), alias -> { + String finalAlias = alias; + getActivity().runOnUiThread(() -> { + if (finalAlias != null) { + certificateSetup.setTitle(R.string.nc_client_cert_change); + } else { + certificateSetup.setTitle(R.string.nc_client_cert_setup); + } + }); + + if (alias == null) { + alias = ""; + } + + + userUtils.createOrUpdateUser(null, null, null, null, null, null, null, userEntity.getId(), + null, alias); + }, new String[]{"RSA", "EC"}, null, finalHost, finalPort, userEntity.getClientCertificate + ())); } @Override @@ -290,7 +337,7 @@ public class SettingsController extends BaseController { dbQueryDisposable = userUtils.createOrUpdateUser(null, null, null, displayName, null, true, - userProfileOverall.getOcs().getData().getUserId(), userEntity.getId(), null) + userProfileOverall.getOcs().getData().getUserId(), userEntity.getId(), null, null) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(userEntityResult -> { diff --git a/app/src/main/java/com/nextcloud/talk/controllers/SwitchAccountController.java b/app/src/main/java/com/nextcloud/talk/controllers/SwitchAccountController.java index 61f8dab17..01f451c4d 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/SwitchAccountController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/SwitchAccountController.java @@ -100,7 +100,7 @@ public class SwitchAccountController extends BaseController { UserEntity userEntity = ((AdvancedUserItem) userItems.get(position)).getEntity(); userUtils.createOrUpdateUser(null, null, null, null, - null, true, null, userEntity.getId(), null) + null, true, null, userEntity.getId(), null, null) .subscribe(new Observer() { @Override public void onSubscribe(Disposable d) { diff --git a/app/src/main/java/com/nextcloud/talk/controllers/WebViewLoginController.java b/app/src/main/java/com/nextcloud/talk/controllers/WebViewLoginController.java index 0609a1092..d6d18f1dc 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/WebViewLoginController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/WebViewLoginController.java @@ -20,6 +20,7 @@ */ package com.nextcloud.talk.controllers; +import android.annotation.SuppressLint; import android.content.pm.ActivityInfo; import android.net.http.SslCertificate; import android.net.http.SslError; @@ -29,7 +30,6 @@ import android.security.KeyChain; import android.security.KeyChainException; import android.support.annotation.NonNull; import android.text.TextUtils; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -52,13 +52,13 @@ import com.nextcloud.talk.models.database.UserEntity; import com.nextcloud.talk.utils.ApplicationWideMessageHolder; import com.nextcloud.talk.utils.bundle.BundleKeys; import com.nextcloud.talk.utils.database.user.UserUtils; +import com.nextcloud.talk.utils.preferences.AppPreferences; import com.nextcloud.talk.utils.ssl.MagicTrustManager; import org.greenrobot.eventbus.EventBus; import java.lang.reflect.Field; -import java.net.MalformedURLException; -import java.net.URL; +import java.net.CookieManager; import java.net.URLDecoder; import java.security.PrivateKey; import java.security.cert.CertificateException; @@ -86,13 +86,16 @@ public class WebViewLoginController extends BaseController { @Inject UserUtils userUtils; @Inject + AppPreferences appPreferences; + @Inject ReactiveEntityStore dataStore; @Inject MagicTrustManager magicTrustManager; @Inject EventBus eventBus; @Inject - java.net.CookieManager cookieManager; + CookieManager cookieManager; + @BindView(R.id.webview) WebView webView; @@ -140,6 +143,7 @@ public class WebViewLoginController extends BaseController { return inflater.inflate(R.layout.controller_web_view_login, container, false); } + @SuppressLint("SetJavaScriptEnabled") @Override protected void onViewBound(@NonNull View view) { super.onViewBound(view); @@ -167,6 +171,7 @@ public class WebViewLoginController extends BaseController { webView.clearCache(true); webView.clearFormData(); webView.clearHistory(); + WebView.clearClientCertPreferences(null); CookieSyncManager.createInstance(getActivity()); android.webkit.CookieManager.getInstance().removeAllCookies(null); @@ -204,7 +209,7 @@ public class WebViewLoginController extends BaseController { if (!TextUtils.isEmpty(username) && !TextUtils.isEmpty(password)) { if (loginStep == 1) { webView.loadUrl("javascript: {document.getElementsByClassName('login')[0].click(); };"); - } else if (!automatedLoginAttempted){ + } else if (!automatedLoginAttempted) { automatedLoginAttempted = true; webView.loadUrl("javascript: {" + "document.getElementById('user').value = '" + username + "';" + @@ -218,32 +223,55 @@ public class WebViewLoginController extends BaseController { @Override public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) { - String host = null; + UserEntity userEntity; - try { - URL url = new URL(webView.getUrl()); - host = url.getHost(); - } catch (MalformedURLException e) { - Log.d(TAG, "Failed to create url"); + String alias = null; + if (!isPasswordUpdate) { + alias = appPreferences.getTemporaryClientCertAlias(); } - KeyChain.choosePrivateKeyAlias(getActivity(), alias -> { - try { - if (alias != null) { - PrivateKey privateKey = KeyChain.getPrivateKey(getActivity(), alias); - X509Certificate[] certificates = KeyChain.getCertificateChain(getActivity(), alias); - request.proceed(privateKey, certificates); + if (TextUtils.isEmpty(alias) && (userEntity = userUtils.getCurrentUser()) != null) { + alias = userEntity.getClientCertificate(); + } + + if (!TextUtils.isEmpty(alias)) { + String finalAlias = alias; + new Thread(() -> { + try { + PrivateKey privateKey = KeyChain.getPrivateKey(getActivity(), finalAlias); + X509Certificate[] certificates = KeyChain.getCertificateChain(getActivity(), finalAlias); + if (privateKey != null && certificates != null) { + request.proceed(privateKey, certificates); + } else { + request.cancel(); + } + } catch (KeyChainException | InterruptedException e) { + request.cancel(); + } + }).start(); + } else { + KeyChain.choosePrivateKeyAlias(getActivity(), chosenAlias -> { + if (chosenAlias != null) { + appPreferences.setTemporaryClientCertAlias(chosenAlias); + new Thread(() -> { + PrivateKey privateKey = null; + try { + privateKey = KeyChain.getPrivateKey(getActivity(), chosenAlias); + X509Certificate[] certificates = KeyChain.getCertificateChain(getActivity(), chosenAlias); + if (privateKey != null && certificates != null) { + request.proceed(privateKey, certificates); + } else { + request.cancel(); + } + } catch (KeyChainException | InterruptedException e) { + request.cancel(); + } + }).start(); } else { request.cancel(); } - } catch (KeyChainException e) { - Log.e(TAG, "Failed to get keys via keychain exception"); - request.cancel(); - } catch (InterruptedException e) { - Log.e(TAG, "Failed to get keys due to interruption"); - request.cancel(); - } - }, new String[]{"RSA"}, null, host, -1, null); + }, new String[]{"RSA", "EC"}, null, request.getHost(), request.getPort(), null); + } } @Override @@ -339,7 +367,7 @@ public class WebViewLoginController extends BaseController { if (currentUser != null) { userQueryDisposable = userUtils.createOrUpdateUser(null, null, null, null, null, true, - null, currentUser.getId(), null). + null, currentUser.getId(), null, appPreferences.getTemporaryClientCertAlias()). subscribe(userEntity -> { if (finalMessageType != null) { ApplicationWideMessageHolder.getInstance().setMessageType(finalMessageType); diff --git a/app/src/main/java/com/nextcloud/talk/controllers/base/BaseController.java b/app/src/main/java/com/nextcloud/talk/controllers/base/BaseController.java index 2ad8e6758..81ca1df79 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/base/BaseController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/base/BaseController.java @@ -25,10 +25,28 @@ import android.util.Log; import android.view.View; import com.bluelinelabs.conductor.Controller; +import com.nextcloud.talk.application.NextcloudTalkApplication; +import com.nextcloud.talk.controllers.AccountVerificationController; +import com.nextcloud.talk.controllers.ServerSelectionController; +import com.nextcloud.talk.controllers.WebViewLoginController; import com.nextcloud.talk.controllers.base.providers.ActionBarProvider; +import com.nextcloud.talk.utils.preferences.AppPreferences; +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; + +import autodagger.AutoInjector; + +@AutoInjector(NextcloudTalkApplication.class) public abstract class BaseController extends RefWatchingController { + @Inject + AppPreferences appPreferences; + + private List temporaryClassNames = new ArrayList<>(); + private static final String TAG = "BaseController"; protected BaseController() { @@ -38,6 +56,12 @@ public abstract class BaseController extends RefWatchingController { super(args); } + @Override + protected void onViewBound(@NonNull View view) { + NextcloudTalkApplication.getSharedApplication().getComponentApplication().inject(this); + super.onViewBound(view); + } + // Note: This is just a quick demo of how an ActionBar *can* be accessed, not necessarily how it *should* // be accessed. In a production app, this would use Dagger instead. protected ActionBar getActionBar() { @@ -54,6 +78,14 @@ public abstract class BaseController extends RefWatchingController { protected void onAttach(@NonNull View view) { setTitle(); getActionBar().setDisplayHomeAsUpEnabled(false); + + temporaryClassNames.add(ServerSelectionController.class.getName()); + temporaryClassNames.add(AccountVerificationController.class.getName()); + temporaryClassNames.add(WebViewLoginController.class.getName()); + + if (!temporaryClassNames.contains(getClass().getName())) { + appPreferences.removeTemporaryClientCertAlias(); + } super.onAttach(view); } diff --git a/app/src/main/java/com/nextcloud/talk/controllers/base/bottomnavigation/BottomNavigationController.java b/app/src/main/java/com/nextcloud/talk/controllers/base/bottomnavigation/BottomNavigationController.java index 0770c264b..af72f0405 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/base/bottomnavigation/BottomNavigationController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/base/bottomnavigation/BottomNavigationController.java @@ -121,7 +121,7 @@ public abstract class BottomNavigationController extends BaseController { protected void onViewBound(@NonNull View view) { super.onViewBound(view); - /* Setup the BottomNavigationView with the constructor supplied Menu resource */ + /* Setup the BottomNavigationView with the constructor supplied Menu resource */ bottomNavigationView.inflateMenu(getMenuResource()); bottomNavigationView.setOnNavigationItemSelectedListener( @@ -138,20 +138,20 @@ public abstract class BottomNavigationController extends BaseController { protected void onAttach(@NonNull View view) { super.onAttach(view); - /* Fresh start, setup everything */ + /* Fresh start, setup everything */ if (routerSavedStateBundles == null) { Menu menu = bottomNavigationView.getMenu(); int menuSize = menu.size(); routerSavedStateBundles = new SparseArray<>(menuSize); for (int i = 0; i < menuSize; i++) { MenuItem menuItem = menu.getItem(i); - /* Ensure the first checked item is shown */ + /* Ensure the first checked item is shown */ if (menuItem.isChecked()) { - /* - * Seems like the BottomNavigationView always initializes index 0 as isChecked / Selected, - * regardless of what was set in the menu xml originally. - * So basically all we're doing here is always setting up menuItem index 0. - */ + /* + * Seems like the BottomNavigationView always initializes index 0 as isChecked / Selected, + * regardless of what was set in the menu xml originally. + * So basically all we're doing here is always setting up menuItem index 0. + */ int itemId = menuItem.getItemId(); configureRouter(getChildRouter(itemId), itemId); bottomNavigationView.setSelectedItemId(itemId); @@ -160,11 +160,11 @@ public abstract class BottomNavigationController extends BaseController { } } } else { - /* - * Since we are restoring our state, - * and onRestoreInstanceState is called before onViewBound, - * all we need to do is rebind. - */ + /* + * Since we are restoring our state, + * and onRestoreInstanceState is called before onViewBound, + * all we need to do is rebind. + */ Router childRouter = getChildRouter(currentlySelectedItemId); childRouter.rebindIfNeeded(); lastActiveChildRouter = childRouter; @@ -235,7 +235,7 @@ public abstract class BottomNavigationController extends BaseController { if (currentlySelectedItemId != itemId) { destroyChildRouter(lastActiveChildRouter, currentlySelectedItemId); - /* Ensure correct Checked state based on new selection */ + /* Ensure correct Checked state based on new selection */ Menu menu = bottomNavigationView.getMenu(); for (int i = 0; i < menu.size(); i++) { MenuItem menuItem = menu.getItem(i); @@ -249,21 +249,21 @@ public abstract class BottomNavigationController extends BaseController { currentlySelectedItemId = itemId; Router childRouter = getChildRouter(currentlySelectedItemId); if (configureRouter(childRouter, currentlySelectedItemId)) { - /* Determine if a Controller of same class already exists in the backstack */ + /* Determine if a Controller of same class already exists in the backstack */ Controller backstackController; int size = childRouter.getBackstackSize(); for (int i = 0; i < size; i++) { backstackController = childRouter.getBackstack().get(i).controller(); if (BottomNavigationUtils.equals(backstackController.getClass(), controller.getClass())) { - /* Match found at root - so just set new root */ + /* Match found at root - so just set new root */ if (i == size - 1) { childRouter.setRoot(RouterTransaction.with(controller)); } else { - /* Match found at i - pop until we're at the matching Controller */ + /* Match found at i - pop until we're at the matching Controller */ for (int j = size; j < i; j--) { childRouter.popCurrentController(); } - /* Replace the existing matching Controller with the new */ + /* Replace the existing matching Controller with the new */ childRouter.replaceTopController(RouterTransaction.with(controller)); } } @@ -301,21 +301,21 @@ public abstract class BottomNavigationController extends BaseController { @Override protected void onSaveInstanceState(@NonNull Bundle outState) { if (lastActiveChildRouter == null && cachedSavedInstanceState != null) { - /* - * Here we assume that we're in a state - * where the BottomNavigationController itself is in the backstack, - * it has been restored, and is now being saved again. - * In this case, the BottomNavigationController won't ever have had onViewBound() called, - * and thus won't have any views to setup the Child Routers with. - * In this case, we assume that we've previously had onSaveInstanceState() called - * on us successfully, and thus have a cachedSavedInstanceState to use. - * - * To replicate issue this solves: - * Navigate from BottomNavigationController to another controller not hosted in - * the childRouter, background the app - * (with developer setting "don't keep activities in memory" enabled on the device), - * open the app again, and background it once more, and open it again to see it crash. - */ + /* + * Here we assume that we're in a state + * where the BottomNavigationController itself is in the backstack, + * it has been restored, and is now being saved again. + * In this case, the BottomNavigationController won't ever have had onViewBound() called, + * and thus won't have any views to setup the Child Routers with. + * In this case, we assume that we've previously had onSaveInstanceState() called + * on us successfully, and thus have a cachedSavedInstanceState to use. + * + * To replicate issue this solves: + * Navigate from BottomNavigationController to another controller not hosted in + * the childRouter, background the app + * (with developer setting "don't keep activities in memory" enabled on the device), + * open the app again, and background it once more, and open it again to see it crash. + */ outState.putSparseParcelableArray( KEY_STATE_ROUTER_BUNDLES, cachedSavedInstanceState.getSparseParcelableArray(KEY_STATE_ROUTER_BUNDLES)); @@ -323,20 +323,20 @@ public abstract class BottomNavigationController extends BaseController { KEY_STATE_CURRENTLY_SELECTED_ID, cachedSavedInstanceState.getInt(KEY_STATE_CURRENTLY_SELECTED_ID)); } else if (currentlySelectedItemId != 0) { - /* - * Only save state if we have a valid item selected. - * - * Otherwise we may be in a state where we are in a backstack, but have never been shown. - * I.e. if we are put in a synthesized backstack, we've never been shown any UI, - * and therefore have nothing to save. - */ + /* + * Only save state if we have a valid item selected. + * + * Otherwise we may be in a state where we are in a backstack, but have never been shown. + * I.e. if we are put in a synthesized backstack, we've never been shown any UI, + * and therefore have nothing to save. + */ save(lastActiveChildRouter, currentlySelectedItemId); outState.putSparseParcelableArray(KEY_STATE_ROUTER_BUNDLES, routerSavedStateBundles); - /* - * For some reason the BottomNavigationView does not seem to correctly restore its - * selectedId, even though the view appears with the correct state. - * So we keep track of it manually - */ + /* + * For some reason the BottomNavigationView does not seem to correctly restore its + * selectedId, even though the view appears with the correct state. + * So we keep track of it manually + */ outState.putInt(KEY_STATE_CURRENTLY_SELECTED_ID, currentlySelectedItemId); lastActiveChildRouter = null; } @@ -344,10 +344,10 @@ public abstract class BottomNavigationController extends BaseController { @Override public boolean handleBack() { - /* - * The childRouter should handleBack, - * as this BottomNavigationController doesn't have a back step sensible to the user. - */ + /* + * The childRouter should handleBack, + * as this BottomNavigationController doesn't have a back step sensible to the user. + */ return lastActiveChildRouter != null && lastActiveChildRouter.handleBack(); } diff --git a/app/src/main/java/com/nextcloud/talk/controllers/bottomsheet/OperationsMenuController.java b/app/src/main/java/com/nextcloud/talk/controllers/bottomsheet/OperationsMenuController.java index 9a6c84e00..a0a98e6c5 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/bottomsheet/OperationsMenuController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/bottomsheet/OperationsMenuController.java @@ -352,6 +352,7 @@ public class OperationsMenuController extends BaseController { } + private void showResultImage(boolean everythingOK, boolean isGuestSupportError) { progressBar.setVisibility(View.GONE); diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/DatabaseModule.java b/app/src/main/java/com/nextcloud/talk/dagger/modules/DatabaseModule.java index 410939851..369269a91 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/DatabaseModule.java +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/DatabaseModule.java @@ -49,7 +49,7 @@ public class DatabaseModule { return new SqlCipherDatabaseSource(context, Models.DEFAULT, context.getResources().getString(R.string.nc_app_name).toLowerCase() .replace(" ", "_").trim() + ".sqlite", - context.getString(R.string.nc_talk_database_encryption_key), 3); + context.getString(R.string.nc_talk_database_encryption_key), 4); } @Provides diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java b/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java index 624a864f2..e63336b65 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RestModule.java @@ -30,7 +30,9 @@ import com.nextcloud.talk.BuildConfig; import com.nextcloud.talk.api.NcApi; import com.nextcloud.talk.application.NextcloudTalkApplication; import com.nextcloud.talk.utils.ApiUtils; +import com.nextcloud.talk.utils.database.user.UserUtils; import com.nextcloud.talk.utils.preferences.AppPreferences; +import com.nextcloud.talk.utils.ssl.MagicKeyManager; import com.nextcloud.talk.utils.ssl.MagicTrustManager; import com.nextcloud.talk.utils.ssl.SSLSocketFactoryCompat; @@ -38,9 +40,16 @@ import java.io.IOException; import java.net.CookieManager; import java.net.InetSocketAddress; import java.net.Proxy; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; import java.util.concurrent.TimeUnit; import javax.inject.Singleton; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.X509KeyManager; import dagger.Module; import dagger.Provides; @@ -110,8 +119,35 @@ public class RestModule { @Provides @Singleton - SSLSocketFactoryCompat provideSslSocketFactoryCompat(MagicTrustManager magicTrustManager) { - return new SSLSocketFactoryCompat(magicTrustManager); + MagicKeyManager provideKeyManager(AppPreferences appPreferences, UserUtils userUtils) { + KeyStore keyStore = null; + try { + keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, null); + X509KeyManager origKm = (X509KeyManager) kmf.getKeyManagers()[0]; + return new MagicKeyManager(origKm, userUtils, appPreferences); + } catch (KeyStoreException e) { + Log.e(TAG, "KeyStoreException " + e.getLocalizedMessage()); + } catch (CertificateException e) { + Log.e(TAG, "CertificateException " + e.getLocalizedMessage()); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "NoSuchAlgorithmException " + e.getLocalizedMessage()); + } catch (IOException e) { + Log.e(TAG, "IOException " + e.getLocalizedMessage()); + } catch (UnrecoverableKeyException e) { + Log.e(TAG, "UnrecoverableKeyException " + e.getLocalizedMessage()); + } + + return null; + } + + @Provides + @Singleton + SSLSocketFactoryCompat provideSslSocketFactoryCompat(MagicKeyManager keyManager, MagicTrustManager + magicTrustManager) { + return new SSLSocketFactoryCompat(keyManager, magicTrustManager); } @Provides @@ -145,10 +181,10 @@ public class RestModule { if (BuildConfig.DEBUG) { HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(); loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY); - httpClient.addInterceptor(loggingInterceptor); } + // Trust own CA and all self-signed certs httpClient.sslSocketFactory(sslSocketFactoryCompat, magicTrustManager); httpClient.retryOnConnectionFailure(true); httpClient.hostnameVerifier(magicTrustManager.getHostnameVerifier(OkHostnameVerifier.INSTANCE)); diff --git a/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesJob.java b/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesJob.java index 76ec1316d..b6427bde9 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesJob.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/CapabilitiesJob.java @@ -118,7 +118,7 @@ public class CapabilitiesJob extends Job { userUtils.createOrUpdateUser(null, null, null, null, null, null, null, internalUserEntity.getId(), - LoganSquare.serialize(capabilitiesOverall.getOcs().getData().getCapabilities())) + LoganSquare.serialize(capabilitiesOverall.getOcs().getData().getCapabilities()), null) .subscribeOn(Schedulers.newThread()) .subscribe(new Observer() { @Override diff --git a/app/src/main/java/com/nextcloud/talk/models/database/User.java b/app/src/main/java/com/nextcloud/talk/models/database/User.java index 4775b5d5b..0a6440520 100644 --- a/app/src/main/java/com/nextcloud/talk/models/database/User.java +++ b/app/src/main/java/com/nextcloud/talk/models/database/User.java @@ -55,6 +55,8 @@ public interface User extends Parcelable, Persistable, Serializable { String getCapabilities(); + String getClientCertificate(); + boolean getCurrent(); boolean getScheduledForDeletion(); diff --git a/app/src/main/java/com/nextcloud/talk/utils/PushUtils.java b/app/src/main/java/com/nextcloud/talk/utils/PushUtils.java index 8a9f701a2..26290767c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/PushUtils.java +++ b/app/src/main/java/com/nextcloud/talk/utils/PushUtils.java @@ -318,7 +318,7 @@ public class PushUtils { null, null, userEntity.getDisplayName(), LoganSquare.serialize(pushConfigurationState), null, - null, userEntity.getId(), null) + null, userEntity.getId(), null, null) .subscribe(new Observer() { @Override public void onSubscribe(Disposable d) { diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserUtils.java b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserUtils.java index d57a25d4c..a947c8222 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserUtils.java +++ b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserUtils.java @@ -161,7 +161,8 @@ public class UserUtils { @Nullable Boolean currentUser, @Nullable String userId, @Nullable Long internalId, - @Nullable String capabilities) { + @Nullable String capabilities, + @Nullable String certificateAlias) { Result findUserQueryResult; if (internalId == null) { findUserQueryResult = dataStore.select(User.class).where(UserEntity.USERNAME.eq(username). @@ -194,6 +195,10 @@ public class UserUtils { user.setCapabilities(capabilities); } + if (!TextUtils.isEmpty(certificateAlias)) { + user.setClientCertificate(certificateAlias); + } + user.setCurrent(true); } else { @@ -218,6 +223,11 @@ public class UserUtils { user.setCapabilities(capabilities); } + if (certificateAlias != null && !certificateAlias.equals(user.getClientCertificate())) { + user.setClientCertificate(certificateAlias); + } + + if (currentUser != null) { user.setCurrent(currentUser); } diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index 555feca07..6434d0610 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -115,8 +115,19 @@ public interface AppPreferences { void setPushToken(String pushToken); @KeyByString("push_token") + @RemoveMethod void removePushToken(); + @KeyByString("tempClientCertAlias") + String getTemporaryClientCertAlias(); + + @KeyByString("tempClientCertAlias") + void setTemporaryClientCertAlias(String alias); + + @KeyByString("tempClientCertAlias") + @RemoveMethod + void removeTemporaryClientCertAlias(); + @KeyByString("pushToTalk_intro_shown") boolean getPushToTalkIntroShown(); @@ -124,9 +135,9 @@ public interface AppPreferences { void setPushToTalkIntroShown(boolean shown); @KeyByString("pushToTalk_intro_shown") + @RemoveMethod void removePushToTalkIntroShown(); - @ClearMethod void clear(); } diff --git a/app/src/main/java/com/nextcloud/talk/utils/ssl/MagicKeyManager.java b/app/src/main/java/com/nextcloud/talk/utils/ssl/MagicKeyManager.java new file mode 100644 index 000000000..79f2189f0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/ssl/MagicKeyManager.java @@ -0,0 +1,202 @@ +/* + * Nextcloud Talk application + * + * @author Mario Danic + * Copyright (C) 2017-2018 Mario Danic + * + * 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 . + */ + +package com.nextcloud.talk.utils.ssl; + +import android.content.Context; +import android.security.KeyChain; +import android.security.KeyChainException; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Log; + +import com.nextcloud.talk.application.NextcloudTalkApplication; +import com.nextcloud.talk.models.database.UserEntity; +import com.nextcloud.talk.utils.database.user.UserUtils; +import com.nextcloud.talk.utils.preferences.AppPreferences; + +import java.net.Socket; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.net.ssl.X509KeyManager; + +public class MagicKeyManager implements X509KeyManager { + private static final String TAG = "MagicKeyManager"; + private final X509KeyManager keyManager; + + private UserUtils userUtils; + private AppPreferences appPreferences; + private Context context; + + public MagicKeyManager(X509KeyManager keyManager, UserUtils userUtils, AppPreferences appPreferences) { + this.keyManager = keyManager; + this.userUtils = userUtils; + this.appPreferences = appPreferences; + + context = NextcloudTalkApplication.getSharedApplication().getApplicationContext(); + } + + @Override + public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) { + String alias; + if (!TextUtils.isEmpty(alias = userUtils.getCurrentUser().getClientCertificate()) || + !TextUtils.isEmpty(alias = appPreferences.getTemporaryClientCertAlias()) + && new ArrayList<>(Arrays.asList(getClientAliases())).contains(alias)) { + return alias; + } + + return null; + } + + @Override + public String chooseServerAlias(String s, Principal[] principals, Socket socket) { + return null; + } + + private X509Certificate[] getCertificatesForAlias(@Nullable String alias) { + if (alias != null) { + GetCertificatesForAliasRunnable getCertificatesForAliasRunnable = new GetCertificatesForAliasRunnable(alias); + Thread getCertificatesThread = new Thread(getCertificatesForAliasRunnable); + getCertificatesThread.start(); + try { + getCertificatesThread.join(); + return getCertificatesForAliasRunnable.getCertificates(); + } catch (InterruptedException e) { + Log.e(TAG, "Failed to join the thread while getting certificates: " + e.getLocalizedMessage()); + } + } + + return null; + } + + private PrivateKey getPrivateKeyForAlias(@Nullable String alias) { + if (alias != null) { + GetPrivateKeyForAliasRunnable getPrivateKeyForAliasRunnable = new GetPrivateKeyForAliasRunnable(alias); + Thread getPrivateKeyThread = new Thread(getPrivateKeyForAliasRunnable); + getPrivateKeyThread.start(); + try { + getPrivateKeyThread.join(); + return getPrivateKeyForAliasRunnable.getPrivateKey(); + } catch (InterruptedException e) { + Log.e(TAG, "Failed to join the thread while getting private key: " + e.getLocalizedMessage()); + } + } + + return null; + } + + + @Override + public X509Certificate[] getCertificateChain(String s) { + if (new ArrayList<>(Arrays.asList(getClientAliases())).contains(s)) { + return getCertificatesForAlias(s); + } + + return null; + } + + private String[] getClientAliases() { + Set aliases = new HashSet<>(); + String alias; + if (!TextUtils.isEmpty(alias = appPreferences.getTemporaryClientCertAlias())) { + aliases.add(alias); + } + + List userEntities = userUtils.getUsers(); + for (int i = 0; i < userEntities.size(); i++) { + if (!TextUtils.isEmpty(alias = userEntities.get(i).getClientCertificate())) { + aliases.add(alias); + } + } + + return aliases.toArray(new String[aliases.size()]); + } + + @Override + public String[] getClientAliases(String s, Principal[] principals) { + return getClientAliases(); + } + + @Override + public String[] getServerAliases(String s, Principal[] principals) { + return null; + } + + @Override + public PrivateKey getPrivateKey(String s) { + if (new ArrayList<>(Arrays.asList(getClientAliases())).contains(s)) { + return getPrivateKeyForAlias(s); + } + + return null; + } + + private class GetCertificatesForAliasRunnable implements Runnable { + private volatile X509Certificate[] certificates; + private String alias; + + public GetCertificatesForAliasRunnable(String alias) { + this.alias = alias; + } + + @Override + public void run() { + try { + certificates = KeyChain.getCertificateChain(context, alias); + } catch (KeyChainException | InterruptedException e) { + Log.e(TAG, e.getLocalizedMessage()); + } + } + + public X509Certificate[] getCertificates() { + return certificates; + } + } + + private class GetPrivateKeyForAliasRunnable implements Runnable { + private volatile PrivateKey privateKey; + private String alias; + + public GetPrivateKeyForAliasRunnable(String alias) { + this.alias = alias; + } + + @Override + public void run() { + try { + privateKey = KeyChain.getPrivateKey(context, alias); + } catch (KeyChainException | InterruptedException e) { + Log.e(TAG, e.getLocalizedMessage()); + } + } + + public PrivateKey getPrivateKey() { + return privateKey; + } + } + +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ssl/SSLSocketFactoryCompat.kt b/app/src/main/java/com/nextcloud/talk/utils/ssl/SSLSocketFactoryCompat.kt index cf7463e93..9e6ca2cc1 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ssl/SSLSocketFactoryCompat.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ssl/SSLSocketFactoryCompat.kt @@ -14,12 +14,10 @@ import java.net.InetAddress import java.net.Socket import java.security.GeneralSecurityException import java.util.* -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSocket -import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.X509TrustManager +import javax.net.ssl.* -class SSLSocketFactoryCompat(trustManager: X509TrustManager) : SSLSocketFactory() { +class SSLSocketFactoryCompat(keyManager: KeyManager?, + trustManager: X509TrustManager) : SSLSocketFactory() { private var delegate: SSLSocketFactory @@ -101,7 +99,10 @@ class SSLSocketFactoryCompat(trustManager: X509TrustManager) : SSLSocketFactory( init { try { val sslContext = SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(trustManager), null) + sslContext.init( + if (keyManager != null) arrayOf(keyManager) else null, + arrayOf(trustManager), + null) delegate = sslContext.socketFactory } catch (e: GeneralSecurityException) { throw IllegalStateException() // system has no TLS diff --git a/app/src/main/res/layout/controller_server_selection.xml b/app/src/main/res/layout/controller_server_selection.xml index f897a9225..ba249ad78 100644 --- a/app/src/main/res/layout/controller_server_selection.xml +++ b/app/src/main/res/layout/controller_server_selection.xml @@ -91,4 +91,19 @@ android:textAllCaps="true" android:textColor="@color/nc_light_blue_color"/> + + diff --git a/app/src/main/res/layout/controller_settings.xml b/app/src/main/res/layout/controller_settings.xml index 96f4871e6..9de453aa0 100644 --- a/app/src/main/res/layout/controller_settings.xml +++ b/app/src/main/res/layout/controller_settings.xml @@ -86,10 +86,17 @@ apc:mp_title="@string/nc_settings_reauthorize"/> + + proxy_credentials Switch between accounts Reauthorize + Set up client certificate + Change client certificate Remove account Add a new account Only current account can be reauthorized @@ -135,6 +137,8 @@ Push-to-talk With microphone disabled, click&hold to use Push-to-talk Have private video calls and chat using your own server. + Select authentication certificate + Change authentication certificate Use Nextcloud Talk to have one-on-one or group audio or video calls, create or join web conferences and send chat messages. All communication is fully encrypted and mediated by your own server, providing the highest degree of privacy possible. Nextcloud Talk is easy to use and will always be completely free!