Validate BIMI certificates

This commit is contained in:
M66B 2021-07-14 17:42:22 +02:00
parent d15322120f
commit 7a476d777d
6 changed files with 175 additions and 29 deletions

View File

@ -0,0 +1,34 @@
-----BEGIN CERTIFICATE-----
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
0wberd/tg3N0dJsSSznZjwYB
-----END CERTIFICATE-----

View File

@ -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<String> getDnsNames(X509Certificate certificate) throws CertificateParsingException {
List<String> result = new ArrayList<>();
Collection<List<?>> 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<String> names) {
static boolean matches(String server, List<String> names) {
for (String name : names)
if (matches(server, name)) {
Log.i("Trusted server=" + server + " name=" + name);

View File

@ -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<Future<Favicon>> futures = new ArrayList<>();
if (bimi) {
final String txt = "default._bimi." + domain;
final String _domain = domain;
futures.add(executorFavicon.submit(new Callable<Favicon>() {
@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<PemObject> 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<X509Certificate> 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<String> 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<TrustAnchor> 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;
}
}

View File

@ -835,7 +835,7 @@ public class EmailProvider implements Parcelable {
Certificate[] certs = sslSocket.getSession().getPeerCertificates();
for (Certificate cert : certs)
if (cert instanceof X509Certificate) {
List<String> names = ConnectionHelper.getDnsNames((X509Certificate) cert);
List<String> names = EntityCertificate.getDnsNames((X509Certificate) cert);
EntityLog.log(context, "Certificate " + address +
" " + TextUtils.join(",", names));
if (ConnectionHelper.matches(host, names)) {

View File

@ -832,7 +832,7 @@ public class EmailService implements AutoCloseable {
}
// Check host name
List<String> names = ConnectionHelper.getDnsNames(certificate);
List<String> names = EntityCertificate.getDnsNames(certificate);
if (ConnectionHelper.matches(server, names))
return;

View File

@ -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<String> getDnsNames(X509Certificate certificate) throws CertificateParsingException {
List<String> result = new ArrayList<>();
Collection<List<?>> 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);