How should I do hostname validation when using JSSE?

Java 7 (and above)

You can implicitly use the X509ExtendedTrustManager introduced in Java 7 using this (see this answer:

SSLParameters sslParams = new SSLParameters();
sslParams.setEndpointIdentificationAlgorithm("HTTPS");
sslSocket.setSSLParameters(sslParams); // also works on SSLEngine

Android

I’m less familiar with Android, but Apache HTTP Client should be bundled with it, so it’s not really an additional library. As such, you should be able to use org.apache.http.conn.ssl.StrictHostnameVerifier. (I haven’t tried this code.)

SSLSocketFactory ssf = (SSLSocketFactory) SSLSocketFactory.getDefault();
// It's important NOT to resolve the IP address first, but to use the intended name.
SSLSocket socket = (SSLSocket) ssf.createSocket("my.host.name", 443);

socket.startHandshake();
SSLSession session = socket.getSession();

StrictHostnameVerifier verifier = new StrictHostnameVerifier();
if (!verifier.verify(session.getPeerHost(), session)) {
    // throw some exception or do something similar.
}

Other

Unfortunately, the verifier needs to be implemented manually. The Oracle JRE obviously has some host name verifier implementation, but as far as I’m aware, it’s not available via the public API.

There are more details about the rules in this recent answer.

Here is an implementation I’ve written. It could certainly do with being reviewed… Comments and feedback welcome.

public void verifyHostname(SSLSession sslSession)
        throws SSLPeerUnverifiedException {
    try {
        String hostname = sslSession.getPeerHost();
        X509Certificate serverCertificate = (X509Certificate) sslSession
                .getPeerCertificates()[0];

        Collection<List<?>> subjectAltNames = serverCertificate
                .getSubjectAlternativeNames();

        if (isIpv4Address(hostname)) {
            /*
             * IP addresses are not handled as part of RFC 6125. We use the
             * RFC 2818 (Section 3.1) behaviour: we try to find it in an IP
             * address Subject Alt. Name.
             */
            for (List<?> sanItem : subjectAltNames) {
                /*
                 * Each item in the SAN collection is a 2-element list. See
                 * <a href=
                 * "http://docs.oracle.com/javase/7/docs/api/java/security/cert/X509Certificate.html#getSubjectAlternativeNames%28%29"
                 * >X509Certificate.getSubjectAlternativeNames()</a>. The
                 * first element in each list is a number indicating the
                 * type of entry. Type 7 is for IP addresses.
                 */
                if ((sanItem.size() == 2)
                        && ((Integer) sanItem.get(0) == 7)
                        && (hostname.equalsIgnoreCase((String) sanItem
                                .get(1)))) {
                    return;
                }
            }
            throw new SSLPeerUnverifiedException(
                    "No IP address in the certificate did not match the requested host name.");
        } else {
            boolean anyDnsSan = false;
            for (List<?> sanItem : subjectAltNames) {
                /*
                 * Each item in the SAN collection is a 2-element list. See
                 * <a href=
                 * "http://docs.oracle.com/javase/7/docs/api/java/security/cert/X509Certificate.html#getSubjectAlternativeNames%28%29"
                 * >X509Certificate.getSubjectAlternativeNames()</a>. The
                 * first element in each list is a number indicating the
                 * type of entry. Type 2 is for DNS names.
                 */
                if ((sanItem.size() == 2)
                        && ((Integer) sanItem.get(0) == 2)) {
                    anyDnsSan = true;
                    if (matchHostname(hostname, (String) sanItem.get(1))) {
                        return;
                    }
                }
            }

            /*
             * If there were not any DNS Subject Alternative Name entries,
             * we fall back on the Common Name in the Subject DN.
             */
            if (!anyDnsSan) {
                String commonName = getCommonName(serverCertificate);
                if (commonName != null
                        && matchHostname(hostname, commonName)) {
                    return;
                }
            }
            throw new SSLPeerUnverifiedException(
                    "No host name in the certificate did not match the requested host name.");
        }
    } catch (CertificateParsingException e) {
        /*
         * It's quite likely this exception would have been thrown in the
         * trust manager before this point anyway.
         */
        throw new SSLPeerUnverifiedException(
                "Unable to parse the remote certificate to verify its host name: "
                        + e.getMessage());
    }
}

public boolean isIpv4Address(String hostname) {
    String[] ipSections = hostname.split("\\.");
    if (ipSections.length != 4) {
        return false;
    }
    for (String ipSection : ipSections) {
        try {
            int num = Integer.parseInt(ipSection);
            if (num < 0 || num > 255) {
                return false;
            }
        } catch (NumberFormatException e) {
            return false;
        }
    }
    return true;
}

public boolean matchHostname(String hostname, String certificateName) {
    if (hostname.equalsIgnoreCase(certificateName)) {
        return true;
    }
    /*
     * Looking for wildcards, only on the left-most label.
     */
    String[] certificateNameLabels = certificateName.split(".");
    String[] hostnameLabels = certificateName.split(".");
    if (certificateNameLabels.length != hostnameLabels.length) {
        return false;
    }
    /*
     * TODO: It could also be useful to check whether there is a minimum
     * number of labels in the name, to protect against CAs that would issue
     * wildcard certificates too loosely (e.g. *.com).
     */
    /*
     * We check that whatever is not in the first label matches exactly.
     */
    for (int i = 1; i < certificateNameLabels.length; i++) {
        if (!hostnameLabels[i].equalsIgnoreCase(certificateNameLabels[i])) {
            return false;
        }
    }
    /*
     * We allow for a wildcard in the first label.
     */
    if ("*".equals(certificateNameLabels[0])) {
        // TODO match wildcard that are only part of the label.
        return true;
    }
    return false;
}

public String getCommonName(X509Certificate cert) {
    try {
        LdapName ldapName = new LdapName(cert.getSubjectX500Principal()
                .getName());
        /*
         * Looking for the "most specific CN" (i.e. the last).
         */
        String cn = null;
        for (Rdn rdn : ldapName.getRdns()) {
            if ("CN".equalsIgnoreCase(rdn.getType())) {
                cn = rdn.getValue().toString();
            }
        }
        return cn;
    } catch (InvalidNameException e) {
        return null;
    }
}

/* BouncyCastle implementation, should work with Android. */
public String getCommonName(X509Certificate cert) {
    String cn = null;
    X500Name x500name = X500Name.getInstance(cert.getSubjectX500Principal()
            .getEncoded());
    for (RDN rdn : x500name.getRDNs(BCStyle.CN)) {
        // We'll assume there's only one AVA in this RDN.
        cn = IETFUtils.valueToString(rdn.getFirst().getValue());
    }
    return cn;
}

There are two getCommonName implementations: one using javax.naming.ldap and one using BouncyCastle, depending on what’s available.

The main subtleties are about:

  • Matching IP address only in SANs (This question is about IP address matching and Subject Alternative Names.). Perhaps something could be done about IPv6 matching too.
  • Wildcard matching.
  • Only falling back on the CN if there is no DNS SAN.
  • What the “most specific” CN really means. I’ve assumed this is the last one here. (I’m not even considering a single CN RDN with multiple Attribute-Value Assertions (AVA): BouncyCastle can deal with them, but this is an extremely rare case anyway as far as I know.)
  • I haven’t checked at all what should happen for internationalised (non-ASCII) domain names (see RFC 6125.)

EDIT:

To make my proposal of “borrow from Apache HttpComponents” more
concrete, I’ve created a small library which contains the
HostnameVerifier implementations (most notably StrictHostnameVerifier
and BrowserCompatHostnameVerifier) extracted from Apache
HttpComponents. […] But firstly, is there any reason I shouldn’t do
it this way?

Yes, there are reasons not to do it this way.

Firstly, you’ve effectively forked a library, and you’ll now have to maintain it, depending on further changes made to these classes in the original Apache HttpComponents. I’m not against creating a library (I’ve done so myself, and I’m not discouraging you to do so), but you have to take this into account. Are you really trying to save some space? Surely, there are tools that can remove unused code for your final product if you need to reclaim space (ProGuard comes to mind).

Secondly, even the StrictHostnameVerifier isn’t compliant with RFC 2818 or RFC 6125. As far as I can tell from its code:

  • It will accept IP addresses in the CN, when it shouldn’t.
  • It will not just fall back on the CN when no DNS SANs are present, but also treat the CN as a first choice too. This could lead to a cert with CN=cn.example.com and a SAN for www.example.com but no SAN for cn.example.com be valid for cn.example.com when it shouldn’t.
  • I’m a bit sceptical about the way the CN is extracted. Subject DN string serialisation can be a bit funny, especially if some RDNs include commas, and the awkward case where some RDNs can have multiple AVAs.

It’s hard to see a general “better way”. Giving this feedback to the Apache HttpComponents library would be one way of course. Copying and pasting the code I wrote earlier above certainly doesn’t sound like a good way either (snippets of code on SO generally aren’t maintained, are not 100% tested and may be prone to errors).

A better way might be to try to try to convince the Android development team to support the same SSLParameters and X509ExtendedTrustManager as it was done for Java 7. This still leaves the issue of legacy devices.

Leave a Comment

tech