diff --git a/app/build.gradle b/app/build.gradle index d6ea30b5..5e3b8124 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -79,4 +79,5 @@ dependencies { implementation "com.github.chrisbanes:PhotoView:2.3.0" implementation "com.pddstudio:highlightjs-android:1.5.0" implementation "com.github.barteksc:android-pdf-viewer:3.2.0-beta.1" + implementation 'org.conscrypt:conscrypt-android:2.2.1' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a3415c93..6de240b1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -73,6 +73,7 @@ + \ No newline at end of file diff --git a/app/src/main/java/org/mian/gitnex/activities/LoginActivity.java b/app/src/main/java/org/mian/gitnex/activities/LoginActivity.java index 17ff3cef..1b6aef02 100644 --- a/app/src/main/java/org/mian/gitnex/activities/LoginActivity.java +++ b/app/src/main/java/org/mian/gitnex/activities/LoginActivity.java @@ -21,6 +21,7 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import com.tooltip.Tooltip; +import org.jetbrains.annotations.NotNull; import org.mian.gitnex.R; import org.mian.gitnex.clients.RetrofitClient; import org.mian.gitnex.helpers.Toasty; @@ -454,12 +455,9 @@ public class LoginActivity extends BaseActivity implements View.OnClickListener } @Override - public void onFailure(@NonNull Call callVersion, Throwable t) { - - Log.e("onFailure-version", t.toString()); - + public void onFailure(@NonNull Call callVersion, @NotNull Throwable throwable) { + Log.e("onFailure-version", throwable.toString()); } - }); } @@ -483,7 +481,6 @@ public class LoginActivity extends BaseActivity implements View.OnClickListener if (response.isSuccessful()) { if (response.code() == 200) { - tinyDb.putBoolean("loggedInMode", true); assert userDetails != null; tinyDb.putString(userDetails.getLogin() + "-token", loginToken_); diff --git a/app/src/main/java/org/mian/gitnex/clients/IssuesService.java b/app/src/main/java/org/mian/gitnex/clients/IssuesService.java index e8393d25..d65afc57 100644 --- a/app/src/main/java/org/mian/gitnex/clients/IssuesService.java +++ b/app/src/main/java/org/mian/gitnex/clients/IssuesService.java @@ -2,9 +2,14 @@ package org.mian.gitnex.clients; import android.content.Context; import androidx.annotation.NonNull; +import org.mian.gitnex.ssl.MemorizingTrustManager; import org.mian.gitnex.util.AppUtil; import java.io.File; import java.io.IOException; +import java.security.SecureRandom; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; import okhttp3.Cache; import okhttp3.Interceptor; import okhttp3.OkHttpClient; @@ -20,42 +25,47 @@ import retrofit2.converter.gson.GsonConverterFactory; public class IssuesService { - public static S createService(Class serviceClass, String instanceURL, Context ctx) { + public static S createService(Class serviceClass, String instanceURL, Context context) { - final boolean connToInternet = AppUtil.haveNetworkConnection(ctx); - File httpCacheDirectory = new File(ctx.getCacheDir(), "responses"); + final boolean connToInternet = AppUtil.haveNetworkConnection(context); + File httpCacheDirectory = new File(context.getCacheDir(), "responses"); int cacheSize = 50 * 1024 * 1024; // 50MB Cache cache = new Cache(httpCacheDirectory, cacheSize); HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); - OkHttpClient okHttpClient = new OkHttpClient.Builder() - .cache(cache) - //.addInterceptor(logging) - .addInterceptor(new Interceptor() { - @NonNull - @Override public Response intercept(@NonNull Chain chain) throws IOException { - Request request = chain.request(); - if (connToInternet) { - request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); - } else { - request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); - } - return chain.proceed(request); + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + + MemorizingTrustManager memorizingTrustManager = new MemorizingTrustManager(context); + sslContext.init(null, new X509TrustManager[]{memorizingTrustManager}, new SecureRandom()); + + OkHttpClient okHttpClient = new OkHttpClient.Builder().cache(cache).sslSocketFactory(sslContext.getSocketFactory(), memorizingTrustManager).hostnameVerifier(memorizingTrustManager.wrapHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier())).addInterceptor(new Interceptor() { + + @NonNull + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + + Request request = chain.request(); + if(connToInternet) { + request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); } - }) - .build(); + else { + request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); + } + return chain.proceed(request); + } + }).build(); - Retrofit.Builder builder = new Retrofit.Builder() - .baseUrl(instanceURL) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()); + Retrofit.Builder builder = new Retrofit.Builder().baseUrl(instanceURL).client(okHttpClient).addConverterFactory(GsonConverterFactory.create()); - Retrofit retrofit = builder.build(); - - return retrofit.create(serviceClass); + Retrofit retrofit = builder.build(); + return retrofit.create(serviceClass); + } catch(Exception e) { + e.printStackTrace(); + } + return null; } - } diff --git a/app/src/main/java/org/mian/gitnex/clients/PullRequestsService.java b/app/src/main/java/org/mian/gitnex/clients/PullRequestsService.java index 0f5f8ad3..c54077ef 100644 --- a/app/src/main/java/org/mian/gitnex/clients/PullRequestsService.java +++ b/app/src/main/java/org/mian/gitnex/clients/PullRequestsService.java @@ -2,9 +2,14 @@ package org.mian.gitnex.clients; import android.content.Context; import androidx.annotation.NonNull; +import org.mian.gitnex.ssl.MemorizingTrustManager; import org.mian.gitnex.util.AppUtil; import java.io.File; import java.io.IOException; +import java.security.SecureRandom; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; import okhttp3.Cache; import okhttp3.Interceptor; import okhttp3.OkHttpClient; @@ -20,42 +25,47 @@ import retrofit2.converter.gson.GsonConverterFactory; public class PullRequestsService { - public static S createService(Class serviceClass, String instanceURL, Context ctx) { + public static S createService(Class serviceClass, String instanceURL, Context context) { - final boolean connToInternet = AppUtil.haveNetworkConnection(ctx); - File httpCacheDirectory = new File(ctx.getCacheDir(), "responses"); + final boolean connToInternet = AppUtil.haveNetworkConnection(context); + File httpCacheDirectory = new File(context.getCacheDir(), "responses"); int cacheSize = 50 * 1024 * 1024; // 50MB Cache cache = new Cache(httpCacheDirectory, cacheSize); HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); - OkHttpClient okHttpClient = new OkHttpClient.Builder() - .cache(cache) - //.addInterceptor(logging) - .addInterceptor(new Interceptor() { - @NonNull - @Override public Response intercept(@NonNull Chain chain) throws IOException { - Request request = chain.request(); - if (connToInternet) { - request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); - } else { - request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); - } - return chain.proceed(request); + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + + MemorizingTrustManager memorizingTrustManager = new MemorizingTrustManager(context); + sslContext.init(null, new X509TrustManager[]{memorizingTrustManager}, new SecureRandom()); + + OkHttpClient okHttpClient = new OkHttpClient.Builder().cache(cache).sslSocketFactory(sslContext.getSocketFactory(), memorizingTrustManager).hostnameVerifier(memorizingTrustManager.wrapHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier())).addInterceptor(new Interceptor() { + + @NonNull + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + + Request request = chain.request(); + if(connToInternet) { + request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); } - }) - .build(); + else { + request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); + } + return chain.proceed(request); + } + }).build(); - Retrofit.Builder builder = new Retrofit.Builder() - .baseUrl(instanceURL) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()); + Retrofit.Builder builder = new Retrofit.Builder().baseUrl(instanceURL).client(okHttpClient).addConverterFactory(GsonConverterFactory.create()); - Retrofit retrofit = builder.build(); - - return retrofit.create(serviceClass); + Retrofit retrofit = builder.build(); + return retrofit.create(serviceClass); + } catch(Exception e) { + e.printStackTrace(); + } + return null; } - } diff --git a/app/src/main/java/org/mian/gitnex/clients/RetrofitClient.java b/app/src/main/java/org/mian/gitnex/clients/RetrofitClient.java index c127c63c..7c19f895 100644 --- a/app/src/main/java/org/mian/gitnex/clients/RetrofitClient.java +++ b/app/src/main/java/org/mian/gitnex/clients/RetrofitClient.java @@ -1,16 +1,17 @@ package org.mian.gitnex.clients; import android.content.Context; -import androidx.annotation.NonNull; import org.mian.gitnex.interfaces.ApiInterface; +import org.mian.gitnex.ssl.MemorizingTrustManager; import org.mian.gitnex.util.AppUtil; import java.io.File; -import java.io.IOException; +import java.security.SecureRandom; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; import okhttp3.Cache; -import okhttp3.Interceptor; import okhttp3.OkHttpClient; import okhttp3.Request; -import okhttp3.Response; import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; @@ -24,41 +25,47 @@ public class RetrofitClient { private Retrofit retrofit; - private RetrofitClient(String instanceUrl, Context ctx) { - - final boolean connToInternet = AppUtil.haveNetworkConnection(ctx); + private RetrofitClient(String instanceUrl, Context context) { + final boolean connToInternet = AppUtil.haveNetworkConnection(context); int cacheSize = 50 * 1024 * 1024; // 50MB - File httpCacheDirectory = new File(ctx.getCacheDir(), "responses"); + File httpCacheDirectory = new File(context.getCacheDir(), "responses"); Cache cache = new Cache(httpCacheDirectory, cacheSize); HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); - OkHttpClient okHttpClient = new OkHttpClient.Builder() - .cache(cache) - //.addInterceptor(logging) - .addInterceptor(new Interceptor() { - @NonNull - @Override public Response intercept(@NonNull Chain chain) throws IOException { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + + MemorizingTrustManager memorizingTrustManager = new MemorizingTrustManager(context); + sslContext.init(null, new X509TrustManager[] { memorizingTrustManager }, new SecureRandom()); + + OkHttpClient.Builder okHttpClient = new OkHttpClient.Builder() + .cache(cache) + .sslSocketFactory(sslContext.getSocketFactory(), memorizingTrustManager) + .hostnameVerifier(memorizingTrustManager.wrapHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier())) + .addInterceptor(chain -> { Request request = chain.request(); - if (connToInternet) { - request = request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build(); - } else { - request = request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); - } + + request = (connToInternet) ? + request.newBuilder().header("Cache-Control", "public, max-age=" + 60).build() : + request.newBuilder().header("Cache-Control", + "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 30).build(); + return chain.proceed(request); - } - }) - .build(); + }); - Retrofit.Builder builder = new Retrofit.Builder() - .baseUrl(instanceUrl) - .client(okHttpClient) - .addConverterFactory(ScalarsConverterFactory.create()) - .addConverterFactory(GsonConverterFactory.create()); + Retrofit.Builder builder = new Retrofit.Builder() + .baseUrl(instanceUrl) + .client(okHttpClient.build()) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()); - retrofit = builder.build(); + retrofit = builder.build(); + } catch(Exception e) { + e.printStackTrace(); + } } public static synchronized RetrofitClient getInstance(String instanceUrl, Context ctx) { diff --git a/app/src/main/java/org/mian/gitnex/ssl/MTMDecision.java b/app/src/main/java/org/mian/gitnex/ssl/MTMDecision.java new file mode 100644 index 00000000..9eba8ee2 --- /dev/null +++ b/app/src/main/java/org/mian/gitnex/ssl/MTMDecision.java @@ -0,0 +1,33 @@ +/* MemorizingTrustManager - a TrustManager which asks the user about invalid + * certificates and memorizes their decision. + * + * Copyright (c) 2010 Georg Lukas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.mian.gitnex.ssl; + +class MTMDecision { + public final static int DECISION_INVALID = 0; + public final static int DECISION_ABORT = 1; + public final static int DECISION_ONCE = 2; + public final static int DECISION_ALWAYS = 3; + + int state = DECISION_INVALID; +} diff --git a/app/src/main/java/org/mian/gitnex/ssl/MemorizingActivity.java b/app/src/main/java/org/mian/gitnex/ssl/MemorizingActivity.java new file mode 100644 index 00000000..b2da9f45 --- /dev/null +++ b/app/src/main/java/org/mian/gitnex/ssl/MemorizingActivity.java @@ -0,0 +1,59 @@ +/* MemorizingTrustManager - a TrustManager which asks the user about invalid + * certificates and memorizes their decision. + * + * Copyright (c) 2010 Georg Lukas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.mian.gitnex.ssl; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.os.Bundle; +import org.mian.gitnex.R; + +public class MemorizingActivity extends Activity { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + int decisionId = intent.getIntExtra(MemorizingTrustManager.DECISION_INTENT_ID, MTMDecision.DECISION_INVALID); + int titleId = intent.getIntExtra(MemorizingTrustManager.DECISION_TITLE_ID, R.string.mtm_accept_cert); + String cert = intent.getStringExtra(MemorizingTrustManager.DECISION_INTENT_CERT); + + AlertDialog.Builder builder = new AlertDialog.Builder(MemorizingActivity.this); + builder.setTitle(titleId); + builder.setMessage(cert); + + builder.setPositiveButton(R.string.mtm_decision_always, (dialog, which) -> onSendResult(decisionId, MTMDecision.DECISION_ALWAYS)); + builder.setNeutralButton(R.string.mtm_decision_once, (dialog, which) -> onSendResult(decisionId, MTMDecision.DECISION_ONCE)); + builder.setNegativeButton(R.string.mtm_decision_abort, (dialog, which) -> onSendResult(decisionId, MTMDecision.DECISION_ABORT)); + builder.setOnCancelListener(dialog -> onSendResult(decisionId, MTMDecision.DECISION_ABORT)); + + builder.create().show(); + } + + private void onSendResult(int decisionId, int decision) { + MemorizingTrustManager.interactResult(decisionId, decision); + finish(); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/mian/gitnex/ssl/MemorizingTrustManager.java b/app/src/main/java/org/mian/gitnex/ssl/MemorizingTrustManager.java new file mode 100644 index 00000000..44f65716 --- /dev/null +++ b/app/src/main/java/org/mian/gitnex/ssl/MemorizingTrustManager.java @@ -0,0 +1,692 @@ +/* MemorizingTrustManager - a TrustManager which asks the user about invalid + * certificates and memorizes their decision. + * + * Copyright (c) 2010 Georg Lukas + * + * MemorizingTrustManager.java contains the actual trust manager and interface + * code to create a MemorizingActivity and obtain the results. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.mian.gitnex.ssl; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.util.Base64; +import android.util.SparseArray; +import android.os.Build; +import android.os.Handler; + +import androidx.core.app.NotificationCompat; +import org.mian.gitnex.R; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.security.cert.*; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +/** + * A X509 trust manager implementation which asks the user about invalid + * certificates and memorizes their decision. + *

+ * The certificate validity is checked using the system default X509 + * TrustManager, creating a query Dialog if the check fails. + *

+ * WARNING: This only works if a dedicated thread is used for + * opening sockets! + */ +public class MemorizingTrustManager implements X509TrustManager { + private final static String DECISION_INTENT = "de.duenndns.ssl.DECISION"; + final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId"; + final static String DECISION_INTENT_CERT = DECISION_INTENT + ".cert"; + + private final static Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName()); + final static String DECISION_TITLE_ID = DECISION_INTENT + ".titleId"; + private final static int NOTIFICATION_ID = 100509; + + private final static String KEYSTORE_NAME = "keystore"; + private final static String KEYSTORE_KEY = "keystore"; + + private Context context; + private Activity foregroundAct; + private NotificationManager notificationManager; + private static int decisionId = 0; + private static final SparseArray openDecisions = new SparseArray<>(); + + private Handler masterHandler; + private SharedPreferences keyStoreStorage; + private KeyStore appKeyStore; + private X509TrustManager defaultTrustManager; + private X509TrustManager appTrustManager; + + /** Creates an instance of the MemorizingTrustManager class that falls back to a custom TrustManager. + * + * You need to supply the application context. This has to be one of: + * - Application + * - Activity + * - Service + * + * The context is used for file management, to display the dialog / + * notification and for obtaining translated strings. + * + * @param m Context for the application. + * @param defaultTrustManager Delegate trust management to this TM. If null, the user must accept every certificate. + */ + public MemorizingTrustManager(Context m, X509TrustManager defaultTrustManager) { + init(m); + this.appTrustManager = getTrustManager(appKeyStore); + this.defaultTrustManager = defaultTrustManager; + } + + /** Creates an instance of the MemorizingTrustManager class using the system X509TrustManager. + * + * You need to supply the application context. This has to be one of: + * - Application + * - Activity + * - Service + * + * The context is used for file management, to display the dialog / + * notification and for obtaining translated strings. + * + * @param m Context for the application. + */ + public MemorizingTrustManager(Context m) { + init(m); + this.appTrustManager = getTrustManager(appKeyStore); + this.defaultTrustManager = getTrustManager(null); + } + + private void init(Context m) { + context = m; + masterHandler = new Handler(m.getMainLooper()); + notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + keyStoreStorage = m.getSharedPreferences(KEYSTORE_NAME, Context.MODE_PRIVATE); + appKeyStore = loadAppKeyStore(); + } + + + /** + * Returns a X509TrustManager list containing a new instance of + * TrustManagerFactory. + * + * This function is meant for convenience only. You can use it + * as follows to integrate TrustManagerFactory for HTTPS sockets: + * + *

+	 *     SSLContext sc = SSLContext.getInstance("TLS");
+	 *     sc.init(null, MemorizingTrustManager.getInstanceList(this),
+	 *         new java.security.SecureRandom());
+	 *     HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
+	 * 
+ * @param c Activity or Service to show the Dialog / Notification + */ + public static X509TrustManager[] getInstanceList(Context c) { + return new X509TrustManager[] { new MemorizingTrustManager(c) }; + } + + /** + * Binds an Activity to the MTM for displaying the query dialog. + * + * This is useful if your connection is run from a service that is + * triggered by user interaction -- in such cases the activity is + * visible and the user tends to ignore the service notification. + * + * You should never have a hidden activity bound to MTM! Use this + * function in onResume() and @see unbindDisplayActivity in onPause(). + * + * @param act Activity to be bound + */ + private void bindDisplayActivity(Activity act) { + foregroundAct = act; + } + + /** + * Removes an Activity from the MTM display stack. + * + * Always call this function when the Activity added with + * {@link #bindDisplayActivity(Activity)} is hidden. + * + * @param act Activity to be unbound + */ + public void unbindDisplayActivity(Activity act) { + // do not remove if it was overridden by a different activity + if (foregroundAct == act) + foregroundAct = null; + } + + /** + * Get a list of all certificate aliases stored in MTM. + * + * @return an {@link Enumeration} of all certificates + */ + private Enumeration getCertificates() { + try { + return appKeyStore.aliases(); + } catch (KeyStoreException e) { + // this should never happen, however... + throw new RuntimeException(e); + } + } + + /** + * Get a certificate for a given alias. + * + * @param alias the certificate's alias as returned by {@link #getCertificates()}. + * + * @return the certificate associated with the alias or null if none found. + */ + public Certificate getCertificate(String alias) { + try { + return appKeyStore.getCertificate(alias); + } catch (KeyStoreException e) { + // this should never happen, however... + throw new RuntimeException(e); + } + } + + /** + * Removes the given certificate from MTMs key store. + * + *

+ * WARNING: this does not immediately invalidate the certificate. It is + * well possible that (a) data is transmitted over still existing connections or + * (b) new connections are created using TLS renegotiation, without a new cert + * check. + *

+ * @param alias the certificate's alias as returned by {@link #getCertificates()}. + * + * @throws KeyStoreException if the certificate could not be deleted. + */ + public void deleteCertificate(String alias) throws KeyStoreException { + appKeyStore.deleteEntry(alias); + keyStoreUpdated(); + } + + /** + * Creates a new hostname verifier supporting user interaction. + * + *

This method creates a new {@link HostnameVerifier} that is bound to + * the given instance of {@link MemorizingTrustManager}, and leverages an + * existing {@link HostnameVerifier}. The returned verifier performs the + * following steps, returning as soon as one of them succeeds: + *

+ *
    + *
  1. Success, if the wrapped defaultVerifier accepts the certificate.
  2. + *
  3. Success, if the server certificate is stored in the keystore under the given hostname.
  4. + *
  5. Ask the user and return accordingly.
  6. + *
  7. Failure on exception.
  8. + *
+ * + * @param defaultVerifier the {@link HostnameVerifier} that should perform the actual check + * @return a new hostname verifier using the MTM's key store + * + * @throws IllegalArgumentException if the defaultVerifier parameter is null + */ + public HostnameVerifier wrapHostnameVerifier(final HostnameVerifier defaultVerifier) { + if (defaultVerifier == null) + throw new IllegalArgumentException("The default verifier may not be null"); + + return new MemorizingHostnameVerifier(defaultVerifier); + } + + private X509TrustManager getTrustManager(KeyStore ks) { + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509"); + tmf.init(ks); + for (TrustManager t : tmf.getTrustManagers()) { + if (t instanceof X509TrustManager) { + return (X509TrustManager)t; + } + } + } catch (Exception e) { + e.printStackTrace(); + } + + return null; + } + + private KeyStore loadAppKeyStore() { + KeyStore keyStore; + + try { + keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + } catch (KeyStoreException e) { + e.printStackTrace(); + return null; + } + + try { + keyStore.load(null, null); + } catch (NoSuchAlgorithmException | CertificateException | IOException e) { + e.printStackTrace(); + } + + String keystore = keyStoreStorage.getString(KEYSTORE_KEY, null); + + if(keystore != null) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(Base64.decode(keystore, Base64.DEFAULT)); + + try { + keyStore.load(inputStream, "MTM".toCharArray()); + inputStream.close(); + } catch(Exception e) { + e.printStackTrace(); + } + } + + return keyStore; + } + + private void storeCert(String alias, Certificate cert) { + try { + appKeyStore.setCertificateEntry(alias, cert); + } catch (KeyStoreException e) { + e.printStackTrace(); + return; + } + + keyStoreUpdated(); + } + + private void storeCert(X509Certificate cert) { + storeCert(cert.getSubjectDN().toString(), cert); + } + + private void keyStoreUpdated() { + // reload appTrustManager + appTrustManager = getTrustManager(appKeyStore); + + // store KeyStore to shared preferences + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + try { + appKeyStore.store(byteArrayOutputStream, "MTM".toCharArray()); + byteArrayOutputStream.flush(); + byteArrayOutputStream.close(); + + keyStoreStorage.edit().putString(KEYSTORE_KEY, Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.DEFAULT)).apply(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + // if the certificate is stored in the app key store, it is considered "known" + private boolean isCertKnown(X509Certificate cert) { + try { + return appKeyStore.getCertificateAlias(cert) != null; + } catch (KeyStoreException e) { + return false; + } + } + + private static boolean isExpiredException(Throwable e) { + do { + if (e instanceof CertificateExpiredException) + return true; + e = e.getCause(); + } while (e != null); + return false; + } + + private static boolean isPathException(Throwable e) { + do { + if (e instanceof CertPathValidatorException) + return true; + e = e.getCause(); + } while (e != null); + return false; + } + + private void checkCertTrusted(X509Certificate[] chain, String authType, boolean isServer) throws CertificateException { + LOGGER.log(Level.FINE, "checkCertTrusted(" + Arrays.toString(chain) + ", " + authType + ", " + isServer + ")"); + + try { + LOGGER.log(Level.FINE, "checkCertTrusted: trying appTrustManager"); + if (isServer) + appTrustManager.checkServerTrusted(chain, authType); + else + appTrustManager.checkClientTrusted(chain, authType); + } catch (CertificateException ae) { + LOGGER.log(Level.FINER, "checkCertTrusted: appTrustManager did not verify certificate. Will fall back to secondary verification mechanisms (if any).", ae); + // if the cert is stored in our appTrustManager, we ignore expiredness + if (isExpiredException(ae)) { + LOGGER.log(Level.INFO, "checkCertTrusted: accepting expired certificate from keystore"); + return; + } + if (isCertKnown(chain[0])) { + LOGGER.log(Level.INFO, "checkCertTrusted: accepting cert already stored in keystore"); + return; + } + try { + if (defaultTrustManager == null) { + LOGGER.fine("No defaultTrustManager set. Verification failed, throwing " + ae); + throw ae; + } + LOGGER.log(Level.FINE, "checkCertTrusted: trying defaultTrustManager"); + if (isServer) + defaultTrustManager.checkServerTrusted(chain, authType); + else + defaultTrustManager.checkClientTrusted(chain, authType); + } catch (CertificateException e) { + LOGGER.log(Level.FINER, "checkCertTrusted: defaultTrustManager failed", e); + interactCert(chain, authType, e); + } + } + } + + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + checkCertTrusted(chain, authType, false); + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + checkCertTrusted(chain, authType, true); + } + + public X509Certificate[] getAcceptedIssuers() { + return defaultTrustManager.getAcceptedIssuers(); + } + + private static int createDecisionId(MTMDecision d) { + int myId; + synchronized(openDecisions) { + myId = decisionId; + openDecisions.put(myId, d); + decisionId += 1; + } + return myId; + } + + private static String hexString(byte[] data) { + StringBuilder si = new StringBuilder(); + for (int i = 0; i < data.length; i++) { + si.append(String.format("%02x", data[i])); + if (i < data.length - 1) + si.append(":"); + } + return si.toString(); + } + + private static String certHash(final X509Certificate cert, String digest) { + try { + MessageDigest md = MessageDigest.getInstance(digest); + md.update(cert.getEncoded()); + return hexString(md.digest()); + } catch (CertificateEncodingException | NoSuchAlgorithmException e) { + return e.getMessage(); + } + } + + private static void certDetails(StringBuilder si, X509Certificate c) { + SimpleDateFormat validityDateFormater = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + si.append("\n"); + si.append(c.getSubjectDN().toString()); + si.append("\n"); + si.append(validityDateFormater.format(c.getNotBefore())); + si.append(" - "); + si.append(validityDateFormater.format(c.getNotAfter())); + si.append("\nSHA-256: "); + si.append(certHash(c, "SHA-256")); + si.append("\nSHA-1: "); + si.append(certHash(c, "SHA-1")); + si.append("\nSigned by: "); + si.append(c.getIssuerDN().toString()); + si.append("\n"); + } + + private String certChainMessage(final X509Certificate[] chain, CertificateException cause) { + Throwable e = cause; + StringBuilder si = new StringBuilder(); + + if (isPathException(e)) { + si.append(context.getString(R.string.mtm_trust_anchor)); + } else if (isExpiredException(e)) { + si.append(context.getString(R.string.mtm_cert_expired)); + } else { + // get to the cause + while (e.getCause() != null) e = e.getCause(); + si.append(e.getLocalizedMessage()); + } + + si.append("\n\n"); + si.append(context.getString(R.string.mtm_connect_anyway)); + si.append("\n\n"); + si.append(context.getString(R.string.mtm_cert_details)); + for (X509Certificate c : chain) { + certDetails(si, c); + } + return si.toString(); + } + + private String hostNameMessage(X509Certificate cert, String hostname) { + StringBuilder si = new StringBuilder(); + + si.append(context.getString(R.string.mtm_hostname_mismatch, hostname)); + si.append("\n\n"); + + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans == null) { + si.append(cert.getSubjectDN()); + si.append("\n"); + } else for (List altName : sans) { + Object name = altName.get(1); + if (name instanceof String) { + si.append("["); + si.append(altName.get(0)); + si.append("] "); + si.append(name); + si.append("\n"); + } + } + } catch (CertificateParsingException e) { + e.printStackTrace(); + si.append("\n"); + } + + si.append("\n"); + si.append(context.getString(R.string.mtm_connect_anyway)); + si.append("\n\n"); + si.append(context.getString(R.string.mtm_cert_details)); + certDetails(si, cert); + return si.toString(); + } + + /** + * Reflectively call + * Notification.setLatestEventInfo(Context, CharSequence, CharSequence, PendingIntent) + * since it was remove in Android API level 23. + * + */ + private static void setLatestEventInfoReflective(Notification notification, Context context, CharSequence mtmNotification, CharSequence certName, PendingIntent call) { + Method setLatestEventInfo; + + try { + setLatestEventInfo = notification.getClass().getMethod( + "setLatestEventInfo", Context.class, CharSequence.class, + CharSequence.class, PendingIntent.class); + } catch (NoSuchMethodException e) { + throw new IllegalStateException(e); + } + + try { + setLatestEventInfo.invoke(notification, context, mtmNotification, certName, call); + } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + throw new IllegalStateException(e); + } + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private void startActivityNotification(Intent intent, int decisionId, String certName) { + final PendingIntent call = PendingIntent.getActivity(context, 0, intent, 0); + final String mtmNotification = context.getString(R.string.mtm_notification); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "ssl") + .setSmallIcon(android.R.drawable.ic_lock_lock) + .setContentTitle(mtmNotification) + .setContentText(certName) + .setTicker(certName) + .setContentIntent(call) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH); + + notificationManager.notify(NOTIFICATION_ID + decisionId, builder.build()); + } + + /** + * Returns the top-most entry of the activity stack. + * + * @return the Context of the currently bound UI or the master context if none is bound + */ + Context getUI() { + return (foregroundAct != null) ? foregroundAct : context; + } + + private int interact(final String message, final int titleId) { + /* prepare the MTMDecision blocker object */ + MTMDecision choice = new MTMDecision(); + final int myId = createDecisionId(choice); + + masterHandler.post(new Runnable() { + public void run() { + Intent intent = new Intent(context, MemorizingActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setData(Uri.parse(MemorizingTrustManager.class.getName() + "/" + myId)); + intent.putExtra(DECISION_INTENT_ID, myId); + intent.putExtra(DECISION_INTENT_CERT, message); + intent.putExtra(DECISION_TITLE_ID, titleId); + + // we try to directly start the activity and fall back to + // making a notification. If no foreground activity is set + // (foregroundAct==null) or if the app developer set an + // invalid / expired activity, the catch-all fallback is + // deployed. + try { + foregroundAct.startActivity(intent); + } catch (Exception e) { + startActivityNotification(intent, myId, message); + } + } + }); + + try { + synchronized(choice) { + choice.wait(); + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + + return choice.state; + } + + private void interactCert(final X509Certificate[] chain, String authType, CertificateException cause) throws CertificateException { + switch (interact(certChainMessage(chain, cause), R.string.mtm_accept_cert)) { + case MTMDecision.DECISION_ALWAYS: + storeCert(chain[0]); // only store the server cert, not the whole chain + case MTMDecision.DECISION_ONCE: + break; + default: + throw (cause); + } + } + + private boolean interactHostname(X509Certificate cert, String hostname) { + switch (interact(hostNameMessage(cert, hostname), R.string.mtm_accept_servername)) { + case MTMDecision.DECISION_ALWAYS: + storeCert(hostname, cert); + case MTMDecision.DECISION_ONCE: + return true; + default: + return false; + } + } + + static void interactResult(int decisionId, int choice) { + MTMDecision d; + + synchronized(openDecisions) { + d = openDecisions.get(decisionId); + openDecisions.remove(decisionId); + } + + if (d == null) return; + + synchronized(d) { + d.state = choice; + d.notify(); + } + } + + class MemorizingHostnameVerifier implements HostnameVerifier { + private HostnameVerifier defaultVerifier; + + MemorizingHostnameVerifier(HostnameVerifier wrapped) { + defaultVerifier = wrapped; + } + + @Override + public boolean verify(String hostname, SSLSession session) { + // if the default verifier accepts the hostname, we are done + if (defaultVerifier.verify(hostname, session)) return true; + + // otherwise, we check if the hostname is an alias for this cert in our keystore + try { + X509Certificate cert = (X509Certificate)session.getPeerCertificates()[0]; + + if (cert.equals(appKeyStore.getCertificate(hostname.toLowerCase(Locale.US)))) { + return true; + } else { + return interactHostname(cert, hostname); + } + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1a10a6e..fa2f1c16 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -566,4 +566,19 @@ Changelog + + Accept Unknown Certificate? + The server certificate is not signed by a known Certificate Authority. + The server certificate is expired. + Accept Mismatching Server Name? + Server could not authenticate as \"%s\". The certificate is only valid for: + + Do you want to connect anyway? + Certificate details: + + Always + Once + Abort + + Certificate Verification