Java 使用 JSSE 时如何进行主机名验证?

声明:本页面是StackOverFlow热门问题的中英对照翻译,遵循CC BY-SA 4.0协议,如果您需要使用它,必须同样遵循CC BY-SA许可,注明原文地址和作者信息,同时你必须将它归于原作者(不是我):StackOverFlow 原文地址: http://stackoverflow.com/questions/18139448/
Warning: these are provided under cc-by-sa 4.0 license. You are free to use/share it, But you must attribute it to the original authors (not me): StackOverFlow

提示:将鼠标放在中文语句上可以显示对应的英文。显示中英文
时间:2020-08-11 22:41:05  来源:igfitidea点击:

How should I do hostname validation when using JSSE?

javassljsse

提问by user2666524

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

我正在用 Java 编写一个客户端(需要在桌面 JRE 和 Android 上工作),用于通过 TLS 进行的专有协议(特定于我的公司)。我试图找出用 Java 编写 TLS 客户端的最佳方法,特别是确保它正确进行主机名验证。(编辑:我的意思是检查主机名是否与 X.509 证书匹配,以避免中间人攻击。)

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

JSSE 是用于编写 TLS 客户端的明显 API,但我从“世界上最危险的代码”论文(以及实验)中注意到,当使用 SSLSocketFactory API 时,JSSE 不会验证主机名。(这是我必须使用的,因为我的协议不是 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.

因此,似乎在使用 JSSE 时,我必须自己进行主机名验证。而且,与其从头开始编写代码(因为我几乎肯定会弄错),我似乎应该“借用”一些现有的有效代码。因此,我发现的最有可能的候选者是使用 Apache HttpComponents 库(讽刺的是,因为我实际上并没有使用HTTP)并使用 org.apache.http.conn.ssl.SSLSocketFactory 类代替标准的 javax .net.ssl.SSLSocketFactory 类

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?

我的问题是:这是一个合理的行动方案吗?还是我完全误解了事情,走得太远了,实际上有一种更简单的方法可以在 JSSE 中进行主机名验证,而无需引入像 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.

我还查看了 BouncyCastle,它有一个非 JSSE API 用于 TLS,但它似乎更加有限,因为它甚至不进行证书链验证,更不用说主机名验证,所以它似乎是一个非-起动机。

Edit:This question has been answeredfor 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.)

编辑:Java 7已经回答了这个问题,但我仍然很好奇 Java 6 和 Android 的“最佳实践”是什么。(特别是,我的应用程序必须支持 Android。)

Edited again:To make my proposal of "borrow from Apache HttpComponents" more concrete, I've created a small librarywhich 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'tdo 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.)

再次编辑:为了使我的“从 Apache HttpComponents 借用”的建议更加具体,我创建了一个小型库,其中包含从 Apache HttpComponents 中提取的 HostnameVerifier 实现(最显着的是 StrictHostnameVerifier 和 BrowserCompatHostnameVerifier)。(我意识到我需要的只是验证器,而且我不需要 Apache 的 SSLSocketFactory,正如我最初的想法。)如果留给我自己的设备,这就是我将使用的解决方案。但首先,有什么理由我不应该这样做吗?(假设我的目标是像 https 一样进行我的主机名验证。我意识到它本身是有争议的,并且已经在密码列表的线程中进行了讨论,但现在我坚持使用类似 HTTPS 的主机名验证,即使我没有使用 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.)

假设我的解决方案没有任何“错误”,我的问题是:有没有“更好”的方法来做到这一点,同时仍然可以在 Java 6、Java 7 和 Android 上保持可移植性?(其中“更好”意味着更惯用,已经广泛使用,和/或需要更少的外部代码。)

回答by Bruno

Java 7 (and above)

Java 7(及更高版本)

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

您可以使用这个隐式使用X509ExtendedTrustManagerJava 7 中引入的(请参阅此答案

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

我对 Android 不太熟悉,但 Apache HTTP Client 应该与它捆绑在一起,所以它实际上并不是一个额外的库。因此,您应该能够使用org.apache.http.conn.ssl.StrictHostnameVerifier. (我没有试过这个代码。)

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.

不幸的是,验证器需要手动实现。Oracle JRE 显然有一些主机名验证器实现,但据我所知,它不能通过公共 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 getCommonNameimplementations: one using javax.naming.ldapand one using BouncyCastle, depending on what's available.

有两种getCommonName实现:一种使用javax.naming.ldap和一种使用 BouncyCastle,具体取决于可用的内容。

The main subtleties are about:

主要的微妙之处在于:

  • Matching IP address only in SANs (This questionis 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.)
  • 仅在 SAN 中匹配 IP 地址(此问题是关于 IP 地址匹配和主题备用名称。)。也许也可以对 IPv6 匹配做些什么。
  • 通配符匹配。
  • 只有在没有 DNS SAN 时才回退到 CN。
  • “最具体”的 CN 真正意味着什么。我以为这是这里的最后一个。(我什至没有考虑使用具有多个属性值断言 (AVA) 的单个 CN RDN:BouncyCastle 可以处理它们,但据我所知,无论如何这是一种极为罕见的情况。)
  • 我根本没有检查国际化(非 ASCII)域名应该发生什么(参见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?

为了使我的“从 Apache HttpComponents 借用”的提议更加具体,我创建了一个小型库,其中包含从 Apache HttpComponents 中提取的 HostnameVerifier 实现(最著名的是 StrictHostnameVerifier 和 BrowserCompatHostnameVerifier)。[...] 但首先,我有什么理由不应该这样做吗?

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

首先,您已经有效地分叉了一个库,现在您必须维护它,这取决于对原始 Apache HttpComponents 中这些类所做的进一步更改。我不反对创建一个库(我自己已经这样做了,我不鼓励你这样做),但你必须考虑到这一点。你真的想节省一些空间吗?当然,如果您需要回收空间(想到 ProGuard),有一些工具可以为您的最终产品删除未使用的代码。

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

其次,即使是 StrictHostnameVerifier 也不符合 RFC 2818 或 RFC 6125。据我所知,它的代码是:

  • 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.comand a SAN for www.example.combut no SAN for cn.example.combe valid for cn.example.comwhen 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.
  • 它将接受 CN 中的 IP 地址,但不应该接受。
  • 当没有 DNS SAN 时,它不仅会回退到 CN,而且还将 CN 作为首选。这可能会导致证书CN=cn.example.com和 SAN forwww.example.com但没有 SAN cn.example.comforcn.example.com在它不应该时有效。
  • 我对提取 CN 的方式有点怀疑。主题 DN 字符串序列化可能有点有趣,特别是如果某些 RDN 包含逗号,以及某些 RDN 可以有多个 AVA 的尴尬情况。

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

很难看到一般的“更好的方法”。将此反馈提供给 Apache HttpComponents 库当然是一种方式。复制和粘贴我之前写的代码当然听起来也不是一个好方法(SO 上的代码片段通常没有维护,没有 100% 测试,可能容易出错)。

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

一个更好的办法可能是试图试图说服Android开发团队的支持相同SSLParameters,并X509ExtendedTrustManager因为它是针对Java 7做过这仍然留下传统设备的问题。

回答by Dru

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.

要求 jsse 客户端(您)提供您自己的 StrictHostnameVerifier 有很多很好的理由。如果您信任您公司的名称服务器,那么编写一个应该非常简单。

  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.
  1. 从您的主机配置为使用的 DNS 提供商处获取主机名的 IP。
  2. 返回匹配名称的结果。
  3. (可选)验证 IP 的反向查找返回正确的名称。

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.

如果你需要它,我会为你提供一个验证器。如果您希望我提供“充分理由”,我也可以这样做。