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 extends Certificate> 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" />
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9f1ed7ea..120355fb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -40,7 +40,10 @@
Password
Disable SSL Validation
Select CA Certificate
+ Select Client Certificate
Select a Certificate File
+ Select a Client Certificate File
+ Client Certificate Password
Please install a file browser
Failed to read CA: %s
Login
@@ -59,8 +62,10 @@
gotify/android v%s; gotify/server v%s
Advanced Settings
Done
- No certificate selected
+ No CA certificate selected
Remove CA Certificate
+ No client certificate selected
+ Remove Client Certificate
Warning
Using http is insecure and it\'s recommend to use https instead. Use your favorite search engine to get more information about this topic.
I Understand