一次 TLS SNI 问题
转自 一次 TLS SNI 问题
一个遇到的问题
LeanCloud 的实时通信服务能实现类似客服机器人的功能,用户能自己提供一个 Web Hook 地址,有消息发给机器人的时候实时通信服务会将消息发到这个 Web Hook 上,用户从 Web Hook 收到消息之后能对消息进行解析和处理,构造出客服机器人的回答,再通过 REST API 发还给用户。从而用户能实现客服机器人自动应答功能。
前些天,有个用户反馈说 Web Hook 失效了,一直没有消息发过去。查看之下发现报类似这个样子的错误:
javax.net.ssl.SSLException: hostname in certificate didn't match: <expectedhost> != <defaulthost>
at org.apache.http.conn.ssl.AbstractVerifier.verify(AbstractVerifier.java:220)
看上去就是用户的 SSL 证书限定的 Host Name 和用户填的 Web Hook 域名不符,导致 SSL 握手的时候我们这边对证书校验失败。
于是我去检查用户证书:
openssl s_client -connect target.host.name:443
执行下来显示:
CONNECTED(00000003)
depth=2 C = US, O = "VeriSign, Inc.", OU = VeriSign Trust Network, OU = "(c) 2006 VeriSign, Inc. - For authorized use only", CN = VeriSign Class 3 Public Primary Certification Authority - G5
verify return:1
depth=1 C = US, O = Symantec Corporation, OU = Symantec Trust Network, CN = Symantec Class 3 Secure Server CA - G4
verify return:1
depth=0 C = CN, ST = guangdong, L = shenzhen, O = Shenzhen Tencent Computer Systems Company Limited, OU = R&D, CN = *.cdn.myqcloud.com
verify return:1
.....
看到这个证书是颁发给一个叫做 cdn.myqcloud.com 的,而不是用户的域名。从这个域名中能看出一个是证书属于腾讯云,另一个就是这是 CDN 的证书。说明这个用户将 CDN 托管给了腾讯云。此时开始怀疑是因为我们在发请求调用用户 Web Hook 的时候 SSL 握手没有带着 SNI。于是执行:
openssl s_client -connect target.host.name:443 -showcerts -servername target.host.name
此时看到了用户真正的证书:
CONNECTED(00000003)
depth=2 O = Digital Signature Trust Co., CN = DST Root CA X3
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
verify return:1
depth=0 CN = target.host.name
verify return:1
........
从而确认是我们在调用用户 Web Hook 时候 SSL 握手一定是没有带着 SNI 导致握手时拿到的不是用户真实的证书,拿的是用户 CDN 的证书,最终导致握手失败。
证实服务确实是不支持 SNI
首先需要说的是,从 JDK 是从 1.7 开始才真正支持 SNI,也就是说还在使用 1.6 版本 JDK 的话是无论如何都无法使用 SNI 的。
而 HttpClient 是从 4.3.2 开始支持 SNI 的。即使你使用的是 JDK 1.7 或更新版本的 JDK,但还是使用 4.3.2 以前的 HttpClient 的话,也是无法使用 SNI 的。
我们调用 Web Hook 的服务使用的是 clj-http 0.7.8,这个版本的 clj-http 刚好使用的是 4.3.1 的 HttpClient,所以才有了上面说的调用 Web Hook 进行 SSL 握手时没有带着 SNI 导致拿到错误证书。
为了证实 clj-http 0.7.8 在请求 https 服务时,SSL 握手没有带着 SNI ,首先需要添加 JVM 参数:
-Djavax.net.debug=all
这个参数在调试 SSL 握手相关问题时非常有用,能把完整的握手过程,使用的证书等都打印出来。测试就是随意发了个 POST 请求到 https:leancloud.cn 在打印出来的 ClientHello 阶段有如下信息:
*** ClientHello, TLSv1.2
RandomCookie: GMT: 1475193456 bytes = { 187, 13, 85, ...... }
Session ID: {}
Cipher Suites: [TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, .......]
Compression Methods: { 0 }
Extension elliptic_curves, curve names: {secp256r1, .......}
Extension ec_point_formats, formats: [uncompressed]
Extension signature_algorithms, signature_algorithms: SHA512withECDSA, ......
上面内容通过 tcpdump 抓包也能得到,但如果能增加 -Djavax.net.debug=all 这个配置的话还是打印出来会更方便一点。主要是看到上面 Extension 只有三行内容,少了:
Extension server_name, server_name: [type=host_name (0), value=leancloud.cn]
如果支持 SNI 的话是一定会打印上面这个 Extension 信息的。从而证实 clj-http 0.7.8 确实是不支持 SNI 的。
那是不是将 clj-http 升级到最新版,HttpClient 也使用最新版就可以了呢?
还不行。目前 HttpClient 对 SNI 的支持并不是向前兼容的,而是提供了一套新的 API 让用户使用。想要使用 SNI 就必须调用新的 HttpClient 的 API。clj-http 从 0.7.8 直到最新的发布版 2.3.0 都还在使用 HttpClient 老版本的 API,只有更新一些的还在开发中的 3.4.1 才真正切换到了新的 API。
这里就有疑问了,为什么 JDK 支持了 SNI,HttpClient 还得靠增加一套 API 来支持 SNI 呢?
JDK 对 SNI 的支持
为了解开疑问,先来看看 JDK 是怎么支持 SNI 的。JDK 要创建 SSL 的 Socket 需要使用 javax.net.ssl.SSLSocketFactory。SSLSocketFactory 提供了几种构造 Socket 的方式:
Socket createSocket()
Socket createSocket(String host, int port)
Socket createSocket(String host, int port, InetAddress localhost, int localPort) throws IOException, UnknownHostException;
Socket createSocket(InetAddress address, int port)
Socket createSocket(InetAddress address, int port, InetAddress remoteHost, int remotePort)
Socket createSocket(Socket socket, String host, int port, boolean autoClose)
需要注意:
- 有 host 参数的都会在创建 Socket 的时候自动连接 host
- 只有直接以 String 传递 host 的方式才会在握手中使用 SNI,以 InetAddress 传递 host 的方式握手时都不会带着 SNI
- 第 6 行的 createSocket 很特殊,是传入一个 Socket (已连接或未连接),然后建立一个新的 Socket layered over 原来的 Socket,如果原来的 Socket 没有建立连接,则在创建后会立即连接 host
第二条很关键,但在 JDK 文档上竟然完全没有说明。
javax.net.ssl.SSLSocketFactory socketfactory = (javax.net.ssl.SSLSocketFactory)javax.net.ssl.SSLSocketFactory.getDefault();
SSLSocket sock;
// 握手不会带着 SNI
sock = (SSLSocket)socketfactory.createSocket();
sock.connect(new InetSocketAddress("leancloud.cn", 443));
sock.startHandshake();
// 握手不会带着 SNI
sock = (SSLSocket)socketfactory.createSocket(InetAddress.getByName("leancloud.cn"), 443);
sock.startHandshake();
// 握手会带着 SNI
sock = (SSLSocket)socketfactory.createSocket("leancloud.cn", 443);
sock.startHandshake();
// 握手会带着 SNI
Socket plainSocket = SocketFactory.getDefault().createSocket();
// plainSocket 可以先执行 connect,并且这里可以传递 InetSocketAddress
// 只要 Layered Socket 创建时用的传 String 的 createSocket 即可
plainSocket.connect(new InetSocketAddress("leancloud.cn", 443), 30);
// 因为 plainSocket 已经建立连接,所以这里传递 String 的 Host 只是为了将其填入 SNI
sock = socketfactory.createSocket(plainSocket, "leancloud.cn", 443, true);
sock.startHandshake();
从这里也能看出来是否使用 SNI 创建连接藏的很隐晦。据说 JDK 不允许传递 InetAddress 的 createSocket 创建出来的 SSLSocket 在 SSL 握手时自动使用 SNI,是因为 InetAddress 构造的时候支持 getByName 函数,该函数可以传个 IP 而不是 Host。这种情况下用户真传个 IP 进来再允许开启 SNI 将这个 IP 放入 SNI 中就不符合 SNI 使用条件了,因为 SNI 只能填 Host Name。不过感觉理由还是比较牵强,总之就是这个 API 设计的有些诡异,藏得有点深。
HttpClient 对 SNI 的支持
为了了解缘由需要看一下这个 JIRA 讨论。
注意:以下内容基于:[org.apache.httpcomponents/httpcore “4.4.5”]
[org.apache.httpcomponents/httpclient “4.5.2”]
来说。以后内部实现可能还会变化。
在 HttpClient 的框架中,所有 Socket 都是先调用 SocketFactory (有新旧两个版本,org.apache.http.conn.scheme.SocketFactory 和 org.apache.http.conn.socket.ConnectionSocketFactory。两个版本都有 createSocket 和 connectSocket) 的 createSocket 方法先创建 Socket,之后对构造出来的 Socket 进行配置,添加比如 SO_TIMEOUT,SO_REUSEADDR,TCP_NODELAY 等,之后再调用 SocketFactory 的 connectSocket 方法去和 remote 地址建立连接。
在老版本的 HttpClient 下,默认都是用 javax.net.ssl.SSLSocketFactory 无参的 createSocket 函数来创建 Socket 的。在完全不改动上层实现的情况下是无法支持 SNI 了,所以新建立了一套 API。
clj-http 0.7.8 翻译为直接使用 HttpClient 的代码如下,这个是不支持 SNI 的:
SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", 80, PlainSocketFactory.getSocketFactory()));
SSLSocketFactory sslFac = SSLSocketFactory.getSocketFactory();
sslFac.setHostnameVerifier(SSLSocketFactory.STRICT_HOSTNAME_VERIFIER);
registry.register(new Scheme("https", 443, sslFac));
BasicClientConnectionManager manager = new BasicClientConnectionManager(registry);
HttpPost post = new HttpPost("https://leancloud.cn");
DefaultHttpClient httpClient = new DefaultHttpClient(manager);
httpClient.execute(post);
clj-http 3.4.1 翻译为直接使用 HttpClient 的代码如下:
Registry<ConnectionSocketFactory> registry = RegistryBuilder<ConnectionSocketFactory>.create()
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", SSLConnectionSocketFactory.getSocketFactory())
.build();
BasicHttpClientConnectionManager manager = new BasicHttpClientConnectionManager(registry);
HttpPost post = new HttpPost("https://leancloud.cn");
HttpClient httpClient = HttpClients.custom()
.setConnectionManager(manager)
.build();
httpClient.execute(post);
最关键的差别在于老的 clj-http 使用的是 SSLSocketFactory 而新的使用的是 SSLConnectionSocketFactory,再就是老版本使用的是 DefaultHttpClient,新版本使用的是 HttpClients 构造出来的 HttpClient 。
DefaultHttpClient 中,处理连接部分的是:org.apache.http.impl.conn.DefaultClientConnectionOperator
老版 | 新版 | 不同点 |
---|---|---|
SSLSocketFactory |
SSLConnectionSocketFactory |
创建 SSLSocket 的 Factory 不同 |
DefaultHttpClient |
HttpClients 构造出来的 HttpClient
|
使用的 HttpClient 不同 |
DefaultClientConnectionOperator |
DefaultHttpClientConnectionOperator |
HttpClient 构建连接的类不同 |
DefaultClientConnectionOperator 使用 SSLSocketFactory 构造 SSLSocket,用的是 javax.net.ssl.SSLSocketFactory 的无参的 createSocket 构造 SSLSocket,并且在 SSLSocketFactory 内使用创建出来的 SSLSocket 与目标 Host 建立连接时使用的 InetAddress 方式传递目标 Host Name,之后再开始握手流程,这就无法使用 SNI 了。
DefaultHttpClientConnectionOperator 使用的 SSLConnectionSocketFactory 先构造出普通的 Socket,在 SSLConnectionSocketFactory 调用 Socket 的 connect 参数先与目标服务建立连接。注意与 DefaultClientConnectionOperator 的不同,DefaultClientConnectionOperator 在调用 SSLSocketFactory 的 connectSocket 时传入的 Socket 就是 SSLSocket,而 DefaultHttpClientConnectionOperator 在调用 SSLConnectionSocketFactory 的 connectSocket 时传入的 socket 只是普通的 Socket。在这个普通的 Socket 与 remote host 建立连接之后,通过调用 SSLConnectionSocketFactory 内 createLayeredSocket 在普通 Socket 之上调用 javax.net.ssl.SSLSocketFactory 的传递 Socket 和普通 String 形式 Host Name 的 createSocket 函数构造出 SSLSocket,之后开始握手流程就能使用 SNI 了。