diff --git a/app/src/main/java/eu/faircode/email/Bimi.java b/app/src/main/java/eu/faircode/email/Bimi.java new file mode 100644 index 0000000000..b0653ffb5f --- /dev/null +++ b/app/src/main/java/eu/faircode/email/Bimi.java @@ -0,0 +1,235 @@ +package eu.faircode.email; + +/* + This file is part of FairEmail. + + FairEmail is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + FairEmail is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with FairEmail. If not, see . + + Copyright 2018-2021 by Marcel Bokhorst (M66B) +*/ + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.text.TextUtils; +import android.util.Pair; + +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 java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.security.KeyStore; +import java.security.cert.CertPathBuilder; +import java.security.cert.CertPathBuilderResult; +import java.security.cert.CertPathValidator; +import java.security.cert.CertStore; +import java.security.cert.CertStoreParameters; +import java.security.cert.Certificate; +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.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.net.ssl.HttpsURLConnection; + +public class Bimi { + // Beam me up, Scotty + private static final int CONNECT_TIMEOUT = 10 * 1000; // milliseconds + private static final int READ_TIMEOUT = 15 * 1000; // milliseconds + private static final String OID_BrandIndicatorforMessageIdentification = "1.3.6.1.5.5.7.3.31"; + + static Pair get(Context context, String domain, String selector, int scaleToPixels) + throws IOException { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean bimi_vmc = prefs.getBoolean("bimi_vmc", false); + + final String txt = selector + "._bimi." + domain; + Log.i("BIMI fetch TXT=" + txt); + DnsHelper.DnsRecord[] bimi = DnsHelper.lookup(context, txt, "txt"); + if (bimi.length == 0) + return null; + Log.i("BIMI got TXT=" + bimi[0].name); + + Bitmap bitmap = null; + boolean verified = !bimi_vmc; + String[] params = bimi[0].name.split(";"); + for (String param : params) { + String[] kv = param.split("="); + if (kv.length != 2) + continue; + + switch (kv[0].trim().toLowerCase()) { + case "v": { // Version + String version = kv[1].trim(); + if (!"BIMI1".equalsIgnoreCase(version)) + Log.w("BIMI unsupported version=" + version); + break; + } + + case "l": { // Image link + if (!bimi_vmc) + continue; + + String svg = kv[1].trim(); + if (TextUtils.isEmpty(svg)) + continue; + + URL url = new URL(svg); + + Log.i("BIMI favicon " + url); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setReadTimeout(READ_TIMEOUT); + connection.setConnectTimeout(CONNECT_TIMEOUT); + connection.setInstanceFollowRedirects(true); + connection.setRequestProperty("User-Agent", WebViewEx.getUserAgent(context)); + connection.connect(); + + try { + bitmap = ImageHelper.renderSvg( + connection.getInputStream(), + Color.WHITE, scaleToPixels); + } finally { + connection.disconnect(); + } + + break; + } + + 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(READ_TIMEOUT); + connection.setConnectTimeout(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(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)); + } + } + + KeyStore ks = KeyStore.getInstance("AndroidCAStore"); + ks.load(null, null); + Enumeration aliases = ks.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + Certificate c = ks.getCertificate(alias); + if (c instanceof X509Certificate) + trustAnchors.add(new TrustAnchor((X509Certificate) c, null)); + } + + // https://datatracker.ietf.org/doc/html/rfc3709#page-6 + // TODO: logoType + byte[] logoType = cert.getExtensionValue(Extension.logoType.getId()); + + // 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; + } + } + } + + return (bitmap == null ? null : new Pair<>(bitmap, verified)); + } +} diff --git a/app/src/main/java/eu/faircode/email/ContactInfo.java b/app/src/main/java/eu/faircode/email/ContactInfo.java index 29cb34976b..0d14921a95 100644 --- a/app/src/main/java/eu/faircode/email/ContactInfo.java +++ b/app/src/main/java/eu/faircode/email/ContactInfo.java @@ -34,13 +34,11 @@ import android.net.NetworkInfo; import android.net.Uri; import android.provider.ContactsContract; import android.text.TextUtils; +import android.util.Pair; 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; @@ -48,13 +46,11 @@ 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; @@ -63,33 +59,17 @@ import java.net.SocketTimeoutException; import java.net.URL; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; -import java.security.KeyStore; -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.Certificate; 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.Enumeration; 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; @@ -273,7 +253,6 @@ public class ContactInfo { boolean avatars = prefs.getBoolean("avatars", true); boolean gravatars = prefs.getBoolean("gravatars", false); boolean bimi = prefs.getBoolean("bimi", false); - boolean bimi_vmc = prefs.getBoolean("bimi_vmc", false); boolean favicons = prefs.getBoolean("favicons", false); boolean generated = prefs.getBoolean("generated_icons", true); boolean identicons = prefs.getBoolean("identicons", false); @@ -412,168 +391,9 @@ public class ContactInfo { @Override public Favicon call() throws Exception { // TODO: BIMI selector - final String txt = "default._bimi." + _domain; - Log.i("BIMI fetch TXT=" + txt); - DnsHelper.DnsRecord[] bimi = DnsHelper.lookup(context, txt, "txt"); - if (bimi.length == 0) - return null; - Log.i("BIMI got TXT=" + bimi[0].name); - - Bitmap bitmap = null; - boolean verified = !bimi_vmc; - String[] params = bimi[0].name.split(";"); - for (String param : params) { - String[] kv = param.split("="); - if (kv.length != 2) - continue; - - switch (kv[0].trim().toLowerCase()) { - case "v": { // Version - String version = kv[1].trim(); - if (!"BIMI1".equalsIgnoreCase(version)) - Log.w("BIMI unsupported version=" + version); - break; - } - - case "l": { // Image link - if (!bimi_vmc) - continue; - - String svg = kv[1].trim(); - if (TextUtils.isEmpty(svg)) - continue; - - URL url = new URL(svg); - - Log.i("BIMI favicon " + 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(); - - try { - bitmap = ImageHelper.renderSvg( - connection.getInputStream(), - Color.WHITE, scaleToPixels); - } finally { - connection.disconnect(); - } - - break; - } - - 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)); - } - } - - KeyStore ks = KeyStore.getInstance("AndroidCAStore"); - ks.load(null, null); - Enumeration aliases = ks.aliases(); - while (aliases.hasMoreElements()) { - String alias = aliases.nextElement(); - Certificate c = ks.getCertificate(alias); - if (c instanceof X509Certificate) - trustAnchors.add(new TrustAnchor((X509Certificate) c, null)); - } - - // https://datatracker.ietf.org/doc/html/rfc3709#page-6 - byte[] logoType = cert.getExtensionValue(Extension.logoType.getId()); - // TODO: decode - - // 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; - } - } - } - - return (bitmap == null ? null : new Favicon(bitmap, verified)); + Pair bimi = + Bimi.get(context, _domain, "default", scaleToPixels); + return (bimi == null ? null : new Favicon(bimi.first, bimi.second)); } })); } diff --git a/app/src/main/java/eu/faircode/email/EntityCertificate.java b/app/src/main/java/eu/faircode/email/EntityCertificate.java index dd42be9b6f..726ae9f9db 100644 --- a/app/src/main/java/eu/faircode/email/EntityCertificate.java +++ b/app/src/main/java/eu/faircode/email/EntityCertificate.java @@ -79,8 +79,6 @@ 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); }