Session Cache是一個協議中規定的可選特性,也就是伺服器並不是一定要支持Session Cache的,所以客戶端不能認為我建立過一次握手,第二次就一定會被加速,雖然大部分實現仍然默認啟用了Session Cache。

由於一次TLS握手的結果是建立了一條對稱加密的數據通道,這條數據通道相關的參數都是可以在內存中保存的,所以服務端就可以針對這一套參數值生成一個ID,叫做Session ID,這個ID對應的就是這套對稱加密的密碼學的參數,使用這套參數就可以直接復原對稱加密的通信通道。所以當客戶端下一次請求(Client Hello)到達的時候,客戶端如果攜帶了Session ID,服務端就可以根據這個Session ID找到對應的密碼學上下文從而復原信道了。

OpenSSL的Session Cache的設計是基於OpenSSL的上下文模型。由於一個ssl_ctx_st代表的上下文結構體和一個ssl_st代表的SSL連接結構體構成。每一個SSL連接都是以ssl_ctx_st為母板進行生成的。也就是說當我們在服務端配置了OpenSSL的一系列特性之後,實際上是生成了若干個ssl_ctx_st實例,配置的內容都是實際的發生再ssl_ctx_st實例上的。當客戶端來了連接之後,ssl_st結構體被從配置好的ssl_ctx_st結構體位母板生成。也正是由於這種設計的架構,Session Cache就自然而然的融合到這個框架之中。ssl_ctx_st結構體中就包含了Session Cache的鏈表。雖然組織上是一個鏈表,但是實際的查詢是一個哈希演算法(理應如此),在ssl_st結構體創建的時候(也就是應用調用了OpenSSL的SSL_new的API的時候),一個ssl_st會首先創建,緊接著就是處理用戶發來的Client Hello消息,這個消息中就包含了Session ID,也就是OpenSSL檢索緩存的Session Cache的時機。

OpenSSL的Session Cache由於只能實現再OpenSSL的庫裡面,所以他實際上是一個單機的版本,當Nginx這種多進程的程序要使用OpenSSL的時候,Nginx就遇到一個尷尬的問題。就是OpenSSL的Session Cache是不能夠跨進程共享的,而Nginx是一個多進程的業務模型,如果每一個進程一份獨立的Session Cache,Nginx沒有辦法在進程的維度做一致性哈希,所以就會導致同樣一個SSL連接,上一次被一個Worker進程服務,下一次是被另外一個Worker進程服務的。使得整個OpenSSL在內存上造成極大的浪費,並且使得Cache的準確性大幅度下滑。所以Nginx就另外又實現了一套自己的Session Cache系統,這套系統是跨進程的,使用的是共享內存,用紅黑樹的方式組織的Session Cache,並且對Session ID的定義也重新進行了封裝,最終形成了一個跨進程了Session Cache。但是互聯網在使用Nginx的時候一般都是以集羣的形態出現的,這就提出了一個新的要求,也就是跨機器的Session Cache共享。Twitter,阿里等技術領域相對領先的企業一般也是使用的Redis來做的Session Cache,通過Redis這一強大的分散式內存系統修改Nginx的代碼來實現的Session Cache的共享。

我們也能看到Sessoin Cache原生的問題。就是Session Cache需要消耗大量的伺服器存儲資源。對稱加密的上下文並不是一個非常輕量級的內容,當數目很大的時候,內存資源的消耗就會很可觀。另外由伺服器管理這個上下文也帶來了極大的管理成本。另外一個思路就是將這件事情交給客戶端。因為客戶端和服務端對已經建立的加密信道擁有相同的知識,所以客戶端完全可以做到存儲這個上下文,但是這也不能使得服務端完全的從這個事物中解脫,因為服務端如果偽造一個上下文,就失去了認證的作用了。所以客戶端存儲的對稱加密的上下文是要用伺服器的私有密鑰(Session Ticket Encryption Key (STEK))加密過的,也就是經過伺服器傳輸給客戶端的。這樣伺服器在收到一個Session Ticket的時候,自己能夠正常的解密就能證明確實是自己頒發的,從而杜絕了偽造。

由於Session Ticket的客戶端存儲的特性,使得伺服器完全就不需要管Session Cache方案需要面對的資源管理問題,唯一的代價就是一個解密計算的開銷。同時,天然的支持了分散式,但是前提是分散式的所有節點都需要使用同樣的STEK,這無疑嚴重增加了危險係數。所以很多公司在實現的時候都會隔一段時間統一更換STEK。

當需要用自己實現的Session Cache取代OpenSSL實現的版本的時候,有三個函數需要覆蓋,好在OpenSSL已經提供了覆蓋的方法。覆蓋這三個函數的方法是:

SSL_CTX_sess_set_new_cb(ssl->ctx, ngx_ssl_new_session);

SSL_CTX_sess_set_get_cb(ssl->ctx, ngx_ssl_get_cached_session);

SSL_CTX_sess_set_remove_cb(ssl->ctx, ngx_ssl_remove_session);

這是Nginx的邏輯。一個是在一個新的session生成的時候的回調函數,一個是在獲得session的回調函數,最後一個是刪除的回調函數。這樣如果從OpenSSL的配置層面將OpenSSL自己的Session Cache關閉,就可以用Nginx自己定義的三個函數來替代整個Session Cache的功能。並且使用這種方式還可以做到可以不關閉OpenSSL的Session Cache功能,也就是同時啟用Nginx的Session Cache和OpenSSL的Session Cache。因為OpenSSL這裡並不是提供的取代函數,而是提供的回調函數。兩者的本質區別是取代函數是非我即你,而回調函數是我一定可以執行,執行完了才會通知你支持。是否關閉OpenSSL的Session Cache可以用SSL_CTX_set_session_cache_mode函數來控制。

OpenSSL中的Session Cache管理是通過動態內存分配來在OpenSSL內部管理的。由於協議的變遷,Session ID的ID長度也是不固定的,老版本和新版本的TLS協議在Session ID的長度上是有區別的。OpenSSL內部的Session Cache一般不會在企業級應用中使用(Haproxy和Nginx都是自己重新進行了實現),所以我們可以看一下Nginx的實現。

由於Nginx是個多進程模型,所以在啟動的時候創建了一個共享內存塊,後續的Session Cache是跨進程的,每個進程的Session Cache都會放到這個共享內存塊裏。Nginx中參考內核實現了一個Slab內存分配器。Slab專用於分配大小相同的內存塊,所以用來做Session Cache的內存分配是最優的選擇。所有的Session Cache被組織成一顆紅黑樹。雖然不使用OpenSSL本身的Session Cache系統,但是session結構體的產生仍然是要在OpenSSL中的,Nginx在創建Session的時候,會使用i2d_SSL_SESSION從OpenSSL的上下文中獲得整個的session結構體,然後再在Nginx的層面進行處理。

Nginx往這個紅黑樹寫入的時候都是需要加鎖的,Nginx維護了一個過期刪除的Session Cache列表,換句話說,Nginx實現的Session Cache的老化方案非常簡單,就是簡單的超時機制(OpenSSL也是如此)。進入和查找紅黑樹的關鍵是其使用的鍵值,這個鍵值在Nginx的層面是對Session ID的哈希計算的結果。Nginx使用SSL_SESSION_get_id函數獲得到Session ID的內容,然後使用CRC哈希計算Session ID得到一個哈希結果,這個哈希結果就是紅黑樹的排序的鍵。

一個重要的問題是Session ID的值到底是怎麼決定的,看起來Nginx直接從OpenSSL中獲得,但是實際上,Nginx預先有先設置了OpenSSL的上下文如何生成這個Session ID。這個函數是ngx_ssl_session_id_context,裡面通過SSL_CTX_set_session_id_context來設置OpenSSL生成Session ID的模版。Nginx的做法是通過對證書和客戶端支持的CA列表來進行哈希運算得到的。因為這個SessionID會明文發送給客戶端,所以這個SessionID裡面本身就是可以包含信息的。還有一個就是如何生成Session ID本身的演算法,OpenSSL中默認提供了def_generate_session_id函數進行默認的Session ID的生成函數,用戶也可以調用SSL_CTX_set_generate_session_id函數設置自己的Session ID生成函數。Nginx這裡是沒有提供專門的生成函數的,直接使用的默認的。

Nginx在實現這個Session老化的時候的方式非常的優秀。因為所有的Session都是按序加入Session Cache的,雖然加入的是一個紅黑樹,本身是看不出加入順序的,但是Nginx在實現的時候在將Session加入紅黑樹的同時加入了一個老化隊列。這樣這個順序的隊列就代表了一個時間的概念。每次在插入的時候,只需要檢查一下最老的幾個有沒有超時即可。如果超時就在插入的時候就老化掉。這樣帶來的損耗非常小,並且不需要遍歷。也就是說使用了隊列來表達了時間順序,這也是單純的依賴時間進行老化的好處。

Nginx在初始化的時候,由於Session Cache可以選擇Nginx的實現,也可以選擇OpenSSL的實現,甚至可以同時使用,所以對配置的解析並且設置這個OpenSSL的行為就是必須的。這個函數在Nginx中是ngx_ssl_session_cache。SSL_CTX_set_timeout可以設置一個Session的超時時間,ngx_ssl_session_id_context函數初始化OpenSSL的Session上下文(這一步是使OpenSSL產生SessionID必須的),SSL_CTX_set_session_cache_mode設置OpenSSL內部的Session Cache是否打開。無論設置是否打開,只要配置了Session ID的上下文,OpenSSL都是會產生這個Session的,區別在於是否緩存。SSL_CTX_sess_set_cache_size可以設置內部的緩存大小,如果關閉OpenSSL的內部緩存,Nginx的做法是直接設置了1。

實際上OpenSSL內部支持多線程的調用,也就是說如果用戶端是線程池的業務模型,本質上可以直接使用OpenSSL內部的Session Cache,這樣用戶端就不用自己重新做一個Session Cache了,這也是線程池模型相比Nginx的多進程的一個優勢。

Session Ticket是另外一個有雄心取代Session Cache的機制,它的最大優勢是給服務端減輕壓力,把一部分計算放在了客戶端。因為服務端會解壓Session Ticket,從而復原出對稱加密的上下文,所以對於服務端來說這個開銷也是很小。由於其天然的不需要在服務端存儲內容,所以服務端當要組成集羣的時候,只需要定期換一下Session Ticket用來加密的key即可。不像Session Cache那樣需要自己做一個分散式的緩存。

在TLS1.3中,Session Cache和Session Ticket都被完全的取消,取而代之的是PSK(Pre Shared Key)。這個PSK並不是說每個客戶端都要和服務端提前共享一個密鑰,而是與握手相同的首先使用非對稱加密方法直接提前協商一個密鑰出來(psk_dhe_ke ),或者直接從之前協商出來的密鑰參數中得出一個密鑰(psk_ke)。


推薦閱讀:
相關文章