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 roots = new ArrayList(); 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("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 (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}", "/")); 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("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); } }