All requests (API, covers, downloads, the streaming proxy) go through Minamp's own HTTP/TLS client (Bouncy Castle TLS 1.2, Mozilla roots, keep-alive, session resumption) via NetConnection, a drop-in HttpURLConnection, as the iPod version does with BearSSL. The phone reaches the normal https:// address directly; nothing changes on the server. - Trust: Mozilla roots + the user's pinned CAs/certificates feed the new verifier; the trust prompt, renew-safe CA pinning and forged-chain protection behave as before - PATCH is a normal request now (no raw-socket path) - Job, covers, player, downloads and proxy threads get big stacks for the handshake - embedded Roots.java and gen_roots.py replaced by assets/roots - tests: desktop shims read assets; all 104 checks pass (HTTP and HTTPS) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
8.5 KiB
Java
171 lines
8.5 KiB
Java
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 (Mozilla, assets/roots)");
|
|
String[] roots = c.getAssets().list("roots");
|
|
check(roots.length > 100, roots.length + " root certificates bundled");
|
|
X509Certificate isrgCert = null;
|
|
for (String n : roots) {
|
|
X509Certificate x = cert("assets/roots/" + n);
|
|
if (x.getSubjectX500Principal().getName().contains("ISRG Root X1")) isrgCert = x;
|
|
}
|
|
String isrg = isrgCert == null ? "" : Trust.fingerprint(isrgCert).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 bundled, matching its published fingerprint");
|
|
X509Certificate inter = cert(pki + "/int.crt"), leaf = cert(pki + "/leaf.crt");
|
|
|
|
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("Este teléfono non recoñece o certificado do servidor."),
|
|
"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("Emitido por: Test Intermediate R1")
|
|
&& text.contains("certificados emitidos por «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");
|
|
|
|
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, "forged certificate signed by a normal site certificate rejected");
|
|
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 && Util.describe(ee).contains("caducou"),
|
|
"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);
|
|
}
|
|
}
|