Add the shared TLS 1.2 client kit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XoséandClaude Opus 5.5 committed 2026-10-18 19:03:33 +02:00
commit a4d756e6ba
129 files changed
+1119

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
.DS_Store
test/out/
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# Copies the bundled-TLS network package into an app: ./install.sh <app dir> <java package>
# e.g. ./install.sh ../freshmini org.freshmini.net
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
app=$1 pkg=$2
dir=$app/src/$(echo "$pkg" | tr . /)
mkdir -p "$dir" "$app/assets"
for f in "$here"/net/*.java; do sed "s/^package com\.minamp\.net;/package $pkg;/" "$f" > "$dir/$(basename "$f")"; done
rm -rf "$app/assets/roots" && cp -r "$here/roots" "$app/assets/roots"
echo "installed $(ls "$dir" | wc -l | tr -d ' ') classes in $dir and $(ls "$app/assets/roots" | wc -l | tr -d ' ') roots"
+50
View File
@@ -0,0 +1,50 @@
package com.minamp.net;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import android.content.Context;
/** Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der */
public final class AssetRoots implements CertVerifier.RootStore {
private final Context ctx;
private Set<String> names;
public AssetRoots(Context c) { ctx = c.getApplicationContext(); }
public synchronized List<byte[]> find(byte[] subjectDer) {
try {
if (names == null) {
names = new HashSet<String>();
String[] l = ctx.getAssets().list("roots");
if (l != null) for (int i = 0; i < l.length; i++) names.add(l[i]);
}
String h = CertVerifier.sha1Hex(subjectDer);
List<byte[]> out = null;
for (int i = 0; names.contains(h + "-" + i + ".der"); i++) {
if (out == null) out = new ArrayList<byte[]>();
out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der")));
}
return out;
} catch (IOException e) {
return null;
}
}
static byte[] readAll(InputStream in) throws IOException {
try {
ByteArrayOutputStream b = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) > 0) b.write(buf, 0, n);
return b.toByteArray();
} finally {
in.close();
}
}
}
+331
View File
@@ -0,0 +1,331 @@
package com.minamp.net;
import java.io.IOException;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import com.minamp.bc.asn1.ASN1Encoding;
import com.minamp.bc.asn1.ASN1ObjectIdentifier;
import com.minamp.bc.asn1.ASN1OctetString;
import com.minamp.bc.asn1.pkcs.PKCSObjectIdentifiers;
import com.minamp.bc.asn1.x500.RDN;
import com.minamp.bc.asn1.x500.X500Name;
import com.minamp.bc.asn1.x500.style.BCStyle;
import com.minamp.bc.asn1.x500.style.IETFUtils;
import com.minamp.bc.asn1.x509.BasicConstraints;
import com.minamp.bc.asn1.x509.Certificate;
import com.minamp.bc.asn1.x509.Extension;
import com.minamp.bc.asn1.x509.Extensions;
import com.minamp.bc.asn1.x509.GeneralName;
import com.minamp.bc.asn1.x509.GeneralNames;
import com.minamp.bc.asn1.x9.X9ObjectIdentifiers;
import com.minamp.bc.crypto.Digest;
import com.minamp.bc.crypto.digests.SHA1Digest;
import com.minamp.bc.crypto.digests.SHA256Digest;
import com.minamp.bc.crypto.digests.SHA384Digest;
import com.minamp.bc.crypto.digests.SHA512Digest;
import com.minamp.bc.crypto.params.AsymmetricKeyParameter;
import com.minamp.bc.crypto.signers.DSADigestSigner;
import com.minamp.bc.crypto.signers.ECDSASigner;
import com.minamp.bc.crypto.signers.RSADigestSigner;
import com.minamp.bc.crypto.util.PublicKeyFactory;
/**
* Minimal Web PKI path validation for the bundled TLS stack:
* hostname match, validity window, signature chain up to a bundled Mozilla root.
* Optionally one pinned leaf certificate (SHA-256) is trusted as-is, for self-signed servers.
* Shared with Minamp (tools/tlskit in the iOS ports' history); keep the copies in sync.
*/
public final class CertVerifier {
/** Texts shown to the user; apps replace them with their own language. Formats take the host. */
public static String msgMismatch = "The server certificate is for a different address (%1$s).";
public static String msgDates = "The server certificate has expired, or the phone's date is wrong.";
/** Thrown when the certificate is neither CA-trusted nor the pinned one: the app can offer to trust it. */
public static final class Untrusted extends IOException {
public final String fingerprint, subject, issuer, expires;
public final boolean changed; // a pin was set and this is a different certificate
public final List<byte[]> chain; // as the server sent it, leaf first
Untrusted(String fp, String subject, String issuer, String expires, boolean changed, List<byte[]> chain) {
super("untrusted certificate " + fp);
this.chain = chain;
this.fingerprint = fp;
this.subject = subject;
this.issuer = issuer;
this.expires = expires;
this.changed = changed;
}
}
private static volatile String pin = "";
/** SHA-256 of the one leaf certificate to accept without a CA (hex, separators ignored); "" for none. */
public static void setPin(String p) {
String n = norm(p);
if (!n.equals(pin)) {
pin = n;
Http.forgetSessions();
}
}
public static String norm(String s) {
if (s == null) return "";
return s.replaceAll("[^0-9A-Fa-f]", "").toUpperCase(Locale.US);
}
/** "AB:CD:..." SHA-256 of a DER certificate. */
public static String sha256(byte[] der) {
try {
byte[] d = MessageDigest.getInstance("SHA-256").digest(der);
StringBuilder b = new StringBuilder();
for (int i = 0; i < d.length; i++) {
if (i > 0) b.append(':');
String x = Integer.toHexString(d[i] & 0xff).toUpperCase(Locale.US);
if (x.length() == 1) b.append('0');
b.append(x);
}
return b.toString();
} catch (Exception e) {
return "";
}
}
private static Untrusted untrusted(List<byte[]> chainDer, boolean changed) {
byte[] leafDer = chainDer.get(0);
String subject = "?", issuer = "?", expires = "?";
try {
Certificate c = Certificate.getInstance(leafDer);
subject = String.valueOf(c.getSubject());
issuer = String.valueOf(c.getIssuer());
expires = new java.text.SimpleDateFormat("yyyy-MM-dd", Locale.US).format(c.getEndDate().getDate());
} catch (Exception ignored) {}
return new Untrusted(sha256(leafDer), subject, issuer, expires, changed, chainDer);
}
/** Lookup of trusted root certificates (DER) by the DER encoding of their subject name. */
public interface RootStore {
List<byte[]> find(byte[] subjectDer);
}
private static RootStore roots;
/** Chains already proven to lead to a trusted root (keyed by a hash of the chain), so a
* reconnect only re-checks the host name and dates, not every signature again. */
private static final java.util.Set<String> verifiedChains = new java.util.HashSet<String>();
/** Changing what's trusted invalidates everything trusted so far: cached chains and TLS sessions. */
public static void setRootStore(RootStore store) {
roots = store;
synchronized (verifiedChains) { verifiedChains.clear(); }
Http.forgetSessions();
}
private CertVerifier() {}
public static void verify(List<byte[]> chainDer, String host) throws IOException {
if (chainDer == null || chainDer.isEmpty()) throw new IOException("Server sent no certificate");
String p = pin;
if (p.length() > 0) {
// a pinned server: exactly that certificate, whatever its name or issuer
if (norm(sha256(chainDer.get(0))).equals(p)) return;
throw untrusted(chainDer, true);
}
if (roots == null) throw new IOException("No trust store configured");
java.io.ByteArrayOutputStream all = new java.io.ByteArrayOutputStream();
for (int i = 0; i < chainDer.size(); i++) all.write(chainDer.get(i), 0, chainDer.get(i).length);
String chainKey = sha1Hex(all.toByteArray());
boolean known;
synchronized (verifiedChains) { known = verifiedChains.contains(chainKey); }
if (known) {
Certificate leaf = Certificate.getInstance(chainDer.get(0));
if (!hostMatches(leaf, host)) throw new IOException(String.format(msgMismatch, host));
if (new Date().getTime() > 1704067200000L) checkDates(leaf, new Date());
return;
}
verifyFully(chainDer, host);
synchronized (verifiedChains) { verifiedChains.add(chainKey); }
}
private static void verifyFully(List<byte[]> chainDer, String host) throws IOException {
final List<byte[]> chain0 = chainDer;
List<Certificate> chain = new ArrayList<Certificate>();
for (int i = 0; i < chainDer.size(); i++) chain.add(Certificate.getInstance(chainDer.get(i)));
Certificate leaf = chain.get(0);
if (!hostMatches(leaf, host)) {
throw new IOException(String.format(msgMismatch, host));
}
Date now = new Date();
// An unset clock on an old phone (e.g. 1980 after a battery pull) would make every
// certificate look "not yet valid"; only enforce dates when the clock looks sane.
boolean clockSane = now.getTime() > 1704067200000L; // 2024-01-01
Certificate current = leaf;
for (int depth = 0; depth < 8; depth++) {
if (clockSane) checkDates(current, now);
byte[] issuerDer = current.getIssuer().getEncoded(ASN1Encoding.DER);
// 1. Anchored directly by a trusted root?
List<byte[]> anchors = roots.find(issuerDer);
if (anchors != null) {
byte[] currentDer = current.getEncoded(ASN1Encoding.DER);
for (int i = 0; i < anchors.size(); i++) {
// the very certificate the user chose to trust (e.g. self-signed)
if (java.util.Arrays.equals(anchors.get(i), currentDer)) return;
Certificate root = Certificate.getInstance(anchors.get(i));
// anything that signs must be a CA: a trusted site's own certificate can't vouch for another
if (isCa(root) && signatureValid(current, root)) return; // trusted
}
}
// 2. Otherwise find the issuing intermediate among what the server sent.
Certificate next = null;
for (int i = 1; i < chain.size(); i++) {
Certificate cand = chain.get(i);
if (cand == current) continue;
if (!java.util.Arrays.equals(cand.getSubject().getEncoded(ASN1Encoding.DER), issuerDer)) continue;
if (!isCa(cand)) continue;
if (signatureValid(current, cand)) { next = cand; break; }
}
if (next == null) throw untrusted(chain0, false);
current = next;
}
throw new IOException("Certificate chain too long");
}
private static void checkDates(Certificate c, Date now) throws IOException {
Date from = c.getStartDate().getDate();
Date to = c.getEndDate().getDate();
if (now.before(from) || now.after(to)) {
throw new IOException(msgDates);
}
}
private static boolean isCa(Certificate c) {
Extensions ext = c.getTBSCertificate().getExtensions();
if (ext == null) return c.getTBSCertificate().getVersionNumber() < 3; // v1 roots/intermediates
BasicConstraints bc = BasicConstraints.fromExtensions(ext);
return bc != null && bc.isCA();
}
static boolean signatureValid(Certificate subject, Certificate issuer) {
try {
ASN1ObjectIdentifier alg = subject.getSignatureAlgorithm().getAlgorithm();
byte[] tbs = subject.getTBSCertificate().getEncoded(ASN1Encoding.DER);
byte[] sig = subject.getSignature().getOctets();
AsymmetricKeyParameter key = PublicKeyFactory.createKey(issuer.getSubjectPublicKeyInfo());
if (alg.equals(PKCSObjectIdentifiers.sha256WithRSAEncryption)) return rsa(new SHA256Digest(), key, tbs, sig);
if (alg.equals(PKCSObjectIdentifiers.sha384WithRSAEncryption)) return rsa(new SHA384Digest(), key, tbs, sig);
if (alg.equals(PKCSObjectIdentifiers.sha512WithRSAEncryption)) return rsa(new SHA512Digest(), key, tbs, sig);
if (alg.equals(PKCSObjectIdentifiers.sha1WithRSAEncryption)) return rsa(new SHA1Digest(), key, tbs, sig);
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA256)) return ecdsa(new SHA256Digest(), key, tbs, sig);
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA384)) return ecdsa(new SHA384Digest(), key, tbs, sig);
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA512)) return ecdsa(new SHA512Digest(), key, tbs, sig);
return false;
} catch (Exception e) {
return false;
}
}
private static boolean rsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
RSADigestSigner s = new RSADigestSigner(d);
s.init(false, key);
s.update(tbs, 0, tbs.length);
return s.verifySignature(sig);
}
private static boolean ecdsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
DSADigestSigner s = new DSADigestSigner(new ECDSASigner(), d);
s.init(false, key);
s.update(tbs, 0, tbs.length);
return s.verifySignature(sig);
}
// ---- hostname checking ----
static boolean hostMatches(Certificate leaf, String host) {
host = host.toLowerCase(Locale.US);
boolean ip = isIpLiteral(host);
boolean sawDns = false;
Extensions ext = leaf.getTBSCertificate().getExtensions();
if (ext != null) {
GeneralNames names = GeneralNames.fromExtensions(ext, Extension.subjectAlternativeName);
if (names != null) {
GeneralName[] arr = names.getNames();
for (int i = 0; i < arr.length; i++) {
GeneralName n = arr[i];
if (n.getTagNo() == GeneralName.dNSName) {
sawDns = true;
// String.valueOf, not getName().toString(): Dalvik < 4.0 can't call Object methods via an interface
if (!ip && dnsMatch(String.valueOf(n.getName()), host)) return true;
} else if (n.getTagNo() == GeneralName.iPAddress && ip) {
byte[] a = ASN1OctetString.getInstance(n.getName()).getOctets();
if (ipString(a).equals(host)) return true;
}
}
}
}
if (!sawDns && !ip) {
X500Name subject = X500Name.getInstance(leaf.getSubject());
RDN[] cns = subject.getRDNs(BCStyle.CN);
for (int i = 0; i < cns.length; i++) {
String cn = IETFUtils.valueToString(cns[i].getFirst().getValue());
if (dnsMatch(cn, host)) return true;
}
}
return false;
}
static boolean dnsMatch(String pattern, String host) {
pattern = pattern.toLowerCase(Locale.US);
if (pattern.endsWith(".")) pattern = pattern.substring(0, pattern.length() - 1);
if (host.endsWith(".")) host = host.substring(0, host.length() - 1);
if (pattern.startsWith("*.")) {
String suffix = pattern.substring(1); // ".example.com"
if (!host.endsWith(suffix)) return false;
String label = host.substring(0, host.length() - suffix.length());
return label.length() > 0 && label.indexOf('.') < 0;
}
return pattern.equals(host);
}
static boolean isIpLiteral(String host) {
if (host.indexOf(':') >= 0) return true;
for (int i = 0; i < host.length(); i++) {
char c = host.charAt(i);
if (!(c == '.' || (c >= '0' && c <= '9'))) return false;
}
return true;
}
private static String ipString(byte[] a) {
if (a.length == 4) {
return (a[0] & 255) + "." + (a[1] & 255) + "." + (a[2] & 255) + "." + (a[3] & 255);
}
return "";
}
public static String sha1Hex(byte[] data) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] h = md.digest(data);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < h.length; i++) {
String s = Integer.toHexString(h[i] & 255);
if (s.length() == 1) sb.append('0');
sb.append(s);
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
package com.minamp.net;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* In-memory diagnostics log (last few hundred lines): every request with its status and timing,
* sign-in steps, stream attempts. Viewable in the app and savable to the SD card, so a problem
* on a real server can be reported precisely. Tokens are never logged.
*/
public final class DiagLog {
private static final int MAX = 400;
private static final List<String> lines = new ArrayList<String>();
private static final SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss", Locale.US);
private DiagLog() {}
public static void log(String msg) {
String line;
synchronized (fmt) { line = fmt.format(new Date()) + " " + msg; }
synchronized (lines) {
lines.add(line);
if (lines.size() > MAX) lines.remove(0);
}
}
public static String dump() {
StringBuilder sb = new StringBuilder();
synchronized (lines) {
for (int i = 0; i < lines.size(); i++) sb.append(lines.get(i)).append('\n');
}
return sb.toString();
}
public static void clear() {
synchronized (lines) { lines.clear(); }
}
/** URL without the token value, safe to show and share. */
public static String redact(String url) {
if (url == null) return "";
return url.replaceAll("((?:X-Plex-Token|token|T|Passwd)=)[^&]*", "$1***");
}
}
+588
View File
@@ -0,0 +1,588 @@
package com.minamp.net;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Vector;
import com.minamp.bc.tls.DefaultTlsClient;
import com.minamp.bc.tls.NameType;
import com.minamp.bc.tls.ServerName;
import com.minamp.bc.tls.TlsAuthentication;
import com.minamp.bc.tls.TlsClientProtocol;
import com.minamp.bc.tls.TlsCredentials;
import com.minamp.bc.tls.TlsServerCertificate;
import com.minamp.bc.tls.TlsSession;
import com.minamp.bc.tls.CertificateRequest;
import com.minamp.bc.tls.crypto.TlsCrypto;
import com.minamp.bc.tls.crypto.impl.bc.BcTlsCrypto;
/**
* Small HTTP/1.1 client with its own TLS 1.2 (Bouncy Castle), keep-alive pooling and
* session resumption. Android 2.x's built-in SSL only speaks TLS 1.0 and lacks today's roots,
* so every request in the app goes through here. From Minamp; keep the copies in sync.
*/
public final class Http {
public static String USER_AGENT = "Minamp/1.0";
/** Optional HTTP CONNECT proxy, only used when running the networking code on a desktop JVM. */
public static String proxyHost;
public static int proxyPort;
private static TlsCrypto crypto;
private static final Map<String, TlsSession> sessions = new HashMap<String, TlsSession>();
private static final Map<String, List<Conn>> pool = new HashMap<String, List<Conn>>();
private static final long IDLE_MS = 25000;
private Http() {}
private static synchronized TlsCrypto crypto() {
if (crypto == null) crypto = new BcTlsCrypto(new SecureRandom());
return crypto;
}
// ------------------------------------------------------------------ URL
public static final class Url {
public final boolean https;
public final String host;
public final int port;
public final String path; // includes query
public Url(String s) throws IOException {
String lower = s.toLowerCase(Locale.US);
int start;
if (lower.startsWith("https://")) { https = true; start = 8; }
else if (lower.startsWith("http://")) { https = false; start = 7; }
else throw new IOException("Bad URL: " + s);
int slash = s.indexOf('/', start);
String hostPort = slash < 0 ? s.substring(start) : s.substring(start, slash);
path = slash < 0 ? "/" : s.substring(slash);
int colon = hostPort.lastIndexOf(':');
if (colon > 0 && hostPort.indexOf(']') < colon) {
host = hostPort.substring(0, colon);
try { port = Integer.parseInt(hostPort.substring(colon + 1)); }
catch (NumberFormatException e) { throw new IOException("Bad port in " + s); }
} else {
host = hostPort;
port = https ? 443 : 80;
}
}
String key() { return (https ? "s:" : "p:") + host + ":" + port; }
String hostHeader() {
boolean dflt = (https && port == 443) || (!https && port == 80);
return dflt ? host : host + ":" + port;
}
}
// ------------------------------------------------------------------ connection
static final class Conn {
final String key;
Socket socket;
InputStream in;
OutputStream out;
TlsClientProtocol tls;
long idleSince;
Conn(String key) { this.key = key; }
void close() {
try { if (tls != null) tls.close(); } catch (Exception ignored) {}
try { if (socket != null) socket.close(); } catch (Exception ignored) {}
}
}
private static Conn take(String key) {
synchronized (pool) {
List<Conn> list = pool.get(key);
if (list == null) return null;
long now = System.currentTimeMillis();
while (!list.isEmpty()) {
Conn c = list.remove(list.size() - 1);
if (now - c.idleSince < IDLE_MS && !c.socket.isClosed()) return c;
c.close();
}
return null;
}
}
static void release(Conn c) {
c.idleSince = System.currentTimeMillis();
synchronized (pool) {
List<Conn> list = pool.get(c.key);
if (list == null) { list = new ArrayList<Conn>(); pool.put(c.key, list); }
if (list.size() >= 4) { list.remove(0).close(); }
list.add(c);
}
}
/** Forget resumable TLS sessions (next connection to each host does a full, verified handshake). */
static void forgetSessions() {
synchronized (sessions) { sessions.clear(); }
evictAll();
}
/** Drop all pooled connections (e.g. after the network changed). */
public static void evictAll() {
final List<Conn> doomed = new ArrayList<Conn>();
synchronized (pool) {
for (Iterator<List<Conn>> it = pool.values().iterator(); it.hasNext();) doomed.addAll(it.next());
pool.clear();
}
if (doomed.isEmpty()) return;
// closing TLS sends an encrypted alert: keep that off the (small-stack) caller thread
Threads.start(new Runnable() {
public void run() { for (int i = 0; i < doomed.size(); i++) doomed.get(i).close(); }
}, "http-evict");
}
private static Conn connect(final Url u, int timeoutMs) throws IOException {
Conn c = new Conn(u.key());
Socket s = new Socket();
boolean ok = false;
try {
if (proxyHost != null) {
s.connect(new InetSocketAddress(proxyHost, proxyPort), timeoutMs);
s.setSoTimeout(timeoutMs);
OutputStream po = s.getOutputStream();
String req = "CONNECT " + u.host + ":" + u.port + " HTTP/1.1\r\nHost: " + u.host + ":" + u.port + "\r\n\r\n";
po.write(req.getBytes("ISO-8859-1"));
po.flush();
InputStream pi = s.getInputStream();
String status = readLine(pi);
if (status == null || status.indexOf(" 200") < 0) throw new IOException("Proxy refused: " + status);
String line;
while ((line = readLine(pi)) != null && line.length() > 0) { /* skip headers */ }
} else {
s.connect(new InetSocketAddress(u.host, u.port), timeoutMs);
s.setSoTimeout(timeoutMs);
}
s.setTcpNoDelay(true);
c.socket = s;
if (u.https) {
TlsClientProtocol proto = new TlsClientProtocol(s.getInputStream(), s.getOutputStream());
proto.connect(new Client(crypto(), u.host, u.key()));
c.tls = proto;
c.in = new BufferedInputStream(proto.getInputStream(), 8192);
c.out = new BufferedOutputStream(proto.getOutputStream(), 4096);
} else {
c.in = new BufferedInputStream(s.getInputStream(), 8192);
c.out = new BufferedOutputStream(s.getOutputStream(), 4096);
}
ok = true;
return c;
} finally {
if (!ok) { try { s.close(); } catch (Exception ignored) {} }
}
}
private static final class Client extends DefaultTlsClient {
private final String host;
private final String sessionKey;
private final long started = System.currentTimeMillis();
Client(TlsCrypto crypto, String host, String sessionKey) {
super(crypto);
this.host = host;
this.sessionKey = sessionKey;
}
@Override
protected Vector getSNIServerNames() {
if (CertVerifier.isIpLiteral(host)) return null;
Vector v = new Vector();
try {
v.addElement(new ServerName(NameType.host_name, host.getBytes("US-ASCII")));
} catch (java.io.UnsupportedEncodingException e) {
return null;
}
return v;
}
/**
* TLS 1.2 only: this TLS stack can resume 1.2 sessions but not 1.3 ones, and on a 600 MHz
* phone a resumed handshake (no key exchange, no signature checks) is the difference
* between ~10 s and well under a second for every new connection.
*/
@Override
protected com.minamp.bc.tls.ProtocolVersion[] getSupportedVersions() {
return com.minamp.bc.tls.ProtocolVersion.TLSv12.only();
}
@Override
public TlsSession getSessionToResume() {
synchronized (sessions) { return sessions.get(sessionKey); }
}
@Override
public void notifyHandshakeComplete() throws IOException {
super.notifyHandshakeComplete();
boolean resumed = context.getSecurityParametersConnection().isResumedSession();
DiagLog.log("TLS " + (resumed ? "resumed" : "full handshake") + " with " + host
+ " in " + (System.currentTimeMillis() - started) + "ms");
TlsSession s = context.getSession();
if (s != null && s.isResumable()) {
synchronized (sessions) { sessions.put(sessionKey, s); }
}
}
public TlsAuthentication getAuthentication() throws IOException {
return new TlsAuthentication() {
public void notifyServerCertificate(TlsServerCertificate sc) throws IOException {
com.minamp.bc.tls.Certificate cert = sc.getCertificate();
List<byte[]> chain = new ArrayList<byte[]>();
for (int i = 0; i < cert.getLength(); i++) chain.add(cert.getCertificateAt(i).getEncoded());
CertVerifier.verify(chain, host);
}
public TlsCredentials getClientCredentials(CertificateRequest req) {
return null;
}
};
}
}
// ------------------------------------------------------------------ request / response
public static final class Response {
public final int code;
public final String status;
public final Map<String, String> headers; // lower-case names
public final InputStream body;
public final long contentLength; // -1 if unknown
public final boolean chunked;
Response(int code, String status, Map<String, String> headers, InputStream body, long len, boolean chunked) {
this.code = code; this.status = status; this.headers = headers; this.body = body;
this.contentLength = len; this.chunked = chunked;
}
public String header(String name) { return headers.get(name.toLowerCase(Locale.US)); }
public void close() {
try { body.close(); } catch (IOException ignored) {}
}
public byte[] readAll() throws IOException {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream(contentLength > 0 && contentLength < 4000000 ? (int) contentLength : 8192);
byte[] buf = new byte[8192];
int n;
while ((n = body.read(buf)) > 0) bos.write(buf, 0, n);
return bos.toByteArray();
} finally {
close();
}
}
public String readString() throws IOException {
return new String(readAll(), "UTF-8");
}
}
public static Response get(String url, Map<String, String> headers, int timeoutMs) throws IOException {
return request("GET", url, headers, null, timeoutMs);
}
public static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
return request(method, url, headers, body, timeoutMs, true);
}
/** A single request; 3xx responses are returned to the caller. */
public static Response requestNoRedirect(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
return request(method, url, headers, body, timeoutMs, false);
}
private static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs, boolean follow) throws IOException {
for (int redirects = 0; ; redirects++) {
long t0 = System.currentTimeMillis();
Response r;
try {
r = requestOnce(method, url, headers, body, timeoutMs);
} catch (IOException e) {
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED after " + (System.currentTimeMillis() - t0) + "ms: " + Threads.describe(e));
throw e;
} catch (RuntimeException e) {
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED: " + Threads.describe(e));
throw e;
}
if (!url.contains("/photo/:/transcode") && !url.contains("/cover")) { // artwork would drown out everything else
DiagLog.log(method + " " + DiagLog.redact(url) + " -> " + r.code + " (" + (System.currentTimeMillis() - t0) + "ms)");
}
if (follow && (r.code == 301 || r.code == 302 || r.code == 307 || r.code == 308) && redirects < 4
&& ("GET".equals(method) || "HEAD".equals(method))) {
String loc = r.header("location");
r.close();
if (loc == null) throw new IOException("Redirect without location");
if (loc.startsWith("/")) {
Url u = new Url(url);
loc = (u.https ? "https://" : "http://") + u.hostHeader() + loc;
}
url = loc;
continue;
}
return r;
}
}
private static Response requestOnce(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
Url u = new Url(url);
// A pooled connection may have been closed by the server; retry once on a fresh one.
for (int attempt = 0; attempt < 2; attempt++) {
Conn c = take(u.key());
boolean pooled = c != null;
if (c == null) c = connect(u, timeoutMs);
try {
c.socket.setSoTimeout(timeoutMs);
writeRequest(c, u, method, headers, body);
return readResponse(c, method);
} catch (IOException e) {
c.close();
if (!pooled) throw e;
}
}
throw new IOException("Request failed");
}
private static void writeRequest(Conn c, Url u, String method, Map<String, String> headers, byte[] body) throws IOException {
StringBuilder sb = new StringBuilder(512);
sb.append(method).append(' ').append(u.path).append(" HTTP/1.1\r\n");
sb.append("Host: ").append(u.hostHeader()).append("\r\n");
boolean hasUa = false;
if (headers != null) {
for (Map.Entry<String, String> e : headers.entrySet()) {
if (e.getValue() == null) continue;
if ("user-agent".equalsIgnoreCase(e.getKey())) hasUa = true;
sb.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n");
}
}
if (!hasUa) sb.append("User-Agent: ").append(USER_AGENT).append("\r\n");
sb.append("Connection: keep-alive\r\n");
if (body != null || "POST".equals(method) || "PUT".equals(method)) {
sb.append("Content-Length: ").append(body == null ? 0 : body.length).append("\r\n");
}
sb.append("\r\n");
c.out.write(sb.toString().getBytes("UTF-8"));
if (body != null) c.out.write(body);
c.out.flush();
}
private static Response readResponse(Conn c, String method) throws IOException {
String statusLine = readLine(c.in);
if (statusLine == null) throw new IOException("Connection closed");
// HTTP/1.1 200 OK
int sp = statusLine.indexOf(' ');
if (sp < 0) throw new IOException("Bad status line");
int sp2 = statusLine.indexOf(' ', sp + 1);
int code;
try {
code = Integer.parseInt(sp2 < 0 ? statusLine.substring(sp + 1).trim() : statusLine.substring(sp + 1, sp2));
} catch (NumberFormatException e) {
throw new IOException("Bad status line");
}
boolean http10 = statusLine.startsWith("HTTP/1.0");
Map<String, String> h = new LinkedHashMap<String, String>();
String line;
while ((line = readLine(c.in)) != null && line.length() > 0) {
int colon = line.indexOf(':');
if (colon <= 0) continue;
String name = line.substring(0, colon).trim().toLowerCase(Locale.US);
String val = line.substring(colon + 1).trim();
String prev = h.get(name);
h.put(name, prev == null ? val : prev + ", " + val);
}
String te = h.get("transfer-encoding");
String cl = h.get("content-length");
String conn = h.get("connection");
boolean keepAlive = !http10 && (conn == null || conn.toLowerCase(Locale.US).indexOf("close") < 0);
boolean chunked = te != null && te.toLowerCase(Locale.US).indexOf("chunked") >= 0;
long len = -1;
if (!chunked && cl != null) {
try { len = Long.parseLong(cl.trim()); } catch (NumberFormatException ignored) {}
}
InputStream body;
boolean noBody = "HEAD".equals(method) || code == 204 || code == 304 || (code >= 100 && code < 200);
if (noBody) {
body = new FixedStream(c, 0, keepAlive);
len = "HEAD".equals(method) ? len : 0;
} else if (chunked) {
body = new ChunkedStream(c, keepAlive);
} else if (len >= 0) {
body = new FixedStream(c, len, keepAlive);
} else {
body = new EofStream(c);
}
return new Response(code, statusLine, h, body, len, chunked);
}
static String readLine(InputStream in) throws IOException {
StringBuilder sb = new StringBuilder(64);
int b;
while ((b = in.read()) >= 0) {
if (b == '\n') {
int n = sb.length();
if (n > 0 && sb.charAt(n - 1) == '\r') sb.setLength(n - 1);
return sb.toString();
}
sb.append((char) b);
if (sb.length() > 16384) throw new IOException("Header line too long");
}
return sb.length() == 0 ? null : sb.toString();
}
// ------------------------------------------------------------------ body streams
private static final class FixedStream extends InputStream {
private final Conn c; private long left; private final boolean keepAlive; private boolean closed;
FixedStream(Conn c, long len, boolean keepAlive) {
this.c = c; this.left = len; this.keepAlive = keepAlive;
if (len == 0) finish(true);
}
private void finish(boolean complete) {
if (closed) return;
closed = true;
if (complete && keepAlive) release(c); else c.close();
}
@Override public int read() throws IOException {
byte[] one = new byte[1];
int n = read(one, 0, 1);
return n <= 0 ? -1 : one[0] & 255;
}
@Override public int read(byte[] b, int off, int len) throws IOException {
if (left <= 0) { finish(true); return -1; }
if (closed) return -1;
int n = c.in.read(b, off, (int) Math.min(len, left));
if (n < 0) { finish(false); throw new IOException("Unexpected end of stream"); }
left -= n;
if (left == 0) finish(true);
return n;
}
@Override public int available() throws IOException {
if (closed) return 0;
return (int) Math.min(c.in.available(), left);
}
@Override public void close() { finish(left == 0); }
}
private static final class ChunkedStream extends InputStream {
private final Conn c; private final boolean keepAlive;
private long chunkLeft = 0; private boolean eof; private boolean closed;
ChunkedStream(Conn c, boolean keepAlive) { this.c = c; this.keepAlive = keepAlive; }
private void finish(boolean complete) {
if (closed) return;
closed = true;
if (complete && keepAlive) release(c); else c.close();
}
private boolean nextChunk() throws IOException {
if (eof) return false;
String line = readLine(c.in);
if (line == null) throw new IOException("Unexpected end of chunked stream");
if (line.length() == 0) { line = readLine(c.in); if (line == null) throw new IOException("Bad chunk"); }
int semi = line.indexOf(';');
if (semi >= 0) line = line.substring(0, semi);
try { chunkLeft = Long.parseLong(line.trim(), 16); }
catch (NumberFormatException e) { throw new IOException("Bad chunk size"); }
if (chunkLeft == 0) {
String t;
while ((t = readLine(c.in)) != null && t.length() > 0) { /* trailers */ }
eof = true;
finish(true);
return false;
}
return true;
}
@Override public int read() throws IOException {
byte[] one = new byte[1];
int n = read(one, 0, 1);
return n <= 0 ? -1 : one[0] & 255;
}
@Override public int read(byte[] b, int off, int len) throws IOException {
if (closed) return -1;
if (chunkLeft == 0) {
if (!nextChunk()) return -1;
}
int n = c.in.read(b, off, (int) Math.min(len, chunkLeft));
if (n < 0) { finish(false); throw new IOException("Unexpected end of chunk"); }
chunkLeft -= n;
if (chunkLeft == 0) {
// consume CRLF after chunk data
String crlf = readLine(c.in);
if (crlf == null) { finish(false); }
else if (crlf.length() != 0) { finish(false); throw new IOException("Bad chunk terminator"); }
}
return n;
}
@Override public int available() throws IOException {
if (closed) return 0;
return (int) Math.min(c.in.available(), chunkLeft);
}
@Override public void close() { finish(eof); }
}
private static final class EofStream extends InputStream {
private final Conn c; private boolean closed;
EofStream(Conn c) { this.c = c; }
@Override public int read() throws IOException {
if (closed) return -1;
int b = c.in.read();
if (b < 0) close();
return b;
}
@Override public int read(byte[] b, int off, int len) throws IOException {
if (closed) return -1;
int n = c.in.read(b, off, len);
if (n < 0) close();
return n;
}
@Override public void close() {
if (!closed) { closed = true; c.close(); }
}
}
/** Close one in-flight response's socket from another thread (used to abort streams). */
public static void abort(Response r) {
if (r == null) return;
InputStream b = r.body;
Conn c = null;
if (b instanceof FixedStream) c = ((FixedStream) b).c;
else if (b instanceof ChunkedStream) c = ((ChunkedStream) b).c;
else if (b instanceof EofStream) c = ((EofStream) b).c;
if (c != null) {
try { c.socket.close(); } catch (Exception ignored) {}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package com.minamp.net;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
/**
* Background threads with big stacks. Android 2.x gives new threads ~16 KB of stack, and the
* bundled TLS handshake + certificate parsing needs far more; overflowing it kills the app.
* 256 KB is the most Dalvik allows.
*/
public final class Threads {
public static final long STACK = 256 * 1024;
private Threads() {}
public static Thread create(Runnable r, String name) {
Thread t = new Thread(null, r, name, STACK);
t.setDaemon(true);
return t;
}
public static void start(Runnable r, String name) {
create(r, name).start();
}
public static ExecutorService pool(final String name, int n) {
ThreadFactory f = new ThreadFactory() {
private int count;
public synchronized Thread newThread(Runnable r) { return create(r, name + "-" + (++count)); }
};
return n == 1 ? Executors.newSingleThreadExecutor(f) : Executors.newFixedThreadPool(n, f);
}
/** Readable one-liner for any failure, including Errors such as StackOverflowError. */
public static String describe(Throwable t) {
String m = t.getMessage();
String cls = t.getClass().getSimpleName();
if (m == null || m.length() == 0) return cls;
if (t instanceof java.io.IOException) return m;
return cls + ": " + m;
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more