quic協議分析
QUIC是由谷歌設計的一種基於UDP的傳輸層網絡協議,並且已經成為IETF草案。HTTP/3就是基於QUIC協議的。QUIC只是一個協議,可以通過多種方法來實現,目前常見的實現有Google的quiche,微軟的msquic,mozilla的neqo,以及基於go語言的quic-go等。
由於go語言的簡潔性以及編譯的便捷性,本文將選用quic-go進行quic協議的分析,該庫是完全基於go語言實現,可以用於構建客戶端或服務端。
源碼編譯與測試
下載
- 從https://golang.org/dl/下載golang編譯器,要求go版本為1.14+。
- 使用
git clone https://github.com/lucas-clemente/quic-go.git下載庫
編譯
服務端
cd example
go build main.go
之后使用./main -qlog -v -tcp運行即可。
必須帶上-tcp參數是因為瀏覽器第一次訪問時仍然是要通過TCP進行的,如果不帶瀏覽器將無法訪問。
客戶端
先修改example/client/main.go,在60行之后加上qconf.Versions = []protocol.VersionNumber{protocol.VersionDraft29},選擇quic版本為draft-29。
cd example/client
go build main.go
之后使用./main -v -insecure -keylog ssl.log https://quic.rocks:4433/即可訪問支持quic協議的網站。
服務端測試
瀏覽器訪問
在firefox中打開about:config,搜索HTTP3,將值設為True以打開HTTP3的實驗特性。
打開https://localhost:6121/demo/tile網頁,通過調試工具查看請求,當第一次請求該網頁時,會通過TCP協議進行:

而在響應頭中會帶上Alt-Svc,以告訴瀏覽器該服務器支持HTTP3協議:

之后刷新頁面,瀏覽器就會以HTTP3協議來訪問:

抓包
使用wireshark對loopback進行抓包,過濾器設置為udp.port==6121,此時wireshark只顯示為UDP協議,並未解析為quic,需要右鍵Decode As解析為quic。


可以看到,第一個包的類型為Initial,進行了0-RTT的初始化。
問題解決
當訪問時,服務器可能會報錯Client offered version draft-29, sending Version Negotiation,這是因為當使用-tcp選項后,將使用默認設置,而在默認設置中未開啟draft-29版本的支持,因此需要修改源碼,將internal/protocol/version.go:30中的var SupportedVersions = []VersionNumber{VersionTLS}修改為var SupportedVersions = []VersionNumber{VersionTLS, VersionDraft29}即可。
客戶端測試
使用./main -v -insecure -keylog key.log https://quic.rocks:4433/訪問測試網站,可以看見最后成功輸出了網頁的內容 “You have successfully loaded quic.rocks using QUIC!”,使用的協議為HTTP/3,並且錯誤代碼為0x100,即未發生錯誤。

抓包
在wireshark中的首選項-protocol-tls-(pre)-master-secret log filename設置為上面輸出的key.log文件,用來對quic的payload進行解密,之后可以看到客戶端的完整的請求過程,包括1-RTT的握手,HTTP3數據發送,斷開連接等:

協議分析
數據包
quic的數據包是通過UDP數據報進行傳輸的,一個數據報中可以包含一個或多個quic數據包。quic數據包編號被分為三個空間:
- Initial:所有初始包
- Handshake:所有握手包
- Application data:所有 0-RTT 和 1-RTT 加密的數據包
從上圖的抓包中可以看見三種類型的包:Initial,Handshake以及Protected payload即Application data。
首部
quic首部分為兩種:Long header 和 Short Header,通過第一個有效字節的最高位來區分。首部當中有部分字段是於版本有關的,本文將以quic-29為基礎進行分析。
Long header的定義如下:
Long Header Packet {
Header Form (1) = 1,
Fixed Bit (1) = 1,
Long Packet Type (2),
Type-Specific Bits (4),
Version (32),
Destination Connection ID Length (8),
Destination Connection ID (0..160),
Source Connection ID Length (8),
Source Connection ID (0..160),
}
Long Header Packets的類型包括四種:Initial,0-RTT,Handshake,Retry。
Short Header的定義如下:
Short Header Packet {
Header Form (1) = 0,
Fixed Bit (1) = 1,
Spin Bit (1),
Reserved Bits (2),
Key Phase (1),
Packet Number Length (2),
Destination Connection ID (0..160),
Packet Number (8..32),
Packet Payload (..),
}
在版本協商以及1-RTT密鑰傳輸完成后,quic就會使用Short Header Packet來傳輸數據。
連接遷移 Connection Migration
quic通過在首部攜帶Connection ID來保證在底層協議(UPD、IP等)尋址發生變化時也能夠將數據包分發到正確的端點上。在TCP協議中,是通過四元組(源 IP,源端口,目的 IP,目的端口)來標識連接的,而當網絡發生切換時,IP就會發生變化,使得連接需要重新建立,浪費大量時間;而quic通過Connection ID來對連接進行標識,只要ID不變,這條連接就可以保持,這就給quic協議帶來了連接遷移的特性。
握手
quic加密握手提供以下屬性:
- 認證密鑰交換,其中
- 服務端總是經過身份驗證
- 客戶端可以選擇性進行身份驗證
- 每個連接都會產生不同並且不相關的密鑰
- 密鑰材料(keying material)可用於 0-RTT 和 1-RTT 數據包的保護
- 兩個端點(both endpoints)傳輸參數的認證值,以及服務端傳輸參數的保密保護
- 應用協議的認證協商(TLS 使用 ALPN)
1-rtt的握手流程如下所示:
Client Server
Initial[0]: CRYPTO[CH] ->
Initial[0]: CRYPTO[SH] ACK[0]
Handshake[0]: CRYPTO[EE, CERT, CV, FIN]
<- 1-RTT[0]: STREAM[1, "..."]
Initial[1]: ACK[0]
Handshake[0]: CRYPTO[FIN], ACK[0]
1-RTT[0]: STREAM[0, "..."], ACK[0] ->
Handshake[1]: ACK[0]
<- 1-RTT[1]: HANDSHAKE_DONE, STREAM[3, "..."], ACK[0]
0-rtt的握手流程如下所示:
Client Server
Initial[0]: CRYPTO[CH]
0-RTT[0]: STREAM[0, "..."] ->
Initial[0]: CRYPTO[SH] ACK[0]
Handshake[0] CRYPTO[EE, FIN]
<- 1-RTT[0]: STREAM[1, "..."] ACK[0]
Initial[1]: ACK[0]
Handshake[0]: CRYPTO[FIN], ACK[0]
1-RTT[1]: STREAM[0, "..."] ACK[0] ->
Handshake[1]: ACK[0]
<- 1-RTT[1]: HANDSHAKE_DONE, STREAM[3, "..."], ACK[1]
源碼分析
在example的client代碼中,通過http3.RoundTripper建立了一個中間件,之后將roundTripper傳遞給http.Client建立了一個http客戶端,並以此來發起http請求。
roundTripper := &http3.RoundTripper{
TLSClientConfig: &tls.Config{
RootCAs: pool,
InsecureSkipVerify: *insecure,
KeyLogWriter: keyLog,
},
QuicConfig: &qconf,
}
defer roundTripper.Close()
hclient := &http.Client{
Transport: roundTripper,
}
rsp, err := hclient.Get(addr)
http3.RoundTripper實現了net.RoundTripper接口,使http客戶端將發起請求的過程交由該中間件來處理。該接口定義如下,只有一個函數RoundTrip接受一個http請求,返回http響應。
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
在http3.RoundTripper的實現中,將請求又交給了RoundTripOpt函數來處理。該函數中首先判斷請求是否合法,如果不合法就關閉請求,合法就會通過cl, err := r.getClient(hostname, opt.OnlyCachedConn)來獲取quic客戶端。
而在getClient函數中,通過hash表來獲取quic client,如果不存在就會通過newClient函數建立新client。
當獲取到client之后,就會通過client.RoundTrip函數發起請求。
而在client.RoundTrip中,在發起請求之前,會調用authorityAddr來確保源地址不是偽造的。當第一次發送請求時會調用dial函數進行握手,如果使用0rtt請求,就立即發送請求,否在當握手完成后通過doRequest發出請求。
QUIC請求流程分析
時序圖
整個過程的時序圖如下所示,忽略了部分ACK幀:

可以看出在1-RTT時,就開始了數據的傳輸,在2RTT時數據傳輸完成並准備關閉連接。這也就是QUIC協議快於TCP協議的一個主要原因。
數據包的發送
握手的函數調用棧為dial -> dialAddr -> DialAddrEarly -> DialAddrEarlyContext -> dialAddrContext -> dialContext -> newClient -> client.dial -> newClientSession -> session.run -> RunHandshake -> conn.Handshake -> clientHandshake。最終在Conn.clientHandshake函數中完成了握手的設置,之后通過clientHandshakeState.handshark函數完成了發送等工作。
在newClient函數中,通過generateConnectionID和generateConnectionIDForInitial對srcConnID和destConnID進行了生成。
在handshark函數中,調用establishKeys函數,完成了密鑰的生成,之后調用sendFinished函數,將Client Hello幀寫入到TLS Record層,完成握手包的發送。
數據包的接收
在session.run中的runloop中,通過select對接收通道進行監聽,當收到數據包時,就會調用handlePacketImpl -> handleSinglePacket -> handleUnpackedPacket函數進行處理。
在handleUnpackedPacket函數中,如果是第一個包,就會讀取其SrcConnectionID,將其設置為該連接的destination connection ID;之后對包中的幀依次進行讀取,並使用parseFrame函數進行判斷,並調用對應函數進行解析,最后調用handleFrame函數中調用相關函數進行處理。
在握手過程中,接收的第一個Initial包為合並包(coalesced packet),其第一個幀為ACK幀,通過parseAckFrame進行解析,使用handleAckFrame函數進行處理;第二個幀為Crypto幀,消息為Server Hello,通過parseCryptoFrame函數解析,handleCryptoFrame函數進行處理,該函數會通過session.cryptoStreamManager對密鑰信息進行處理。之后第二個Handshake包中只有一個Crypto幀,消息類型為Encrypted Extensions。第三個quic包中包含了一個Stream幀,stream id為3,這個幀會通過handleStreamFrameImpl進行處理,在該函數中會將數據push到frameQueue隊列中去,之后通過signalRead函數來通知數據包的到達。該幀的內容為HTTP3的SETTINGS幀。
連接建立及HTTP3數據傳輸
在第二個RTT中,client先通過Initial包發送ACK幀對收到的包進行確認,之后再通過Handshake包發送了CRYPTO幀和ACK幀,此CRYPTO幀的消息為Handshark protocol: Finished。最后再分別發送了Stream id為0和2的HTTP3 HEADERS幀和SETTINGS幀。
Stream id為0的HEADERS包即為http請求,該包使用了QPACK方法進行壓縮,該方法與http2的HPACK類似,而根據QPACK的定義,id為2和3的stream分別為encoder stream和decoder stream,即上文中提及的兩個SETTINGS幀。
之后client接收到了Handshark包,其中包含一個ACK幀。此時,1-RTT的握手過程已經結束,因此接下來收到的包的類型就變為了Short header packet,收到的第一個包的類型為HANDSHARK_DONE,說明握手完成。
最后,服務端返回了一個HTTP3的DATA幀,該幀中即包含了請求的響應數據,如下圖,可以看到數據的對應文本即為html文檔。

在收到數據后,客戶端就發送了一個CONNECTION_CLOSE的幀關閉連接,Error code為0x100說明正常關閉,未發生錯誤。
