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所示:
接下來以Wireshark抓取接口的握手協議過程為例,針對每條協議消息分析。
ClientHello消息
ClientHello消息的作用是,將客戶端可用于建立加密通道的參數集合,一次性發送給服務端。
消息內容包括:期望協議版本(TLS 1.2)、可供采用的密碼套件(Cipher Suites)、客戶端隨機數(Random)及擴展字段內容(Extension)等信息,如圖2所示。
ServerHello消息
ServerHello消息的作用是,在ClientHello參數集合中選擇適合的參數,并將服務端用于建立加密通道的參數發送給客戶端。
消息內容包括:采取的協議版本(TLS 1.2)、采用的密碼套件(Cipher Suite)、服務端隨機數(Random)、用于恢復會話的會話ID(Session ID)及擴展字段等信息,如圖3所示。
自此客戶端與服務端的協議版本、密碼套件已經協商完畢。
這里服務端下發的會話ID可用于后續恢復會話。若客戶端在ClientHello中攜帶了會話ID,并且服務端認可,則雙方直接通過原主密鑰生成一套新的密鑰即可繼續通信。將兩個網絡往返降低為一個網絡往返,提高通道建立的效率。
Certificate消息
Certificate消息的作用是,將服務端證書的詳細信息發送給客戶端,供客戶端進行服務端身份校驗。
消息內容:服務端下發的證書鏈,如圖4所示。
服務端為了保證下發的證書能夠被客戶端正確識別,就需要將簽發此證書的CA證書一同下發,構成證書鏈,保證客戶端可以根據證書鏈的信息在系統配置中找到根證書,并通過根證書的公鑰逐層向下驗證證書的合法性。
如圖所示,五八服務器下發了兩個證書:自己的證書與簽發CA的證書。通過簽發CA的證書信息,能夠直接找到根證書。
客戶端本地校驗服務端證書,若校驗通過,則客戶端對服務端的身份驗證便完成了。
Certificate這個階段解決了兩端的身份驗證問題。借助CA的力量,通過CA簽發證書,將身份驗證的工作交給了CA處理。
只要是我們認可的CA,簽發的證書我們均認可證書持有者的身份。由于CA的介入,解決了中間人攻擊的問題,因為中間人并沒有服務端的證書可供客戶端驗證。
ServerKeyExchange消息(可能不發送)
ServerKeyExchange消息的作用是,將需要服務端提供的密鑰交換的額外參數,傳給客戶端。有的算法不需要額外參數,則ServerKeyExchange消息可不發送。
消息內容:用于密鑰交換的額外參數,如圖5所示。
如圖5,服務端下發了“EC Diffile-Hellman”密鑰交換算法所需要的參數。
ServerHelloDone消息
ServerHelloDone消息的作用是,通知客戶端ServerHello階段的數據均已發送完畢,等待客戶端下一步消息。
ClientKeyExchange消息
ClientKeyExchange消息的作用是,將客戶端需要為密鑰交換提供的數據發送給服務端。
當我們選用RSA密鑰交換算法時,此消息的內容便是通過證書公鑰加密的用于生成主密鑰的預主密鑰。
如圖6所示,由于選用的密鑰交換算法是“EC Diffie-Hellman”,所以ClientKeyExchange消息發送的是”EC Diffie-Hellman”算法需要的客戶端參數。
當發送了ClientKeyExchange后,兩端均具有了生成主密鑰的完整密鑰數據與隨機數,兩端分別根據所選算法計算主密鑰即可。
至此,ClientKeyExchange發送后,兩端均可生成主密鑰,密鑰交換問題便解決了。
有的讀者可能對隨機數的采用有些疑惑,筆者覺得隨機數的加入是為了提高密鑰的隨機性。
由于客戶端直接生成的密鑰很有可能不夠隨機,而通過預主密鑰加上兩端提供的兩個隨機數做種子,創建的主密鑰可以保證更加貼近真實隨機的密鑰。
ChangeCipherSpec消息
經過以上六條消息,我們已經解決了身份認證問題、密碼套件選取問題、密鑰交換問題。雙方也已經通過主密鑰生成了實際使用的六個加解密密鑰。
ChangeCipherSpec消息的作用,便是聲明后續消息均采用密鑰加密。在此消息后,我們在WireShark上便看不到明文信息了。
Finished消息
Finished消息的作用,是對握手階段所有消息計算摘要,并發送給對方校驗,避免通信過程中被中間人所篡改。
自此,HTTPS如何保證通信安全,通過握手協議的介紹,我們已經有所了解。
但是,在全面使用HTTPS前,我們還需要考慮一個眾所周知的問題——HTTPS性能。
相對HTTP協議來說,HTTPS協議建立數據通道的更加耗時,若直接部署到App中,勢必降低數據傳遞的效率,間接影響用戶體驗。
接下來,介紹HTTPS性能救星——HTTP2協議。
隨著互聯網的快速發展,HTTP1.x協議得到了迅猛發展,但當App一個頁面包含了數十個請求時,HTTP1.x協議的局限性便暴露了出來:
HTTP2正是為了解決HTTP1.x暴露出來的問題而誕生的。
說到HTTP2不得不提spdy。
由于HTTP1.x暴露出來的問題,Google設計了全新的名為spdy的新協議。spdy在五層協議棧的TCP層與HTTP層引入了一個新的邏輯層以提高效率。spdy是一個中間層,對TCP層與HTTP層有很好的兼容,不需要修改HTTP層即可改善應用數據傳輸速度。
spdy通過多路復用技術,使客戶端與服務器只需要保持一條鏈接即可并發多次數據交互,提高了通信效率。
而HTTP2便士基于spdy的思路開發的。
通過流與幀概念的引入,繼承了spdy的多路復用,并增加了一些實用特性。
HTTP2有什么特性呢?HTTP2的特性不僅解決了上述已暴露的問題,還有一些功能使HTTP協議更加好用。
此外,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所示:
服務端收到ClientHello消息后,在客戶端所支持的協議列表中選擇適當協議作為后續應用層協議。如圖8所示:
這樣,兩端便完成了HTTP2協議的協商。
在HTTP2未出現時,spdy也是通過擴展字段,擴展出next_protocol_negotiation字段,以NPN協議進行spdy的協商。不過由于NPN協議協商過于復雜,對https協議侵入性較強,在出現ALPN協商協議后,便逐漸被淘汰了。所以,本文協議協商并為對NPN協議協商做介紹。
http2為了優化http1.x對TCP性能的浪費,提出了多路復用的概念。
多路復用的含義
在HTTP2中,同一域名下的請求,可通過同一條TCP鏈路進行傳輸,使多個請求不必單獨建立鏈路,節省建立鏈路的開銷。
為了達到這個目的,HTTP2提出了流與幀的概念,流代表請求與響應,而請求與響應具體的數據則包裝為幀,對鏈路中傳輸的數據通過流ID與幀類型進行區分處理。圖9便是多路復用的抽象圖,每個塊代表一幀,而相同顏色的塊則代表是同一個流。
那么HTTP2的多路復用是如何實現的呢?
由于網絡請求的場景很多,我們選擇其中一個路徑來介紹:
默認我們已經添加各參數創建了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的多路復用實現思路:
在筆者看來,HTTP2便是一個良好兼容http協議格式的自定義協議,通過Stream將數據分發到各請求,通過Frame將請求數據詳細細分。
HTTP2為了解決HTTP1.x中頭信息過大導致效率低下的問題,提出的解決方案便是壓縮頭部信息。具體的壓縮方式,則引入了HPACK。
HPACK壓縮算法是專門為HTTP2頭部壓縮服務的。為了達到壓縮頭部信息的目的,HPACK將頭部字段緩存為索引,通過索引ID代表頭部字段。客戶端與服務端維護索引表,通信過程中盡可能采用索引進行通信,收到索引后查詢索引表,才能解析出真正的頭部信息。
HPACK索引表劃分為動態索引表與靜態索引表,動態索引表是HTTP2協議通信過程中兩端動態維護的索引表,而靜態索引表是硬編碼進協議中的索引表。
作為分析HPACK壓縮頭信息的基礎,需要先介紹HPACK對索引以及頭部字符串的表示方式。
索引
索引以整型數字表示,由于HPACK需要考慮壓縮與編解碼問題,所以整型數字結構定義如圖10所示:
類別標識
通過類別標識進行HPACK類別分類,指導后續編解碼操作,常見的有1,01,01000000等八個類別。
首字節低位整型
首字節排除類別標識的剩余位,用于表示低位整型。若數值大于剩余位所能表示的容量,則需要后續字節表示高位整型。
結束標識
表示此字節是否為整型解析終止字節。
高位整型
字節余下7bit,用于填充整型高位。
“結束標識+高位整型”字節可能有0個、也有可能有多個,依據數據大小而定。
譬如,若想表示類別為1,索引為2,則使用10000010即可,不需要額外字節增加高位整型。
頭部字符串需要顯式聲明長度,所以數據首字節由“類型標識+數據長度”組成。如圖11所示:
類型標識
是否選用哈夫曼編碼,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字節以內,壓縮效果非常明顯。
本站文章版權歸原作者及原出處所有 。內容為作者個人觀點, 并不代表本站贊同其觀點和對其真實性負責,本站只提供參考并不構成任何投資及應用建議。本站是一個個人學習交流的平臺,網站上部分文章為轉載,并不用于任何商業目的,我們已經盡可能的對作者和來源進行了通告,但是能力有限或疏忽,造成漏登,請及時聯系我們,我們將根據著作權人的要求,立即更正或者刪除有關內容。本站擁有對此聲明的最終解釋權。