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

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