mirror of
https://github.com/M66B/FairEmail.git
synced 2025-02-22 06:01:12 +00:00
Validate BIMI certificates
This commit is contained in:
parent
d15322120f
commit
7a476d777d
6 changed files with 175 additions and 29 deletions
34
app/src/main/assets/DigiCert Verified Mark Root CA.pem
Normal file
34
app/src/main/assets/DigiCert Verified Mark Root CA.pem
Normal file
|
@ -0,0 +1,34 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
0wberd/tg3N0dJsSSznZjwYB
|
||||
-----END CERTIFICATE-----
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue