1 Commits
Author SHA1 Message Date
xockoandClaude Opus 5.5 b7e43d7d61 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>
2026-10-14 20:07:55 +02:00
24 changed files with 1326 additions and 88 deletions

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.miniabs" package="com.miniabs"
android:versionCode="1" android:versionCode="2"
android:versionName="1.0" android:versionName="1.1"
android:installLocation="auto"> android:installLocation="auto">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" /> <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
+52 -4
View File
@@ -10,12 +10,58 @@ A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570)
## Connecting ## Connecting
Use your server's **local http:// address**, e.g. `http://192.168.1.10:13378`. At home, the simplest option is your server's **local http:// address**, e.g. `http://192.168.1.10:13378`.
Android 2.x only speaks old TLS and has an outdated certificate list, so it usually can't connect to modern HTTPS servers (including Let's Encrypt behind a reverse proxy). The "Accept any HTTPS certificate" box only helps with self-signed certificates on servers that still allow older TLS.
Sign in with your username and password, or tick "Use an API key instead". It works with both current servers (2.26+, short-lived access tokens that MiniABS refreshes automatically) and older ones (single long-lived token). Sign in with your username and password, or tick "Use an API key instead". It works with both current servers (2.26+, short-lived access tokens that MiniABS refreshes automatically) and older ones (single long-lived token).
## HTTPS (from outside your home)
Android 2.3 only speaks **TLS 1.0**, which modern servers refuse. So HTTPS needs one change on the server and is handled by the app from there.
### Server: a legacy HTTPS port
Add a second `server` block to nginx, on its own port, used only by the phone. Your normal HTTPS site stays strict.
```nginx
server {
listen 8443 ssl;
server_name abs.example.com;
ssl_certificate /etc/letsencrypt/live/abs.example.com/fullchain.pem; # must be an RSA certificate
ssl_certificate_key /etc/letsencrypt/live/abs.example.com/privkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
# @SECLEVEL=0 is required: OpenSSL 3 refuses TLS 1.0 without it
ssl_ciphers "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA:AES128-SHA:@SECLEVEL=0";
ssl_prefer_server_ciphers on;
client_max_body_size 0;
location / {
proxy_pass http://127.0.0.1:13378;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_buffering off;
}
}
```
Then sign in with `https://abs.example.com:8443`.
- **RSA certificate:** Android 2.3 can't use ECDSA certificates. With certbot, request one with `--key-type rsa`.
- **Security:** TLS 1.0 with these ciphers is weak by today's standards, but much better than plain HTTP over the internet. Only open this port if you need it.
- **Caddy** can't serve TLS 1.0; put nginx (or HAProxy) in front for this port.
### App: certificates
- **Built-in roots:** MiniABS trusts what the phone trusts, plus modern root certificates the phone's 2011-era list lacks: Let's Encrypt, Google Trust Services, Sectigo/ZeroSSL, DigiCert G2, Amazon and others. Most certificates just work.
- **Anything else** (self-signed, a private CA, a new root): MiniABS shows the certificate's SHA-256 fingerprint and asks once. Compare it with the fingerprint your computer's browser shows for the site before tapping Trust. It then trusts the CA that issued it, so renewals keep working.
- **Settings → Certificates you trusted** lets you forget them.
### App: streaming
Android 2.x's media player can't stream https:// URLs, so for HTTPS servers MiniABS runs a small proxy on the phone (127.0.0.1 only, random unguessable URLs). It forwards seeks and gets a fresh sign-in token for each request, so long episodes don't break when the token expires.
## Using it ## Using it
- **Latest**: "Continue listening" plus the newest episodes across your podcast library. Tap the library name at the top to switch libraries. - **Latest**: "Continue listening" plus the newest episodes across your podcast library. Tap the library name at the top to switch libraries.
@@ -45,4 +91,6 @@ Needs a JDK, `aapt`, `dalvik-exchange` (dx), `zipalign`, `apksigner` (Ubuntu: `a
Compiling against the API 8 jar guarantees no newer Android APIs are used. The APK is signed with `miniabs.keystore` (password `miniabs`); keep it to install updates over the top. Compiling against the API 8 jar guarantees no newer Android APIs are used. The APK is signed with `miniabs.keystore` (password `miniabs`); keep it to install updates over the top.
`test/run_tests.sh` runs the API, JSON and progress code on the desktop against a mock Audiobookshelf server (`test/mock_abs.py`). `tools/gen_roots.py` regenerates the bundled root certificates from a system CA store.
`test/run_tests.sh` runs the API, JSON, progress, HTTPS-trust and streaming-proxy code on the desktop JVM against a mock Audiobookshelf server (`test/mock_abs.py`), over plain HTTP and over TLS with good, expired, wrong-host, forged and self-signed certificates (`test/make_pki.sh`).
+1 -8
View File
@@ -71,13 +71,6 @@
android:textSize="12sp" android:textSize="12sp"
android:text="Use an API key instead" /> android:text="Use an API key instead" />
<CheckBox
android:id="@+id/trust"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="Accept any HTTPS certificate (insecure)" />
<Button <Button
android:id="@+id/connect" android:id="@+id/connect"
android:layout_width="fill_parent" android:layout_width="fill_parent"
@@ -103,6 +96,6 @@
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:textSize="10sp" android:textSize="10sp"
android:textColor="@color/dim" android:textColor="@color/dim"
android:text="Tip: Android 2.x can't talk to most modern HTTPS servers. Use your server's local http:// address (on Wi-Fi) for the most reliable connection." /> android:text="At home, use your server's local http:// address. For https://, Android 2.3 needs the server to offer TLS 1.0 on a separate port (see README). If the certificate isn't recognised, you'll be shown its fingerprint to check." />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
+4 -1
View File
@@ -39,6 +39,9 @@
<Preference <Preference
android:key="account" android:key="account"
android:title="Signed in" /> android:title="Signed in" />
<Preference
android:key="pins"
android:title="Certificates you trusted" />
<Preference <Preference
android:key="logout" android:key="logout"
android:title="Log out" /> android:title="Log out" />
@@ -47,7 +50,7 @@
<PreferenceCategory android:title="About"> <PreferenceCategory android:title="About">
<Preference <Preference
android:key="about" android:key="about"
android:title="MiniABS 1.0" android:title="MiniABS 1.1"
android:summary="A tiny Audiobookshelf podcast client for Android 2.2+" /> android:summary="A tiny Audiobookshelf podcast client for Android 2.2+" />
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>
+9 -50
View File
@@ -12,17 +12,10 @@ import java.net.ProtocolException;
import java.net.Socket; import java.net.Socket;
import java.net.URL; import java.net.URL;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException; import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket; 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.JSONArray;
import org.json.JSONException; 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). */ /** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */
final class Api { final class Api {
static final String VERSION = "1.0"; static final String VERSION = "1.1";
static class ApiException extends IOException { static class ApiException extends IOException {
final int code; final int code;
@@ -52,7 +45,6 @@ final class Api {
} }
private static final Object REFRESH_LOCK = new Object(); private static final Object REFRESH_LOCK = new Object();
private static SSLSocketFactory trustAllFactory;
static { static {
// Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off. // Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off.
@@ -70,7 +62,7 @@ final class Api {
conn.setReadTimeout(30000); conn.setReadTimeout(30000);
conn.setUseCaches(false); conn.setUseCaches(false);
conn.setInstanceFollowRedirects(true); 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); setMethod(conn, method);
conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")"); conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
return conn; return conn;
@@ -80,38 +72,6 @@ final class Api {
conn.setRequestMethod(method); 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, * 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. * 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); sock.setSoTimeout(30000);
Socket io = sock; Socket io = sock;
if (https) { if (https) {
boolean trust = p.trustAll(); SSLSocket ss = (SSLSocket) Trust.factory(c).createSocket(sock, u.getHost(), port, true);
SSLSocketFactory f; try { // send the host name (SNI) where the phone supports it (Android 2.3+)
try { ss.getClass().getMethod("setHostname", String.class).invoke(ss, u.getHost());
f = trust ? trustAllFactory() : (SSLSocketFactory) SSLSocketFactory.getDefault(); } catch (Exception ignored) {
} catch (Exception e) {
throw new SSLException("TLS unavailable");
} }
SSLSocket ss = (SSLSocket) f.createSocket(sock, u.getHost(), port, true);
ss.startHandshake(); 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(); ss.close();
throw new SSLException("Certificate doesn't match " + u.getHost()); throw new SSLException("Certificate doesn't match " + u.getHost());
} }
+11
View File
@@ -64,9 +64,20 @@ abstract class BaseActivity extends Activity {
finish(); finish();
return; return;
} }
if (offerTrust(null)) return;
toast(Util.describe(e)); 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 // ------------------------------------------------------------ mini player
protected void setupMiniBar() { protected void setupMiniBar() {
+14
View File
@@ -178,6 +178,12 @@ public class HomeActivity extends BaseActivity {
empty.setVisibility(msg == null ? View.GONE : View.VISIBLE); empty.setVisibility(msg == null ? View.GONE : View.VISIBLE);
} }
private final Runnable retryRefresh = new Runnable() {
public void run() {
refresh();
}
};
private void refresh() { private void refresh() {
App.invalidate(); App.invalidate();
Covers.clearMemory(); Covers.clearMemory();
@@ -315,6 +321,10 @@ public class HomeActivity extends BaseActivity {
return; return;
} }
latestError = Util.describe(e) + "\n\nTap refresh to try again."; 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); if (tab == TAB_LATEST) offlineFallback(e);
} }
}.start(); }.start();
@@ -369,6 +379,10 @@ public class HomeActivity extends BaseActivity {
return; return;
} }
showsError = Util.describe(e) + "\n\nTap refresh to try again."; 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); if (tab == TAB_SHOWS) offlineFallback(e);
} }
}.start(); }.start();
+8 -4
View File
@@ -14,7 +14,7 @@ import android.widget.TextView;
public class LoginActivity extends Activity { public class LoginActivity extends Activity {
private EditText server, user, pass; private EditText server, user, pass;
private CheckBox useKey, trust; private CheckBox useKey;
private TextView status, userLabel, passLabel; private TextView status, userLabel, passLabel;
private Button connect; private Button connect;
@@ -26,7 +26,6 @@ public class LoginActivity extends Activity {
user = (EditText) findViewById(R.id.user); user = (EditText) findViewById(R.id.user);
pass = (EditText) findViewById(R.id.pass); pass = (EditText) findViewById(R.id.pass);
useKey = (CheckBox) findViewById(R.id.use_key); useKey = (CheckBox) findViewById(R.id.use_key);
trust = (CheckBox) findViewById(R.id.trust);
status = (TextView) findViewById(R.id.status); status = (TextView) findViewById(R.id.status);
userLabel = (TextView) findViewById(R.id.user_label); userLabel = (TextView) findViewById(R.id.user_label);
passLabel = (TextView) findViewById(R.id.pass_label); passLabel = (TextView) findViewById(R.id.pass_label);
@@ -35,7 +34,6 @@ public class LoginActivity extends Activity {
Prefs p = Prefs.get(this); Prefs p = Prefs.get(this);
server.setText(p.server()); server.setText(p.server());
user.setText(p.username()); user.setText(p.username());
trust.setChecked(p.trustAll());
String msg = getIntent().getStringExtra("message"); String msg = getIntent().getStringExtra("message");
if (msg != null) status.setText(msg); if (msg != null) status.setText(msg);
@@ -87,7 +85,8 @@ public class LoginActivity extends Activity {
p.logout(); p.logout();
App.clear(); App.clear();
HomeActivity.clearCache(); HomeActivity.clearCache();
p.setServer(srv, trust.isChecked()); p.setServer(srv);
Trust.lastRejected = null;
new Job<String>() { new Job<String>() {
String work() throws Exception { String work() throws Exception {
String version = Api.status(LoginActivity.this, srv); String version = Api.status(LoginActivity.this, srv);
@@ -111,6 +110,11 @@ public class LoginActivity extends Activity {
void failed(Exception e) { void failed(Exception e) {
connect.setEnabled(true); connect.setEnabled(true);
status.setText(Util.describe(e)); status.setText(Util.describe(e));
TrustPrompt.offer(LoginActivity.this, new Runnable() {
public void run() {
go();
}
});
} }
}.start(); }.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); if (ep != null && prepared && state != ENDED) syncNow(true);
releasePlayer(); releasePlayer();
net.shutdown(); net.shutdown();
StreamProxy.shutdown();
try { try {
unregisterReceiver(noisy); unregisterReceiver(noisy);
} catch (Exception ignored) { } 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) { private Source findSource(Context c, Episode e, double startSec) {
Source s = new Source(); Source s = new Source();
File f = Downloads.audio(c, e); 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."; s.error = "This episode needs transcoding, which Android 2.x can't play. Try downloading it.";
} else { } else {
s.contentUrl = cu; s.contentUrl = cu;
s.url = Api.withToken(c, Api.absolute(c, cu)); s.url = streamUrl(c, cu);
} }
} }
} catch (Exception ex) { } catch (Exception ex) {
@@ -603,7 +610,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (gen != generation) return; if (gen != generation) return;
resumeMs = pos; resumeMs = pos;
playOnPrepared = wasPlaying; 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.setText(Util.describe(e));
empty.setVisibility(View.VISIBLE); empty.setVisibility(View.VISIBLE);
offerTrust(new Runnable() {
public void run() {
load();
}
});
} }
}.start(); }.start();
} }
+2 -3
View File
@@ -36,7 +36,6 @@ final class Prefs {
String token() { return s("access"); } String token() { return s("access"); }
String refreshToken() { return s("refresh"); } String refreshToken() { return s("refresh"); }
String mode() { return s("mode"); } String mode() { return s("mode"); }
boolean trustAll() { return sp.getBoolean("trust_all", false); }
boolean wifiOnly() { return sp.getBoolean("wifi_only", true); } boolean wifiOnly() { return sp.getBoolean("wifi_only", true); }
boolean covers() { return sp.getBoolean("covers", true); } boolean covers() { return sp.getBoolean("covers", true); }
String libraryId() { return s("library_id"); } String libraryId() { return s("library_id"); }
@@ -61,8 +60,8 @@ final class Prefs {
return MODE_JWT.equals(mode()) && refreshToken().length() > 0; return MODE_JWT.equals(mode()) && refreshToken().length() > 0;
} }
void setServer(String server, boolean trustAll) { void setServer(String server) {
sp.edit().putString("server", server).putBoolean("trust_all", trustAll).commit(); sp.edit().putString("server", server).remove("trust_all").commit();
} }
synchronized void setAuth(String user, String mode, String access, String refresh) { 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
"MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5l"
+ "dyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVT"
+ "RVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkG"
+ "A1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNU"
+ "IE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUA"
+ "A4ICDwAwggIKAoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B3PHTsdZ7NygRK0faOca8Ohm0"
+ "X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkYtJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+"
+ "bJz/Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47"
+ "aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6c0Pl"
+ "fg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmTYo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaD"
+ "zLhn4ugTncxbgtNMs+1b/97lc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4eeUB9XVKg+/XRj"
+ "L7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeEHg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+C"
+ "DwIDAQABo0IwQDAdBgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w"
+ "DQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPFUp/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtO"
+ "uowhT6KOVWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3ATiUFJVDBwn7YKnuHKsSjKCaXqeY"
+ "alltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR"
+ "iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYzeSf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5o"
+ "GzuCr+yDZ4XUmFF0sbmZgIn/f3gZXHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/qS3fuQL3"
+ "9ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRBVXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGw"
+ "sAvgnEzDHNb842m1R0aBL6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfGjjxDah2nGN59PRbx"
+ "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() { findPreference("logout").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) { public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this) 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 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 ConnectException) return "Can't connect to the server. Is it running and reachable?";
if (e instanceof SSLException) { 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 SocketException) return "Connection dropped. Check your network.";
if (e instanceof FileNotFoundException) return "Not found on the server."; if (e instanceof FileNotFoundException) return "Not found on the server.";
String m = e.getMessage(); 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; return m == null || m.length() == 0 ? e.getClass().getSimpleName() : m;
} }
} }
+2 -2
View File
@@ -48,7 +48,7 @@ public class ApiTest {
check(cut.getString("d").equals("0123456789…"), "long strings truncated with an ellipsis"); check(cut.getString("d").equals("0123456789…"), "long strings truncated with an ellipsis");
System.out.println("Sign-in"); System.out.println("Sign-in");
p.setServer(LoginActivity_normalize(base + "/login/"), false); p.setServer(LoginActivity_normalize(base + "/login/"));
check(p.server().equals(base), "address normalised (" + p.server() + ")"); check(p.server().equals(base), "address normalised (" + p.server() + ")");
check(Api.status(c, p.server()).equals("2.30.0"), "status check finds the server"); check(Api.status(c, p.server()).equals("2.30.0"), "status check finds the server");
try { try {
@@ -193,7 +193,7 @@ public class ApiTest {
} }
System.out.println("Sub-path servers"); System.out.println("Sub-path servers");
p.setServer("http://nas.local:8080/abs", false); p.setServer("http://nas.local:8080/abs");
check(Api.absolute(c, "/abs/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"), check(Api.absolute(c, "/abs/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"),
"content URL under a sub-path not doubled"); "content URL under a sub-path not doubled");
check(Api.absolute(c, "/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"), check(Api.absolute(c, "/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"),
+172
View File
@@ -0,0 +1,172 @@
package com.miniabs;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import android.content.Context;
/** HTTPS: certificate trust, pinning, attacks, raw PATCH and the streaming proxy, over real TLS. */
public class TrustTest {
static int pass, fail;
static Context c = new Context();
static Prefs p = Prefs.get(c);
static void check(boolean ok, String what) {
if (ok) pass++;
else fail++;
System.out.println((ok ? " PASS " : " FAIL ") + what);
}
static X509Certificate cert(String path) throws Exception {
return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(path));
}
/** Tries /status on a server; returns the exception, or null on success. */
static Exception tryStatus(int port) {
Trust.lastRejected = null;
try {
Api.status(c, "https://localhost:" + port);
return null;
} catch (Exception e) {
return e;
}
}
static byte[] get(String url, String range, int[] code, String[] contentRange) throws Exception {
HttpURLConnection h = (HttpURLConnection) new URL(url).openConnection();
if (range != null) h.setRequestProperty("Range", range);
code[0] = h.getResponseCode();
if (contentRange != null) contentRange[0] = h.getHeaderField("Content-Range");
if (code[0] >= 400) return new byte[0];
InputStream in = h.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] b = new byte[8192];
int n;
while ((n = in.read(b)) > 0) out.write(b, 0, n);
in.close();
return out.toByteArray();
}
static byte[] audio(int from, int to) {
byte[] b = new byte[to - from + 1];
for (int i = from; i <= to; i++) b[i - from] = (byte) (i % 251);
return b;
}
public static void main(String[] a) throws Exception {
String pki = a[0];
int good = Integer.parseInt(a[1]), wrongHost = Integer.parseInt(a[2]), forged = Integer.parseInt(a[3]),
expired = Integer.parseInt(a[4]), self = Integer.parseInt(a[5]);
System.out.println("Bundled roots");
List<X509Certificate> roots = new ArrayList<X509Certificate>();
for (String r : Roots.DER_BASE64) roots.add(Trust.parse(android.util.Base64.decode(r, 0)));
check(roots.size() == 12, "12 modern root certificates bundled");
String isrg = Trust.fingerprint(roots.get(0)).replace("\n", ":");
check(isrg.equals("96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6"),
"Let's Encrypt ISRG Root X1 matches its published fingerprint");
X509Certificate root = cert(pki + "/root.crt"), inter = cert(pki + "/int.crt"), leaf = cert(pki + "/leaf.crt");
check(Trust.verify(new X509Certificate[]{leaf, inter}, Arrays.asList(root)) == null,
"leaf + intermediate validate up to a bundled-style root");
check("unknown issuer".equals(Trust.verify(new X509Certificate[]{leaf}, Arrays.asList(root))),
"missing intermediate is rejected");
System.out.println("Unknown certificate, then trusting it");
p.setServer("https://localhost:" + good);
Exception e = tryStatus(good);
Trust.Rejected r = Trust.recentRejection();
check(e instanceof javax.net.ssl.SSLException && r != null, "unrecognised certificate rejected and remembered");
check(Util.describe(e).equals("This phone doesn't recognise the server's certificate."),
"error message: \"" + Util.describe(e) + "\"");
String text = Trust.describe(r, "localhost");
byte[] d = MessageDigest.getInstance("SHA-256").digest(leaf.getEncoded());
String fp = String.format("%02X:%02X:%02X", d[0], d[1], d[2]);
check(text.contains(fp) && text.contains("Issued by: Test Intermediate R1")
&& text.contains("trust certificates issued by \"Test Intermediate R1\""),
"prompt shows the server's fingerprint and what will be trusted");
X509Certificate pinned = Trust.pin(c, r.chain);
check(pinned.equals(inter), "trusting pins the issuing CA, so renewals keep working");
check(tryStatus(good) == null, "server reachable after trusting");
System.out.println("Signed in over HTTPS");
Api.login(c, p.server(), "demo", "pw");
check(Api.libraries(c).length() == 2, "API calls work");
Api.setProgress(c, "pod1", "ep7", 100, 1800, Boolean.TRUE);
JSONArray log = (JSONArray) Api.call(c, "GET", "/__log", null);
boolean patched = false;
for (int i = 0; i < log.length(); i++) {
JSONObject l = log.getJSONObject(i);
if (l.getString("m").equals("PATCH") && l.getString("p").equals("/api/me/progress/pod1/ep7")) patched = true;
}
check(patched, "mark-as-played PATCH works over TLS (raw socket path)");
System.out.println("Streaming proxy");
check(StreamProxy.needed(c), "https server streams through the proxy");
String local = StreamProxy.get(c).register("/api/items/pod1/file/ino7");
check(local.startsWith("http://127.0.0.1:"), "player gets a local http:// URL: " + local.replaceAll("/[0-9a-f]{24}", "/<id>"));
int[] code = new int[1];
String[] cr = new String[1];
byte[] whole = get(local, null, code, null);
check(code[0] == 200 && Arrays.equals(whole, audio(0, 199999)), "whole file streamed correctly (200 KB)");
byte[] part = get(local, "bytes=150000-150999", code, cr);
check(code[0] == 206 && "bytes 150000-150999/200000".equals(cr[0]) && Arrays.equals(part, audio(150000, 150999)),
"seek: Range request forwarded, 206 + Content-Range passed back");
byte[] tail = get(local, "bytes=199000-", code, cr);
check(code[0] == 206 && Arrays.equals(tail, audio(199000, 199999)), "open-ended range works");
int before = ((JSONObject) Api.call(c, "GET", "/__refreshes", null)).getInt("n");
Api.call(c, "GET", "/__expire", null);
byte[] again = get(local, "bytes=0-99", code, cr);
int after = ((JSONObject) Api.call(c, "GET", "/__refreshes", null)).getInt("n");
check(code[0] == 206 && Arrays.equals(again, audio(0, 99)) && after == before + 1,
"token expired mid-episode: proxy refreshes it and the stream continues");
get(local.replaceAll("/[0-9a-f]{24}", "/000000000000000000000000"), null, code, null);
check(code[0] == 404, "unknown stream id refused (other apps can't use the proxy)");
System.out.println("Attacks and broken certificates");
Exception wh = tryStatus(wrongHost);
check(wh != null, "certificate for another host rejected, even from a trusted CA");
Exception fe = tryStatus(forged);
Trust.Rejected fr = Trust.recentRejection();
check(fe != null && fr != null && fr.reason.contains("isn't a CA"),
"forged certificate signed by a normal site certificate rejected (" + (fr == null ? "-" : fr.reason) + ")");
Trust.pin(c, fr.chain);
check(tryStatus(forged) != null, "...and still rejected even if the user taps Trust");
Exception ee = tryStatus(expired);
check(ee != null && Trust.recentRejection() != null && Trust.recentRejection().expired()
&& Util.describe(ee).contains("expired"),
"expired certificate rejected: \"" + Util.describe(ee) + "\"");
System.out.println("Self-signed server");
Exception se = tryStatus(self);
Trust.Rejected sr = Trust.recentRejection();
check(se != null && sr != null, "self-signed certificate needs confirming");
X509Certificate selfPin = Trust.pin(c, sr.chain);
check(selfPin.equals(cert(pki + "/self.crt")) && tryStatus(self) == null, "after trusting, it connects");
System.out.println("Forgetting");
check(Trust.pinned(c).size() == 2, "two certificates trusted (" + Trust.pinned(c).size() + ")");
Trust.forgetPins(c);
check(tryStatus(good) != null && tryStatus(self) != null, "forgetting them brings the prompt back");
System.out.println("Host names");
check(Trust.hostMatches(leaf, "localhost") && Trust.hostMatches(leaf, "127.0.0.1")
&& !Trust.hostMatches(leaf, "evil.com"), "SAN DNS and IP matching");
StreamProxy.shutdown();
System.out.println();
System.out.println(pass + " passed, " + fail + " failed");
System.exit(fail == 0 ? 0 : 1);
}
}
+69
View File
@@ -0,0 +1,69 @@
#!/bin/sh
# Test certificates: a root CA, an intermediate CA, and server certificates, good and bad.
set -e
D=${1:-build/pki}
rm -rf "$D" && mkdir -p "$D" && cd "$D"
q() { openssl "$@" > /dev/null 2>err.log || { cat err.log; exit 1; }; }
cat > ca.ext <<'EOF'
basicConstraints=critical,CA:TRUE
keyUsage=critical,keyCertSign,cRLSign
EOF
cat > leaf.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:localhost,IP:127.0.0.1
EOF
cat > other.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:other.example
EOF
q req -x509 -newkey rsa:2048 -nodes -keyout root.key -out root.crt -days 3650 -subj "/CN=Test Root CA" -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
q req -newkey rsa:2048 -nodes -keyout int.key -out int.csr -subj "/CN=Test Intermediate R1"
q x509 -req -in int.csr -CA root.crt -CAkey root.key -CAcreateserial -out int.crt -days 1825 -extfile ca.ext
# good server certificate
q req -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr -subj "/CN=localhost"
q x509 -req -in leaf.csr -CA int.crt -CAkey int.key -CAcreateserial -out leaf.crt -days 90 -extfile leaf.ext
cat leaf.crt int.crt > good.chain
# certificate for a different host, same CA
q req -newkey rsa:2048 -nodes -keyout other.key -out other.csr -subj "/CN=other.example"
q x509 -req -in other.csr -CA int.crt -CAkey int.key -CAcreateserial -out other.crt -days 90 -extfile other.ext
cat other.crt int.crt > wronghost.chain
# forgery: an attacker uses their own valid (non-CA) certificate to sign one for localhost
q req -newkey rsa:2048 -nodes -keyout forged.key -out forged.csr -subj "/CN=localhost"
q x509 -req -in forged.csr -CA other.crt -CAkey other.key -CAcreateserial -out forged.crt -days 90 -extfile leaf.ext
cat forged.crt other.crt int.crt > forged.chain
cp forged.key forged.chain.key
# expired certificate
q req -newkey rsa:2048 -nodes -keyout old.key -out old.csr -subj "/CN=localhost"
python3 - <<'PY'
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
ca = x509.load_pem_x509_certificate(open("int.crt", "rb").read())
ca_key = serialization.load_pem_private_key(open("int.key", "rb").read(), None)
csr = x509.load_pem_x509_csr(open("old.csr", "rb").read())
cert = (x509.CertificateBuilder().subject_name(csr.subject).issuer_name(ca.subject)
.public_key(csr.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime(2020, 1, 1)).not_valid_after(datetime.datetime(2021, 1, 1))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=False)
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
.sign(ca_key, hashes.SHA256()))
open("old.crt", "wb").write(cert.public_bytes(serialization.Encoding.PEM))
PY
cat old.crt int.crt > expired.chain
# self-signed server
q req -x509 -newkey rsa:2048 -nodes -keyout self.key -out self.crt -days 365 -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
cp self.crt self.chain
cp leaf.key good.chain.key; cp other.key wronghost.chain.key; cp old.key expired.chain.key; cp self.key self.chain.key
ls *.chain
+34 -1
View File
@@ -14,6 +14,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(sys.argv[1]) PORT = int(sys.argv[1])
LEGACY = len(sys.argv) > 2 and sys.argv[2] == "legacy" LEGACY = len(sys.argv) > 2 and sys.argv[2] == "legacy"
CHAIN = sys.argv[3] if len(sys.argv) > 3 else None # serve HTTPS with this certificate chain
AUDIO = bytes(i % 251 for i in range(200000))
lock = threading.Lock() lock = threading.Lock()
state = {"access": None, "refresh": None, "n": 0, "log": [], "progress": {}, "refreshes": 0} state = {"access": None, "refresh": None, "n": 0, "log": [], "progress": {}, "refreshes": 0}
@@ -159,6 +161,26 @@ class H(BaseHTTPRequestHandler):
"currentTime": 600.0, "duration": 1807.5, "playMethod": 0, "currentTime": 600.0, "duration": 1807.5, "playMethod": 0,
"audioTracks": [{"index": 1, "contentUrl": "/api/items/pod1/file/ino7", "audioTracks": [{"index": 1, "contentUrl": "/api/items/pod1/file/ino7",
"mimeType": "audio/mpeg", "duration": 1807.5}]}) "mimeType": "audio/mpeg", "duration": 1807.5}]})
if path == "/api/items/pod1/file/ino7":
rng = self.headers.get("Range")
if rng and rng.startswith("bytes="):
a, _, b = rng[6:].partition("-")
a = int(a)
b = int(b) if b else len(AUDIO) - 1
part = AUDIO[a:b + 1]
self.send_response(206)
self.send_header("Content-Range", "bytes %d-%d/%d" % (a, a + len(part) - 1, len(AUDIO)))
else:
part = AUDIO
self.send_response(200)
self.send_header("Content-Type", "audio/mpeg")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(len(part)))
self.send_header("Connection", "close")
self.end_headers()
if method != "HEAD":
self.wfile.write(part)
return
if path.startswith("/api/session/") and method == "POST": if path.startswith("/api/session/") and method == "POST":
return self.send(200, "OK", "text/plain") return self.send(200, "OK", "text/plain")
if path.startswith("/api/me/progress/") and method == "PATCH": if path.startswith("/api/me/progress/") and method == "PATCH":
@@ -172,8 +194,19 @@ class H(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
self.handle_any("POST") self.handle_any("POST")
def do_HEAD(self):
self.handle_any("HEAD")
def do_PATCH(self): def do_PATCH(self):
self.handle_any("PATCH") self.handle_any("PATCH")
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() srv = ThreadingHTTPServer(("127.0.0.1", PORT), H)
if CHAIN:
import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CHAIN, CHAIN + ".key")
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
# a TLS handshake the client rejects shows up as an error here; that's expected in tests
srv.handle_error = lambda request, client_address: None
srv.serve_forever()
+24 -8
View File
@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
# Runs MiniABS's API/JSON/progress code on the desktop JVM against mock_abs.py. # Runs MiniABS's API/JSON/progress/HTTPS code on the desktop JVM against mock_abs.py.
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
OUT=build/test OUT=build/test
@@ -7,14 +7,30 @@ JSON_JAR=/usr/share/java/com.android.json.jar
rm -rf "$OUT" && mkdir -p "$OUT" rm -rf "$OUT" && mkdir -p "$OUT"
S=src/com/miniabs S=src/com/miniabs
javac -nowarn -encoding UTF-8 -cp "$JSON_JAR" -d "$OUT" $(find test/shim -name '*.java') \ javac -nowarn -encoding UTF-8 -cp "$JSON_JAR" -d "$OUT" $(find test/shim -name '*.java') \
$S/Json.java $S/Prefs.java $S/Episode.java $S/Util.java $S/Api.java $S/App.java test/com/miniabs/ApiTest.java $S/Json.java $S/Prefs.java $S/Episode.java $S/Util.java $S/Api.java $S/App.java \
$S/Trust.java $S/Roots.java $S/StreamProxy.java \
test/com/miniabs/ApiTest.java test/com/miniabs/TrustTest.java
CP="$OUT:$JSON_JAR"
PIDS=""
cleanup() { [ -n "$PIDS" ] && kill $PIDS 2>/dev/null || true; }
trap cleanup EXIT
port() { echo $((20000 + $(od -An -N2 -tu2 /dev/urandom | tr -d ' ') % 20000)); }
for mode in current legacy; do for mode in current legacy; do
PORT=$((18000 + $(od -An -N2 -tu2 /dev/urandom | tr -d ' ') % 1000)) P=$(port)
python3 test/mock_abs.py $PORT $mode & python3 test/mock_abs.py $P $mode & PIDS="$PIDS $!"
PID=$!
sleep 1 sleep 1
echo "=== $mode server" echo "=== $mode server (http)"
java -cp "$OUT:$JSON_JAR" com.miniabs.ApiTest $PORT $mode || { kill $PID; exit 1; } java -cp "$CP" com.miniabs.ApiTest $P $mode
kill $PID
done done
echo "=== https"
test/make_pki.sh build/pki > /dev/null
ARGS="build/pki"
for chain in good wronghost forged expired self; do
P=$(port)
python3 test/mock_abs.py $P current build/pki/$chain.chain & PIDS="$PIDS $!"
ARGS="$ARGS $P"
done
sleep 1
java -cp "$CP" com.miniabs.TrustTest $ARGS
+5 -2
View File
@@ -1,5 +1,8 @@
package android.util; package android.util;
public class Base64 { public class Base64 {
public static final int URL_SAFE = 8; public static final int DEFAULT = 0, NO_WRAP = 2, URL_SAFE = 8;
public static byte[] decode(String s, int flags) { return java.util.Base64.getUrlDecoder().decode(s); } public static byte[] decode(String s, int flags) {
return (flags & URL_SAFE) != 0 ? java.util.Base64.getUrlDecoder().decode(s) : java.util.Base64.getMimeDecoder().decode(s);
}
public static String encodeToString(byte[] b, int flags) { return java.util.Base64.getEncoder().encodeToString(b); }
} }
+52
View File
@@ -0,0 +1,52 @@
"""Generates src/com/miniabs/Roots.java: modern RSA root certificates that Android 2.3's
built-in list lacks (Let's Encrypt, Google Trust Services, Sectigo/ZeroSSL, ...).
Only RSA roots: Android 2.x's TLS 1.0 can't use ECDSA certificates anyway.
Run: python3 tools/gen_roots.py /etc/ssl/certs
"""
import base64
import hashlib
import os
import re
import sys
CERT_DIR = sys.argv[1] if len(sys.argv) > 1 else "/etc/ssl/certs"
NAMES = [
"ISRG_Root_X1", # Let's Encrypt
"GTS_Root_R1", # Google Trust Services (also Cloudflare)
"USERTrust_RSA_Certification_Authority", # Sectigo, ZeroSSL
"DigiCert_Global_Root_G2",
"Amazon_Root_CA_1",
"Microsoft_RSA_Root_Certificate_Authority_2017",
"SSL.com_Root_Certification_Authority_RSA",
"Buypass_Class_2_Root_CA",
"HARICA_TLS_RSA_Root_CA_2021",
"Certum_Trusted_Network_CA",
"Go_Daddy_Root_Certificate_Authority_-_G2",
"Starfield_Root_Certificate_Authority_-_G2",
]
out = []
for n in NAMES:
pem = open(os.path.join(CERT_DIR, n + ".pem")).read()
b64 = "".join(re.search(r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", pem, re.S).group(1).split())
der = base64.b64decode(b64)
out.append((n, b64, hashlib.sha256(der).hexdigest()))
lines = [
"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 = {",
]
for n, b64, sha in out:
lines.append(" // %s sha256=%s" % (n, sha))
chunks = [b64[i:i + 100] for i in range(0, len(b64), 100)]
lines.append(" " + "\n + ".join('"%s"' % c for c in chunks) + ",")
lines += [" };", "}", ""]
open(os.path.join(os.path.dirname(__file__), "..", "src", "com", "miniabs", "Roots.java"), "w").write("\n".join(lines))
print("%d roots written" % len(out))