1. 异常突现

在这普通的一天,我写普通的代码,却突然收到不普通的报警

javax.net.ssl.SSLHandshakeException: server certificate change is restrictedduring renegotiation

查看日志访问xx支付的请求全部报错,紧急联系对方,得知对方更换了服务器证书。由于连接池会缓存连接,旧连接不能及时释放,线上一直在持续报警,最终重启服务器,业务才全部恢复正常。

2. 提出疑问

虽然系统恢复了正常,但是有几个问题一直留在我心里:

  • 为什么会出现这个异常?
  • HttpClient 是如何进行https请求的?
  • 除了重启机器,是否还有其他应对方式?

3. 初探Https

本文主要从源码的角度,探寻HttpClient如何进行Https请求,以及Java是如何进行SSL连接,不涉及SSL/TLS具体协议内容。

3.1 Https基础知识

Https的基础知识前辈们已经有了很好的总结,推荐几篇博文,可以学习一下。

为了更好的理解下文,这里引用两个SSL/TLS握手流程,摘自 HTTPS深入理解

验证服务器握手过程 图1

图片来自网络

(1) 客户端通过Client Hello消息将它支持的SSL版本、加密算法、密钥交换算法、MAC算法等信息发送给SSL服务器。

(2) 服务器确定本次通信采用的版本和加密套件,并通过Server Hello消息通知给客户端。如果服务器允许客户端在以后的通信中重用本次会话,则服务器会为本次会话分配会话ID,并通过Server Hello消息发送给SSL客户端。

(3) 服务器将携带自己公钥信息的数字证书通过Certificate消息发送给客户端。

(4) 服务器发送Server Hello Done消息,通知客户端版本和加密套件协商结束,开始进行密钥交换。

(5) 客户端验证服务器的证书合法后,利用证书中的公钥加密客户端随机生成的premaster secret,并通过Client Key Exchange消息发送给SSL服务器。

(6) 客户端发送Change Cipher Spec消息,通知服务器后续报文将采用协商好的密钥和加密套件进行加密和MAC计算。

(7) 客户端计算已交互的握手消息(除Change Cipher Spec消息外所有已交互的消息)的Hash值,利用协商好的密钥和加密套件处理Hash值(计算并添加MAC值、加密等),并通过Finished消息发送给服务器。服务器利用同样的方法计算已交互的握手消息的Hash值,并与Finished消息的解密结果比较,如果二者相同,且MAC值验证成功,则证明密钥和加密套件协商成功。

(8) 同样地,SSL服务器发送Change Cipher Spec消息,通知客户端后续报文将采用协商好的密钥和加密套件进行加密和MAC计算。

(9) 服务器计算已交互的握手消息的Hash值,利用协商好的密钥和加密套件处理Hash值(计算并添加MAC值、加密等),并通过Finished消息发送给客户端。客户端利用同样的方法计算已交互的握手消息的Hash值,并与Finished消息的解密结果比较,如果二者相同,且MAC值验证成功,则证明密钥和加密套件协商成功。

(10) 客户端接收到服务器发送的Finished消息后,如果解密成功,则可以判断服务器是数字证书的拥有者,即服务器身份验证成功,因为只有拥有私钥的服务器才能从Client Key Exchange消息中解密得到premaster secret,从而间接地实现了客户端对服务器的身份验证。

重用会话的握手过程 图2

协商会话参数、建立会话的过程中,需要使用非对称密钥算法来加密密钥、验证通信对端的身份,计算量较大,占用了大量的系统资源。为了简化SSL握手过程,SSL允许重用已经协商过的会话,具体过程为:

(1) 客户端发送Client Hello消息,消息中的会话ID设置为重用的会话的ID。

(2) 服务器如果允许重用该会话,则通过在Server Hello消息中设置相同的会话ID来应答。这样,客户端和服务器就可以利用原有会话的密钥和加密套件,不必重新协商。

(3) 服务器发送Change Cipher Spec消息,通知客户端后续报文将采用原有会话的密钥和加密套件进行加密和MAC计算。

(4) 服务器计算已交互的握手消息的Hash值,利用原有会话的密钥和加密套件处理Hash值,并通过Finished消息发送给客户端,以便SSL客户端判断密钥和加密套件是否正确。

(5) 客户端发送Change Cipher Spec消息,通知SSL服务器后续报文将采用原有会话的密钥和加密套件进行加密和MAC计算。

(6) 客户端计算已交互的握手消息的Hash值,利用原有会话的密钥和加密套件处理Hash值,并通过Finished消息发送给SSL服务器,以便SSL服务器判断密钥和加密套件是否正确。

3.2 HttpClient 如何处理https/http请求

先看一段小代码,这是一个简单的请求两次考拉主页的代码。通过之后的代码可以看到,在进行第二次请求时,并不会重新建立连接。

public static void main(String[] args) throws IOException {
CloseableHttpClient httpclient = HttpClients.createDefault();
HttpGet httpget = new HttpGet("https://www.kaola.com");
CloseableHttpResponse response1 = httpclient.execute(httpget); CloseableHttpResponse response2 = httpclient.execute(httpget);
}

创建CloseableHttpClient实例

首先看一下创建默认的CloseableHttpClient实例做了哪些事情,这里只讲一些核心属性。入口在org.apache.http.impl.client.HttpClientBuilder#build

  1. ClientExecChain
    执行链,HttpClient使用了责任链模式,请求通过一系列的执行器最终得到结果,每一个执行器都有自己的职责。下面是默认情况下执行链的顺序:
  • RedirectExec:负责处理请求重定向
  • RetryExec:负责判断一个由于I/O异常失败的请求是否应该重试
  • ProtocolExec:负责处理Http协议,内部使用HttpProcessor来构建必要的Http请求头,并处理Http响应头,更新Session状态到HttpClientContext
  • MainClientExec:执行链的最后一环,负责执行request获取response,使用HttpRequestExecutor发送请求
  1. HttpClientConnectionManager
    连接管理器,默认使用的实现时PoolingHttpClientConnectionManager,它创建时注册了支持不同协议的Socket创建工厂,其中https协议对应的是SSLConnectionSocketFactory
    PoolingHttpClientConnectionManager poolingmgr = newPoolingHttpClientConnectionManager( RegistryBuilder.<ConnectionSocketFactory>create() .register("http", PlainConnectionSocketFactory.getSocketFactory()) .register("https", sslSocketFactory) .build());
  2. HttpRoutePlanner
    默认实现是DefaultRoutePlanner,它决定一个request的HttpRoute路由信息,包含域名,端口,协议。它的实现必须是线程安全的。

执行Https请求

HttpGet httpget = new HttpGet("https://www.kaola.com");
CloseableHttpResponse response1 = httpclient.execute(httpget);

直接来看执行链的最后一环,这里只说明一些关键代码:

  1. 代码入口 org.apache.http.impl.execchain.MainClientExec#execute
public CloseableHttpResponse execute(final HttpRoute route,final HttpRequestWrapper request,
final HttpClientContext context,final HttpExecutionAware execAware) throws IOException, HttpException {
//...
//获取连接请求,这里没有真正建立连接
final ConnectionRequest connRequest = connManager.requestConnection(route, userToken);
//...
final RequestConfig config = context.getRequestConfig();
//获取连接,这里没有真正建立连接
final HttpClientConnection managedConn;
try {
final int timeout = config.getConnectionRequestTimeout();
managedConn = connRequest.get(timeout > 0 ? timeout : 0, TimeUnit.MILLISECONDS);
}//... 异常处理 //...
final ConnectionHolder connHolder = new ConnectionHolder(this.log, this.connManager, managedConn);
try {
//...
HttpResponse response;
for (int execCount = 1;; execCount++) {
//...
if (!managedConn.isOpen()) {
try {//2. 建立连接
establishRoute(proxyAuthState, managedConn, route, request, context);
} //异常处理
}
//执行
response = requestExecutor.execute(request, managedConn, context);
//...
}
//释放回连接池
}//...异常处理
}
  1. 建立连接org.apache.http.impl.conn.HttpClientConnectionOperator#connect
    establishRoute方法里,最终会调用connect方法
public void connect(final ManagedHttpClientConnection conn,final HttpHost host,
final InetSocketAddress localAddress,final int connectTimeout,
final SocketConfig socketConfig,final HttpContext context) throws IOException {
//根据协议获取对应的Socket工厂
final Lookup<ConnectionSocketFactory> registry = getSocketFactoryRegistry(context);
//因为是https协议,这里获取到SSLConnectionSocketFactory
final ConnectionSocketFactory sf = registry.lookup(host.getSchemeName());
//这里会从dns查到多个IP地址,如果连接失败,会尝试连接下一个IP
final InetAddress[] addresses = host.getAddress() != null ?
new InetAddress[] { host.getAddress() } : this.dnsResolver.resolve(host.getHostName());
final int port = this.schemePortResolver.resolve(host);
for (int i = 0; i < addresses.length; i++) {
final InetAddress address = addresses[i];
final boolean last = i == addresses.length - 1; Socket sock = sf.createSocket(context);//注意,这里创建的一个普通的socket连接
//...
conn.bind(sock);
//...
try {//3. 建立TLS连接
sock = sf.connectSocket(connectTimeout, sock, host, remoteAddress, localAddress, context);
conn.bind(sock);
return;
}//处理异常
}
  1. 建立TLS连接 org.apache.http.conn.ssl.SSLConnectionSocketFactory#connectSocket
    这里需要注意,Https协议是基于SSL/TLS协议,SSL/TLS是基于TCP协议,所以我们需要先建立TCP连接,再建立SSL/TLS连接,最后在SSL/TLS上传输Http报文。
public Socket connectSocket(final int connectTimeout,final Socket socket,
final HttpHost host,final InetSocketAddress remoteAddress,
final InetSocketAddress localAddress,final HttpContext context) throws IOException {
//...
try {
sock.connect(remoteAddress, connectTimeout);//建立TCP连接
} //异常处理
//...
//建立TLS连接
return createLayeredSocket(sock, host.getHostName(), remoteAddress.getPort(), context);
}

org.apache.http.conn.ssl.SSLConnectionSocketFactory#createLayeredSocket

public Socket createLayeredSocket(final Socket socket,final String target,
final int port,final HttpContext context) throws IOException {
//基于socket创建SSLScoket
final SSLSocket sslsock = (SSLSocket) this.socketfactory.createSocket(socket,target,port,true);
//...
prepareSocket(sslsock);//空实现,预留的hook
sslsock.startHandshake();//开始握手
verifyHostname(sslsock, target);
return sslsock;
}

到此HttpClient的请求部分就结束了,连接成功后会进行正常的数据交互(https有些特殊处理),下面看一下Java如何建立SSL/TLS连接

3.3 Java建立SSL/TLS连接

从这里开始没有源码,部分变量名是根据自己的理解填充的,不过JDK的自解释性还是很好的,大部分都可以理解

执行初始化握手

sun.security.ssl.SSLSocketImpl#performInitialHandshake

private void performInitialHandshake() throws IOException {
synchronized(this.handshakeLock) {
if(this.getConnectionState() == 1) {//连接状态初始化,如果复用连接不会走到这
this.kickstartHandshake();//1.开始握手,发送Client Hello消息
//...2. 读取服务端返回数据
this.readRecord(this.inrec, false);
this.inrec = null;
} }
}
  1. 发送Client Hello消息sun.security.ssl.Handshaker#kickstart

这里对应图1中的(1)

void kickstart() throws IOException {
if(this.state < 0) {
HandshakeMessage messge = this.getKickstartMessage();//构造消息体
//发送消息
messge.write(this.output);
this.output.flush();
this.state = messge.messageType();//握手消息 messageType=22
}
}

构造Client Hello消息体sun.security.ssl.ClientHandshaker#getKickstartMessage

HandshakeMessage getKickstartMessage() throws SSLException {
SessionId sessionId = SSLSessionImpl.nullSession.getSessionId();
CipherSuiteList cipherSuiteList = this.getActiveCipherSuites();
this.maxProtocolVersion = this.protocolVersion;
//取session,这里是造成异常的原因,之后分析
this.session = ((SSLSessionContextImpl)this.sslContext.engineGetClientSessionContext()).get(this.getHostSE(),this.getPortSE());
//...
if(this.session != null) {
//从sessino中还原信息
}
if(this.session == null && !this.enableNewSession) {
throw new SSLHandshakeException("No existing session to resume");
} else {
//获取可支持的加密套件
if(!isNegotiable) {
throw new SSLHandshakeException("No negotiable cipher suite");
} else {
//这里会把sessionId添加到ClientHello消息
ClientHello clientHello = new ClientHello(this.sslContext.getSecureRandom(), this.maxProtocolVersion, sessionId, cipherSuiteList);
//...
return clientHello;
}
}
}
  1. 接收服务端数据sun.security.ssl.SSLSocketImpl#readRecord

一直循环的读取服务端数据,直到握手完成

private void readRecord(InputRecord inputRecord, boolean var2) throws IOException {
synchronized(this.readLock) {
while(true) {
int var3;
if((var3 = this.getConnectionState()) != 6 && var3 != 4 && var3 != 7) {
try {
inputRecord.setAppDataValid(false);
//读取服务器返回
inputRecord.read(this.sockInput, this.sockOutput);
} //异常处理
//解码
synchronized(this) {
switch(inputRecord.contentType()) {//根据不同的消息类型进行处理
case 20://change_cipher_spec 服务端通知更换密钥 图1 (8)
//...
this.changeReadCiphers();
this.expectingFinished = true;
continue;
case 21://alert
this.recvAlert(inputRecord);
continue;
case 22://handshake 握手相关消息
this.initHandshaker();//初始化ClientHandshaker
//...
//处理数据
this.handshaker.process_record(inputRecord, this.expectingFinished);
this.expectingFinished = false;
//... 完成握手,保存信息,退出
continue;
case 23://application_data
//...
break;
default:
//...
}
return;
}
inputRecord.close();
return;
}
}
}

查看process_record方法的调用链,最终会找到握手消息处理的方法sun.security.ssl.ClientHandshaker#processMessage,通过不同的消息类型进行不同的处理,可以对照图1的理解.

void processMessage(byte handshakeType, int length) throws IOException {
if(this.state >= handshakeType && handshakeType != 0) {
//... 异常
} else {
label105:
switch(handshakeType) {
case 0://hello_request
this.serverHelloRequest(new HelloRequest(this.input));
break;
//...
case 2://sever_hello 图1 (2)
this.serverHello(new ServerHello(this.input, length));
break;
case 11:///certificate 图1 (3)
this.serverCertificate(new CertificateMsg(this.input));
this.serverKey = this.session.getPeerCertificates()[0].getPublicKey();
break;
case 12://server_key_exchange 该消息并不是必须的,取决于协商出的key交换算法
//...
case 13: //certificate_request 客户端双向验证时需要
//...
case 14://server_hello_done 图1 (4)
this.serverHelloDone(new ServerHelloDone(this.input));
break;
case 20://finished 图1 (9)
this.serverFinished(new Finished(this.protocolVersion, this.input, this.cipherSuite));
}
if(this.state < handshakeType) {//握手状态
this.state = handshakeType;
}
}
}

sun.security.ssl.ClientHandshaker#serverHelloDone方法中,客户端会根据服务端返回加密套件决定加密方式,构造不同的Client Key Exchange消息,例如RSAClientKeyExchangeDHClientKeyExchange,ECDHClientKeyExchange 对应图1 (5).

发送ClientKeyExchange后,紧接着sendChangeCipherAndFinish方法会发送Change Cipher Spec消息和Finished消息,对应图1 (6) (7).

之后接收服务端的Change Cipher Spec 消息 图1 (8),完成密钥切换后,等待Finished消息 图1 (9),如果此时不是恢复会话过程,会见session存入缓存中

至此成功建立连接,由于篇幅有限,每一步具体的收发消息就不细述,跟着上述分析可以清楚的找到所有入口。

3.4 复用会话的握手过程

复用会话的流程包含在是在上述流程中,只是部分节点会判断是否存在session,是否是恢复会话过程 进行不同的动作

  1. 携带Session信息的Client Hello 图2 (1)

在握手时会查看缓存时是否已经存在session (key=ip:port) ,如果存在session,在Client Hello消息中会带上session信息sun.security.ssl.ClientHandshaker#getKickstartMessage

this.sslContext.engineGetClientSessionContext()).get(this.getHostSE(),this.getPortSE());
  1. 携带Sessin信息的Server Hello 图2 (2)

sun.security.ssl.ClientHandshaker#serverHello方法中会判断客户端sessionId与服务端传来的是否一致

if(this.session.getSessionId().equals(severHello.sessionId)) {
//从session中恢复信息
}
  1. 接收Change Cipher Spec消息 图2 (3)

sun.security.ssl.SSLSocketImpl#changeReadCiphers方法中处理

  1. 接收Finished消息 图2 (4)

在收到Finished消息后,于初次建立连接不同,如果判断恢复会话,会发出Change Cipher Spec消息和Finished消息,对应图2 (5)(6)

if(this.resumingSession) {
this.input.digestNow();
this.sendChangeCipherAndFinish(true);
}

3.6 数据发送

SSL/TLS建立连接之后,Http报文将如何发送?

对于上层来说并不需要关注SSL/TLS层,数据会由SSLSocketImpl进行加密,解密。发送Http报文的入口在

org.apache.http.protocol.HttpRequestExecutor#execute,底层使用AppOutputStream输出流。最终由sun.security.ssl.SSLSocketImpl#writeRecordInternal输出数据

private void writeRecordInternal(OutputRecord outputRecord, boolean var2) throws IOException {
outputRecord.addMAC(this.writeMAC);
outputRecord.encrypt(this.writeCipher);//数据加密
//...
outputRecord.write(this.sockOutput, var2, this.heldRecordBuffer);//输出
//...
}

4. 解决疑问

发生了什么?为什么会出现异常?

服务端更换证书后,客户端建立的SSL/TLS连接并没有失效,在握手时使用缓存中的sessionId进行简化的握手流程,由此触发了异常。

HttpClient如何进行Https请求?

HttpClient根据不同的协议使用不同的Socket工厂创建连接,对于https会使用SSLConnectionSocketFactory.具体的SSL/TLS连接建立过程交由SSLSocketImpl处理。

建立连接后会HttpClient并不需要关注底层的数据加密,SSLSocketImpl会负责数据的读写。

如何处理?

重启机器

由于连接池的存在,等待连接报错重新建立新的连接不是一个好的选择,这可能会造成系统持续异常。如果没有其他措施,重启大法欢迎你。

禁用session

在建立连接之后使缓存失效可以避免使用简化的握手流程,不过性能影响较大,不提倡

SSLSocket.getSession().invalidate();

失效连接

既然是因为连接池的存在要重启机器,那我们可以把连接池清空。在连接池管理器PoolingHttpClientConnectionManager中有清理空闲连接的方法。

void closeIdleConnections(long idletime, TimeUnit tunit);

我们可以将idletime设置很小,就可以关闭大部分连接了。不过这样做法有些粗暴,可能会造成误伤。

连接池是可以自定义的,按需要定制自己想要的功能,如远程清空连接池,更精细一些,根据ip+port清理指定的连接。

https://zhuanlan.zhihu.com/p/44786952

java https的更多相关文章

  1. Java Https双向验证

    CA: Certificate Authority,证书颁发机构 CA证书:证书颁发机构颁发的数字证书 参考资料 CA证书和TLS介绍 HTTPS原理和CA证书申请(满满的干货) 单向 / 双向认证 ...

  2. java https单向认证(忽略认证)并支持http基本认证

    https单向认证(忽略认证)并支持http基本认证, 温馨提示 1,jar包要导入对 2,有匿名类编译要注意 3,欢迎提问,拿走不谢!背景知识 Https访问的相关知识中,主要分为单向验证和双向验证 ...

  3. java https tomcat 单双认证(含证书生成和代码实现) 原创转载请备注,谢谢O(∩_∩)O

    server: apache-tomcat-6.0.44 jdk1.7.0_79client: jdk1.7.0_79 jks是JAVA的keytools证书工具支持的证书私钥格式. pfx是微软支持 ...

  4. package-info.java https://www.intertech.com/Blog/whats-package-info-java-for/

    mybatis-3/src/main/java/org/apache/ibatis/cache/package-info.java What’s package-info.java for? http ...

  5. Java https认证的坑

    https单向认证的服务端证书不是权威机构颁发的,网上找了点代码不对https证书进行认证后,报如下异常 javax.net.ssl.SSLHandshakeException: Received f ...

  6. java Https工具类

    import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import ja ...

  7. java https post请求并忽略证书,参数放在body中

    1 新建java类,作用是绕过证书用 package cn.smartercampus.core.util; import java.security.cert.CertificateExceptio ...

  8. Java https ssl证书导入删除

    下载并命名 例如命名github.cer 放进jre的lib\security下 keytool -delete [OPTION]... 选项: -alias <alias> 要处理的条目 ...

  9. java https客户端请求

    String pathname = Test3.class.getResource("/client.jks").getFile(); System.out.println(pat ...

随机推荐

  1. 6:Partial Update 内部原理 和 乐观锁并发控制

    Partial Update 内部执行过程: 首先,ES文档是不可变的,它们只能被修改,不能被替换.Update Api 也不例外. Update API 简单使用与之前描述相同的 检索-修改-重建索 ...

  2. 【洛谷 P4254】 [JSOI2008]Blue Mary开公司(李超线段树)

    题目链接 其实这东西很好懂的..用来维护一次函数. 每个结点存一个值,表示x=这个区间的mid时值最大的函数的编号. 把插入线段的斜率和当前结点的斜率和大小比较来更新左右儿子的值. 查询是实际上是查询 ...

  3. 从零开始学虚拟DOM

    此文主要翻译自:Building a Simple Virtual DOM from Scratch,看原文的同学请直达! 此文是作者在一次现场编程演讲时现场所做的,有关演讲的相关资料我们也可以在原英 ...

  4. p6.BTC-挖矿难度

    挖矿就是不断调整nouce和header中其他可变字段,使得整个block header 的hash值小于等于target,target越小,挖矿难度越大. 出块时间设置为了10分钟,可以尽可能避免同 ...

  5. SQL Server 字段提取拼音首字母

    目前工作中遇到一个情况,需要将SQL Server中的一个字段提取拼音的首字母,字段由汉字.英文.数字以及“-”构成,百度了一堆,找到如下方法,记录一下,以备后用! 首先建立一个函数 --生成拼音首码 ...

  6. python 编码设置

    py 文件设置编码: # -*- coding: utf-8 -*- #coding=utf-8 两种方式任选一种即可 输出到浏览器设置编码: import io import sys sys.std ...

  7. 国际化(i18n) 各国语言缩写

    internationalization (国际化)简称:i18n,因为在i和n之间还有18个字符,localization(本地化 ),简称L10n. 一般用语言_地区的形式表示一种语言,如:zh_ ...

  8. 《团队名称》第九次团队作业:Beta冲刺与验收准备

    项目 内容 这个作业属于哪个课程 软件工程 这个作业的要求在哪里 实验十三 团队作业9:Beta冲刺与团队项目冲刺 团队名称 发际线总和我作队 作业学习目标 (1)掌握软件黑盒测试技术:(2)掌握软件 ...

  9. postgresql —— 数组类型

    创建数组 CREATE TABLE sal_emp ( name text, pay_by_quarter integer[] --还可以定义为integer[4]或integer ARRAY[4] ...

  10. Maven安装及其IDEA的配置

    相关内容网上很多,本文转载自csdn博主 击中我,https://blog.csdn.net/qq_36267611/article/details/85274885,内文略有修改. 一.下载安装前往 ...