- Translate the interface to Galician (tools/translate_gl.py), including Galician month abbreviations for dates, which Android 2.3 lacks - Resize for the Galaxy Mini's 240x320 screen: larger text, rows, tabs and buttons; all sizes now live in res/values/dimens.xml - Redraw play/pause/refresh icons for every density; slightly brighter secondary text colours Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
13 KiB
Java
341 lines
13 KiB
Java
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.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("As conexións seguras non están dispoñibles: " + 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("Este teléfono non recoñece o certificado de ").append(host).append(".\n\n");
|
|
sb.append("Emitido para: ").append(commonName(leaf.getSubjectX500Principal())).append('\n');
|
|
sb.append("Emitido por: ").append(commonName(leaf.getIssuerX500Principal())).append('\n');
|
|
sb.append("Válido ata: ").append(Util.date(leaf.getNotAfter().getTime(), true)).append("\n\n");
|
|
sb.append("Pegada dixital SHA-256:\n").append(fingerprint(leaf)).append("\n\n");
|
|
sb.append("Continúa só se coincide coa pegada que amosa o navegador do teu ordenador para este sitio.");
|
|
if (top != leaf) {
|
|
sb.append("\n\nMiniABS confiará desde agora nos certificados emitidos por «")
|
|
.append(commonName(top.getSubjectX500Principal())).append("».");
|
|
}
|
|
return sb.toString();
|
|
}
|
|
}
|