diff --git a/app/src/main/java/com/github/gotify/SSLSettings.java b/app/src/main/java/com/github/gotify/SSLSettings.java index cb8c4a6b..ea83d6ec 100644 --- a/app/src/main/java/com/github/gotify/SSLSettings.java +++ b/app/src/main/java/com/github/gotify/SSLSettings.java @@ -3,9 +3,13 @@ public class SSLSettings { public boolean validateSSL; public String cert; + public String clientCert; + public String clientCertPassword; - public SSLSettings(boolean validateSSL, String cert) { + public SSLSettings(boolean validateSSL, String cert, String clientCert, String clientCertPassword) { this.validateSSL = validateSSL; this.cert = cert; + this.clientCert = clientCert; + this.clientCertPassword = clientCertPassword; } } diff --git a/app/src/main/java/com/github/gotify/Settings.java b/app/src/main/java/com/github/gotify/Settings.java index 14139ff2..74f05c2a 100644 --- a/app/src/main/java/com/github/gotify/Settings.java +++ b/app/src/main/java/com/github/gotify/Settings.java @@ -11,6 +11,30 @@ public Settings(Context context) { sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE); } + public void clientCertUri(String clientCertUri) { + sharedPreferences.edit().putString("clientCertUri", clientCertUri).apply(); + } + + public String clientCertUri() { + return sharedPreferences.getString("clientCertUri", null); + } + + public void clientCert(String clientCert) { + sharedPreferences.edit().putString("clientCert", clientCert).apply(); + } + + public String clientCert() { + return sharedPreferences.getString("clientCert", null); + } + + public void clientCertPass(String clientCertPass) { + sharedPreferences.edit().putString("clientCertPass", clientCertPass).apply(); + } + + public String clientCertPass() { + return sharedPreferences.getString("clientCertPass", ""); + } + public void url(String url) { sharedPreferences.edit().putString("url", url).apply(); } @@ -36,6 +60,9 @@ public void clear() { token(null); validateSSL(true); cert(null); + clientCert(null); + clientCertUri(null); + clientCertPass(""); } public void user(String name, boolean admin) { @@ -77,6 +104,6 @@ public void cert(String cert) { } public SSLSettings sslSettings() { - return new SSLSettings(validateSSL(), cert()); + return new SSLSettings(validateSSL(), cert(), clientCert(), clientCertPass()); } } diff --git a/app/src/main/java/com/github/gotify/Utils.java b/app/src/main/java/com/github/gotify/Utils.java index 2a3dd489..d519c54a 100644 --- a/app/src/main/java/com/github/gotify/Utils.java +++ b/app/src/main/java/com/github/gotify/Utils.java @@ -5,9 +5,12 @@ import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.os.Build; import android.text.format.DateUtils; +import java.util.Base64; import android.view.View; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; import com.github.gotify.client.JSON; import com.github.gotify.log.Log; import com.google.android.material.snackbar.Snackbar; @@ -77,6 +80,21 @@ public void onPrepareLoad(Drawable placeHolderDrawable) {} }; } + @RequiresApi(api = Build.VERSION_CODES.O) + public static String binaryFileToBase64(@NonNull InputStream inputStream) { + byte[] bytes; + + try { + bytes = new byte[inputStream.available()]; + //noinspection ResultOfMethodCallIgnored + inputStream.read(bytes); + } catch (IOException e) { + throw new IllegalArgumentException("failed to read input"); + } + + return Base64.getEncoder().encodeToString(bytes); + } + public static String readFileFromStream(@NonNull InputStream inputStream) { StringBuilder sb = new StringBuilder(); String currentLine; diff --git a/app/src/main/java/com/github/gotify/api/CertUtils.java b/app/src/main/java/com/github/gotify/api/CertUtils.java index 0ca70101..ff52444c 100644 --- a/app/src/main/java/com/github/gotify/api/CertUtils.java +++ b/app/src/main/java/com/github/gotify/api/CertUtils.java @@ -1,18 +1,28 @@ package com.github.gotify.api; import android.annotation.SuppressLint; +import android.os.Build; +import androidx.annotation.RequiresApi; import com.github.gotify.SSLSettings; import com.github.gotify.Utils; import com.github.gotify.log.Log; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.security.UnrecoverableKeyException; import java.security.cert.Certificate; +import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.Base64; import java.util.Collection; import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; @@ -60,21 +70,62 @@ public static void applySslSettings(OkHttpClient.Builder builder, SSLSettings se } if (settings.cert != null) { + KeyManager[] keyManagers = new KeyManager[] {}; TrustManager[] trustManagers = certToTrustManager(settings.cert); if (trustManagers != null && trustManagers.length > 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (settings.clientCert != null) { + keyManagers = getClientCerts(settings.clientCert, settings.clientCertPassword.toCharArray()); + } + } + SSLContext context = SSLContext.getInstance("TLS"); - context.init(new KeyManager[] {}, trustManagers, new SecureRandom()); + context.init(keyManagers, trustManagers, new SecureRandom()); builder.sslSocketFactory( context.getSocketFactory(), (X509TrustManager) trustManagers[0]); } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (settings.clientCert != null) { + KeyManager[] keyManagers = getClientCerts(settings.clientCert, settings.clientCertPassword.toCharArray()); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { + throw new IllegalStateException("Unexpected default trust managers:"); + } + X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; + + SSLContext context = SSLContext.getInstance("TLS"); + context.init(keyManagers, new TrustManager[] { trustManager }, null); + builder.sslSocketFactory( + context.getSocketFactory(), trustManager); + } + } } - } catch (Exception e) { + } + catch (Exception e) { // We shouldn't have issues since the cert is verified on login. Log.e("Failed to apply SSL settings", e); } } + @RequiresApi(api = Build.VERSION_CODES.O) + private static KeyManager[] getClientCerts(String clientCert, char[] password) + throws CertificateException, IOException, NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException { + KeyStore ks = KeyStore.getInstance("PKCS12"); + InputStream bs = new ByteArrayInputStream(Base64.getDecoder().decode(clientCert)); + ks.load(bs, password); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance("X509"); + kmf.init(ks, password); + + return kmf.getKeyManagers(); + } + private static TrustManager[] certToTrustManager(String cert) throws GeneralSecurityException { CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); Collection certificates = diff --git a/app/src/main/java/com/github/gotify/login/AdvancedDialog.java b/app/src/main/java/com/github/gotify/login/AdvancedDialog.java index f52fd1e2..3bfcde56 100644 --- a/app/src/main/java/com/github/gotify/login/AdvancedDialog.java +++ b/app/src/main/java/com/github/gotify/login/AdvancedDialog.java @@ -2,16 +2,19 @@ import android.app.AlertDialog; import android.content.Context; +import android.os.Build; import android.view.LayoutInflater; import android.view.View; import android.widget.Button; import android.widget.CheckBox; import android.widget.CompoundButton; +import android.widget.EditText; import android.widget.TextView; import androidx.annotation.Nullable; import butterknife.BindView; import butterknife.ButterKnife; import com.github.gotify.R; +import com.github.gotify.Settings; class AdvancedDialog { @@ -20,6 +23,8 @@ class AdvancedDialog { private CompoundButton.OnCheckedChangeListener onCheckedChangeListener; private Runnable onClickSelectCaCertificate; private Runnable onClickRemoveCaCertificate; + private Runnable onClickSelectClientCertificate; + private Runnable onClickRemoveClientCertificate; AdvancedDialog(Context context) { this.context = context; @@ -41,24 +46,48 @@ AdvancedDialog onClickRemoveCaCertificate(Runnable onClickRemoveCaCertificate) { return this; } - AdvancedDialog show(boolean disableSSL, @Nullable String selectedCertificate) { + AdvancedDialog onClickSelectClientCertificate(Runnable onClickSelectClientCertificate) { + this.onClickSelectClientCertificate = onClickSelectClientCertificate; + return this; + } + + AdvancedDialog onClickRemoveClientCertificate(Runnable onClickRemoveClientCertificate) { + this.onClickRemoveClientCertificate = onClickRemoveClientCertificate; + return this; + } + AdvancedDialog show(boolean disableSSL, @Nullable String selectedCaCertificate, @Nullable String selectedClientCertificate, String password, Settings settings) { View dialogView = LayoutInflater.from(context).inflate(R.layout.advanced_settings_dialog, null); holder = new ViewHolder(dialogView); holder.disableSSL.setChecked(disableSSL); holder.disableSSL.setOnCheckedChangeListener(onCheckedChangeListener); + holder.editClientCertPass.setText(password); - if (selectedCertificate == null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + holder.editClientCertPass.setVisibility(View.GONE); + holder.selectedClientCertificate.setVisibility(View.GONE); + holder.toggleClientCert.setVisibility(View.GONE); + } + + if (selectedCaCertificate == null) { showSelectCACertificate(); } else { - showRemoveCACertificate(selectedCertificate); + showRemoveCACertificate(selectedCaCertificate); + } + + if (selectedClientCertificate == null) { + showSelectClientCertificate(); + } else { + showRemoveClientCertificate(selectedClientCertificate); } new AlertDialog.Builder(context) .setView(dialogView) .setTitle(R.string.advanced_settings) - .setPositiveButton(context.getString(R.string.done), (ignored, ignored2) -> {}) + .setPositiveButton(context.getString(R.string.done), (ignored, ignored2) -> { + settings.clientCertPass(holder.editClientCertPass.getText().toString()); + }) .show(); return this; } @@ -66,7 +95,7 @@ AdvancedDialog show(boolean disableSSL, @Nullable String selectedCertificate) { private void showSelectCACertificate() { holder.toggleCaCert.setText(R.string.select_ca_certificate); holder.toggleCaCert.setOnClickListener((a) -> onClickSelectCaCertificate.run()); - holder.selectedCaCertificate.setText(R.string.no_certificate_selected); + holder.selectedCaCertificate.setText(R.string.no_ca_certificate_selected); } void showRemoveCACertificate(String certificate) { @@ -79,6 +108,22 @@ void showRemoveCACertificate(String certificate) { holder.selectedCaCertificate.setText(certificate); } + private void showSelectClientCertificate() { + holder.toggleClientCert.setText(R.string.select_client_certificate); + holder.toggleClientCert.setOnClickListener((a) -> onClickSelectClientCertificate.run()); + holder.selectedClientCertificate.setText(R.string.no_client_certificate_selected); + } + + void showRemoveClientCertificate(String certificate) { + holder.toggleClientCert.setText(R.string.remove_client_certificate); + holder.toggleClientCert.setOnClickListener( + (a) -> { + showSelectClientCertificate(); + onClickRemoveClientCertificate.run(); + }); + holder.selectedClientCertificate.setText(certificate); + } + class ViewHolder { @BindView(R.id.disableSSL) CheckBox disableSSL; @@ -86,9 +131,18 @@ class ViewHolder { @BindView(R.id.toggle_ca_cert) Button toggleCaCert; - @BindView(R.id.seleceted_ca_cert) + @BindView(R.id.selected_ca_cert) TextView selectedCaCertificate; + @BindView(R.id.toggle_client_cert) + Button toggleClientCert; + + @BindView(R.id.selected_client_cert) + TextView selectedClientCertificate; + + @BindView(R.id.edit_client_cert_pass) + EditText editClientCertPass; + ViewHolder(View view) { ButterKnife.bind(this, view); } diff --git a/app/src/main/java/com/github/gotify/login/LoginActivity.java b/app/src/main/java/com/github/gotify/login/LoginActivity.java index a57f2737..c399051d 100644 --- a/app/src/main/java/com/github/gotify/login/LoginActivity.java +++ b/app/src/main/java/com/github/gotify/login/LoginActivity.java @@ -46,7 +46,8 @@ public class LoginActivity extends AppCompatActivity { // return value from startActivityForResult when choosing a certificate - private final int FILE_SELECT_CODE = 1; + private final int CA_FILE_SELECT_CODE = 1; + private final int CLI_CERT_FILE_SELECT_CODE = 2; @BindView(R.id.username) EditText usernameField; @@ -76,6 +77,9 @@ public class LoginActivity extends AppCompatActivity { private boolean disableSSLValidation; private String caCertContents; + private String clientCertContents; + private String clientCertPassword; + private String clientCertUri; private AdvancedDialog advancedDialog; @Override @@ -153,6 +157,9 @@ public void openLogs() { void toggleShowAdvanced() { String selectedCertName = caCertContents != null ? getNameOfCertContent(caCertContents) : null; + clientCertUri = settings.clientCertUri(); + clientCertContents = settings.clientCert(); + clientCertPassword = settings.clientCertPass(); advancedDialog = new AdvancedDialog(this) @@ -164,17 +171,31 @@ void toggleShowAdvanced() { .onClickSelectCaCertificate( () -> { invalidateUrl(); - doSelectCACertificate(); + doSelectCertificate(R.string.select_ca_file, CA_FILE_SELECT_CODE); }) .onClickRemoveCaCertificate( () -> { invalidateUrl(); caCertContents = null; }) - .show(disableSSLValidation, selectedCertName); + .onClickSelectClientCertificate(() -> { + invalidateUrl(); + doSelectCertificate(R.string.select_client_cert_file, CLI_CERT_FILE_SELECT_CODE); + }) + .onClickRemoveClientCertificate(() -> { + invalidateUrl(); + clientCertContents = null; + settings.clientCert(null); + settings.clientCertPass(""); + clientCertPassword = ""; + settings.clientCertUri(null); + clientCertUri = null; + + }) + .show(disableSSLValidation, selectedCertName, clientCertUri, clientCertPassword, settings); } - private void doSelectCACertificate() { + private void doSelectCertificate(int intentChooserDescriptionCode, int FILE_SELECT_CODE) { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); // we don't really care what kind of file it is as long as we can parse it intent.setType("*/*"); @@ -182,7 +203,7 @@ private void doSelectCACertificate() { try { startActivityForResult( - Intent.createChooser(intent, getString(R.string.select_ca_file)), + Intent.createChooser(intent, getString(intentChooserDescriptionCode)), FILE_SELECT_CODE); } catch (ActivityNotFoundException e) { // case for user not having a file browser installed @@ -194,7 +215,7 @@ private void doSelectCACertificate() { protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); try { - if (requestCode == FILE_SELECT_CODE) { + if (requestCode == CA_FILE_SELECT_CODE || requestCode == CLI_CERT_FILE_SELECT_CODE) { if (resultCode != RESULT_OK) { throw new IllegalArgumentException(String.format("result was %d", resultCode)); } else if (data == null) { @@ -211,12 +232,24 @@ protected void onActivityResult(int requestCode, int resultCode, @Nullable Inten throw new IllegalArgumentException("file path was invalid"); } - String content = Utils.readFileFromStream(fileStream); - String name = getNameOfCertContent(content); - - // temporarily set the contents (don't store to settings until they decide to login) - caCertContents = content; - advancedDialog.showRemoveCACertificate(name); + if (requestCode == CA_FILE_SELECT_CODE) { + String content = Utils.readFileFromStream(fileStream); + String name = getNameOfCertContent(content); + // temporarily set the contents (don't store to settings until they decide to login) + caCertContents = content; + advancedDialog.showRemoveCACertificate(name); + } else if (requestCode == CLI_CERT_FILE_SELECT_CODE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + String content = Utils.binaryFileToBase64(fileStream); + + clientCertContents = content; + String path = uri.getLastPathSegment(); + + settings.clientCert(clientCertContents); + settings.clientCertUri(path); + advancedDialog.showRemoveClientCertificate(path); + } + } } } catch (Exception e) { Utils.showSnackBar(this, getString(R.string.select_ca_failed, e.getMessage())); @@ -296,6 +329,7 @@ private void onCreatedClient(Client client) { settings.token(client.getToken()); settings.validateSSL(!disableSSLValidation); settings.cert(caCertContents); + settings.clientCert(clientCertContents); Utils.showSnackBar(this, getString(R.string.created_client)); startActivity(new Intent(this, InitializationActivity.class)); @@ -318,6 +352,6 @@ private String versionError(String url, ApiException exception) { } private SSLSettings tempSSLSettings() { - return new SSLSettings(!disableSSLValidation, caCertContents); + return new SSLSettings(!disableSSLValidation, caCertContents, clientCertContents, clientCertPassword); } } diff --git a/app/src/main/res/layout/advanced_settings_dialog.xml b/app/src/main/res/layout/advanced_settings_dialog.xml index bf569ff3..12492a29 100644 --- a/app/src/main/res/layout/advanced_settings_dialog.xml +++ b/app/src/main/res/layout/advanced_settings_dialog.xml @@ -17,8 +17,29 @@ android:text="@string/select_ca_certificate" /> + android:text="@string/no_ca_certificate_selected" /> + +