加密与序列号
主线位置:记录主线的加解密一环。发送侧,分好片的明文在
encrypt_outgoing里拿到序列号、变成密文记录;接收侧,DeframerIter切出的记录在decrypt_incoming里原地解密。密钥从哪来见密钥日程,AEAD 的实现来自 provider,见 CryptoProvider。
flowchart TB
pm["OutboundPlainMessage"] --> pre{"pre_encrypt_action"}
pre -->|Nothing| enc["encrypt_outgoing"]
pre -->|RefreshOrClose| act["KeyUpdate 或 close_notify"]
pre -->|Refuse| stop["不再加密"]
enc --> seq["seq 取值后加一"]
seq --> nonce["nonce = iv xor seq"]
nonce --> seal["AEAD seal"]
seal --> om["OutboundOpaqueMessage"]
代码:record_layer.rs(358 行,含测试)管状态和序列号,crypto/cipher.rs 定义 provider 要实现的 trait 和 nonce 的构造,具体的 AEAD 调用在 crypto/ring/ 和 crypto/aws_lc_rs/ 各自的 tls12.rs、tls13.rs 里。实验程序是 exp-rustls/rec-lab 的 seqnonce、tls12nonce、keyupdate,以及分别链接 rustls 0.23.45 和 0.23.46 的 rec-kuold、rec-kunew。
RecordLayer
RecordLayer 是 CommonState 的一个字段,两个方向各一套:
pub(crate) struct RecordLayer {
message_encrypter: Box<dyn MessageEncrypter>,
message_decrypter: Box<dyn MessageDecrypter>,
write_seq_max: u64,
write_seq: u64,
read_seq: u64,
has_decrypted: bool,
encrypt_state: DirectionState,
decrypt_state: DirectionState,
// Message encrypted with other keys may be encountered, so failures
// should be swallowed by the caller. This struct tracks the amount
// of message size this is allowed for.
trial_decryption_len: Option<usize>,
}
- 每个方向有三个状态(
DirectionState):Invalid(没有密钥)、Prepared(密钥有了但还没启用)、Active。Prepared只给 TLS 1.2 用:密钥在算出主密钥后就能派生,但要等发出或收到 ChangeCipherSpec 才启用,start_encryption_tls12先prepare,状态机在 CCS 处再start_encrypting/start_decrypting。TLS 1.3 没有这一步,set_message_encrypter直接到Active。 - 解密方向不是
Active时,decrypt_incoming把记录原样当明文放行,握手开头的 ClientHello、ServerHello 就是这样通过的。 - 换密钥(
prepare_*/set_*)时对应方向的序列号归零。序列号每个方向、每把密钥一个,所以一次握手的抓包里服务端回的pong用的是SERVER_TRAFFIC_SECRET_0下的序列号 1:序列号 0 已经被那条装着两张会话票据的记录用掉了。 has_decrypted只为去帧里「明文告警」的判断存在;trial_decryption_len给 0-RTT 被拒的服务端用,下面讲。
加解密本身委托给两个 trait 对象,MessageEncrypter 和 MessageDecrypter:
pub trait MessageDecrypter: Send + Sync {
fn decrypt<'a>(
&mut self,
msg: InboundOpaqueMessage<'a>,
seq: u64,
) -> Result<InboundPlainMessage<'a>, Error>;
}
pub trait MessageEncrypter: Send + Sync {
fn encrypt(
&mut self,
msg: OutboundPlainMessage<'_>,
seq: u64,
) -> Result<OutboundOpaqueMessage, Error>;
fn encrypted_payload_len(&self, payload_len: usize) -> usize;
}
序列号由 RecordLayer 传进去,provider 不用自己计数。decrypt 的输入和输出带同一个生命周期 'a:明文是密文所在缓冲的一段,解密不分配。encrypt 返回一个自带缓冲的 OutboundOpaqueMessage,这块缓冲一次分配好,前面留出 5 字节给记录头(PrefixedPayload),后面连标签一起放下,encrypted_payload_len 就是为了算这个容量。encrypt_outgoing 很短:
pub(crate) fn encrypt_outgoing(
&mut self,
plain: OutboundPlainMessage<'_>,
) -> OutboundOpaqueMessage {
debug_assert!(self.encrypt_state == DirectionState::Active);
assert!(self.next_pre_encrypt_action() != PreEncryptAction::Refuse);
let seq = self.write_seq;
self.write_seq += 1;
self.message_encrypter
.encrypt(plain, seq)
.unwrap()
}
那个 assert! 是最后一道保险:序列号绝不回绕,调用方应该在此之前就停下(见下文)。
nonce = IV 异或序列号
AEAD 要求同一把密钥下 nonce 不重复。TLS 1.3 的做法(RFC 8446 §5.3)是:每个方向从流量密钥派生一个 12 字节的 iv,把 64 位序列号按大端放进 12 字节的右边、左边补零,和 iv 逐字节异或。Nonce::new:
pub fn new(iv: &Iv, seq: u64) -> Self {
let mut seq_bytes = [0u8; NONCE_LEN];
codec::put_u64(seq, &mut seq_bytes[4..]);
Self::new_from_seq(iv, seq_bytes)
}
nonce 不上线,双方各自用同一个序列号算出来,所以记录丢了、重复了或者顺序乱了,解密都会失败。附加数据(AAD)是这条记录的 5 字节头(make_tls13_aad),长度是密文长度。
实测(seqnonce):TLS 1.3、TLS13_AES_128_GCM_SHA256,客户端配一个 KeyLog 拿到 CLIENT_TRAFFIC_SECRET_0,实验程序自己用 HKDF-Expand-Label 派生 key 和 iv、自己按上面的公式算 nonce,用 ring 的 AEAD 解开 rustls 发出的三条记录:
client_application_traffic_secret_0: 32 bytes
write iv = ad907eba0b83f726c2b0173b
3 writes -> 72 bytes on the wire, 3 records
seq 0: header 17 0303 len 18 nonce ad907eba0b83f726c2b0173b inner plaintext 6117
seq 1: header 17 0303 len 19 nonce ad907eba0b83f726c2b0173a inner plaintext 626217
seq 2: header 17 0303 len 20 nonce ad907eba0b83f726c2b01739 inner plaintext 63636317
record seq 1 opened with seq 0's nonce: Err(Unspecified)
server read "abbccc"
nonce 只有最后一个字节在变:3b 依次异或 0、1、2。三次 write 是三条记录,每条的长度是明文 + 1 + 16。用错一个序列号,AEAD 校验就失败。
TLS 1.2 的 AES-GCM 不一样:RFC 5288 把 nonce 拆成 4 字节隐式部分和 8 字节「显式 nonce」,后者随每条记录明文发送,怎么取值留给实现。rustls 的 gcm_iv 把密钥块里多派生的 8 字节当起点,然后同样异或序列号,GcmMessageEncrypter 把 nonce 的后 8 字节写在密文前面。manual/tlsvulns.rs 的「GCM nonces」一节说的就是这个:「rustls uses a counter from a random starting point for GCM nonces」。实测(tls12nonce,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256)四条应用数据记录的前 8 字节:
record 0: type 23 version 0303 len 25 explicit nonce 1c430885b97d5a2d
record 1: type 23 version 0303 len 25 explicit nonce 1c430885b97d5a2e
record 2: type 23 version 0303 len 25 explicit nonce 1c430885b97d5a2f
record 3: type 23 version 0303 len 25 explicit nonce 1c430885b97d5a28
起点 …5a2c 依次异或 1、2、3、4:序列号 0 已经被客户端的 Finished 用掉了。每条记录比 TLS 1.3 多 8 字节(25 = 1 + 8 + 16,没有内层类型字节)。
TLS 1.3 的内层内容类型与填充
TLS 1.3 的密文记录外层一律写 application_data(23)、版本 0x0303,真实的内容类型是明文的最后一个字节,后面还可以跟任意多个 0 作为填充(TLSInnerPlaintext,RFC 8446 §5.2)。ring 的 Tls13MessageEncrypter 发送时只追加类型字节,不填充:
let nonce = aead::Nonce::assume_unique_for_key(Nonce::new(&self.iv, seq).0);
let aad = aead::Aad::from(make_tls13_aad(total_len));
payload.extend_from_chunks(&msg.payload);
payload.extend_from_slice(&msg.typ.to_array());
self.enc_key
.seal_in_place_append_tag(nonce, aad, &mut payload)
.map_err(|_| Error::EncryptError)?;
上面实测里的 6117、626217 就是明文加 0x17。接收时 Tls13MessageDecrypter 原地 open_in_place、截掉标签,再调 into_tls13_unpadded_message:
- 去填充前明文超过 16385 字节(
MAX_FRAGMENT_LEN + 1),报PeerSentOversizedRecord。 unpad_tls13_payload从尾部一个个pop掉 0,遇到第一个非零字节就是内容类型;全是 0(或空)返回类型 0,报IllegalTlsInnerPlaintext。- 内层类型是 ChangeCipherSpec,报
IllegalMiddleboxChangeCipherSpec:兼容用的 CCS 只能是明文。 - 去掉填充后还超过 16384,报
PeerSentOversizedRecord。
pop 只是把切片截短一个字节,填充再长也不拷贝。实测接着上面的连接,实验程序用同一套 key 和 iv 自己封两条记录喂给服务端:序列号 3 是 "pad" + 0x17 + 100 个 0,序列号 4 的明文是 8 个 0:
padded record: 125 bytes on the wire
server: Ok(3), read "pad"
all-zero inner plaintext: Some(PeerMisbehaved(IllegalTlsInnerPlaintext))
server queued 0 bytes: []
带填充的记录被正常接受。全零那条被拒绝,但服务端没有排队任何告警:RFC 8446 §5.4 要求这种情况用 unexpected_message 告警终止连接,而 0.23.46 的 handle_deframe_error 只给 InvalidMessage、PeerSentOversizedRecord、DecryptError、IllegalMiddleboxChangeCipherSpec 配了告警,IllegalTlsInnerPlaintext 落进最后的 error => error。连接照样作废,只是对端收不到原因。
试解密:被拒绝的 0-RTT
客户端发 0-RTT 早期数据时,用的是只有接受了它的服务端才有的密钥。服务端决定拒绝早期数据后,客户端已经发出的那些记录仍会到达,服务端解不开、也不该报错。这时服务端用 set_message_decrypter_with_trial_decryption 设置握手解密器,同时给一个字节预算(配置的 max_early_data_size,配置为 0 时取 16384,即一条最大记录)。doing_trial_decryption 在每次解密失败时从预算里扣掉这条记录的长度,扣得动就吞掉错误(Ok(None)),扣不动才报 DecryptError;第一条成功解出的握手消息到达后预算清空。0-RTT 的完整流程见会话恢复与 0-RTT。
序列号的上限:PreEncryptAction
序列号是 64 位,但 AEAD 有自己的上限:同一把 AES-GCM 密钥加密的记录太多,安全余量会下降。套件的 confidentiality_limit 记录这个上限,prepare_message_encrypter 取 min(SEQ_SOFT_LIMIT, confidentiality_limit) 存进 write_seq_max:
| 套件 | confidentiality_limit | 源码 |
|---|---|---|
| TLS 1.3 / 1.2 的 AES-128-GCM、AES-256-GCM | 1 << 24(16777216 条记录) | 如 TLS13_AES_128_GCM_SHA256 |
| ChaCha20-Poly1305 | u64::MAX,实际受 SEQ_SOFT_LIMIT 限制 | TLS13_CHACHA20_POLY1305_SHA256 |
ring 和 aws-lc-rs 两个 provider 的数值相同(QUIC 的 AES-GCM 是 1 << 23,另算)。两个全局常量是 SEQ_SOFT_LIMIT = 0xffff_ffff_ffff_0000 和 SEQ_HARD_LIMIT = 0xffff_ffff_ffff_fffe。加密前的检查是 pre_encrypt_action:
pub(crate) fn pre_encrypt_action(&self, add: u64) -> PreEncryptAction {
match self.write_seq.saturating_add(add) {
SEQ_HARD_LIMIT.. => PreEncryptAction::Refuse,
v if v >= self.write_seq_max => PreEncryptAction::RefreshOrClose,
_ => PreEncryptAction::Nothing,
}
}
add 是「下一条之后的第几条」,0 表示下一条。三种结果(PreEncryptAction)由 CommonState 的 check_pre_encrypt_action 处理:
RefreshOrClose:TLS 1.3 设置refresh_traffic_keys_pending,由连接对象发 KeyUpdate;TLS 1.2 没有密钥更新,记一条错误日志(「traffic keys exhausted, closing connection to prevent security failure」)并发 close_notify。Refuse:不加密。离 2^64 只差 2,正常连接到不了;源码注释说它「basically untestable」。- 告警不受限制:
send_single_fragment对告警跳过检查,所以 TLS 1.2 的 close_notify 本身用的是第 2^24 号序列号,超出上限一条。
TLS 1.3 的 KeyUpdate 什么时候发,由 send_appdata_encrypt 决定。它逐条加密分好的片,每条之前问一次:
for m in iter {
// The key update takes the last sequence number allowed by the current keys,
// unless they have not been used yet (as updating them would not help then).
if stop_for_key_update
&& self.negotiated_version == Some(ProtocolVersion::TLSv1_3)
&& self.record_layer.write_seq() > 0
&& self.record_layer.pre_encrypt_action(1) != PreEncryptAction::Nothing
{
// driven by caller, as we don't have the `State` here
self.refresh_traffic_keys_pending = true;
return sent;
}
sent += m.payload.len();
self.send_single_fragment(m);
}
pre_encrypt_action(1) 不是 Nothing,意思是「再下一条就到上限了」,也就是下一条是旧密钥能用的最后一个序列号。这时停下、返回已经发出的字节数,把最后一个序列号留给 KeyUpdate。调用方 ConnectionCommon 的 send_plaintext 循环处理这个中断:
fn send_plaintext(&mut self, payload: OutboundChunks<'_>) -> usize {
let mut len = 0;
loop {
len += self
.core
.common_state
.buffer_plaintext(payload.split_at(len).1, &mut self.sendable_plaintext);
if !self.core.maybe_refresh_traffic_keys() || len == payload.len() {
return len;
}
}
}
maybe_refresh_traffic_keys 让当前状态调 request_key_update_and_update_encrypter:用旧密钥加密一条 KeyUpdate(update_requested),派生下一代应用流量密钥,换上新的加密器(序列号归零),然后回到循环里用新密钥发剩下的数据。对端在 handle_key_update 里换读密钥,因为请求了更新,也换自己的写密钥并回一条 KeyUpdate。对端收到 KeyUpdate 的次数有上限:TemperCounters 允许连续 32 次,收到应用数据就重置。
74343e70(「conn: update TLS 1.3 traffic keys within fragmented writes」,首次进入 0.23.46,修的是 issue #3302)之前,write 先把整块数据分片、全部加密,最后才看要不要换密钥。提交说明写道:「a key update was only sent after an entire write had been encrypted, so a write spanning the confidentiality limit kept encrypting records under the exhausted keys」。改动就是上面两段代码:send_appdata_encrypt 多了 stop_for_key_update 参数和那个提前返回,write / write_vectored 改成调循环的 send_plaintext;同一天的 2b82b814 给 unbuffered API 做了同样的修复。说明里称这是「a targeted backport of the fix for #3302 from #3342」,即从 main 上的修复挑出来的。用同一份源码分别链接 0.23.45 和 0.23.46:先发 2^24 − 3 条 1 字节记录,再一次 write 8 × 16384 字节,看这次写入产出的记录(线上字节数,27 字节那条是 KeyUpdate):
rustls rec-kuold: 16777213 one-byte records sent (seq 0..=16777212)
one write of 131072 bytes -> returned 131072; records (bytes on the wire, first seq 16777213): [16406, 16406, 16406, 16406, 16406, 16406, 16406, 16406, 27]
server received 16908285 plaintext bytes, still ok: true
rustls rec-kunew: 16777213 one-byte records sent (seq 0..=16777212)
one write of 131072 bytes -> returned 131072; records (bytes on the wire, first seq 16777213): [16406, 16406, 27, 16406, 16406, 16406, 16406, 16406, 16406]
server received 16908285 plaintext bytes, still ok: true
读方向也有一个检查:read_seq 正好到 SEQ_SOFT_LIMIT 时,decrypt_incoming 在结果里带上 want_close_before_decrypt,连接发 close_notify。注释的说法是安全问题已经发生了,没有理由拒绝解密,发告警只是希望有 bug 的对端在序列号回绕前停下。
TLS13_AES_128_GCM_SHA256,握手后客户端关掉缓冲上限,连续 write(b"x") 2^24 + 4 次,每次都把输出交给服务端。KeyUpdate 是握手后的第几条记录、线上多少字节?换成 TLS 1.2 的 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 和 TLS 1.3 的 ChaCha20 呢?实验程序是 keyupdate,它逐条检查线上记录的长度,把和「1 字节应用数据」长度不同的记录报出来。答案在文末。
动脑筋答案
mode tls13-aes: suite TLS13_AES_128_GCM_SHA256, version TLSv1_3
16777220 writes of 1 byte in 4.4s: accepted 16777220 bytes, 0 writes returned 0
records after the handshake: 16777221, of which one-byte appdata (23 B on the wire): 16777220
odd record: index 16777215 (seq 16777215 if no earlier rekey), type 23, 27 bytes on the wire, after 16777215 appdata records
...
server: received 16777220 plaintext bytes, error None, peer_has_closed Ok(false)
...
mode tls12-aes: suite TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, version TLSv1_2
...
records after the handshake: 16777216, of which one-byte appdata (30 B on the wire): 16777215
odd record: index 16777215 (seq 16777215 if no earlier rekey), type 21, 31 bytes on the wire, after 16777215 appdata records
...
server: received 16777215 plaintext bytes, error None, peer_has_closed Ok(true)
...
mode tls13-chacha: suite TLS13_CHACHA20_POLY1305_SHA256, version TLSv1_3
16777220 writes of 1 byte in 9.4s: accepted 16777220 bytes, 0 writes returned 0
records after the handshake: 16777220, of which one-byte appdata (23 B on the wire): 16777220
...
server: received 16777220 plaintext bytes, error None, peer_has_closed Ok(false)
- TLS 1.3 AES-GCM:KeyUpdate 是第 16777215 条(从 0 数),27 字节:4 字节握手头 + 1 字节请求标志 + 1 字节内层类型 + 16 字节标签 + 5 字节记录头。前面正好 2^24 − 1 条应用数据,占序列号 0 到 16777214,KeyUpdate 拿走最后一个序列号 16777215,之后 5 条用新密钥,服务端一个字节不少地收到 16777220 字节。
- TLS 1.2 AES-GCM:第 16777215 条是一条 31 字节的告警(类型 21,2 字节告警 + 8 字节显式 nonce + 16 字节标签 + 5),即 close_notify,服务端看到
peer_has_closed。应用数据只有 2^24 − 1 条,因为客户端的 Finished 已经用掉了序列号 0,close_notify 本身用的是序列号 2^24,比上限多一条(告警不受限制)。连接到此结束,TLS 1.2 无法续用。 - TLS 1.3 ChaCha20:没有任何特殊记录,2^24 + 4 条全是应用数据,上限是
SEQ_SOFT_LIMIT,远到不了。ChaCha20 比 AES-GCM 慢一倍多(9.4 s 对 4.4 s,M2 有 AES 指令),吞吐对比见发送路径。
小结
RecordLayer两个方向各有加解密器、序列号和Invalid/Prepared/Active状态;Prepared服务于 TLS 1.2 的 CCS 时机,换密钥时序列号归零。- nonce = IV 异或大端序列号,不上线;TLS 1.2 AES-GCM 的 8 字节显式 nonce 也是「起点异或序列号」,实测连续递增。解密原地进行,不分配。
- TLS 1.3 外层一律
application_data,真实类型在明文最后一个字节;rustls 发送不填充,接收时从尾部剥掉 0,全零报IllegalTlsInnerPlaintext(0.23.46 不为它发告警)。 - AES-GCM 每把密钥上限 2^24 条记录:TLS 1.3 在旧密钥的最后一个序列号上发 KeyUpdate,0.23.46 起一次大写入也会在中途停下换密钥;TLS 1.2 到上限发 close_notify;ChaCha20 实际不受限。
- 服务端拒绝 0-RTT 时,用一个字节预算吞掉解不开的早期数据记录。