http://geek.csdn.net/news/detail/188003
HTTPS協議原理分析
HTTPS協議需要解決的問題
HTTPS作為安全協議而誕生,那么就不得不面對以下兩大安全問題:
-
身份驗證
確保通信雙方身份的真實性。直白一些,A希望與B通信,A如何確認B的身份不是由C偽造的。
(由C偽造B的身份與A通信,稱為中間人攻擊)
-
通信加密
通信的機密性、完整性依賴於算法與密鑰,通信雙方是如何選擇算法與密鑰的。
能同時解決以上兩個問題,就能確保真實有效的通信雙方采取有效的算法與密鑰進行通信,便完成了協議安全的初衷。
在介紹HTTPS協議如何解決兩大安全問題前,我們首先了解幾個概念。
-
數字證書
數字證書是互聯網通信中標識雙方身份信息的數字文件,由CA簽發。
-
CA
CA(certification authority)是數字證書的簽發機構。作為權威機構,其審核申請者身份后簽發數字證書,這樣我們只需要校驗數字證書即可確定對方的真實身份。
-
HTTPS協議、SSL協議、TLS協議、握手協議的關系
HTTPS是Hypertext Transfer Protocol over Secure Socket Layer的縮寫,即HTTP over SSL,可理解為基於SSL的HTTP協議。HTTPS協議安全是由SSL協議(目前常用的,本文基於TLS 1.2進行分析)實現的。
SSL協議是一種記錄協議,擴展性良好,可以很方便的添加子協議,而握手協議便是SSL協議的一個子協議。
TLS協議是SSL協議的后續版本,本文中涉及的SSL協議默認是TLS協議1.2版本。
HTTPS協議的安全性由SSL協議實現,當前使用的TLS協議1.2版本包含了四個核心子協議:握手協議、密鑰配置切換協議、應用數據協議及報警協議。
解決身份驗證與通信加密的核心,便是握手協議,接下來着重介紹握手協議。
握手協議
握手協議的作用便是通信雙方進行身份確認、協商安全連接各參數(加密算法、密鑰等),確保雙方身份真實並且協商的算法與密鑰能夠保證通信安全。
對握手協議的介紹限於客戶端對服務端的身份驗證,單向身份驗證也是目前互聯網公司最常見的認證方式。
首先我們看一下協議交互,如圖1所示:

圖1 握手協議
接下來以Wireshark抓取接口的握手協議過程為例,針對每條協議消息分析。
ClientHello消息
ClientHello消息的作用是,將客戶端可用於建立加密通道的參數集合,一次性發送給服務端。
消息內容包括:期望協議版本(TLS 1.2)、可供采用的密碼套件(Cipher Suites)、客戶端隨機數(Random)及擴展字段內容(Extension)等信息,如圖2所示。

圖2 ClientHello
ServerHello消息
ServerHello消息的作用是,在ClientHello參數集合中選擇適合的參數,並將服務端用於建立加密通道的參數發送給客戶端。
消息內容包括:采取的協議版本(TLS 1.2)、采用的密碼套件(Cipher Suite)、服務端隨機數(Random)、用於恢復會話的會話ID(Session ID)及擴展字段等信息,如圖3所示。
自此客戶端與服務端的協議版本、密碼套件已經協商完畢。
這里服務端下發的會話ID可用於后續恢復會話。若客戶端在ClientHello中攜帶了會話ID,並且服務端認可,則雙方直接通過原主密鑰生成一套新的密鑰即可繼續通信。將兩個網絡往返降低為一個網絡往返,提高通道建立的效率。

圖3 ServerHello
Certificate消息
Certificate消息的作用是,將服務端證書的詳細信息發送給客戶端,供客戶端進行服務端身份校驗。
消息內容:服務端下發的證書鏈,如圖4所示。
服務端為了保證下發的證書能夠被客戶端正確識別,就需要將簽發此證書的CA證書一同下發,構成證書鏈,保證客戶端可以根據證書鏈的信息在系統配置中找到根證書,並通過根證書的公鑰逐層向下驗證證書的合法性。
如圖所示,五八服務器下發了兩個證書:自己的證書與簽發CA的證書。通過簽發CA的證書信息,能夠直接找到根證書。

圖4 Certificate
客戶端本地校驗服務端證書,若校驗通過,則客戶端對服務端的身份驗證便完成了。
Certificate這個階段解決了兩端的身份驗證問題。借助CA的力量,通過CA簽發證書,將身份驗證的工作交給了CA處理。
只要是我們認可的CA,簽發的證書我們均認可證書持有者的身份。由於CA的介入,解決了中間人攻擊的問題,因為中間人並沒有服務端的證書可供客戶端驗證。
ServerKeyExchange消息(可能不發送)
ServerKeyExchange消息的作用是,將需要服務端提供的密鑰交換的額外參數,傳給客戶端。有的算法不需要額外參數,則ServerKeyExchange消息可不發送。
消息內容:用於密鑰交換的額外參數,如圖5所示。

圖5 ServerKeyExchange
如圖5,服務端下發了“EC Diffile-Hellman”密鑰交換算法所需要的參數。
ServerHelloDone消息
ServerHelloDone消息的作用是,通知客戶端ServerHello階段的數據均已發送完畢,等待客戶端下一步消息。
ClientKeyExchange消息
ClientKeyExchange消息的作用是,將客戶端需要為密鑰交換提供的數據發送給服務端。
當我們選用RSA密鑰交換算法時,此消息的內容便是通過證書公鑰加密的用於生成主密鑰的預主密鑰。
如圖6所示,由於選用的密鑰交換算法是“EC Diffie-Hellman”,所以ClientKeyExchange消息發送的是”EC Diffie-Hellman”算法需要的客戶端參數。

圖6 ClientKeyExchange
當發送了ClientKeyExchange后,兩端均具有了生成主密鑰的完整密鑰數據與隨機數,兩端分別根據所選算法計算主密鑰即可。
至此,ClientKeyExchange發送后,兩端均可生成主密鑰,密鑰交換問題便解決了。
有的讀者可能對隨機數的采用有些疑惑,筆者覺得隨機數的加入是為了提高密鑰的隨機性。
由於客戶端直接生成的密鑰很有可能不夠隨機,而通過預主密鑰加上兩端提供的兩個隨機數做種子,創建的主密鑰可以保證更加貼近真實隨機的密鑰。
ChangeCipherSpec消息
經過以上六條消息,我們已經解決了身份認證問題、密碼套件選取問題、密鑰交換問題。雙方也已經通過主密鑰生成了實際使用的六個加解密密鑰。
ChangeCipherSpec消息的作用,便是聲明后續消息均采用密鑰加密。在此消息后,我們在WireShark上便看不到明文信息了。
Finished消息
Finished消息的作用,是對握手階段所有消息計算摘要,並發送給對方校驗,避免通信過程中被中間人所篡改。
HTTPS協議總結
自此,HTTPS如何保證通信安全,通過握手協議的介紹,我們已經有所了解。
但是,在全面使用HTTPS前,我們還需要考慮一個眾所周知的問題——HTTPS性能。
相對HTTP協議來說,HTTPS協議建立數據通道的更加耗時,若直接部署到App中,勢必降低數據傳遞的效率,間接影響用戶體驗。
接下來,介紹HTTPS性能救星——HTTP2協議。
協議新寵-HTTP2
協議介紹
隨着互聯網的快速發展,HTTP1.x協議得到了迅猛發展,但當App一個頁面包含了數十個請求時,HTTP1.x協議的局限性便暴露了出來:
- 每個請求與響應需要單獨建立鏈路進行請求(Connection字段能夠解決部分問題),浪費資源。
- 每個請求與響應都需要添加完整的頭信息,應用數據傳輸效率較低。
- 默認沒有進行加密,數據在傳輸過程中容易被監聽與篡改。
HTTP2正是為了解決HTTP1.x暴露出來的問題而誕生的。
說到HTTP2不得不提spdy。
由於HTTP1.x暴露出來的問題,Google設計了全新的名為spdy的新協議。spdy在五層協議棧的TCP層與HTTP層引入了一個新的邏輯層以提高效率。spdy是一個中間層,對TCP層與HTTP層有很好的兼容,不需要修改HTTP層即可改善應用數據傳輸速度。
spdy通過多路復用技術,使客戶端與服務器只需要保持一條鏈接即可並發多次數據交互,提高了通信效率。
而HTTP2便士基於spdy的思路開發的。
通過流與幀概念的引入,繼承了spdy的多路復用,並增加了一些實用特性。
HTTP2有什么特性呢?HTTP2的特性不僅解決了上述已暴露的問題,還有一些功能使HTTP協議更加好用。
- 多路復用
- 壓縮頭信息
- 對請求划分優先級
- 支持服務端Push消息到客戶端
此外,HTTP2目前在實際使用中,只用於HTTPS協議場景下,通過握手階段ClientHello與ServerHello的extension字段協商而來,所以目前HTTP2的使用場景,都是默認安全加密的。
下面介紹HTTP2協議協商以及多路復用與壓縮頭信息兩大特性,實現部分采用okhttp源碼(基於parent-3.4.2)進行分析與介紹。
okhttp是目前使用最廣泛的支持HTTP2的Android端開源網絡庫,以okhttp為例介紹HTTP2特性也可方便讀者提前了解okhttp,方便后續接入okhttp。
協議協商
HTTP2協議的協商是在握手階段進行的。
協商的方式是通過握手協議extension擴展字段進行擴展,新增Application Layer Protocol Negotiation字段進行協商。
在握手協議的ClientHello階段,客戶端將所支持的協議列表填入Application Layer Protocol Negotiation字段,供服務端進行挑選。如圖7所示:

圖7 ALPN1
服務端收到ClientHello消息后,在客戶端所支持的協議列表中選擇適當協議作為后續應用層協議。如圖8所示:

圖8 ALPN2
這樣,兩端便完成了HTTP2協議的協商。
在HTTP2未出現時,spdy也是通過擴展字段,擴展出next_protocol_negotiation字段,以NPN協議進行spdy的協商。不過由於NPN協議協商過於復雜,對https協議侵入性較強,在出現ALPN協商協議后,便逐漸被淘汰了。所以,本文協議協商並為對NPN協議協商做介紹。
協議特性之多路復用
http2為了優化http1.x對TCP性能的浪費,提出了多路復用的概念。
多路復用的含義
在HTTP2中,同一域名下的請求,可通過同一條TCP鏈路進行傳輸,使多個請求不必單獨建立鏈路,節省建立鏈路的開銷。
為了達到這個目的,HTTP2提出了流與幀的概念,流代表請求與響應,而請求與響應具體的數據則包裝為幀,對鏈路中傳輸的數據通過流ID與幀類型進行區分處理。圖9便是多路復用的抽象圖,每個塊代表一幀,而相同顏色的塊則代表是同一個流。

圖9 http2_stream
那么HTTP2的多路復用是如何實現的呢?
由於網絡請求的場景很多,我們選擇其中一個路徑來介紹:
- 客戶端與服務端在某個域名的TCP通道已建立
- 新創建的客戶端請求通過已連接的TCP通道進行請求發送與響應處理
多路復用實現
默認我們已經添加各參數創建了Request對象r,並通過Request對象創建了Call對象c。並在獨立線程中,調用c.execute()方法,進行同步請求操作。
okhttp調用execute方法后,實際上是由一系列的interceptor來負責執行的。
interceptor根據添加順序依此執行,其中我們關注的是RetryAndFollowUpInterceptor、ConnectInterceptor0、CallServerInterceptor。
1.在RetryAndFollowUpInterceptor中,okhttp為我們創建了一個StreamAllocation對象,StreamAllocation中含有基於url創建的Address對象。
Address類的url字段與Request類的url字段不同,Address類的url字段不包括path與query字段,只含有scheme與authority部分,這點在進行Connection復用的equal操作時起了很大作用。
2.在ConnectInterceptor中,StreamAllocation對象的Address與連接池中每個Connection對象的Address依次進行匹配,匹配成功並滿足一些條件的Connection便可復用。基於匹配出的Connection創建Http2xStream,用於后續讀寫操作。
與連接池中Address匹配主要通過Address的url,url由於只含有scheme與authority所以可用於域名的匹配,這便是okhttp基於域名層面多路復用的基礎。
實際上真正進行流讀寫操作的是FramedConnection與FramedStream,Connection與Http2xStream是抽象於具體操作的類,以方便上層使用。
3.在CallServerInterceptor中,Http2xStream創建FramedStream用於Request發送,並將FramedStream與對應的StreamID綁定緩存下來,以便Response到來時,能夠根據StreamID索引到對應的FramedSteam進行后續操作。
在FramedStream發送完Request后,執行readResponseHeaders方法時進行調用了wait,將當前線程掛起。
並在FramedConnection讀線程收到StreamID消息時,在緩存中查詢FramedStream並將對應線程喚醒進行Response解碼。
歸納下okhttp的多路復用實現思路:
- 通過請求的Address與連接池中現有連接Address依次匹配,選出可用的Connection。
- 通過Http2xStream創建的FramedStream在發送了請求后,將FramedStream對象與StreamID的映射關系緩存到FramedConnection中。
- 收到消息后,FramedConnection解析幀信息,在Map中通過解析的StreamID選出緩存的FramedStream,並喚醒FramedStream進行Response的處理。
在筆者看來,HTTP2便是一個良好兼容http協議格式的自定義協議,通過Stream將數據分發到各請求,通過Frame將請求數據詳細細分。
協議特性之壓縮頭信息
HTTP2為了解決HTTP1.x中頭信息過大導致效率低下的問題,提出的解決方案便是壓縮頭部信息。具體的壓縮方式,則引入了HPACK。
HPACK壓縮算法是專門為HTTP2頭部壓縮服務的。為了達到壓縮頭部信息的目的,HPACK將頭部字段緩存為索引,通過索引ID代表頭部字段。客戶端與服務端維護索引表,通信過程中盡可能采用索引進行通信,收到索引后查詢索引表,才能解析出真正的頭部信息。
HPACK索引表划分為動態索引表與靜態索引表,動態索引表是HTTP2協議通信過程中兩端動態維護的索引表,而靜態索引表是硬編碼進協議中的索引表。
作為分析HPACK壓縮頭信息的基礎,需要先介紹HPACK對索引以及頭部字符串的表示方式。
索引
索引以整型數字表示,由於HPACK需要考慮壓縮與編解碼問題,所以整型數字結構定義如圖10所示:

圖10 int_strut
-
類別標識
通過類別標識進行HPACK類別分類,指導后續編解碼操作,常見的有1,01,01000000等八個類別。
-
首字節低位整型
首字節排除類別標識的剩余位,用於表示低位整型。若數值大於剩余位所能表示的容量,則需要后續字節表示高位整型。
-
結束標識
表示此字節是否為整型解析終止字節。
-
高位整型
字節余下7bit,用於填充整型高位。
“結束標識+高位整型”字節可能有0個、也有可能有多個,依據數據大小而定。
譬如,若想表示類別為1,索引為2,則使用10000010即可,不需要額外字節增加高位整型。
頭部字符串
頭部字符串需要顯式聲明長度,所以數據首字節由“類型標識+數據長度”組成。如圖11所示:

圖11 string_strut
-
類型標識
是否選用哈夫曼編碼,1為選用,0為不選用,okhttp默認不選用哈夫曼編碼。
-
數據長度
標識數據長度,采用上面提到的整型表示法表示。
-
數據內容
二進制數據。
解碼實例
下面綜合okhttp源碼分析HPACK解碼頭部字段過程。
對編碼部分感興趣的讀者,可以查閱RFC 7541或直接分析OkHttp源碼。
當我們需要解碼頭部字段時,首先解析頭部字段首字節(HPACK頭部字段首字節分為8個類別,摘選其中3個類別說明),首字節用於指導當前頭部字段的解析規則:
-
1xxxxxxx
類別標識為1,代表收到一條K、V均為索引的頭部字段。
K、V值:通過解析HPACK整型獲取KV對的索引值,並根據索引值映射對應的頭部原字段即可,壓縮效率最高。
-
01xxxxxx
類別標識為01,代表收到一條K為索引、V為原字段,且需要加入動態索引表的頭部字段。
K值:通過解析HPACK整型獲取K值索引值,並通過索引值映射對應的頭部原字段。
V值:通過解析HPACK字符串獲取V值原字段。
獲取K、V值后還需插入動態索引表中。
-
01000000
01000000代表收到一條K、V均為原字段,且需要加入動態索引表的頭部字段。
K、V值:通過解析HPACK字符串獲取K、V原字段,並插入動態索引表中。
還有不加入動態索引表、調整索引表大小等類別,這里就不展開了,感興趣的可以看okhttp源碼實現。
okhttp解析頭信息的核心方法實現如下:
void readHeaders() throws IOException { while (!source.exhausted()) { int b = source.readByte() & 0xff; if (b == 0x80) { // 10000000 //類別標識為1,但索引為0 throw new IOException("index == 0"); } else if ((b & 0x80) == 0x80) { // 1NNNNNNN //類別為1,通過readIndexedHeader解析整型index。 int index = readInt(b, PREFIX_7_BITS); //通過index獲取完整頭部字段 readIndexedHeader(index - 1); } else if (b == 0x40) { // 01000000 //01000000代表KV均為原字段,解析字符串依次獲取K值、V值,並插入動態表中 readLiteralHeaderWithIncrementalIndexingNewName(); } else if ((b & 0x40) == 0x40) { // 01NNNNNN //01xxxxxx代表K值為索引,V值為原字符串,依次解析整型index與字符串,並插入動態表中 int index = readInt(b, PREFIX_6_BITS); readLiteralHeaderWithIncrementalIndexingIndexedName(index - 1); } else if ((b & 0x20) == 0x20) { // 001NNNNN //類別為001,含義是更新動態列表容量 maxDynamicTableByteCount = readInt(b, PREFIX_5_BITS); if (maxDynamicTableByteCount < 0 || maxDynamicTableByteCount > headerTableSizeSetting) { throw new IOException("Invalid dynamic table size update " + maxDynamicTableByteCount); } adjustDynamicTableByteCount(); } else if (b == 0x10 || b == 0) { // 000?0000 - Ignore never indexed bit. //這個類別代表KV均為原字符串,依次解析字符串,並不對解析后的KV值插入動態表。 readLiteralHeaderWithoutIndexingNewName(); } else { // 000?NNNN - Ignore never indexed bit. //與上一類別類似,但K值為索引,V值為原字符串 int index = readInt(b, PREFIX_4_BITS); readLiteralHeaderWithoutIndexingIndexedName(index - 1); } } }
壓縮效果
K值為“accept-encoding”、V值為“gzip, deflate”的頭部字段在HTTP2中可通過索引值15代替,從而達到頭部字段壓縮的效果。
“accept-charset”頭部字段則通過14代表頭部K值,而Value值根據HPACK規則編碼寫入流中。
通過HPACK,一個頭部字段變化較少的App,每個頭部字段將會縮減至4字節以內,壓縮效果非常明顯。