From 7a476d777d70a970f7a706e6bfe01116b963a1fe Mon Sep 17 00:00:00 2001 From: M66B Date: Wed, 14 Jul 2021 17:42:22 +0200 Subject: [PATCH] Validate BIMI certificates --- .../assets/DigiCert Verified Mark Root CA.pem | 34 +++++ .../eu/faircode/email/ConnectionHelper.java | 22 +-- .../java/eu/faircode/email/ContactInfo.java | 127 +++++++++++++++++- .../java/eu/faircode/email/EmailProvider.java | 2 +- .../java/eu/faircode/email/EmailService.java | 2 +- .../eu/faircode/email/EntityCertificate.java | 17 +++ 6 files changed, 175 insertions(+), 29 deletions(-) create mode 100644 app/src/main/assets/DigiCert Verified Mark Root CA.pem diff --git a/app/src/main/assets/DigiCert Verified Mark Root CA.pem b/app/src/main/assets/DigiCert Verified Mark Root CA.pem new file mode 100644 index 0000000000..5db801937f --- /dev/null +++ b/app/src/main/assets/DigiCert Verified Mark Root CA.pem @@ -0,0 +1,34 @@ +-----BEGIN CERTIFICATE----- +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 +0wberd/tg3N0dJsSSznZjwYB +-----END CERTIFICATE----- diff --git a/app/src/main/java/eu/faircode/email/ConnectionHelper.java b/app/src/main/java/eu/faircode/email/ConnectionHelper.java index 56ce77e95f..4596d68761 100644 --- a/app/src/main/java/eu/faircode/email/ConnectionHelper.java +++ b/app/src/main/java/eu/faircode/email/ConnectionHelper.java @@ -37,14 +37,8 @@ import androidx.preference.PreferenceManager; import com.sun.mail.iap.ConnectionException; import com.sun.mail.util.FolderClosedIOException; -import org.bouncycastle.asn1.x509.GeneralName; - import java.io.IOException; -import java.security.cert.CertificateParsingException; -import java.security.cert.X509Certificate; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -463,21 +457,7 @@ public class ConnectionHelper { Settings.Global.AIRPLANE_MODE_ON, 0) != 0; } - static List getDnsNames(X509Certificate certificate) throws CertificateParsingException { - List result = new ArrayList<>(); - - Collection> altNames = certificate.getSubjectAlternativeNames(); - if (altNames == null) - return result; - - for (List altName : altNames) - if (altName.get(0).equals(GeneralName.dNSName)) - result.add((String) altName.get(1)); - - return result; - } - - static boolean matches(String server, List names) { + static boolean matches(String server, List names) { for (String name : names) if (matches(server, name)) { Log.i("Trusted server=" + server + " name=" + name); diff --git a/app/src/main/java/eu/faircode/email/ContactInfo.java b/app/src/main/java/eu/faircode/email/ContactInfo.java index 7f058d06fc..4d03e233de 100644 --- a/app/src/main/java/eu/faircode/email/ContactInfo.java +++ b/app/src/main/java/eu/faircode/email/ContactInfo.java @@ -38,6 +38,9 @@ import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemReader; import org.json.JSONArray; import org.json.JSONObject; import org.jsoup.nodes.Document; @@ -45,11 +48,13 @@ import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.ConnectException; @@ -58,17 +63,30 @@ import java.net.SocketTimeoutException; import java.net.URL; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; +import java.security.cert.CertPathBuilder; +import java.security.cert.CertPathBuilderResult; +import java.security.cert.CertPathValidator; import java.security.cert.CertPathValidatorException; +import java.security.cert.CertStore; +import java.security.cert.CertStoreParameters; import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.CollectionCertStoreParameters; +import java.security.cert.PKIXBuilderParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509CertSelector; +import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; @@ -385,10 +403,11 @@ public class ContactInfo { List> futures = new ArrayList<>(); if (bimi) { - final String txt = "default._bimi." + domain; + final String _domain = domain; futures.add(executorFavicon.submit(new Callable() { @Override public Favicon call() throws Exception { + final String txt = "default._bimi." + _domain; Log.i("BIMI fetch TXT=" + txt); DnsHelper.DnsRecord[] bimi = DnsHelper.lookup(context, txt, "txt"); if (bimi.length == 0) @@ -396,7 +415,7 @@ public class ContactInfo { Log.i("BIMI got TXT=" + bimi[0].name); Bitmap bitmap = null; - boolean verified = true; + boolean verified = false; String[] params = bimi[0].name.split(";"); for (String param : params) { String[] kv = param.split("="); @@ -404,10 +423,11 @@ public class ContactInfo { continue; switch (kv[0].trim().toLowerCase()) { - case "v": + case "v": // Version + // TODO: check version break; - case "l": { + case "l": { // Image link String svg = kv[1].trim(); if (TextUtils.isEmpty(svg)) continue; @@ -434,8 +454,103 @@ public class ContactInfo { break; } - case "a": - verified = true; + case "a": // Certificate link + String a = kv[1].trim(); + if (TextUtils.isEmpty(a)) + continue; + + URL url = new URL(a); + + try { + Log.i("BIMI PEM " + url); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setReadTimeout(FAVICON_READ_TIMEOUT); + connection.setConnectTimeout(FAVICON_CONNECT_TIMEOUT); + connection.setInstanceFollowRedirects(true); + connection.setRequestProperty("User-Agent", WebViewEx.getUserAgent(context)); + connection.connect(); + + // Fetch PEM objects + List pems = new ArrayList<>(); + try { + InputStreamReader isr = new InputStreamReader(connection.getInputStream()); + PemReader reader = new PemReader(isr); + while (true) { + PemObject pem = reader.readPemObject(); + if (pem == null) + break; + else + pems.add(pem); + } + } finally { + connection.disconnect(); + } + + if (pems.size() == 0) + throw new IllegalArgumentException("No PEM objects"); + + // Convert to X.509 certificates + List certs = new ArrayList<>(); + CertificateFactory fact = CertificateFactory.getInstance("X.509"); + for (PemObject pem : pems) { + ByteArrayInputStream bis = new ByteArrayInputStream(pem.getContent()); + certs.add((X509Certificate) fact.generateCertificate(bis)); + } + + // Get first certificate + // https://datatracker.ietf.org/doc/draft-fetch-validation-vmc-wchuang/ + X509Certificate cert = certs.remove(0); + + // Check certificate type + List ku = cert.getExtendedKeyUsage(); + if (!ku.contains(EntityCertificate.OID_BrandIndicatorforMessageIdentification)) + throw new IllegalArgumentException("Invalid certificate type"); + + // Check subject + if (!EntityCertificate.getDnsNames(cert).contains(_domain)) + throw new IllegalArgumentException("Invalid certificate domain"); + + // Get trust anchors + Set trustAnchors = new HashSet<>(); + for (String ca : context.getAssets().list("")) + if (ca.endsWith(".pem")) { + Log.i("Reading ca=" + ca); + try (InputStream is = context.getAssets().open(ca)) { + X509Certificate c = (X509Certificate) fact.generateCertificate(is); + trustAnchors.add(new TrustAnchor(c, null)); + } + } + + // https://datatracker.ietf.org/doc/html/rfc3709#page-6 + byte[] logoType = cert.getExtensionValue(Extension.logoType.getId()); + // TODO: decode + + + //KeyStore ks = KeyStore.getInstance("AndroidCAStore"); + //ks.load(null, null); + + // Validate certificate + X509CertSelector target = new X509CertSelector(); + target.setCertificate(cert); + + PKIXBuilderParameters pparams = new PKIXBuilderParameters(trustAnchors, target); + CertStoreParameters intermediates = new CollectionCertStoreParameters(certs); + pparams.addCertStore(CertStore.getInstance("Collection", intermediates)); + pparams.setRevocationEnabled(false); + pparams.setDate(null); + + CertPathBuilder builder = CertPathBuilder.getInstance("PKIX"); + CertPathBuilderResult path = builder.build(pparams); + + CertPathValidator cpv = CertPathValidator.getInstance("PKIX"); + cpv.validate(path.getCertPath(), pparams); + + Log.i("BIMI valid domain=" + _domain); + verified = true; + } catch (Throwable ex) { + Log.w(new Throwable("BIMI", ex)); + } break; } } diff --git a/app/src/main/java/eu/faircode/email/EmailProvider.java b/app/src/main/java/eu/faircode/email/EmailProvider.java index 481f7842d8..dc096335c9 100644 --- a/app/src/main/java/eu/faircode/email/EmailProvider.java +++ b/app/src/main/java/eu/faircode/email/EmailProvider.java @@ -835,7 +835,7 @@ public class EmailProvider implements Parcelable { Certificate[] certs = sslSocket.getSession().getPeerCertificates(); for (Certificate cert : certs) if (cert instanceof X509Certificate) { - List names = ConnectionHelper.getDnsNames((X509Certificate) cert); + List names = EntityCertificate.getDnsNames((X509Certificate) cert); EntityLog.log(context, "Certificate " + address + " " + TextUtils.join(",", names)); if (ConnectionHelper.matches(host, names)) { diff --git a/app/src/main/java/eu/faircode/email/EmailService.java b/app/src/main/java/eu/faircode/email/EmailService.java index 1e2869d87c..d7c3a30eae 100644 --- a/app/src/main/java/eu/faircode/email/EmailService.java +++ b/app/src/main/java/eu/faircode/email/EmailService.java @@ -832,7 +832,7 @@ public class EmailService implements AutoCloseable { } // Check host name - List names = ConnectionHelper.getDnsNames(certificate); + List names = EntityCertificate.getDnsNames(certificate); if (ConnectionHelper.matches(server, names)) return; diff --git a/app/src/main/java/eu/faircode/email/EntityCertificate.java b/app/src/main/java/eu/faircode/email/EntityCertificate.java index 2c9c5bf4a4..dd42be9b6f 100644 --- a/app/src/main/java/eu/faircode/email/EntityCertificate.java +++ b/app/src/main/java/eu/faircode/email/EntityCertificate.java @@ -79,6 +79,8 @@ public class EntityCertificate { @NonNull public String data; + static final String OID_BrandIndicatorforMessageIdentification = "1.3.6.1.5.5.7.3.31"; + static EntityCertificate from(X509Certificate certificate, String email) throws CertificateEncodingException, NoSuchAlgorithmException { return from(certificate, false, email); } @@ -167,6 +169,21 @@ public class EntityCertificate { return result; } + static List getDnsNames(X509Certificate certificate) throws CertificateParsingException { + List result = new ArrayList<>(); + + Collection> altNames = certificate.getSubjectAlternativeNames(); + if (altNames == null) + return result; + + for (List altName : altNames) + if (altName.get(0).equals(GeneralName.dNSName)) + result.add((String) altName.get(1)); + + return result; + } + + public JSONObject toJSON() throws JSONException { JSONObject json = new JSONObject(); json.put("id", id);