这篇blog是关于QUIC协议如何使用TLS1.3来建立安全的QUIC连接,以及QUIC层如何对packet进行加密认证保护的内容,主要内容来自于rfc9001,结合自己的理解做了一些内容上的调整。原RFC文档个人认为写的非常全面易读了(至少比QUIC本身的RFC9000要易读多了。。),有兴趣的最还是要去读一读原文。
+--------------+--------------+ +-------------+
| TLS | TLS | | QUIC |
| Handshake | Alerts | | Applications|
| | | | (h3, etc.) |
+--------------+--------------+-+-------------+
| |
| QUIC Transport |
| (streams, reliability, congestion, etc.) |
| |
+---------------------------------------------+
| |
| QUIC Packet Protection |
| |
+---------------------------------------------+
和tls1.3 over tcp不一样,quic和tls1.3并非严格的上下层关系,而是quic连接的建立过程会复用tls1.3的handshake协议来进行密钥协商,tls1.3依赖quic transport提供的传输层可靠性保证。因此quic 和tls1.3的关系是同一个layer中的两个组件。
+------------+ +------------+
| |<---- Handshake Messages ----->| |
| |<- Validate 0-RTT Parameters ->| |
| |<--------- 0-RTT Keys ---------| |
| QUIC |<------- Handshake Keys -------| TLS |
| |<--------- 1-RTT Keys ---------| |
| |<------- Handshake Done -------| |
+------------+ +------------+
| ^
| Protect | Protected
v | Packet
+------------+
| QUIC |
| Packet |
| Protection |
+------------+
quic中没有使用tls的record协议作为handshake、alerts、application数据的载体,因为quic packet本身就是载体。
Client Server
ClientHello
(0-RTT Application Data) -------->
ServerHello
{EncryptedExtensions}
{Finished}
<-------- [Application Data]
{Finished} -------->
[Application Data] <-------> [Application Data]
() Indicates messages protected by Early Data (0-RTT) Keys
{} Indicates messages protected using Handshake Keys
[] Indicates messages protected using Application Data
(1-RTT) Keys
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]
我们知道QUIC中所有数据都是加密认证的,但是在连接的不同阶段加密认证的等级是不一样,这也很容易理解,比如对于一个全新的quic连接建立而言,第一个数据包不可能做到高等级的加密,因为这时候双方此前没有经过任何的密钥交互,quic 称这个数据包加密级别为Initial,采用的密钥是协议版本相关的静态密钥。当server将收到clientHello消息(client通过Initial包里的CRYPTO帧发给)后,按照tls1.3协议此时server就可以计算出Handshake secret了,那么后面数据包的加密等级就可以升级为Handshake级别了。等到双方完成握手,加密等级再次升级为1-RTT级别。
不同的加密级别对应的密钥不同,而quic包有可能会乱序到达,因此对端收到包后需要知道包使用的密钥才能正常解密,因此quic将不同的加密级别的包进行区分,对应不用的包类型(packet type)。
quic中每个包都有一个唯一的packet number,用来标识这个quic packet,而且quic的packet number严格单调递增,意味着即便是包丢失导致重传,quic也都是采用一个递增的PN,承载所丢失的stream frame数据,这样做的好处是避免tcp里在预估rtt时的重传歧义问题。
所谓的PN space就是PN的取值空间,quic协议将采用不同加密级别的包划分到几个独立的PN space中,这样做的好处主要是为了将握手包和数据包的PN space区分开,期望简化loss detection和congestion controller的实现(https://www.youtube.com/watch?v=mDc2kHPtavE&ab_channel=DanielStenberg @11:06)。
TLS协议假设传输层是有序的,因此QUIC中 通过CRYPTO frame专门携带tls handshake消息。CRYPTO帧可以看作是一种特殊的不带stream id的STREAM帧,和STREAM帧一样和有offset、length、data等属性,因此可以做到有序交付给TLS。所有CRYPTO消息发送在这个特殊的stream上以保证握手消息的有序性。CRYPTO帧不受flow controller控制(但因该会受congestion controller控制)。
由于0-RTT类型的包是用来传输应用数据,所以虽然0-RTT包在握手过程中传输,但是QUIC不会用CRYPTO帧来封装0-RTT数据,而是采用常规的STREAM帧。
由于在握手完成后TLS可能还会有些post-handshake消息,比如NewSessionTicket,所有1-RTT包里也可能会有CRYPTO帧。因此CRYPTO帧会发送在Initial、Handshake、1-RTT包中。
QUIC规定了一些和TLS交互行为。
握手过程中主要有:
一旦一端收到并验证了对端的tls Finished消息、并且发送了本端的tls Finished消息,那么对于该端来说握手就已经完成。QUIC还规定了在TLS握手完成时的交互行为:
握手完成后,TLS进行被动状态, 意味着TLS可以接受CRYPTO帧的数据,但是一般不会再有新的数据需要通过QUIC发送,除非应用层或者QUIC主动请求要发送一些数据,比如NewSessionTicket消息。
总结下:
Client Server
====== ======
Get Handshake
Initial ------------->
Install tx 0-RTT keys
0-RTT - - - - - - - ->
Handshake Received
Get Handshake
<------------- Initial
Install rx 0-RTT keys
Install Handshake keys
Get Handshake
<----------- Handshake
Install tx 1-RTT keys
<- - - - - - - - 1-RTT
Handshake Received (Initial)
Install Handshake keys
Handshake Received (Handshake)
Get Handshake
Handshake ----------->
Handshake Complete
Install 1-RTT keys
1-RTT - - - - - - - ->
Handshake Received
Handshake Complete
Handshake Confirmed
Install rx 1-RTT keys
<--------------- 1-RTT
(HANDSHAKE_DONE)
Handshake Confirmed
Session Resumption 在QUIC完全交给TLS stack处理,QUIC server端由TLS stack产生NewSessionTicket消息(一般session ticket里编码了session状态信息),然后通过CRYPTO帧发送给QUIC client端,client端收到后deprotect再交给TLS stack保存(通常来说,TLS stack会保存当前sesstion ticket以及对应的tls session状态信息)。整个过程client端的QUIC stack不需要保存任何状态信息。
QUIC 中启用0-RTT基本和TLS1.3中0-RTT一样,数据通过PSK 加密(PSK通常由NewSessionTicket消息生产,因此0-RTT依赖于Session Resumption)。首先client在TLS的ClientHello消息中带上early_data 扩展,表示client接下来希望发送0-RTT数据包。紧接着client开始发送0-RTT包。然后server在ServerHello之后的EncryptedExtensions消息中带上加密的early_data扩展,告诉client自己接受0-RTT数据(如果没有带上,表示拒绝0-RTT数据)。
QUIC 0-RTT区别于TLS1.3 0-RTT的地方主要有:
前面提到,quic不再使用tls的record协议,而是将tls record层做的事情挪到了quic packet上,利用tls handshake协商出的各种密钥、AEAD、KDF函数来对packet进行密钥学保护。
QUIC中几乎所有的packet都做了密码学保护,不同packet类型的保护级别不一样(前面也提到了)。具体来说有:
这里回顾下TLS key schedule过程如下:
0
|
v
PSK -> HKDF-Extract = Early Secret
|
+-----> Derive-Secret(., "ext binder" | "res binder", "")
| = binder_key
|
+-----> Derive-Secret(., "c e traffic", ClientHello)
| = client_early_traffic_secret
|
+-----> Derive-Secret(., "e exp master", ClientHello)
| = early_exporter_master_secret
v
Derive-Secret(., "derived", "")
|
v
(EC)DHE -> HKDF-Extract = Handshake Secret
|
+-----> Derive-Secret(., "c hs traffic",
| ClientHello...ServerHello)
| = client_handshake_traffic_secret
|
+-----> Derive-Secret(., "s hs traffic",
| ClientHello...ServerHello)
| = server_handshake_traffic_secret
v
Derive-Secret(., "derived", "")
|
v
0 -> HKDF-Extract = Main Secret
|
+-----> Derive-Secret(., "c ap traffic",
| ClientHello...server Finished)
| = client_application_traffic_secret_0
|
+-----> Derive-Secret(., "s ap traffic",
| ClientHello...server Finished)
| = server_application_traffic_secret_0
|
+-----> Derive-Secret(., "exp master",
| ClientHello...server Finished)
| = exporter_secret
|
+-----> Derive-Secret(., "res master",
ClientHello...client Finished)
= resumption_secret
注意到上面调度过程TLS为Handshake和1-rtt 数据都派生client和server两个secret。有了secret后,在原来TLS协议中还需基于这个secret派生出key和iv,tls中通过下面方法派生得到:
[sender]_write_key = HKDF-Expand-Label(Secret, "key", "", key_length)
[sender]_write_iv = HKDF-Expand-Label(Secret, "iv", "", iv_length)
QUIC中将由secret派生出key和iv这部分工作挪到了QUIC层,另外还会新增派生了header protection key(后面会介绍)。具体通过下面得到:
[sender]_write_key = HKDF-Expand-Label(Secret, "quic key", "", key_length)
[sender]_write_iv = HKDF-Expand-Label(Secret, "quic iv", "", iv_length)
[sender]_header_protection_key = HKDF-Expand-Label(Secret, "quic hp", "", key_length)
initial secrets由于是QUIC中引入的, 和0-rtt、handshake、1-rtt secrets不一样,后者是由TLS stack生产。Initial secrets则由QUIC通过client initial packet中的Destionation CID采用TLS的提供的HKDF_Extract和HKDF-Expand-Label函数派生&扩展得到,具体如下所示:
initial_salt(0x38762cf7f55934b34d179ae6a4c80cadccbb7f0a)
|
v
D_CID -> HKDF-Extract = Initial Secret
|
|
+-----> HKDF-Expand-Label(., "client in", "", Hash.length)
| = client_initial_secret
|
+-----> HKDF-Expand-Label(., "server in", "", Hash.length)
= server_initial_secret
这里有两点需要注意:
QUIC支持TLS1.3中除了TLS_AES_128_CCM_8_SHA256之外的其他AEAD cipher suites,这些AEAD 函数的都将输出一个16字节大小的authentication tag,因此最终加密得到的密文要比输入的明文大16个字节。
我们知道AEAD函数加密的定义形如:
AEADEncrypted = AEAD-Encrypt(write_key, nonce, additional_data, plaintext)
plaintext of encrypted_record =
AEAD-Decrypt(peer_write_key, nonce, additional_data, AEADEncrypted)
在TLS中, nonce通过tls的seq_num XOR (client_write_iv / server_write_iv)
得到,QUIC中没有seq_num,因此很自然的采用的是packet number。additional_data则采用的当前QUIC packet的 header(从header的第一个字节开始到packet number最后一个字节结束)
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),
Type-Specific Payload (..),
}
其中不变部分可视化表示如下:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+
|1|X X X X X X X|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Version (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| DCID Len (8) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Connection ID (0..2040) ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| SCID Len (8) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Connection ID (0..2040) ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|X X X X X X X X X X X X X X X X X X X X X X X X X X X X X X ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
上面这些字段是QUIC 所有packet type的long header共有的,除了这些字段外,header里往往还有几个常见的字段:
对于Long header而言,header protection保护的是 Type-Specific Bits (包含Reserved Bits和Packet Number Length) + Packet Number
Short Header定义如下:
1-RTT 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 (8..),
}
其中版本不变的部分可视化为:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+
|0|X X X X X X X|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Connection ID (*) ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|X X X X X X X X X X X X X X X X X X X X X X X X X X X X X X ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
对于Short header,保护的是第一个字节的后5个bits (Reserved Bits + Key Phase + Packet Number Length) + Packer Number
首先header protection工作在packet protection之后,对packet protection得到ciphertext的进行采样作为加密算法的输入,加密算法依赖于协商得到的AEAD算法,加密算法输出是一个5-bytes的Mask,header protection过程就是将这个Mask和header中被保护的内容进行XOR。伪代码如下:
mask = header_protection(hp_key, sample)
pn_length = (packet[0] & 0x03) + 1
if (packet[0] & 0x80) == 0x80:
# Long header: 4 bits masked
packet[0] ^= mask[0] & 0x0f
else:
# Short header: 5 bits masked
packet[0] ^= mask[0] & 0x1f
# pn_offset is the start of the Packet Number field.
packet[pn_offset:pn_offset+pn_length] ^= mask[1:1+pn_length]
总结一下整个packet protection过程:
基于TCP的tls协议中,由于TCP层做了有序传输的保证,对端tls层在收到消息解密的时候,只需要采用当前secret就行了。到了QUIC这里,这个做法就不再work了。比如由于乱序导致Server端提前收到了下一个encryption level的包,此时前面提到过server端可以先buffer起来,等到tls层告知quic 提升encryption level后再deproect这个包。
Client Server
====== ======
Get Handshake
Initial ------------->
Install tx 0-RTT keys
0-RTT - - - - - - - ->
Handshake Received
Get Handshake
<------------- Initial
Install rx 0-RTT keys
Install Handshake keys
Get Handshake
<----------- Handshake
Install tx 1-RTT keys
<- - - - - - - - 1-RTT
Handshake Received (Initial)
Install Handshake keys
Handshake Received (Handshake)
Get Handshake
Handshake ----------->
Handshake Complete
Install 1-RTT keys
1-RTT - - - - - - - ->
Handshake Received
Handshake Complete
Handshake Confirmed
Install rx 1-RTT keys
<--------------- 1-RTT
(HANDSHAKE_DONE)
Handshake Confirmed
这里有几个地方协议特别指出来:
TLS1.3中key update请求可由任一方发起,一旦发起表示后续sending方向的record采用新的secret派生出的keys加密。接受方接受到key update请求同步更新自己receiving方向的secret,然后根据请求里的KeyUpdateRequest标识决定是否响应自己sending方向的Key udpate。
显然TLS的这种key update机制依赖于Key update消息和使用新的secret的record消息的时序。QUIC里弃用了TLS的key update机制,而是采用short header中的Key Phase bit来标识是否要更新secret。Key Phase bit一开始置为0,后面每次更新secret时都flip下。
Initiating Peer Responding Peer
@M [0] QUIC Packets
... Update to @N
@N [1] QUIC Packets
-------->
Update to @N ...
QUIC Packets [1] @N
<--------
QUIC Packets [1] @N
containing ACK
<--------
... Key Update Permitted
@N [1] QUIC Packets
containing ACK for @N packets
-------->
Key Update Permitted ...
为了保证通信数据的confidentiality, 很多AEAD算法对同一个key 的使用次数是有限制的,这也是为什么TLS和QUIC里都有key update机制的原因。同样,通信数据的integrity保证也会要求很多AEAD算法限制同一个key的使用次数。在TLS协议里如果收到一个 record经过AEAD-decrypt失败(表示数据的integrity受损,可能被attacker篡改),那么TLS会立即中断连接。
QUIC里不一样,由于QUIC包的可能延迟达到,QUIC选择丢弃那些不能成功deprotect的包,这就给active attacker伪造packet创造了暴破攻击的条件,因为QUIC将AEAD的confidentiality和integrity的限制分开处理,confidentiality的限制应用在使用同一个key进行protect时,integrity限制应用在使用同一个key进行deprotect失败时。如果当前key使用达到confidentiality limit时,需要立即触发key update。如果key使用达到了integrity limit,那么QUIC需要中止使用connection。
首先所有TLS的安全性同样适用于QUIC,QUIC本身也会引入一些额外的安全性问题