From adb99553e048814d48b5c26d9b879df137ed1585 Mon Sep 17 00:00:00 2001 From: polizz Date: Sun, 24 Apr 2022 21:15:32 -0500 Subject: [PATCH 1/5] Initial client certificates --- .../java/com/github/gotify/SSLSettings.java | 6 +- .../main/java/com/github/gotify/Settings.java | 29 ++++++++- .../main/java/com/github/gotify/Utils.java | 18 ++++++ .../java/com/github/gotify/api/CertUtils.java | 43 +++++++++++++- .../com/github/gotify/api/ClientFactory.java | 8 +++ .../github/gotify/login/AdvancedDialog.java | 59 +++++++++++++++++-- .../github/gotify/login/LoginActivity.java | 53 +++++++++++++---- .../res/layout/advanced_settings_dialog.xml | 25 +++++++- app/src/main/res/values/strings.xml | 7 ++- 9 files changed, 223 insertions(+), 25 deletions(-) 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..8e7f25a3 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,24 @@ 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.SecureRandom; import java.security.cert.Certificate; 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; @@ -46,6 +52,7 @@ public static Certificate parseCertificate(String cert) { } } + @RequiresApi(api = Build.VERSION_CODES.O) public static void applySslSettings(OkHttpClient.Builder builder, SSLSettings settings) { // Modified from ApiClient.applySslSettings in the client package. @@ -69,7 +76,41 @@ public static void applySslSettings(OkHttpClient.Builder builder, SSLSettings se context.getSocketFactory(), (X509TrustManager) trustManagers[0]); } } - } catch (Exception e) { + + if (settings.clientCert != null) { + KeyStore ks = KeyStore.getInstance("PKCS12"); + InputStream bs = new ByteArrayInputStream(Base64.getDecoder().decode(settings.clientCert)); + ks.load(bs, settings.clientCertPassword.toCharArray()); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance("X509"); + kmf.init(ks, 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(kmf.getKeyManagers(), new TrustManager[] { trustManager }, null); + builder.sslSocketFactory( + context.getSocketFactory(), trustManager); + } + } catch (IOException iex) { + String tx = iex.toString(); + if (iex.toString().contains("wrong password")) { + Log.e("Incorrect client certificate password.", iex); + return; + } + + Log.e("Error opening client certificate.", iex); + } + catch (Exception e) { // We shouldn't have issues since the cert is verified on login. Log.e("Failed to apply SSL settings", e); } diff --git a/app/src/main/java/com/github/gotify/api/ClientFactory.java b/app/src/main/java/com/github/gotify/api/ClientFactory.java index 1e10e7c9..ecfe877f 100644 --- a/app/src/main/java/com/github/gotify/api/ClientFactory.java +++ b/app/src/main/java/com/github/gotify/api/ClientFactory.java @@ -1,5 +1,7 @@ package com.github.gotify.api; +import android.os.Build; +import androidx.annotation.RequiresApi; import com.github.gotify.SSLSettings; import com.github.gotify.Settings; import com.github.gotify.client.ApiClient; @@ -9,11 +11,13 @@ import com.github.gotify.client.auth.HttpBasicAuth; public class ClientFactory { + @RequiresApi(api = Build.VERSION_CODES.O) public static com.github.gotify.client.ApiClient unauthorized( String baseUrl, SSLSettings sslSettings) { return defaultClient(new String[0], baseUrl + "/", sslSettings); } + @RequiresApi(api = Build.VERSION_CODES.O) public static ApiClient basicAuth( String baseUrl, SSLSettings sslSettings, String username, String password) { ApiClient client = defaultClient(new String[] {"basicAuth"}, baseUrl + "/", sslSettings); @@ -23,6 +27,7 @@ public static ApiClient basicAuth( return client; } + @RequiresApi(api = Build.VERSION_CODES.O) public static ApiClient clientToken(String baseUrl, SSLSettings sslSettings, String token) { ApiClient client = defaultClient(new String[] {"clientTokenHeader"}, baseUrl + "/", sslSettings); @@ -31,15 +36,18 @@ public static ApiClient clientToken(String baseUrl, SSLSettings sslSettings, Str return client; } + @RequiresApi(api = Build.VERSION_CODES.O) public static VersionApi versionApi(String baseUrl, SSLSettings sslSettings) { return unauthorized(baseUrl, sslSettings).createService(VersionApi.class); } + @RequiresApi(api = Build.VERSION_CODES.O) public static UserApi userApiWithToken(Settings settings) { return clientToken(settings.url(), settings.sslSettings(), settings.token()) .createService(UserApi.class); } + @RequiresApi(api = Build.VERSION_CODES.O) private static ApiClient defaultClient( String[] authentications, String baseUrl, SSLSettings sslSettings) { ApiClient client = new ApiClient(authentications); 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..51e5b974 100644 --- a/app/src/main/java/com/github/gotify/login/AdvancedDialog.java +++ b/app/src/main/java/com/github/gotify/login/AdvancedDialog.java @@ -7,11 +7,13 @@ 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 +22,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 +45,42 @@ 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 (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 +88,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 +101,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 +124,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..581a07b7 100644 --- a/app/src/main/java/com/github/gotify/login/LoginActivity.java +++ b/app/src/main/java/com/github/gotify/login/LoginActivity.java @@ -12,6 +12,7 @@ import android.widget.ImageView; import android.widget.ProgressBar; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ContextThemeWrapper; @@ -46,7 +47,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 +78,8 @@ public class LoginActivity extends AppCompatActivity { private boolean disableSSLValidation; private String caCertContents; + private String clientCertContents; + private String clientCertPassword; private AdvancedDialog advancedDialog; @Override @@ -153,6 +157,9 @@ public void openLogs() { void toggleShowAdvanced() { String selectedCertName = caCertContents != null ? getNameOfCertContent(caCertContents) : null; + String selectedClientCertUri = settings.clientCertUri(); + clientCertContents = settings.clientCert(); + clientCertPassword = settings.clientCertPass(); advancedDialog = new AdvancedDialog(this) @@ -164,17 +171,24 @@ void toggleShowAdvanced() { .onClickSelectCaCertificate( () -> { invalidateUrl(); - doSelectCACertificate(); + doSelectCertificate(R.string.select_ca_file, CA_FILE_SELECT_CODE); }) .onClickRemoveCaCertificate( () -> { invalidateUrl(); caCertContents = null; }) - .show(disableSSLValidation, selectedCertName); + .onClickSelectClientCertificate(() -> + doSelectCertificate(R.string.select_client_cert_file, CLI_CERT_FILE_SELECT_CODE)) + .onClickRemoveClientCertificate(() -> { + clientCertContents = null; + settings.clientCertPass(""); + clientCertPassword = ""; + }) + .show(disableSSLValidation, selectedCertName, selectedClientCertUri, 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 +196,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 @@ -190,11 +204,12 @@ private void doSelectCACertificate() { } } + @RequiresApi(api = Build.VERSION_CODES.O) @Override 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 +226,22 @@ 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) { + String content = Utils.binaryFileToBase64(fileStream); + + clientCertContents = content; + String path = uri.toString(); + + settings.clientCert(clientCertContents); + settings.clientCertUri(path); + advancedDialog.showRemoveClientCertificate(path); + } } } catch (Exception e) { Utils.showSnackBar(this, getString(R.string.select_ca_failed, e.getMessage())); @@ -249,6 +274,7 @@ private Callback.ErrorCallback onInvalidUrl(String url) { }; } + @RequiresApi(api = Build.VERSION_CODES.O) @OnClick(R.id.login) public void doLogin() { String username = usernameField.getText().toString(); @@ -296,6 +322,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 +345,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" /> + +