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
ClientExecChain
執行鏈,HttpClient使用了責任鏈模式,請求通過一系列的執行器最終得到結果,每一個執行器都有自己的職責。下面是默認情況下執行鏈的順序:
- RedirectExec:負責處理請求重定向
- RetryExec:負責判斷一個由於I/O異常失敗的請求是否應該重試
- ProtocolExec:負責處理Http協議,內部使用
HttpProcessor
來構建必要的Http請求頭,並處理Http響應頭,更新Session狀態到HttpClientContext
中 - MainClientExec:執行鏈的最后一環,負責執行request獲取response,使用
HttpRequestExecutor
發送請求
HttpClientConnectionManager
連接管理器,默認使用的實現時PoolingHttpClientConnectionManager
,它創建時注冊了支持不同協議的Socket創建工廠,其中https協議對應的是SSLConnectionSocketFactory
PoolingHttpClientConnectionManager poolingmgr = newPoolingHttpClientConnectionManager( RegistryBuilder.<ConnectionSocketFactory>create() .register("http", PlainConnectionSocketFactory.getSocketFactory()) .register("https", sslSocketFactory) .build());HttpRoutePlanner
默認實現是DefaultRoutePlanner
,它決定一個request的HttpRoute
路由信息,包含域名,端口,協議。它的實現必須是線程安全的。
執行Https請求
HttpGet httpget = new HttpGet("https://www.kaola.com");
CloseableHttpResponse response1 = httpclient.execute(httpget);
直接來看執行鏈的最后一環,這里只說明一些關鍵代碼:
- 代碼入口
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);
//...
}
//釋放回連接池
}//...異常處理
}
- 建立連接
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;
}//處理異常
}
- 建立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;
}
}
}
- 發送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;
}
}
}
- 接收服務端數據
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消息,例如RSAClientKeyExchange
,DHClientKeyExchange
,ECDHClientKeyExchange
對應圖1 (5).
發送ClientKeyExchange后,緊接着sendChangeCipherAndFinish
方法會發送Change Cipher Spec消息和Finished消息,對應圖1 (6) (7).
之后接收服務端的Change Cipher Spec 消息 圖1 (8),完成密鑰切換后,等待Finished消息 圖1 (9),如果此時不是恢復會話過程,會見session存入緩存中
至此成功建立連接,由於篇幅有限,每一步具體的收發消息就不細述,跟着上述分析可以清楚的找到所有入口。
3.4 復用會話的握手過程
復用會話的流程包含在是在上述流程中,只是部分節點會判斷是否存在session,是否是恢復會話過程 進行不同的動作
- 攜帶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());
- 攜帶Sessin信息的Server Hello 圖2 (2)
在sun.security.ssl.ClientHandshaker#serverHello
方法中會判斷客戶端sessionId與服務端傳來的是否一致
if(this.session.getSessionId().equals(severHello.sessionId)) {
//從session中恢復信息
}
- 接收Change Cipher Spec消息 圖2 (3)
在sun.security.ssl.SSLSocketImpl#changeReadCiphers
方法中處理
- 接收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