现象:对于现在服务器部署的域名可多个后,在使用https协议时,会部署多个ssl证书链,而在android5.0及以下的系统使用httpclient访问这类某个域名时,有时候会出现hostname校验不过的情况,报的错误是
Certificate for < host > doesn't match common name of the certificate subject: cn
host:是要访问的域名地址
cn:是证书中的域名地址
分析:对此我尝试用浏览器去打开这个域名,发现能成功访问并没有报错,对于应用层来说并不需要改变什么,那么问题应该出在SSL协议层,为了验证这个想法,我对机器ROOT后采用了tcpdump进行捉包,也可以在笔记本用fiddler,wireshark类软件捉包。
对于SSL协议中的四次握手,这里就不做详细说明,想了解可以去查阅相关的资料,还是很多的,
首次连接,客户端会向服务器发送一个clientHello,里面包含了协议版本号,客户端支持的加密算法列表(身份验证算法,生成传输 密钥的算法,使用传输密钥加密的算法,MAC完整性校验),compression方法,extension扩展域。
这是使用httpclient访问中捉出的SSL报文,访问结果hostname验证不过
这是使用浏览器成功访问截取的clientHello,
对比发现有2个地方不同,1)客户端支持的加密算法列表;2)extension域
因为可以看到ServerHello中最终返回的加密套件都是一样,因此2者的请求中都包含这个结果,同时也经过测试发现客户端的clientHello的加密算法域只传一个主流使用的也是能成功访问的,因此问题就出现的extension域了。
查询了相关资料发现,对于extension域,SNI已经是常见的字段,说起这个字段的缘由:在早期的SSL协议中并没有这个字段,因为那时候都是一个服务器对应一个域名的,所以当服务器支持多域名时,在SSL握手时,服务器并不知道客户端想访问的具体是哪个域名(只有到了http协议中的host域中才有具体的域名),因此服务器会返回默认的第一个配置的证书给客户端,如果此时访问的域名是A,第一个配置的证书是A,客户端是可以正常校验通过的;如果访问的是域名B,则会出现域名校验不过,也就是文中现象所指的问题。
因此只要在httpClient中让其ClientHello报文支持SNI即可解决这个问题。
对于android SDK自带的httpClient(版本很旧了),需要继承org.apache.http.conn.ssl.SSLSocketFactory类,并重写public Socket createSocket(Socket arg0, String arg1, int arg2, boolean arg3)方法
Socket socket = sslContext.getSocketFactory().createSocket(arg0, arg1, arg2, arg3); try { java.lang.reflect.Method setHostnameMethod = ((SSLSocket) socket).getClass() .getMethod("setHostname", String.class); setHostnameMethod.invoke(((SSLSocket) socket), arg1); Log.i("haha", "sslSetHostNameOk"); } catch (Exception ex) { ex.printStackTrace(); } return socket;
通过SSLSocket的setHostname方法可以添加SNI字段,因为有些版本的SDK并没有开放这个方法, 因此采用类映射的方法调用,然后重新编译运行,即可成功访问。
android SDK中自带的httpClient确实太旧了,问题一堆,后续android6.0也移除了这部分的代码,google不愿去做维护了,因此我猜想新的httpClient包是否已经解决这个问题呢,我下载了httpclient-android-4.3.5.1.jar并导入项目中使用(不能使用httpClient.jar,httpCore.jar那几个包,里面的类和android SDK里面的重复了,系统会优先加载系统的类,导致运行错误)
因为httpclient-android-4.3.5.1.jar已不再使用DefaultHttpClient,而是使用 HttpClients.custom().build()生成CloseableHttpClient,基本用法不变,但对于HTTPS的配置部分,使用的不再是SSLSocketFactory,而是SSLConnectionSocketFactory这个类,在使用系统default类的情况下,依然会出现hostname校验不过,发现这个最新的jar并没有把SNI加上,因为也是采用重写SSLConnectionSocketFactory这个类的
public Socket connectSocket(int arg0, Socket arg1, HttpHost arg2,InetSocketAddress arg3, InetSocketAddress arg4, HttpContext arg5)
方法去映射调用setHostname方法去设置SNI的值。
对于网上也有很多建议使用自定义TrustManager,重写checkServerTrust时空实现,来达到对于服务器返回任何的证书都允许通过,对此是十分不建议的,首先这容易被中间人攻击,是不安全的。如果非要使用自定义TrustManager,一定要实现checkServerTrust的方法,对服务器返回的证书进行有效性验证,如果客户端访问的域名是固定的,也可以预先把服务器的ca证书放到asset中,进行强校验,只有和asset中的ca证书匹配才能校验通过,如果只是正常的校验的只要保证1)域名校验;2)证书有效性校验即可
对于1)中的域名校验,可在创建SSLSocket时,
((SSLSocket)socket).startHandshake(); SSLSession session = ((SSLSocket) socket).getSession(); if (session == null) { throw new SSLException("Cannot verify SSL socket without session"); } if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(arg2.getHostName(),session)) { throw new SSLPeerUnverifiedException("Cannot verify hostname: "+ arg1); }
使用上述方法对SSLSocket链路中返回的证书进行域名校验。
这是使用httpClient遇到的一点问题,httpClient资源库使用确实方便,不过确实太旧了,如果有更好的建议欢迎告知。