-->

How should I do hostname validation when using JSS

2019-01-21 20:11发布

问题:

I'm writing a client in Java (needs to work both on the desktop JRE and on Android) for a proprietary protocol (specific to my company) carried over TLS. I'm trying to figure out the best way to write a TLS client in Java, and in particular, make sure that it does hostname validation properly. (Edit: By which, I mean checking that the hostname matches the X.509 certificate, to avoid man-in-the-middle attacks.)

JSSE is the obvious API for writing a TLS client, but I noticed from the "Most Dangerous Code in the World" paper (as well as from experimentation) that JSSE doesn't validate the hostname when one is using the SSLSocketFactory API. (Which is what I have to use, since my protocol is not HTTPS.)

So, it appears that when using JSSE, I have to do hostname validation myself. And, rather than writing that code from scratch (since I would almost certainly get it wrong), it seems that I should "borrow" some existing code that works. So, the most likely candidate I've found is to use the Apache HttpComponents library (ironic, since I'm not actually doing HTTP) and use the org.apache.http.conn.ssl.SSLSocketFactory class in place of the standard javax.net.ssl.SSLSocketFactory class.

My question is: is this a reasonable course of action? Or have I completely misunderstood things, gone off the deep end, and there's actually a much easier way to get hostname validation in JSSE, without pulling in a third-party library like HttpComponents?

I also looked at BouncyCastle, too, which has a non-JSSE API for TLS, but it appears to be even more limited, in that it doesn't even do certificate chain validation, much less hostname validation, so it seemed like a non-starter.

Edit: This question has been answered for Java 7, but I'm still curious what the "best practice" is for Java 6 and Android. (In particular, I have to support Android for my application.)

Edited again: 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. (I realized all I need are the verifiers, and I don't need Apache's SSLSocketFactory as I was originally thinking.) If left to my own devices, this is the solution I will use. But firstly, is there any reason I shouldn't do it this way? (Assuming that my goal is to do my hostname validation the same way https does. I realize that itself is open to debate, and has been discussed in the thread on the cryptography list, but for now I'm sticking with HTTPS-like hostname validation, even though I'm not doing HTTPS.)

Assuming there's nothing "wrong" with my solution, my question is this: is there a "better" way to do it, while still remaining portable across Java 6, Java 7, and Android? (Where "better" means more idiomatic, already widely in use, and/or needing less external code.)

回答1:

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.



回答2:

There are many good reasons for requiring the jsse client (you) to provide your own StrictHostnameVerifier. If you trust your company's nameserver, writing one should be pretty straightforward.

  1. get the IP for the hostname from the DNS provider your host is configured to use.
  2. return the result of matching the names.
  3. optionally, verify that the reverse lookup for the IP returns the correct name.

if you need it, I will provide you with a verifier. If you want me to provide the 'good reasons', I can do that too.



标签: java ssl jsse