Rust in Everything
基于 rustls 0.23.46

加密与序列号

主线位置:记录主线的加解密一环。发送侧,分好片的明文在 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:

  1. 去填充前明文超过 16385 字节(MAX_FRAGMENT_LEN + 1),报 PeerSentOversizedRecord。
  2. unpad_tls13_payload 从尾部一个个 pop 掉 0,遇到第一个非零字节就是内容类型;全是 0(或空)返回类型 0,报 IllegalTlsInnerPlaintext。
  3. 内层类型是 ChangeCipherSpec,报 IllegalMiddleboxChangeCipherSpec:兼容用的 CCS 只能是明文。
  4. 去掉填充后还超过 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-GCM1 << 24(16777216 条记录)如 TLS13_AES_128_GCM_SHA256
ChaCha20-Poly1305u64::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 次,收到应用数据就重置。

📝NOTE
冷知识:0.23.46 才让一次大写入在中途换密钥

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
0.23.45 用旧密钥加密了序列号 16777213 到 16777220 的 8 条记录,超出上限 5 条,然后才发 KeyUpdate;0.23.46 用旧密钥发了 2 条,KeyUpdate 占序列号 16777215,剩下 6 条用新密钥。两种情况下服务端都照收不误:接收方不检查对方是否超限,守住上限只能靠发送方。

读方向也有一个检查:read_seq 正好到 SEQ_SOFT_LIMIT 时,decrypt_incoming 在结果里带上 want_close_before_decrypt,连接发 close_notify。注释的说法是安全问题已经发生了,没有理由拒绝解密,发告警只是希望有 bug 的对端在序列号回绕前停下。

❗IMPORTANT
动脑筋:TLS 1.3、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 时,用一个字节预算吞掉解不开的早期数据记录。

💬 关联讨论

发起讨论
加载中...