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)};
+ }
+
+ /**
+ * 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:
+ * /p>
+ *
+ * - Success, if the wrapped defaultVerifier accepts the certificate.
+ * - Success, if the server certificate is stored in the keystore under the given hostname.
+ * - Ask the user and return accordingly.
+ * - Failure on exception.
+ *
+ *
+ * @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 {
+ try {
+
+ if(isServer) {
+ appTrustManager.checkServerTrusted(chain, authType);
+ }
+ else {
+ appTrustManager.checkClientTrusted(chain, authType);
+ }
+ }
+ catch(CertificateException ae) {
+ // if the cert is stored in our appTrustManager, we ignore expiredness
+ if(isExpiredException(ae) || isCertKnown(chain[0])) {
+ return;
+ }
+
+ try {
+ if(defaultTrustManager == null) {
+ throw ae;
+ }
+ if(isServer) {
+ defaultTrustManager.checkServerTrusted(chain, authType);
+ }
+ else {
+ defaultTrustManager.checkClientTrusted(chain, authType);
+ }
+ }
+ catch(CertificateException 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 stringBuilder, X509Certificate c) {
+
+ SimpleDateFormat validityDateFormater = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
+
+ stringBuilder.append("\n")
+ .append(c.getSubjectDN().toString())
+ .append("\n")
+ .append(validityDateFormater.format(c.getNotBefore()))
+ .append(" - ")
+ .append(validityDateFormater.format(c.getNotAfter()))
+ .append("\nSHA-256: ")
+ .append(certHash(c, "SHA-256"))
+ .append("\nSHA-1: ")
+ .append(certHash(c, "SHA-1"))
+ .append("\nSigned by: ")
+ .append(c.getIssuerDN().toString())
+ .append("\n");
+ }
+
+ private String certChainMessage(final X509Certificate[] chain, CertificateException cause) {
+
+ Throwable e = cause;
+ StringBuilder stringBuilder = new StringBuilder();
+
+ if(isPathException(e)) {
+ stringBuilder.append(context.getString(R.string.mtm_trust_anchor));
+ }
+ else if(isExpiredException(e)) {
+ stringBuilder.append(context.getString(R.string.mtm_cert_expired));
+ }
+ else {
+ // get to the cause
+ while(e.getCause() != null) {
+ e = e.getCause();
+ }
+
+ stringBuilder.append(e.getLocalizedMessage());
+ }
+
+ stringBuilder.append("\n\n");
+ stringBuilder.append(context.getString(R.string.mtm_connect_anyway));
+ stringBuilder.append("\n\n");
+ stringBuilder.append(context.getString(R.string.mtm_cert_details));
+
+ for(X509Certificate c : chain) {
+ certDetails(stringBuilder, c);
+ }
+
+ return stringBuilder.toString();
+ }
+
+ private String hostNameMessage(X509Certificate cert, String hostname) {
+
+ StringBuilder stringBuilder = new StringBuilder();
+
+ stringBuilder.append(context.getString(R.string.mtm_hostname_mismatch, hostname));
+ stringBuilder.append("\n\n");
+
+ try {
+ Collection> sans = cert.getSubjectAlternativeNames();
+
+ if(sans == null) {
+ stringBuilder.append(cert.getSubjectDN());
+ stringBuilder.append("\n");
+ }
+ else {
+ for(List> altName : sans) {
+ Object name = altName.get(1);
+ if(name instanceof String) {
+ stringBuilder.append("[");
+ stringBuilder.append(altName.get(0));
+ stringBuilder.append("] ");
+ stringBuilder.append(name);
+ stringBuilder.append("\n");
+ }
+ }
+ }
+ }
+ catch(CertificateParsingException e) {
+ e.printStackTrace();
+ stringBuilder.append("\n");
+ }
+
+ stringBuilder.append("\n");
+ stringBuilder.append(context.getString(R.string.mtm_connect_anyway));
+ stringBuilder.append("\n\n");
+ stringBuilder.append(context.getString(R.string.mtm_cert_details));
+ certDetails(stringBuilder, cert);
+ return stringBuilder.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);
+ }
+ }
+
+ 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());
+ }
+
+ private int interact(final String message, final int titleId) {
+ MTMDecision choice = new MTMDecision();
+ final int myId = createDecisionId(choice);
+
+ masterHandler.post(new Runnable() {
+
+ public void run() {
+
+ Intent intent = new Intent(context, MemorizingActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
+ intent.putExtra("DECISION_INTENT_ID", myId);
+ intent.putExtra("DECISION_INTENT_CERT", message);
+ intent.putExtra("DECISION_TITLE_ID", titleId);
+
+ try {
+ context.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 {
+
+ if(interact(certChainMessage(chain, cause), R.string.mtm_accept_cert) == MTMDecision.DECISION_ALWAYS) {
+ storeCert(chain[0]); // only store the server cert, not the whole chain
+ } else {
+ throw (cause);
+ }
+ }
+
+ private boolean interactHostname(X509Certificate cert, String hostname) {
+
+ if(interact(hostNameMessage(cert, hostname), R.string.mtm_accept_servername) == MTMDecision.DECISION_ALWAYS) {
+ storeCert(hostname, cert);
+ return true;
+ }
+
+ 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/drawable/ic_security_24dp.xml b/app/src/main/res/drawable/ic_security_24dp.xml
new file mode 100644
index 00000000..6ffc522d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_security_24dp.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
index 5f6eb145..274ee8a1 100644
--- a/app/src/main/res/layout/fragment_settings.xml
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -33,7 +33,7 @@
layout="@layout/layout_settings_fileview"/>
+
+
+
+
diff --git a/app/src/main/res/layout/layout_settings_security.xml b/app/src/main/res/layout/layout_settings_security.xml
new file mode 100644
index 00000000..c06342fe
--- /dev/null
+++ b/app/src/main/res/layout/layout_settings_security.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 641bc7dc..4a173dc5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -92,6 +92,7 @@
1- Choose the correct protocol(https or http). \n2- Enter Gitea url e.g: try.gitea.io. \n3- If you have enabled 2FA for your account, enter the code in the OTP Code field. \n4- For HTTP basic auth use USERNAME@DOMAIN.COM in the URL field.
Wrong username/password
://
+ Couldn\'t connect to host. Please check your URL or port for any errors.
It is not recommended to use HTTP protocol unless you are testing on local network.
Malformed JSON was received. Server response was not successful.
Instance URL is required
@@ -245,6 +246,10 @@
Translation
+ Security
+ Delete Trusted Certificates
+ Delete Trusted Certificates?
+ Are you sure to delete any manually trusted certificate or hostname? \n\nYou will also be logged out.
Date & Time
Settings saved
Language
@@ -569,4 +574,18 @@
Committed by %1$s
View Commits
Changelog
+
+
+ Certificate Verification
+ 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:
+ Trust
+ Once
+ Abort
+