How should I do hostname validation when using JSSE?
Asked Answered
M

2

34

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.)

Mande answered 9/8, 2013 at 2:56 Comment(13)
I can't really figure out why this has been so heavily downvoted and put on hold as off-topic. How to implement hostname validation when using JSSE clearly is on-topic! (The question also clearly mention which APIs have been considered.)Ruby
Where would be the right place to ask this question? I haven't been able to find a mailing list for JSSE. Is there one?Mande
While waiting for this question to be (hopefully) re-opened, you might be interested in this (which should give you an answer for Java 7): https://mcmap.net/q/452100/-sslsocket-ignores-domain-mismatchRuby
Thanks @Bruno! That is actually a useful answer to my question. (Unfortunately, I hadn't found that thread before when searching for answers.) That said, I probably can't use it, since although I might be able to get away with saying we have to use Java 7 instead of Java 6 on the desktop, the problem is that I also have to support Android, and I'm betting that Android doesn't support the new Java 7 thing, since it isn't really Java-compliant anyway. But now maybe the answer is that I should go ask this question in an Android-specific forum.Mande
This question isn't off-topic at all. This is an important issue that's poorly addressed in the documentation. While the question refers to his code, the question isn't about his implementation: it's about how to solve a general problem faced by anyone using the Java SSL implementation.Caseation
If it's a proprietary protocol, and you only connect to your own servers, you can validate the cert any way you want, but it would generally include: is it issued by a trusted CA, or chained to a trusted root; and does the certificate name the server you believe you're connecting to (possibly by a DNS name, but could be any proprietary naming scheme). For such a protocol, HTTPS-style name matching (with wildcards, etc.) is probably overkill.Caseation
I think you basically need what is implied by the javax.net.ssl.HostnameVerifier interface. At least this would give you a JDK-compliant way of wrapping whatever implementation you come up with. I agree entirely with @Ruby and others that this question is 100% on-topic and I have voted to reopen. You don't need Tim Dierk's first two points, as JSSE already checks those, but you do need the third. Where exactly in the certificate the hostname should be and how it may match may vary for you but in general you could do worse than follow the HTTPS specification for this bit.Volotta
I'm planning on doing https-style validation, although I'm not yet sure if I need wildcards or not. Yes, HostnameVerifier pretty much does what I want, but it seems that the JDK only has the HostnameVerifier interface, and doesn't publicly provide any concrete implementations of HostnameVerifier. That's why I still have my eye on Apache HttpComponents; it provides concrete implementations like StrictHostnameVerifier and BrowserCompatHostnameVerifier. Although the one slight drawback to HostnameVerifier is that it only returns true or false, rather than an error message if it fails.Mande
@Mande The JDK does have a HostNameVerifier built in, of course, and you can get at it with HttpsURLConnection.getDefaultHostnameVerifier(). But my point was really that this interface specifies sufficiently the API that you need to implement: i.e. all you need access to is the hostname and the SSLSession. But if you need more than a boolean I guess that won't work either.Volotta
For more discussion around this, I'll point to this mailing list thread: lists.randombit.net/pipermail/cryptography/2013-August/… : "best practices" for hostname validation when using JSSEVeneer
@EJP, looking at the source for OpenJDK 6, HttpsURLConnection.getDefaultHostnameVerifier() returns an instance of sun.net.www.protocol.https.DefaultHostnameVerifier, which is a stub class whose verify() method always returns false. I'm still working through the code, and it's hard to follow, but it looks like (at least some of) the magic happens in the checkURLSpoofing() method of sun.net.www.protocol.http.HttpsClient, and it actually uses the class sun.security.util.HostnameChecker (which does not implement the HostnameVerifier interface) to check the X509 certificate...Mande
...and it only invokes the user-supplied HostnameVerifier if HostnameChecker fails. There's also some nasty stuff going on in the afterConnect() method, where it checks if the HostnameVerifier is an instanceof DefaultHostnameVerifier, and does something special in that case. I haven't figured it all out yet, but it seems very sneaky. On the other hand, Android (aka Harmony) has a completely different (and more straightforward) architecture, where getDefaultHostnameVerifier() actually does give you a HostnameVerifier which does the X509 verification itself.Mande
Regarding "hostname validation the same way https does [...]": that's why there is RFC 6125, to unify the behaviour across protocols. Concretely, it's very similar to what HTTPS says anyway (except it doesn't handle IP addresses).Ruby
R
22

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.

Ruby answered 11/8, 2013 at 17:29 Comment(1)
Yes, I've forked a library, so perhaps there's an argument for using Apache HttpComponents as-is, even though I only need a tiny fraction. I can see that both ways. (And no, it's not about saving space.) But I certainly don't see any justification for rewriting this code myself, or using code someone threw together in answering this question. Using existing open-source code is like writing it myself, except I don't actually have to write it, and it's going to have been debugged more. It's at least no worse than writing it myself. And changing Android is not viable in the near term.Mande
H
-3

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.

Harder answered 17/8, 2013 at 22:5 Comment(5)
Like I said, I've already got a HostnameVerifier from Apache, so I'm planning on using that. I just wanted to make sure there wasn't a better solution that I was overlooking (such as something built into JSSE), but I think the clear answer by now is "no, there isn't." Everyone seems to want to either write a HostnameVerifier for me, or tell me "how easy" it is to write my own, but in my view, neither of these is as good as using off-the-shelf code that's already been in use. Reinventing the wheel over and over is not good software engineering. So, I'll use Apache's wheel.Mande
Verifying the host name via DNS is certainly mixing everything up and won't provide the security you'd expect at all. (In addition reverse lookup is unnecessary, and will cause more problems.)Ruby
@Ruby How do ascertain that the host is authorized to provide the? And is the host identified in the certificate?Harder
That's the role of the CA. The client looks for a host name, gets a certificate from the server. If the client trusts the certificate (via the CA) and if the host name matches, then it's the right host. DNS resolution doesn't come into this at all (of course, the client needs it to connect at first, but that doesn't have anything to do with knowing whether to trust the connection: if DNS is tampered with, the certificate won't be the correct one anyway).Ruby
@Ruby so what if you initiate a mitm attack on a dns requestTrochanter

© 2022 - 2024 — McMap. All rights reserved.