【问题标题】:How should I do hostname validation when using JSSE?使用 JSSE 时应该如何进行主机名验证?
【发布时间】:2013-08-10 22:59:53
【问题描述】:

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

JSSE 是用于编写 TLS 客户端的明显 API,但我从“Most Dangerous Code in the World”论文(以及实验)中注意到,当使用 SSLSocketFactory API 时,JSSE 不会验证主机名。 (这是我必须使用的,因为我的协议不是 HTTPS。)

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

我的问题是:这是一个合理的做法吗?还是我完全误解了一些事情,走得太远了,实际上有一种更简单的方法可以在 JSSE 中验证主机名,而无需引入像 HttpComponents 这样的第三方库?

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

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

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

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

【问题讨论】:

  • 我真的不明白为什么这被如此严重地否决并被搁置为题外话。明确使用 JSSE 时如何实现主机名验证是主题! (问题还清楚地提到了哪些 API 已被考虑。)
  • 哪里是问这个问题的正确地方?我找不到 JSSE 的邮件列表。有吗?
  • 在等待这个问题(希望)重新打开时,您可能对此感兴趣(这应该会为您提供 Java 7 的答案):stackoverflow.com/a/17979954/372643
  • 感谢@Bruno!这实际上是对我的问题的有用答案。 (不幸的是,我之前在搜索答案时没有找到该线程。)也就是说,我可能无法使用它,因为尽管我可能能够侥幸说我们必须使用桌面上的 Java 7 而不是 Java 6,问题是我还必须支持 Android,而且我打赌 Android 不支持新的 Java 7,因为它无论如何都不是真正的 Java 兼容。但现在也许答案是我应该去 Android 专用论坛问这个问题。
  • 这个问题一点也不离题。这是一个重要的问题,在文档中没有得到很好的解决。虽然问题是指他的代码,但问题不在于他的实现:它是关于如何解决使用 Java SSL 实现的任何人所面临的一般问题。

标签: java ssl jsse


【解决方案1】:

Java 7(及以上)

您可以使用此隐式使用 Java 7 中引入的X509ExtendedTrustManager(请参阅this answer

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 获得。

this recent answer中有更多关于规则的细节。

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

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

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

主要的微妙之处在于:

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

编辑

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

是的,有理由不这样做。

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

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

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

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

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

【讨论】:

  • 是的,我已经分叉了一个库,所以即使我只需要一小部分,也可能存在按原样使用 Apache HttpComponents 的论据。我可以看到这两种方式。 (不,这不是为了节省空间。)但我当然看不出有任何理由自己重写这段代码,或者使用别人在回答这个问题时拼凑的代码。使用现有的开源代码就像我自己编写它,只是我实际上不必编写它,而且它会被更多地调试。至少没有比自己写的更糟糕。短期内改变 Android 是不可行的。
【解决方案2】:

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

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

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

【讨论】:

  • 就像我说的,我已经从 Apache 获得了 HostnameVerifier,所以我打算使用它。我只是想确保没有我忽略的更好的解决方案(例如 JSSE 内置的东西),但我认为现在明确的答案是“不,没有”。每个人似乎都想为我编写一个 HostnameVerifier,或者告诉我自己编写一个“多么容易”,但在我看来,这些都不如使用已经在使用的现成代码好.一遍又一遍地重新发明轮子并不是好的软件工程。所以,我将使用 Apache 的轮子。
  • 通过 DNS 验证主机名肯定会混淆所有内容,并且根本无法提供您期望的安全性。 (另外反向查找是不必要的,会导致更多的问题。)
  • @Bruno 如何确定主机被授权提供?证书中是否标识了主机?
  • 这就是 CA 的角色。客户端查找主机名,从服务器获取证书。如果客户端信任证书(通过 CA)并且如果主机名匹配,那么它就是正确的主机。 DNS解析根本不涉及(当然,客户端首先需要它来连接,但这与知道是否信任连接没有任何关系:如果DNS被篡改,证书将不会'无论如何都不是正确的)。
  • @Bruno 如果你对 dns 请求发起 mitm 攻击会怎样
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-02-29
  • 1970-01-01
  • 1970-01-01
  • 2019-02-10
  • 2015-08-07
  • 1970-01-01
相关资源
最近更新 更多