使用 JSSE 时应如何进行主机名验证?Java 7(及更高版本)人造人其他

2022-09-01 11:04:09

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

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

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

我的问题是:这是一个合理的行动方案吗?还是我完全误解了事情,偏离了深渊,实际上有一种更简单的方法可以在JSSE中获得主机名验证,而无需拉入像HttpComponents这样的第三方库?

我也看了BouncyCastle,它有一个用于TLS的非JSSE API,但它似乎更加有限,因为它甚至不做证书链验证,更不用说主机名验证了,所以它看起来像是一个非启动器。

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

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

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


答案 1

Java 7(及更高版本)

您可以使用以下命令隐式使用 Java 7 中引入的 (请参阅以下答案X509ExtendedTrustManager

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

人造人

我对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.
}

其他

不幸的是,验证器需要手动实现。Oracle JRE显然有一些主机名验证器实现,但据我所知,它不能通过公共API获得。

在最近的答案中,有更多关于规则的细节。

这是我写的一个实现。它当然可以与被审查有关...欢迎评论和反馈。

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;
}

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

主要的微妙之处是关于:

  • 仅在 SAN 中匹配 IP 地址(此问题与 IP 地址匹配和使用者备用名称有关。也许也可以对IPv6匹配做些什么。
  • 通配符匹配。
  • 仅当没有 DNS SAN 时才回退到 CN。
  • “最具体”CN的真正含义是什么。我以为这是最后一个。(我甚至没有考虑具有多个属性值断言(AVA)的单个CN RDN:BouncyCastle可以处理它们,但据我所知,这是一个非常罕见的情况。
  • 我根本没有检查国际化(非ASCII)域名应该发生什么(请参阅RFC 6125)。

编辑

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

是的,有理由不这样做。

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

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

  • 它将接受 CN 中的 IP 地址,而它不应该接受。
  • 当不存在 DNS SAN 时,它不仅会回退到 CN,而且还会将 CN 视为首选。这可能会导致证书和 SAN,但没有 SAN 在不应该有效的时候有效。CN=cn.example.comwww.example.comcn.example.comcn.example.com
  • 我对CN的提取方式有点怀疑。主题 DN 字符串序列化可能有点滑稽,特别是如果某些 RDN 包含逗号,以及某些 RDN 可以有多个 AVA 的尴尬情况。

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

更好的方法可能是尝试说服Android开发团队支持与Java 7相同的方法。这仍然留下了遗留设备的问题。SSLParametersX509ExtendedTrustManager


答案 2

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

  1. 从主机配置为使用的 DNS 提供商处获取主机名的 IP。
  2. 返回匹配名称的结果。
  3. (可选)验证 IP 的反向查找是否返回了正确的名称。

如果你需要它,我会给你一个验证器。如果你想让我提供“充分的理由”,我也可以这样做。