MiniABS 1.1: HTTPS support

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

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

No files matched your search

+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();
}
}