MiniABS 1.1: HTTPS support

- Bundle current root certificates (Let's Encrypt, GTS, Sectigo, DigiCert G2,
  Amazon, ...) missing from Android 2.x's store (Roots.java, tools/gen_roots.py)
- Ask once for unknown certificates, showing the SHA-256 fingerprint, and
  remember the answer; replaces "accept any certificate" (Trust*, Settings)
- Local streaming proxy so Android 2.x's MediaPlayer can play https:// servers,
  with seeking and a fresh token per request (StreamProxy.java)
- Tests over real TLS with good, expired, wrong-host, forged and self-signed
  certificates (test/make_pki.sh, TrustTest.java)
- README: server setup for a legacy TLS 1.0 port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-14 20:07:55 +02:00
1 parent 4fe9eed870
commit b7e43d7d61
24 files changed
+1326 -88

No files matched your search

+9 -50
View File
@@ -12,17 +12,10 @@ import java.net.ProtocolException;
import java.net.Socket;
import java.net.URL;
import java.net.URLEncoder;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.json.JSONArray;
import org.json.JSONException;
@@ -33,7 +26,7 @@ import android.os.Build;
/** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */
final class Api {
static final String VERSION = "1.0";
static final String VERSION = "1.1";
static class ApiException extends IOException {
final int code;
@@ -52,7 +45,6 @@ final class Api {
}
private static final Object REFRESH_LOCK = new Object();
private static SSLSocketFactory trustAllFactory;
static {
// Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off.
@@ -70,7 +62,7 @@ final class Api {
conn.setReadTimeout(30000);
conn.setUseCaches(false);
conn.setInstanceFollowRedirects(true);
if (conn instanceof HttpsURLConnection && Prefs.get(c).trustAll()) trustAll((HttpsURLConnection) conn);
if (conn instanceof HttpsURLConnection) Trust.apply(c, (HttpsURLConnection) conn);
setMethod(conn, method);
conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
return conn;
@@ -80,38 +72,6 @@ final class Api {
conn.setRequestMethod(method);
}
private static synchronized SSLSocketFactory trustAllFactory() throws Exception {
if (trustAllFactory == null) {
TrustManager tm = new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String type) {
}
public void checkServerTrusted(X509Certificate[] chain, String type) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[]{tm}, null);
trustAllFactory = ctx.getSocketFactory();
}
return trustAllFactory;
}
private static void trustAll(HttpsURLConnection conn) {
try {
conn.setSSLSocketFactory(trustAllFactory());
conn.setHostnameVerifier(new HostnameVerifier() {
public boolean verify(String host, SSLSession session) {
return true;
}
});
} catch (Exception ignored) {
}
}
/**
* PATCH over a plain socket. Android 2.x's HttpURLConnection only sends a body with POST or PUT,
* and Audiobookshelf uses PATCH for "mark as played" and for saving progress without a session.
@@ -130,16 +90,15 @@ final class Api {
sock.setSoTimeout(30000);
Socket io = sock;
if (https) {
boolean trust = p.trustAll();
SSLSocketFactory f;
try {
f = trust ? trustAllFactory() : (SSLSocketFactory) SSLSocketFactory.getDefault();
} catch (Exception e) {
throw new SSLException("TLS unavailable");
SSLSocket ss = (SSLSocket) Trust.factory(c).createSocket(sock, u.getHost(), port, true);
try { // send the host name (SNI) where the phone supports it (Android 2.3+)
ss.getClass().getMethod("setHostname", String.class).invoke(ss, u.getHost());
} catch (Exception ignored) {
}
SSLSocket ss = (SSLSocket) f.createSocket(sock, u.getHost(), port, true);
ss.startHandshake();
if (!trust && !HttpsURLConnection.getDefaultHostnameVerifier().verify(u.getHost(), ss.getSession())) {
java.security.cert.Certificate[] peer = ss.getSession().getPeerCertificates();
if (peer.length == 0 || !(peer[0] instanceof java.security.cert.X509Certificate)
|| !Trust.hostMatches((java.security.cert.X509Certificate) peer[0], u.getHost())) {
ss.close();
throw new SSLException("Certificate doesn't match " + u.getHost());
}
+11
View File
@@ -64,9 +64,20 @@ abstract class BaseActivity extends Activity {
finish();
return;
}
if (offerTrust(null)) return;
toast(Util.describe(e));
}
/** If a request just failed on an unrecognised certificate, ask about it. retry runs after trusting. */
protected boolean offerTrust(final Runnable retry) {
return TrustPrompt.offer(this, new Runnable() {
public void run() {
if (retry != null) retry.run();
else toast("Certificate trusted. Try again.");
}
});
}
// ------------------------------------------------------------ mini player
protected void setupMiniBar() {
+14
View File
@@ -178,6 +178,12 @@ public class HomeActivity extends BaseActivity {
empty.setVisibility(msg == null ? View.GONE : View.VISIBLE);
}
private final Runnable retryRefresh = new Runnable() {
public void run() {
refresh();
}
};
private void refresh() {
App.invalidate();
Covers.clearMemory();
@@ -315,6 +321,10 @@ public class HomeActivity extends BaseActivity {
return;
}
latestError = Util.describe(e) + "\n\nTap refresh to try again.";
if (offerTrust(retryRefresh)) {
if (tab == TAB_LATEST) show(null, null);
return;
}
if (tab == TAB_LATEST) offlineFallback(e);
}
}.start();
@@ -369,6 +379,10 @@ public class HomeActivity extends BaseActivity {
return;
}
showsError = Util.describe(e) + "\n\nTap refresh to try again.";
if (offerTrust(retryRefresh)) {
if (tab == TAB_SHOWS) show(null, null);
return;
}
if (tab == TAB_SHOWS) offlineFallback(e);
}
}.start();
+8 -4
View File
@@ -14,7 +14,7 @@ import android.widget.TextView;
public class LoginActivity extends Activity {
private EditText server, user, pass;
private CheckBox useKey, trust;
private CheckBox useKey;
private TextView status, userLabel, passLabel;
private Button connect;
@@ -26,7 +26,6 @@ public class LoginActivity extends Activity {
user = (EditText) findViewById(R.id.user);
pass = (EditText) findViewById(R.id.pass);
useKey = (CheckBox) findViewById(R.id.use_key);
trust = (CheckBox) findViewById(R.id.trust);
status = (TextView) findViewById(R.id.status);
userLabel = (TextView) findViewById(R.id.user_label);
passLabel = (TextView) findViewById(R.id.pass_label);
@@ -35,7 +34,6 @@ public class LoginActivity extends Activity {
Prefs p = Prefs.get(this);
server.setText(p.server());
user.setText(p.username());
trust.setChecked(p.trustAll());
String msg = getIntent().getStringExtra("message");
if (msg != null) status.setText(msg);
@@ -87,7 +85,8 @@ public class LoginActivity extends Activity {
p.logout();
App.clear();
HomeActivity.clearCache();
p.setServer(srv, trust.isChecked());
p.setServer(srv);
Trust.lastRejected = null;
new Job<String>() {
String work() throws Exception {
String version = Api.status(LoginActivity.this, srv);
@@ -111,6 +110,11 @@ public class LoginActivity extends Activity {
void failed(Exception e) {
connect.setEnabled(true);
status.setText(Util.describe(e));
TrustPrompt.offer(LoginActivity.this, new Runnable() {
public void run() {
go();
}
});
}
}.start();
}
+13 -2
View File
@@ -129,6 +129,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (ep != null && prepared && state != ENDED) syncNow(true);
releasePlayer();
net.shutdown();
StreamProxy.shutdown();
try {
unregisterReceiver(noisy);
} catch (Exception ignored) {
@@ -380,6 +381,12 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
});
}
/** Direct URL for http:// servers; a local proxy URL for https:// ones (see StreamProxy). */
private static String streamUrl(Context c, String contentUrl) throws java.io.IOException {
if (StreamProxy.needed(c)) return StreamProxy.get(c).register(contentUrl);
return Api.withToken(c, Api.absolute(c, contentUrl));
}
private Source findSource(Context c, Episode e, double startSec) {
Source s = new Source();
File f = Downloads.audio(c, e);
@@ -405,7 +412,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
s.error = "This episode needs transcoding, which Android 2.x can't play. Try downloading it.";
} else {
s.contentUrl = cu;
s.url = Api.withToken(c, Api.absolute(c, cu));
s.url = streamUrl(c, cu);
}
}
} catch (Exception ex) {
@@ -603,7 +610,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (gen != generation) return;
resumeMs = pos;
playOnPrepared = wasPlaying;
prepare(Api.withToken(ctx, Api.absolute(ctx, cu)), msg);
try {
prepare(streamUrl(ctx, cu), msg);
} catch (Exception ex) {
fail(Util.describe(ex));
}
}
});
}
+5
View File
@@ -117,6 +117,11 @@ public class PodcastActivity extends BaseActivity {
}
empty.setText(Util.describe(e));
empty.setVisibility(View.VISIBLE);
offerTrust(new Runnable() {
public void run() {
load();
}
});
}
}.start();
}
+2 -3
View File
@@ -36,7 +36,6 @@ final class Prefs {
String token() { return s("access"); }
String refreshToken() { return s("refresh"); }
String mode() { return s("mode"); }
boolean trustAll() { return sp.getBoolean("trust_all", false); }
boolean wifiOnly() { return sp.getBoolean("wifi_only", true); }
boolean covers() { return sp.getBoolean("covers", true); }
String libraryId() { return s("library_id"); }
@@ -61,8 +60,8 @@ final class Prefs {
return MODE_JWT.equals(mode()) && refreshToken().length() > 0;
}
void setServer(String server, boolean trustAll) {
sp.edit().putString("server", server).putBoolean("trust_all", trustAll).commit();
void setServer(String server) {
sp.edit().putString("server", server).remove("trust_all").commit();
}
synchronized void setAuth(String user, String mode, String access, String refresh) {
+226
View File
@@ -0,0 +1,226 @@
package com.miniabs;
/** Generated by tools/gen_roots.py from the system CA store. Do not edit. */
final class Roots {
private Roots() {
}
static final String[] DER_BASE64 = {
// ISRG_Root_X1 sha256=96bcec06264976f37460779acf28c5a7cfe8a3c0aae11a8ffcee05c0bddf08c6
"MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIElu"
+ "dGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUw"
+ "NjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNV"
+ "BAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZX"
+ "oHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDm"
+ "gK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rg"
+ "TBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3"
+ "x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJ"
+ "xQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8B"
+ "CNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo"
+ "//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD"
+ "AQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1"
+ "XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo7"
+ "7x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNz"
+ "ktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3J"
+ "LF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc"
+ "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7"
+ "y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcY"
+ "xn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=",
// GTS_Root_R1 sha256=d947432abde7b7fa90fc2e6b59101b1280e0e1c7e4e40fa3c6887fff57a7f4cf
"MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xl"
+ "IFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBH"
+ "MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIi"
+ "MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo27xqLpeXo4xL+Sv2"
+ "sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7wCl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g"
+ "4M/SCYe7zUjwTcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0PfyblqAj+lug8aJRT7oM6iCsVlgmy"
+ "4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4"
+ "Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Or"
+ "p2yjj32JgfpMpf/VjsPOS+C12LOORc92wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70paDPv"
+ "OmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrNVjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1"
+ "/oFB2ACYPTrIrnqYNxgFlQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU5K8rJnEa"
+ "K0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibbC5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmm"
+ "WJyq+bXmYOfg6LEeQkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuyh6f88/qBVRRiClmpIgUx"
+ "PoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM47HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/K"
+ "YnAK9y8JZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6EfMgfdG+RCzgwARWGAtQsgWSl4vflV"
+ "y2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT"
+ "6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNb"
+ "oTXns5lndcEZOitHTtNCjv0xyBZm2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bbbP6MvPJw"
+ "NQzcmRk13NfIRmPVNnGuV/u3gm3c",
// USERTrust_RSA_Certification_Authority sha256=e793c9b02fd8aa13e21c31228accb08119643b749c898964b1746d46c3d4cbd2
"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"
+ "YvnKkKj9",
// DigiCert_Global_Root_G2 sha256=cb3ccbb76031e5e0138f8dd39a23f9de47ffc35e43c1144cea27d46a5ab1cb5f
"MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGln"
+ "aUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMjAeFw0x"
+ "MzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsT"
+ "EHd3dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A"
+ "MIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULec"
+ "YxpsMNzaHxmx1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQq2EGnI/yuum06ZIya7XzV+hd"
+ "G82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5WztCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9"
+ "g+UQvIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB"
+ "/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY1Yl9"
+ "PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/J"
+ "sw1HKj2ZL7tcu7XUIOGZX1NGFdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ918rGOmaFvE7FB"
+ "cf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTepLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyN"
+ "xF1WW6LZZm6zNTflMrY=",
// Amazon_Root_CA_1 sha256=8ecde6884f3d87b1125ba31ac3fcb13d7016de7f57cc904fe1cb97c6ae98196e
"MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsFADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMG"
+ "QW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTELMAkGA1UE"
+ "BhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC"
+ "AQoCggEBALJ4gHHKeNXjca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM9O6II8c+6zf1tRn4"
+ "SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qwIFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mj"
+ "UcAwhmahRWa6VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L93FcXmn/6pUCyziKrlA4b9v7"
+ "LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQmjgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E"
+ "BAMCAYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUAA4IBAQCY8jdaQZChGsV2USggNiMOruYo"
+ "u6r4lK5IpDB/G/wkjUu0yKGX9rbxenDIU5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUsN+gD"
+ "S63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vvo/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1"
+ "bldZwgJcJmApzyMZFo6IQ6XU5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpyrqXRfboQnoZs"
+ "G4q5WTP468SQvvG5",
// Microsoft_RSA_Root_Certificate_Authority_2017 sha256=c741f70f4b2a8d88bf2e71c14122ef53ef10eba0cfa5e64cfa20f418853073e0
"MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWlj"
+ "cm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcw"
+ "HhcNMTkxMjE4MjI1MTIyWhcNNDIwNzE4MjMwMDIzWjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0"
+ "aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwggIiMA0GCSqGSIb3DQEB"
+ "AQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZNt9GkMml7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/"
+ "xztokLaCLeX0ZdDMbRnMlfl7rEqUrQ7eS0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1HLUX6NZFndiyJrDKxHBKrmCk"
+ "3bPZ7Pw71VdyvD/IybLeS2v4I2wDwAW9lcfNcztmgGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+dkC0zVJhUXAoP8XF"
+ "WvLJjEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49FyGcohJUcaDDv70ngNXtk55iwlNpNhTs+VcQor1fz"
+ "nhPbRiefHqJeRIOkpcrVE7NLP8TjwuaGYaRSMLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6W6IY"
+ "ZVcSn2i51BVrlMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4KUGsTuqwPN1q3ErWQgR5WrlcihtnJ0tHXUeOrO8ZV"
+ "/R4O03QK0dqq6mm4lyiPSMQH+FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJClTUFLkqqNfs+avNJVgyeY+QW5g5xAgGwax/"
+ "Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tN"
+ "siMwEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZCLgLNFgVZJ8og6Lq46Brs"
+ "TaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OCgMNPOsduET/m4xaRhPtthH80dK2Jp86519efhGSSvpWhrQlTM93uCupK"
+ "UY5vVau6tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk+ONVFT24bcMKpBLBaYVu32TxU5nhSnUgnZUP5NbcA/FZGOhHibJXWpS2"
+ "qdgXKxdJ5XbLwVaZOjex/2kskZGT4d9Mozd2TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDyAmH3"
+ "pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGRxpl/j8nWZjEgQRCHLQzWwa80mMpkg/sTV9HB"
+ "8Dx6jKXB/ZUhoHHBk2dxEuqPiAppGWSZI1b7rCoucL5mxAyE7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9dOLITzNy"
+ "4ZQ5bb0Sr74MTnB8G2+NszKTc0QWbej09+CVgI+WXTik9KveCjCHk9hNAHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D"
+ "5KbvtwEwXlGjefVwaaZBRA+GsCyRxj3qrg+E",
// SSL.com_Root_Certification_Authority_RSA sha256=85666a562ee0be5ce925c1d8890a6f76a87ec16d4d7d5f29ea7419cf20123b69
"MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYD"
+ "VQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9u"
+ "IEF1dGhvcml0eSBSU0EwHhcNMTYwMjEyMTczOTM5WhcNNDEwMjEyMTczOTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4"
+ "YXMxEDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNvbSBSb290IENlcnRp"
+ "ZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh"
+ "4/WvpOz6Sl2RxFdHaxh3a3by/ZPkPQ/CFp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aXqhvIuG5iXmmxX9sqAn78bMrz"
+ "Qdjt0Oj8P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcCC52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/geoeOy3ZExqysdBP+l"
+ "SgQ36YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkpk8zruFvh/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9A"
+ "hnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrFYD3ZfBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93EJNyA"
+ "KoFBbZQ+yODJgUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVcUS4cK38acijnALXRdMbX5J+tB5O2UzU1/Dfkw/Zd"
+ "Fr4hc96SCvigY2q8lpJqPvi8ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi81xtZPCvM8hnIk2snYxnP/Okm+Mpxm3+T/jRn"
+ "hE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4sbE6x/c+cCbqiM+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV"
+ "7jiAJQ2mWTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4GA1UdDwEB/wQEAwIBhjAN"
+ "BgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGVcpNxJK1ok1iOMq8bs3AD/CUrdIWQPXhq9LmLpZc7tRiRux6n+UBbkflV"
+ "ma8eEdBcHadm47GUBwwyOabqG7B52B2ccETjit3E+ZUfijhDPwGFpUenPUayvOUiaPd7nNgsPgohyC0zrL/FgZkxdMF1ccW+sfAj"
+ "RfSda/wZY52jvATGGAslu1OJD7OAUN5F7kR/q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjqerQ0"
+ "cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jra6x+3uxjMxW3IwiPxg+NQVrdjsW5j+VFP3jb"
+ "utIbQLH+cU0/4IGiul607BXgk90IH37hVZkLId6Tngr75qNJvTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/YK9f1JmzJ"
+ "BjSWFupwWRoyeXkLtoh/D1JIPb9s2KJELtFOt3JY04kTlf5Eq/jXixtunLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406y"
+ "wKBjYZC6VWg3dGq2ktufoYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NIWuuA8ShYIc2wBlX7Jz9TkHCp"
+ "BB5XJ7k=",
// Buypass_Class_2_Root_CA sha256=9a114025197c5bb95d94e63d55cd43790847b646b23cdf11ada4a00eff15fb48
"MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwUQnV5cGFzcyBBUy05ODMxNjMz"
+ "MjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMiBSb290IENBMB4XDTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1owTjELMAkG"
+ "A1UEBhMCTk8xHTAbBgNVBAoMFEJ1eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCC"
+ "AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1g1Lr6hxhFUi7HQfKjK6w"
+ "3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPVL4O2fuPn9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fU"
+ "l8FVM5I+GC911K2GScuVr1QGbNgGE41b/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHxMlAQTn/0hpPshNOOvEu/XAFO"
+ "Bz3cFIqUCqTqc/sLUegTBxj6DvEr0VQVfTzh97QZQmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeffawrbD02TTqigzXsu"
+ "8lkBarcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgIzRFo1clrUs3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPs"
+ "aRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLiFRhnBkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRSP/Ti"
+ "zPJhk9H9Z2vXUq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN9SG9dKpN6nIDSdvHXx1iY8f93ZHsM+71bbRuMGje"
+ "yNYmsHVee7QHIJihdjK4TWxPAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMmAd+BikoL1RpzzuvdMw964o605"
+ "MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAU18h9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6um"
+ "wHJdELFx7rxP462sA20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3tOluwlN5E40EIosHsHdb9"
+ "T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo+fsicdl9sz1Gv7SEr5AcD48Saq/v7h56rgJKihcrdv6sVIkkLE8/trKn"
+ "ToyokZf7KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYdDnkM/crqJIByw5c/8nerQyIKx+u2DISCLIBrQYoIwOula9+ZEsuK1V6A"
+ "DJHgJgg2SMX6OBE1/yWDLfJ6v9r9jv6ly0UsH8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0oyLQ"
+ "I+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK75t98biGCwWg5TbSYWGZizEqQXsP6JwSxeRV0"
+ "mcy+rSDeJmAc61ZRpqPq5KM/p/9h3PFaTWwyI0PurKju7koSCTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPzY11aWOIv"
+ "4x3kqdbQCtCev9eBCfHJxyYNrJgWVqA=",
// HARICA_TLS_RSA_Root_CA_2021 sha256=d95d0e8eda79525bf9beb11b14d2100d3294985f0c62d9fabd9cd999eccb7b1d
"MIIFpDCCA4ygAwIBAgIQOcqTHO9D88aOk8f0ZIk4fjANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQGEwJHUjE3MDUGA1UECgwuSGVs"
+ "bGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0EgUm9vdCBD"
+ "QSAyMDIxMB4XDTIxMDIxOTEwNTUzOFoXDTQ1MDIxMzEwNTUzN1owbDELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFj"
+ "YWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgUlNBIFJvb3QgQ0EgMjAyMTCC"
+ "AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIvC569lmwVnlskNJLnQDmT8zuIkGCyEf3dRywQRNrhe7Wlxp57kJQmXZ8FH"
+ "ws+RFjZiPTgE4VGC/6zStGndLuwRo0Xua2s7TL+MjaQenRG56Tj5eg4MmOIjHdFOY9TnuEFE+2uva9of08WRiFukiZLRgeaMOVig"
+ "1mlDqa2YUlhu2wr7a89o+uOkXjpFc5gH6l8Cct4MpbOfrqkdtx2z/IpZ525yZa31MJQjB/OCFks1mJxTuy/K5FrZx40d/JiZ+yyk"
+ "gmvwKh+OC19xXFyuQnspiYHLA6OZyoieC0AJQTPb5lh6/a6ZcMBaD9YThnEvdmn8kN3bLW7R8pv1GmuebxWMevBLKKAiOIAkbDak"
+ "O/IwkfN4E8/BPzWr8R0RI7VDIp4BkrcYAuUR0YLbFQDMYTfBKnya4dC6s1BG7oKsnTH4+yPiAwBIcKMJJnkVU2DzOFytOOqBAGMU"
+ "uTNe3QvboEUHGjMJ+E20pwKmafTCWQWIZYVWrkvL4N48fS0ayOn7H6NhStYqE613TBoYm5EPWNgGVMWX+Ko/IIqmhaZ39qb8HOLu"
+ "bpQzKoNQhArlT4b4UEV4AIHrW2jjJo3Me1xR9BQsQL4aYB16cmEdH2MtiKrOokWQCPxrvrNQKlr9qEgYRtaQQJKQCoReaDH46+0N"
+ "0x3GfZkYVVYnZS6NRcUk7M7jAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFApII6ZgpJIKM+qTW8VX6iVNvRLu"
+ "MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAPpBIqm5iFSVmewzVjIuJndftTgfvnNAUX15QvWiWkKQUEapobQk1"
+ "OUAJ2vQJLDSle1mESSmXdMgHHkdt8s4cUCbjnj1AUz/3f5Z2EMVGpdAgS1D0NTsY9FVqQRtHBmg8uwkIYtlfVUKqrFOFrJVWNlar"
+ "5AWMxajaH6NpvVMPxP/cyuN+8kyIhkdGGvMA9YCRotxDQpSbIPDRzbLrLFPCU3hKTwSUQZqPJzLB5UkZv/HywouoCjkxKLR9YjYs"
+ "TewfM7Z+d21+UPCfDtcRj88YxeMn/ibvBZ3PzzfF0HvaO7AWhAw6k9a+F9sPPg4ZeAnHqQJyIkv3N3a6dcSFA1pj1bF1BcK5vZSt"
+ "jBWZp5N99sXzqnTPBIWUmAD04vnKJGW/4GKvyMX6ssmeVkjaef2WdhW+o45WxLM0/L5H9MG0qPzVMIho7suuyWPEdr6sOBjhXlzP"
+ "rjoiUevRi7PzKzMHVIf6tLITe7pTBGIBnfHAT+7hOtSLIBD6Alfm78ELt5BGnBkpjNxvoEppaZS3JGWg/6w/zgH7IS79aPib8qXP"
+ "MThcFarmlwDB31qlpzmq6YR/PFGoOtmUW4y/Twhx5duoXNTSpv4Ao8YWxw/ogM4cKGR0GQjTQuPOAF1/sdwTsOEFy9EgqoZ0njnn"
+ "kf3/W9b3raYvAwtt41dU63ZTGI0RmLo=",
// Certum_Trusted_Network_CA sha256=5c58468d55f58e497e743982d2b50010b6d165374acf83a7d4a32db768c4408e
"MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQKExlVbml6ZXRvIFRlY2hub2xv"
+ "Z2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVk"
+ "IE5ldHdvcmsgQ0EwHhcNMDgxMDIyMTIwNzM3WhcNMjkxMjMxMTIwNzM3WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0"
+ "byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSIwIAYDVQQDExlDZXJ0"
+ "dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7pt"
+ "J11/91sts1rHUV+rpDKmYYe2bg+G0jACl/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LMTXPb865Px1bVWqeWifrzq2jU"
+ "I4ZZJ88JJ7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVUBBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4fOQtf/WsX+sWn7Et"
+ "0brMkUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0cvW0QM8xAcPs3hEtF10fuFDRXhmnad4HMyjKUJX5"
+ "p1TLVIZQRan5SQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNVHQ8B"
+ "Af8EBAMCAQYwDQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15ysHhE49wcrwn9I0j6vSrEuVUEtRCjjSfeC4Jj0O7e"
+ "DDd5QVsisrCaQVymcODU0HfLI9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1mS1FhIrlQgnXdAIv94nYmem8J9RHjboNRhx3"
+ "zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5ajZt3hrvJBW8qYVoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssD"
+ "xLQqKi2WF+A5VLxI03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw=",
// Go_Daddy_Root_Certificate_Authority_-_G2 sha256=45140b3247eb9cc8c5b4f0d7b53091f73292089e6e5a63e2749dd3aca9198eda
"MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcT"
+ "ClNjb3R0c2RhbGUxGjAYBgNVBAoTEUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRpZmljYXRl"
+ "IEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdB"
+ "cml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UEAxMoR28gRGFkZHkg"
+ "Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJ"
+ "GKP3gElY6SKDE6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH/PMWdYA5UXvl+TW2dE6pjYIT"
+ "5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7RnwyDfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlR"
+ "JQVhGkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGRtDtwKj9useiciAF9n9T521NtYJ2/LOdY"
+ "q7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFDqa"
+ "hQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmXWWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7P"
+ "dIxztDhC2gV7+AJ1uP2lsdeu9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTrgIOrmgIttRD0"
+ "2JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPK"
+ "f1855eJ1usV2GDPOLPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI4uJEvlz36hz1",
// Starfield_Root_Certificate_Authority_-_G2 sha256=2ce1cb0bf9d2f9e102993fbe215152c3b2dd0cabde1c68e5319b839154dbb7f5
"MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcT"
+ "ClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVsZCBSb290"
+ "IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVT"
+ "MRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVjaG5vbG9naWVzLCBJ"
+ "bmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQAD"
+ "ggEPADCCAQoCggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMgnLRJdzIpVv257IzdIvpy3Cdh"
+ "l+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNM"
+ "Yq/NHwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dNdloedl40wOiWVpmKs/B/pM293DIxfJHP"
+ "4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV"
+ "HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0GCSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+C"
+ "hTnUdgWUXuEok21iXQnCoKjUsHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu34jd/IroAow57"
+ "UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6"
+ "nH7PfrHxBy22/L/KpL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1mMpYjn0q7pBZc2T5NnRe"
+ "JaH1ZgUufzkVqSr7UIuOhWn0",
};
}
+20
View File
@@ -55,6 +55,26 @@ public class SettingsActivity extends PreferenceActivity {
}
});
final Preference pins = findPreference("pins");
int n = Trust.pinned(this).size();
pins.setSummary(n == 0 ? "None" : n + " (tap to forget)");
pins.setEnabled(n > 0);
pins.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this)
.setMessage("Forget the certificates you chose to trust? You'll be asked again next time.")
.setPositiveButton("Forget", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
Trust.forgetPins(SettingsActivity.this);
pins.setSummary("None");
pins.setEnabled(false);
}
})
.setNegativeButton("Cancel", null).show();
return true;
}
});
findPreference("logout").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this)
+202
View File
@@ -0,0 +1,202 @@
package com.miniabs;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.SecureRandom;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import android.content.Context;
/**
* Lets Android's media player stream from an HTTPS server. The player can't do HTTPS before
* Android 3.1 and wouldn't use MiniABS's certificate trust anyway, so it plays
* http://127.0.0.1:PORT/<random id> and this proxy fetches the real stream (forwarding seeks as
* Range requests) with a fresh sign-in token each time.
*/
final class StreamProxy implements Runnable {
private static StreamProxy instance;
private final Context c;
private final ServerSocket server;
private final LinkedHashMap<String, String> routes = new LinkedHashMap<String, String>() { // id -> content URL
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > 20;
}
};
private final SecureRandom random = new SecureRandom();
private volatile boolean running = true;
private StreamProxy(Context c) throws IOException {
this.c = c.getApplicationContext();
server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"));
Thread t = new Thread(this, "MiniABS proxy");
t.setDaemon(true);
t.start();
}
static synchronized StreamProxy get(Context c) throws IOException {
if (instance == null || !instance.running) instance = new StreamProxy(c);
return instance;
}
static synchronized void shutdown() {
if (instance != null) {
instance.running = false;
try {
instance.server.close();
} catch (IOException ignored) {
}
instance = null;
}
}
/** True when streams need to go through the proxy (an https:// server). */
static boolean needed(Context c) {
return Prefs.get(c).server().toLowerCase(Locale.US).startsWith("https://");
}
/** Returns a local URL the media player can open for this server-relative content URL. */
synchronized String register(String contentUrl) {
byte[] b = new byte[12];
random.nextBytes(b);
StringBuilder id = new StringBuilder();
for (byte x : b) id.append(Integer.toHexString((x & 0xff) | 0x100).substring(1));
routes.put(id.toString(), contentUrl);
// keep an extension so the player can guess the format
String path = contentUrl.split("\\?")[0];
int dot = path.lastIndexOf('.');
String ext = dot > path.lastIndexOf('/') ? path.substring(dot) : "";
return "http://127.0.0.1:" + server.getLocalPort() + "/" + id + ext;
}
private synchronized String route(String id) {
return routes.get(id);
}
public void run() {
while (running) {
try {
final Socket s = server.accept();
Thread t = new Thread(new Runnable() {
public void run() {
serve(s);
}
}, "MiniABS proxy conn");
t.setDaemon(true);
t.start();
} catch (IOException e) {
if (!running) return;
}
}
}
private void serve(Socket s) {
HttpURLConnection up = null;
try {
s.setSoTimeout(30000);
InputStream in = s.getInputStream();
OutputStream out = s.getOutputStream();
String request = readLine(in);
String range = null;
String line;
while ((line = readLine(in)) != null && line.length() > 0) {
int colon = line.indexOf(':');
if (colon > 0 && line.substring(0, colon).trim().equalsIgnoreCase("Range")) {
range = line.substring(colon + 1).trim();
}
}
String[] parts = request == null ? new String[0] : request.split(" ");
boolean head = parts.length > 0 && parts[0].equals("HEAD");
String id = parts.length > 1 ? parts[1].replaceFirst("^/", "").replaceFirst("\\..*$", "") : "";
String target = route(id);
if (target == null) {
respond(out, "404 Not Found");
return;
}
int code = 0;
for (int attempt = 0; attempt < 2; attempt++) {
Api.ensureFresh(c, 120);
up = Api.open(c, Api.withToken(c, Api.absolute(c, target)), head ? "HEAD" : "GET");
up.setRequestProperty("Accept-Encoding", "identity");
if (range != null) up.setRequestProperty("Range", range);
code = Api.responseCode(up);
if (code == 401 && attempt == 0 && Prefs.get(c).canRefresh()) {
up.disconnect();
Api.refresh(c, true);
continue;
}
break;
}
StringBuilder h = new StringBuilder();
h.append("HTTP/1.1 ").append(code).append(' ').append(reason(code)).append("\r\n");
copyHeader(up, h, "Content-Type");
copyHeader(up, h, "Content-Length");
copyHeader(up, h, "Content-Range");
copyHeader(up, h, "Accept-Ranges");
copyHeader(up, h, "Last-Modified");
h.append("Connection: close\r\n\r\n");
out.write(h.toString().getBytes("ISO-8859-1"));
if (!head && code < 400) {
InputStream body = up.getInputStream();
try {
byte[] buf = new byte[16384];
int n;
while (running && (n = body.read(buf)) > 0) out.write(buf, 0, n);
} finally {
body.close();
}
}
out.flush();
} catch (Exception e) {
// the player closed the connection (seek or stop), or the server went away
} finally {
if (up != null) up.disconnect();
try {
s.close();
} catch (IOException ignored) {
}
}
}
private static void copyHeader(HttpURLConnection up, StringBuilder h, String name) {
String v = up.getHeaderField(name);
if (v != null) h.append(name).append(": ").append(v).append("\r\n");
}
private static void respond(OutputStream out, String status) throws IOException {
out.write(("HTTP/1.1 " + status + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").getBytes("ISO-8859-1"));
out.flush();
}
private static String reason(int code) {
switch (code) {
case 200: return "OK";
case 206: return "Partial Content";
case 401: return "Unauthorized";
case 403: return "Forbidden";
case 404: return "Not Found";
case 416: return "Range Not Satisfiable";
default: return "Status";
}
}
private static String readLine(InputStream in) throws IOException {
StringBuilder sb = new StringBuilder();
int ch;
while ((ch = in.read()) != -1) {
if (ch == '\n') break;
if (ch != '\r' && sb.length() < 8192) sb.append((char) ch);
}
return ch == -1 && sb.length() == 0 ? null : sb.toString();
}
}
+341
View File
@@ -0,0 +1,341 @@
package com.miniabs;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import android.content.Context;
import android.util.Base64;
/**
* Decides which HTTPS certificates MiniABS accepts:
* 1. anything the phone itself trusts;
* 2. certificates chaining to the modern roots in {@link Roots} (Android 2.3's list predates Let's Encrypt);
* 3. certificates the user chose to trust after checking the fingerprint ("pins").
*/
final class Trust {
private Trust() {
}
/** The last certificate chain that wasn't trusted, so the UI can offer to trust it. */
static final class Rejected {
final X509Certificate[] chain;
final String reason;
final long at;
Rejected(X509Certificate[] chain, String reason) {
this.chain = chain;
this.reason = reason;
this.at = System.currentTimeMillis();
}
boolean expired() {
return reason != null && reason.startsWith("expired");
}
}
static volatile Rejected lastRejected;
private static SSLSocketFactory factory;
private static List<X509Certificate> bundled;
// ------------------------------------------------------------------ setup
static synchronized SSLSocketFactory factory(Context c) throws IOException {
if (factory == null) {
try {
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[]{new Manager(c.getApplicationContext())}, null);
factory = ctx.getSocketFactory();
} catch (Exception e) {
throw new IOException("Secure connections aren't available: " + e.getMessage());
}
}
return factory;
}
static void apply(Context c, HttpsURLConnection conn) throws IOException {
conn.setSSLSocketFactory(factory(c));
}
/** Recently rejected (this attempt), or null. */
static Rejected recentRejection() {
Rejected r = lastRejected;
return r != null && System.currentTimeMillis() - r.at < 60000 ? r : null;
}
private static synchronized List<X509Certificate> bundled() {
if (bundled == null) {
List<X509Certificate> list = new ArrayList<X509Certificate>();
for (String b64 : Roots.DER_BASE64) {
try {
list.add(parse(Base64.decode(b64, Base64.DEFAULT)));
} catch (Exception ignored) {
// a root this phone can't parse; skip it
}
}
bundled = list;
}
return bundled;
}
static X509Certificate parse(byte[] der) throws CertificateException {
return (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(der));
}
// ------------------------------------------------------------------ pins
static List<X509Certificate> pinned(Context c) {
List<X509Certificate> out = new ArrayList<X509Certificate>();
String s = Prefs.get(c).sp.getString("pins", "");
if (s.length() == 0) return out;
for (String b64 : s.split(",")) {
try {
out.add(parse(Base64.decode(b64, Base64.DEFAULT)));
} catch (Exception ignored) {
}
}
return out;
}
/** Trusts the top certificate the server sent (its issuing CA, or the certificate itself if self-signed). */
static synchronized X509Certificate pin(Context c, X509Certificate[] chain) {
X509Certificate top = top(chain);
try {
String b64 = Base64.encodeToString(top.getEncoded(), Base64.NO_WRAP);
String s = Prefs.get(c).sp.getString("pins", "");
if (!Arrays.asList(s.split(",")).contains(b64)) {
Prefs.get(c).sp.edit().putString("pins", s.length() == 0 ? b64 : s + "," + b64).commit();
}
} catch (Exception ignored) {
}
factory = null;
lastRejected = null;
return top;
}
static synchronized void forgetPins(Context c) {
Prefs.get(c).sp.edit().remove("pins").commit();
factory = null;
}
/** Walks up the chain as far as the server's own certificates go. */
static X509Certificate top(X509Certificate[] chain) {
X509Certificate cur = chain[0];
for (int step = 0; step < 8; step++) {
if (cur.getIssuerX500Principal().equals(cur.getSubjectX500Principal())) return cur; // self-signed
X509Certificate next = issuerIn(cur, chain);
if (next == null) return cur;
cur = next;
}
return cur;
}
// ------------------------------------------------------------------ verification
private static final class Manager implements X509TrustManager {
private final Context c;
private X509TrustManager system;
Manager(Context c) {
this.c = c;
try {
TrustManagerFactory f = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
f.init((KeyStore) null);
for (TrustManager t : f.getTrustManagers()) {
if (t instanceof X509TrustManager) system = (X509TrustManager) t;
}
} catch (Exception ignored) {
// no system trust store: rely on bundled roots and pins
}
}
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
if (chain == null || chain.length == 0) throw new CertificateException("No certificate");
if (system != null) {
try {
system.checkServerTrusted(chain, authType);
return;
} catch (CertificateException ignored) {
// fall through to our own roots and pins
} catch (RuntimeException ignored) {
}
}
List<X509Certificate> anchors = new ArrayList<X509Certificate>(bundled());
anchors.addAll(pinned(c));
String why = verify(chain, anchors);
if (why == null) return;
lastRejected = new Rejected(chain.clone(), why);
throw new CertificateException("Certificate not trusted: " + why);
}
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
throw new CertificateException("Not used");
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
/**
* Returns null if chain[0] chains up to one of the anchors, or a short reason why not.
* Every certificate that signs another must be a CA, so a website's own certificate
* can't be used to forge one for a different site.
*/
static String verify(X509Certificate[] chain, List<X509Certificate> anchors) {
X509Certificate cur = chain[0];
try {
cur.checkValidity();
} catch (Exception e) {
return "expired or not yet valid (check the phone's date)";
}
for (int step = 0; step < 8; step++) {
for (X509Certificate a : anchors) {
if (Arrays.equals(encoded(a), encoded(cur))) return null; // trusted as-is
}
for (X509Certificate a : anchors) {
if (a.getSubjectX500Principal().equals(cur.getIssuerX500Principal()) && isCa(a) && signedBy(cur, a)) {
return null;
}
}
X509Certificate next = issuerIn(cur, chain);
if (next == null) return "unknown issuer";
if (!isCa(next)) return "signed by a certificate that isn't a CA";
try {
next.checkValidity();
} catch (Exception e) {
return "expired intermediate certificate (check the phone's date)";
}
cur = next;
}
return "chain too long";
}
private static X509Certificate issuerIn(X509Certificate cert, X509Certificate[] chain) {
for (X509Certificate x : chain) {
if (x == cert || Arrays.equals(encoded(x), encoded(cert))) continue;
if (x.getSubjectX500Principal().equals(cert.getIssuerX500Principal()) && signedBy(cert, x)) return x;
}
return null;
}
private static boolean isCa(X509Certificate x) {
if (x.getBasicConstraints() < 0) return false;
boolean[] ku = x.getKeyUsage();
return ku == null || ku.length <= 5 || ku[5]; // keyCertSign
}
private static boolean signedBy(X509Certificate cert, X509Certificate issuer) {
try {
cert.verify(issuer.getPublicKey());
return true;
} catch (Exception e) {
return false;
}
}
private static byte[] encoded(X509Certificate x) {
try {
return x.getEncoded();
} catch (Exception e) {
return new byte[0];
}
}
/** Standard host name check: DNS/IP subject alternative names (one-label wildcards), else the CN. */
static boolean hostMatches(X509Certificate cert, String host) {
host = host.toLowerCase(Locale.US);
boolean hasDns = false;
try {
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans != null) {
for (List<?> e : sans) {
int type = ((Integer) e.get(0)).intValue();
String v = String.valueOf(e.get(1)).toLowerCase(Locale.US);
if (type == 2) {
hasDns = true;
if (nameMatches(v, host)) return true;
} else if (type == 7 && v.equals(host)) {
return true;
}
}
}
} catch (Exception ignored) {
}
if (hasDns) return false;
return nameMatches(commonName(cert.getSubjectX500Principal()).toLowerCase(Locale.US), host);
}
private static boolean nameMatches(String pattern, String host) {
if (pattern.startsWith("*.")) {
String suffix = pattern.substring(1); // ".example.com"
if (host.length() <= suffix.length() || !host.endsWith(suffix)) return false;
return host.substring(0, host.length() - suffix.length()).indexOf('.') < 0;
}
return pattern.equals(host);
}
// ------------------------------------------------------------------ display
static String fingerprint(X509Certificate x) {
try {
byte[] d = MessageDigest.getInstance("SHA-256").digest(x.getEncoded());
StringBuilder sb = new StringBuilder();
for (int i = 0; i < d.length; i++) {
if (i > 0) sb.append(i == 16 ? "\n" : ":");
int v = d[i] & 0xff;
sb.append("0123456789ABCDEF".charAt(v >> 4)).append("0123456789ABCDEF".charAt(v & 15));
}
return sb.toString();
} catch (Exception e) {
return "?";
}
}
static String commonName(javax.security.auth.x500.X500Principal p) {
String n = p.getName();
Matcher m = Pattern.compile("(?:^|,)CN=([^,]+)").matcher(n);
if (m.find()) return m.group(1);
m = Pattern.compile("(?:^|,)O=([^,]+)").matcher(n);
return m.find() ? m.group(1) : n;
}
/** Text for the "trust this server?" dialog. */
static String describe(Rejected r, String host) {
X509Certificate leaf = r.chain[0];
X509Certificate top = top(r.chain);
StringBuilder sb = new StringBuilder();
sb.append("This phone doesn't recognise the certificate of ").append(host).append(".\n\n");
sb.append("Issued to: ").append(commonName(leaf.getSubjectX500Principal())).append('\n');
sb.append("Issued by: ").append(commonName(leaf.getIssuerX500Principal())).append('\n');
sb.append("Valid until: ").append(DateFormat.getDateInstance().format(leaf.getNotAfter())).append("\n\n");
sb.append("SHA-256 fingerprint:\n").append(fingerprint(leaf)).append("\n\n");
sb.append("Only continue if this matches the fingerprint your computer's browser shows for the site.");
if (top != leaf) {
sb.append("\n\nMiniABS will then trust certificates issued by \"")
.append(commonName(top.getSubjectX500Principal())).append("\".");
}
return sb.toString();
}
}
+50
View File
@@ -0,0 +1,50 @@
package com.miniabs;
import java.net.URL;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
/** Asks the user whether to trust a certificate the phone didn't recognise. */
final class TrustPrompt {
private TrustPrompt() {
}
private static boolean showing;
/**
* If the last connection failed on an unrecognised certificate, shows its details and offers to trust it.
* Returns true if the prompt was shown.
*/
static boolean offer(final Activity a, final Runnable retry) {
final Trust.Rejected r = Trust.recentRejection();
if (r == null || r.expired() || showing || a.isFinishing()) return false;
String host;
try {
host = new URL(Prefs.get(a).server()).getHost();
} catch (Exception e) {
host = "the server";
}
showing = true;
new AlertDialog.Builder(a)
.setTitle("Check this certificate")
.setMessage(Trust.describe(r, host))
.setPositiveButton("Trust", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
Trust.pin(a, r.chain);
if (retry != null) retry.run();
}
})
.setNegativeButton("Cancel", null)
.setOnCancelListener(null)
.show()
.setOnDismissListener(new DialogInterface.OnDismissListener() {
public void onDismiss(DialogInterface d) {
showing = false;
Trust.lastRejected = null;
}
});
return true;
}
}
+8 -1
View File
@@ -97,11 +97,18 @@ final class Util {
if (e instanceof SocketTimeoutException) return "The server took too long to answer.";
if (e instanceof ConnectException) return "Can't connect to the server. Is it running and reachable?";
if (e instanceof SSLException) {
return "Secure (HTTPS) connection failed. Android 2.x can't use modern HTTPS: try your server's local http:// address.";
Trust.Rejected r = Trust.recentRejection();
if (r != null) {
return r.expired() ? "The server's certificate has expired, or the phone's date is wrong."
: "This phone doesn't recognise the server's certificate.";
}
return "Couldn't set up a secure connection. Android 2.3 only speaks TLS 1.0, so the server needs "
+ "a legacy HTTPS port (see README). On your home network, the http:// address works too.";
}
if (e instanceof SocketException) return "Connection dropped. Check your network.";
if (e instanceof FileNotFoundException) return "Not found on the server.";
String m = e.getMessage();
if (m != null && m.contains("not verified")) return "The server's certificate is for a different address.";
return m == null || m.length() == 0 ? e.getClass().getSimpleName() : m;
}
}