java https


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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM