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:
1 parent
4fe9eed870
commit
b7e43d7d61
24 files changed
+1326
-88
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user