一次 TLS SNI 问题
一个遇到的问题
LeanCloud 的实时通信服务能实现类似客服机器人的功能,用户能自己提供一个 Web Hook 地址,有消息发给机器人的时候实时通信服务会将消息发到这个 Web Hook 上,用户从 Web Hook 收到消息之后能对消息进行解析和处理,构造出客服机器人的回答,再通过 REST API 发还给用户。从而用户能实现客服机器人自动应答功能。
前些天,有个用户反馈说 Web Hook 失效了,一直没有消息发过去。查看之下发现报类似这个样子的错误:
|
|
看上去就是用户的 SSL 证书限定的 Host Name 和用户填的 Web Hook 域名不符,导致 SSL 握手的时候我们这边对证书校验失败。
于是我去检查用户证书:
|
|
执行下来显示:
|
|
看到这个证书是颁发给一个叫做 cdn.myqcloud.com 的,而不是用户的域名。从这个域名中能看出一个是证书属于腾讯云,另一个就是这是 CDN 的证书。说明这个用户将 CDN 托管给了腾讯云。此时开始怀疑是因为我们在发请求调用用户 Web Hook 的时候 SSL 握手没有带着 SNI。于是执行:
|
|
此时看到了用户真正的证书:
|
|
从而确认是我们在调用用户 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 参数:
|
|
这个参数在调试 SSL 握手相关问题时非常有用,能把完整的握手过程,使用的证书等都打印出来。测试就是随意发了个 POST 请求到 https:leancloud.cn 在打印出来的 ClientHello 阶段有如下信息:
|
|
上面内容通过 tcpdump 抓包也能得到,但如果能增加 -Djavax.net.debug=all 这个配置的话还是打印出来会更方便一点。主要是看到上面 Extension 只有三行内容,少了:
|
|
如果支持 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 的方式:
|
|
需要注意:
- 有 host 参数的都会在创建 Socket 的时候自动连接 host
- 只有直接以 String 传递 host 的方式才会在握手中使用 SNI,以 InetAddress 传递 host 的方式握手时都不会带着 SNI
- 第 6 行的 createSocket 很特殊,是传入一个 Socket (已连接或未连接),然后建立一个新的 Socket layered over 原来的 Socket,如果原来的 Socket 没有建立连接,则在创建后会立即连接 host
第二条很关键,但在 JDK 文档上竟然完全没有说明。
|
|
从这里也能看出来是否使用 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 的:
|
|
clj-http 3.4.1 翻译为直接使用 HttpClient 的代码如下:
|
|
最关键的差别在于老的 clj-http 使用的是 SSLSocketFactory 而新的使用的是 SSLConnectionSocketFactory,再就是老版本使用的是 DefaultHttpClient,新版本使用的是 HttpClients 构造出来的 HttpClient 。
DefaultHttpClient 中,处理连接部分的是:org.apache.http.impl.conn.DefaultClientConnectionOperator
不同点 | 新版 | 老版 |
---|---|---|
创建 SSLSocket 的 Factory 不同 | SSLConnectionSocketFactory | SSLSocketFactory |
使用的 HttpClient 不同 | HttpClients 构造出来的 HttpClient | DefaultHttpClient |
HttpClient 构建连接的类不同 | DefaultHttpClientConnectionOperator | DefaultClientConnectionOperator |
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 了。