我正在编写一个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,它有一个非JSSE的TLS API,但它似乎更有限,因为它甚至没有进行证书链验证,更不用说主机名验证,所以看起来似乎就像一个非首发。
编辑:这个问题has been answered适用于Java 7,但我仍然很好奇Java 6和Android的“最佳实践”。 (特别是,我必须为我的应用程序支持Android。)
再次编辑:为了使我的“从Apache HttpComponents借用”的提议更具体,我创建了一个small library,其中包含HostnameVerifier实现(最值得注意的是StrictHostnameVerifier和BrowserCompatHostnameVerifier)来自Apache HttpComponents。 (我意识到我需要的只是验证器,我不需要像我原先想的那样使用Apache的SSLSocketFactory。)如果留给我自己的设备,这就是我将使用的解决方案。但首先,有什么理由我不应该这样做吗? (假设我的目标是以与https相同的方式进行主机名验证。我意识到它本身可以辩论,并且已经在加密列表的线程中进行了讨论,但是现在我坚持使用类似HTTPS的主机名验证,即使我没有使用HTTPS。)
假设我的解决方案没有“错误”,我的问题是:有没有“更好”的方法来实现它,同时仍然可以在Java 6,Java 7和Android上保持可移植性? (“更好”意味着更惯用,已经广泛使用,和/或需要更少的外部代码。)
答案 0 :(得分:17)
您可以使用此隐式使用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,具体取决于可用的内容。
主要的细微之处在于:
修改强>:
让我的建议“更多地从Apache HttpComponents借用” 具体来说,我创建了一个包含的小型库 HostnameVerifier实现(最值得注意的是StrictHostnameVerifier 和BrowserCompatHostnameVerifier)从Apache中提取 HttpComponents。 [...]但首先,我不应该做任何理由 这样吗?
是的,有理由不这样做。
首先,你已经有效地分叉了一个库,你现在必须维护它,这取决于原始Apache HttpComponents中对这些类所做的进一步更改。我并不反对创建一个图书馆(我自己这样做了,我并不劝你这样做),但你必须考虑到这一点。你真的想节省一些空间吗?当然,如果您需要回收空间,有一些工具可以删除最终产品的未使用代码(请记住ProGuard)。
其次,即使StrictHostnameVerifier也不符合RFC 2818或RFC 6125.据我所知,从代码中可以看出:
CN=cn.example.com
的证书和www.example.com
的SAN,但cn.example.com
的{{1}}无法生效。很难看到一般的“更好的方式”。将此反馈提供给Apache HttpComponents库当然是一种方法。复制和粘贴我上面写的代码肯定听起来不是一个好方法(SO上的代码片段通常不会被维护,不经过100%测试,可能容易出错)。
更好的方法可能是尝试说服Android开发团队支持与Java 7相同的cn.example.com
和SSLParameters
。这仍然会留下遗留设备的问题。
答案 1 :(得分:-3)
要求jsse客户端(您)提供自己的StrictHostnameVerifier有很多充分的理由。如果您信任您公司的名称服务器,那么编写一个应该非常简单。
如果您需要,我会为您提供验证。 如果你想让我提供'好理由',我也可以这样做。