Rust in Everything
基于 rustls 0.23.46

迷你密钥日程

主线位置:握手主线里密钥日程那条链的缩小版,外加记录主线的最后一步。从 PSK 和 ECDHE 共享秘密推出各级秘密、流量密钥和 Finished,是 密钥日程 讲的 KeySchedule* 类型链做的事;拿流量密钥打开一条记录,是 加密与序列号 讲的 MessageDecrypter 做的事。本页用不到三百行自己写一遍,再拿外部向量和本卷的真实抓包当标准答案。

flowchart TB
  psk["PSK 或全零"] --> es["EarlySecret"]
  es -->|"derived 加 ECDHE"| hs["HandshakeSecret"]
  hs -->|"derived 加全零"| ms["MasterSecret"]
  es --> cet["c e traffic"]
  hs --> chs["c hs traffic"]
  hs --> shs["s hs traffic"]
  ms --> cap["c ap traffic"]
  ms --> sap["s ap traffic"]
  ms --> res["res master"]
  shs --> fin["Finished"]
  shs --> kiv["key 和 iv"]
  sap --> kiv
  kiv --> rec["打开一条记录"]

代码在 exp-rustls/design-mks:src/lib.rs 281 行(去掉空行和注释 201 行),只依赖 ring 0.17.14,不依赖 rustls。HMAC、摘要和 AEAD 用 ring 的 hmac、digest、aead 模块;HKDF(RFC 5869)没有用 ring 的 hkdf 模块,而是在 ring::hmac 上自己写,好让 Extract 和 Expand 的每一步都看得见。测试在 tests/vectors.rs,解抓包的程序是 src/bin/mainline.rs。实验平台:M2 Max、macOS 27、rustc 1.99.0,release 构建。

HKDF:在 HMAC 上写二十行

TLS 1.3 的密钥日程只用到 HKDF 的两半。Extract 把输入的秘密「压」成一个伪随机密钥(PRK),Expand 用 PRK 和一段 info 展开出任意长度的输出:

/// RFC 5869 HKDF-Extract: PRK = HMAC-Hash(salt, IKM).
///
/// An absent salt is "HashLen zeros"; HMAC pads its key with zeros to the block size,
/// so an empty salt gives the same PRK and needs no special case.
pub fn hkdf_extract(hash: Hash, salt: &[u8], ikm: &[u8]) -> Vec<u8> {
  hash.hmac(salt, &[ikm])
}

/// RFC 5869 HKDF-Expand: T(i) = HMAC-Hash(PRK, T(i-1) | info | i), output = T(1) | T(2) | ...
///
/// `info` is passed as parts, like rustls does, so the label is never concatenated.
pub fn hkdf_expand(hash: Hash, prk: &[u8], info: &[&[u8]], len: usize) -> Vec<u8> {
  assert!(len <= 255 * hash.len(), "RFC 5869: L must be <= 255 * HashLen");
  let blocks = len.div_ceil(hash.len());
  let mut out = Vec::with_capacity(blocks * hash.len());
  let mut previous: Vec<u8> = Vec::new();
  for i in 1..(blocks + 1) {
    let counter = [i as u8];
    let mut parts: Vec<&[u8]> = Vec::with_capacity(info.len() + 2);
    parts.push(&previous);
    parts.extend_from_slice(info);
    parts.push(&counter);
    previous = hash.hmac(prk, &parts);
    out.extend_from_slice(&previous);
  }
  out.truncate(len);
  out
}

Hash 是一个两值枚举(Sha256、Sha384),hash.hmac(key, parts) 用 ring::hmac::Context 把各段依次 update 再 sign。两点说明:

  • 空盐不用特判。 RFC 5869 说没有盐时用 HashLen 个零字节。HMAC 先把密钥用零补到分组长度(SHA-256 是 64 字节),所以空串和 32 个零补出来是同一个密钥。测试里 RFC 5869 的 A.3(盐和 info 都为空)同时核对了这两种写法。
  • rustls 里有一份几乎一样的代码。 HkdfExpanderUsingHmac 的 expand_unchecked 也是「上一块、info 各段、计数器」三样一起喂给 HMAC,info 同样以切片数组传入。它和 HkdfUsingHmac 是给只提供 HMAC 的自定义 provider 用的(CryptoProvider);内置的 ring 和 aws-lc-rs provider 走各自库里的 HKDF。

HKDF-Expand-Label 与 Derive-Secret

RFC 8446 §7.1 把 TLS 1.3 用的标签、上下文和输出长度编成一个 HkdfLabel 结构,作为 Expand 的 info:

pub fn hkdf_expand_label(hash: Hash, secret: &[u8], label: &str, context: &[u8], len: usize) -> Vec<u8> {
  const PREFIX: &[u8] = b"tls13 ";
  let length = (len as u16).to_be_bytes();
  let label_len = [(PREFIX.len() + label.len()) as u8];
  let context_len = [context.len() as u8];
  let info: [&[u8]; 6] = [&length, &label_len, PREFIX, label.as_bytes(), &context_len, context];
  hkdf_expand(hash, secret, &info, len)
}

/// Derive-Secret(Secret, Label, Messages), given Transcript-Hash(Messages) directly.
pub fn derive_secret(hash: Hash, secret: &[u8], label: &str, transcript_hash: &[u8]) -> Vec<u8> {
  hkdf_expand_label(hash, secret, label, transcript_hash, hash.len())
}

六段切片和 rustls 的 hkdf_expand_label_inner 一一对应:两字节输出长度、一字节标签长度、"tls13 " 前缀、标签、一字节上下文长度、上下文(密钥日程里引过那段代码)。RFC 里的 Derive-Secret(Secret, Label, Messages) 接收的是消息本身,这里直接收转录哈希:转录怎么维护是另一件事,rustls 交给 HandshakeHash,本页交给调用方。

三级阶梯

阶梯的每一级是一个 PRK。级与级之间的那一步永远相同:用 Derive-Secret(., "derived", "") 作盐,对新的输入再做一次 Extract。

/// One rung of the ladder: a PRK plus the hash it belongs to.
struct Rung {
  hash: Hash,
  secret: Vec<u8>,
}

impl Rung {
  fn derive(&self, label: &str, transcript_hash: &[u8]) -> Vec<u8> {
    derive_secret(self.hash, &self.secret, label, transcript_hash)
  }

  /// HKDF-Extract(salt = Derive-Secret(., "derived", ""), ikm): the step between rungs.
  fn next(self, ikm: &[u8]) -> Rung {
    let salt = self.derive("derived", &self.hash.hash(b""));
    Rung { hash: self.hash, secret: hkdf_extract(self.hash, &salt, ikm) }
  }
}

注意 "derived" 的上下文是 self.hash.hash(b""),即空串的哈希,不是空的上下文:RFC 里 Derive-Secret 的第三个参数是「消息」,空消息的转录哈希是 Hash("")。这一点写错会怎样,见本页的动脑筋。

三级各包一层新类型,into_* 按值拿走自己:

pub struct EarlySecret(Rung);
pub struct HandshakeSecret(Rung);
pub struct MasterSecret(Rung);

impl EarlySecret {
  pub fn new(hash: Hash, psk: Option<&[u8]>) -> Self {
    let zeros = vec![0u8; hash.len()];
    let ikm = psk.unwrap_or(&zeros);
    EarlySecret(Rung { hash, secret: hkdf_extract(hash, &zeros, ikm) })
  }

  /// "ext binder" for an external PSK, "res binder" for a resumption PSK.
  pub fn binder_key(&self, external: bool) -> Vec<u8> {
    let label = if external { "ext binder" } else { "res binder" };
    self.0.derive(label, &self.0.hash.hash(b""))
  }

  pub fn client_early_traffic_secret(&self, client_hello_hash: &[u8]) -> Vec<u8> {
    self.0.derive("c e traffic", client_hello_hash)
  }
  // ... early_exporter_master_secret: "e exp master"

  /// Consumes the early secret: it cannot be used after the (EC)DHE input.
  pub fn into_handshake(self, ecdhe_shared_secret: &[u8]) -> HandshakeSecret {
    HandshakeSecret(self.0.next(ecdhe_shared_secret))
  }
}

impl HandshakeSecret {
  /// `hello_hash` = Transcript-Hash(ClientHello..ServerHello).
  pub fn client_handshake_traffic_secret(&self, hello_hash: &[u8]) -> Vec<u8> {
    self.0.derive("c hs traffic", hello_hash)
  }
  // ... server_handshake_traffic_secret: "s hs traffic"

  pub fn into_master(self) -> MasterSecret {
    let zeros = vec![0u8; self.0.hash.len()];
    MasterSecret(self.0.next(&zeros))
  }
}
// ... impl MasterSecret: "c ap traffic", "s ap traffic", "exp master", "res master"

三个类型、两个消耗 self 的转换,已经能挡住「没输入 ECDHE 就派生握手秘密」这种错误。rustls 拆成了八个类型(密钥日程的表),多出来的五个管的是这里不管的事:客户端和服务端在主秘密这一级走不同的路(服务端先装应用加密器、等客户端 Finished),已派生的流量秘密什么时候装进记录层,以及应用阶段只留当前秘密、丢掉主秘密。

反过来,这里有两样 rustls 0.23.46 没有的:binder_key(true) 的 ext binder(外部 PSK)和 early_exporter_master_secret 的 e exp master(0-RTT 阶段的导出密钥)。rustls 不支持外部 PSK、不提供早期导出,SecretKind 里就没有这两个标签;所以 NIST ACVP 向量的第八项,在 rustls 里核对不了,在这里可以。

Finished 与流量密钥

剩下几样都是对某个流量秘密再做一次 HKDF-Expand-Label:

pub fn finished_verify_data(hash: Hash, base_key: &[u8], transcript_hash: &[u8]) -> Vec<u8> {
  let finished_key = hkdf_expand_label(hash, base_key, "finished", b"", hash.len());
  hash.hmac(&finished_key, &[transcript_hash])
}

/// Checks a received Finished in constant time (ring::hmac::verify).
pub fn verify_finished(hash: Hash, base_key: &[u8], transcript_hash: &[u8], received: &[u8]) -> bool {
  let finished_key = hkdf_expand_label(hash, base_key, "finished", b"", hash.len());
  hmac::verify(&hash.hmac_key(&finished_key), transcript_hash, received).is_ok()
}

impl TrafficKeys {
  pub fn derive(hash: Hash, aead: Aead, traffic_secret: &[u8]) -> Self {
    let key = hkdf_expand_label(hash, traffic_secret, "key", b"", aead.key_len());
    let mut iv = [0u8; 12];
    iv.copy_from_slice(&hkdf_expand_label(hash, traffic_secret, "iv", b"", 12));
    TrafficKeys { aead, key, iv }
  }
}

对应 rustls 的 sign_verify_data、derive_traffic_key 和 derive_traffic_iv。收到的 Finished 要用常数时间比较:这里用 ring::hmac::verify,rustls 在客户端的 ExpectFinished 里用 subtle 的 ConstantTimeEq::ct_eq。next_traffic_secret(traffic upd,KeyUpdate 用)和 resumption_psk(resumption 加票据 nonce)也各是一行,不再列出。

打开一条 TLS 1.3 记录:nonce 是 IV 和左补零的序列号异或,附加数据是 5 字节记录头,解开后从末尾往前跳过填充的零,第一个非零字节是真正的内容类型:

  pub fn open(&self, seq: u64, record: &[u8]) -> Result<(u8, Vec<u8>), OpenError> {
    if record.len() < 5 {
      return Err(OpenError::Truncated);
    }
    let (header, body) = record.split_at(5);
    // per-record nonce = iv XOR seq, seq left-padded to 12 bytes
    let mut nonce = self.iv;
    for (n, s) in nonce[4..].iter_mut().zip(seq.to_be_bytes()) {
      *n ^= s;
    }
    let key = aead::UnboundKey::new(self.aead.algorithm(), &self.key).map_err(|_| OpenError::BadKey)?;
    let key = aead::LessSafeKey::new(key);
    let mut buf = body.to_vec();
    let plain = key
      .open_in_place(aead::Nonce::assume_unique_for_key(nonce), aead::Aad::from(header), &mut buf)
      .map_err(|_| OpenError::Decrypt)?;
    // TLSInnerPlaintext = content | ContentType | zeros
    let end = plain.iter().rposition(|&b| b != 0).ok_or(OpenError::NoContentType)?;
    Ok((plain[end], plain[..end].to_vec()))
  }

rustls 里这三步分别是 Nonce::new、provider 的 MessageDecrypter 和 into_tls13_unpadded_message(加密与序列号)。

四组测试向量

RFC 8448(TLS 1.3 的示例握手)原文本机没有,rustls 自己也不用它(密钥日程)。本页用的四组向量都能离线拿到:

测试向量出处核对什么
rfc5869_a1、rfc5869_a3_empty_salt_and_infoRFC 5869 附录 A.1、A.3,转载于 Go 1.27.3 src/crypto/hkdf/hkdf_test.go;rustls 的 crypto/tls13.rs 测试里也有附录 A 的几组手写的 Extract / Expand,包括空盐、空 info
draft07_traffic_keydraft-ietf-tls-tls13-vectors-07(RFC 8448 的草案),转载于 Go src/crypto/tls/key_schedule_test.go 的 TestTrafficKey握手流量秘密 → 16 字节 key 和 12 字节 iv,即 HKDF-Expand-Label 的拼法
acvp_full_chainNIST ACVP「TLS-v1.3-KDF-RFC8446」,同一个 Go 文件的 TestACVPVectors有 PSK 的整条链,8 个秘密,含 rustls 没有的 e exp master
rustls_openssl_vectorsrustls 0.23.46 test_vectors(注释「generated with OpenSSL」)无 PSK 的链,4 个流量秘密和各自的 key、iv

ACVP 向量给的不是真实握手消息,而是四个随机值,约定依次写进转录:

  let mut transcript = Vec::new();
  let mut hashes = Vec::new();
  for r in &randoms {
    transcript.extend_from_slice(r);
    hashes.push(Hash::Sha256.hash(&transcript));
  }

hashes[0] 到 hashes[3] 分别代替「到 ClientHello」「到 ServerHello」「到服务器 Finished」「到客户端 Finished」的转录哈希。另有两条反向测试:acvp_flipped_dhe_breaks_only_later_secrets 把 ECDHE 秘密翻一位,断言前两个早期秘密仍然对、后六个全错;finished_round_trip_and_tamper 改动 verify_data 的一位,断言 verify_finished 返回假。cargo test --release --test vectors:

running 7 tests
test finished_round_trip_and_tamper ... ok
test draft07_traffic_key ... ok
test acvp_full_chain ... ok
test rfc5869_a1 ... ok
test acvp_flipped_dhe_breaks_only_later_secrets ... ok
test rfc5869_a3_empty_salt_and_info ... ok
test rustls_openssl_vectors ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

全绿的测试要先确认它会红。把前缀 "tls13 " 改成 "tls1.3 "(复制到 design-mks-mut 里改),四个拿外部答案核对 HKDF-Expand-Label 的测试都挂了;没挂的三个里,两组 RFC 5869 根本不经过它,Finished 那条是自己算、自己验,前缀错了两边一起错:

test acvp_flipped_dhe_breaks_only_later_secrets ... FAILED
test acvp_full_chain ... FAILED
test draft07_traffic_key ... FAILED
test finished_round_trip_and_tamper ... ok
test rfc5869_a1 ... ok
test rfc5869_a3_empty_salt_and_info ... ok
test rustls_openssl_vectors ... FAILED
...
test result: FAILED. 3 passed; 4 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
❗IMPORTANT
动脑筋:把 Rung::next 里的 self.derive("derived", &self.hash.hash(b"")) 改成 self.derive("derived", b""),即把「空串的哈希」当成「空上下文」。七个测试里哪几个会挂?提示:哪些测试经过了「级与级之间」那一步?答案在文末。

解开本卷抓到的记录

一次握手的共享抓包 exp-rustls/mainline/ 里有每条记录的原始字节(records.hex)和 KeyLogFile 写出的 5 个秘密(keylog.txt)。那次握手协商的是 TLS13_AES_256_GCM_SHA384,所以这里用 Hash::Sha384 和 Aead::Aes256Gcm:秘密 48 字节,key 32 字节。

mainline.rs 做四件事,全部只用上面的函数:

  1. 用 SERVER_HANDSHAKE_TRAFFIC_SECRET 派生 key 和 iv,打开第 4 条记录(服务端加密的那一批),按 4 字节握手头切出各条消息;
  2. 转录从第 1、2 条明文记录的正文(ClientHello、ServerHello)开始,逐条追加;遇到服务器 Finished 时,用到 CertificateVerify 为止的转录哈希重算 verify_data,和收到的比;
  3. 打开第 6 条记录(客户端 Finished),用到服务器 Finished 为止的转录哈希、CLIENT_HANDSHAKE_TRAFFIC_SECRET 重算并比较;再故意少放服务器 Finished 比一次;
  4. 用两个 *_TRAFFIC_SECRET_0 打开第 7、8、9 条记录,第 9 条再用错的序列号试一次。
record 4: inner type 22, 509 bytes
  handshake type 8 (10 bytes)
  handshake type 11 (367 bytes)
  handshake type 15 (80 bytes)
  Finished(52) server verify_data matches: true
record 6: inner type 22, Finished(52) client verify_data matches: true
  same check with the transcript cut before server Finished: false
record 7: SERVER_TRAFFIC_SECRET_0 seq 0 -> inner type 22, 162 bytes
record 8: CLIENT_TRAFFIC_SECRET_0 seq 0 -> inner type 23, "ping"
record 9: SERVER_TRAFFIC_SECRET_0 seq 1 -> inner type 23, "pong"
record 9 with seq 0 instead: Some("record 9: Decrypt")
  • 第 4 条:线上 526 字节,减去 16 字节 GCM 标签和 1 字节内层类型,剩 509 字节握手数据:EncryptedExtensions(类型 8)10 字节、Certificate(11)367 字节、CertificateVerify(15)80 字节、Finished(20)52 字节,即 4 字节头加 48 字节 verify_data。和 一次握手 的记录表一致。
  • 两条 Finished 都对上了:rustls 发出的 verify_data,用这里自己写的 finished 标签、HMAC-SHA384 和自己拼的转录重算,逐字节相同。少放一条消息,转录哈希就变了,结果是假。
  • 第 7 条是两张票据(内层类型 22,162 字节,81 加 81),所以服务端方向的应用数据从序列号 1 开始,第 9 条 pong 用序列号 0 打不开。这正是一次握手的动脑筋。
📝NOTE
没有蠢问题

问: 抓包里有了握手流量秘密和应用流量秘密,能不能顺便验证应用流量秘密是从主秘密正确推出来的?

答: 不能。KeyLogFile 只写出 5 个流量和导出秘密(密钥日程的动脑筋答案),不写 ECDHE 共享秘密、握手秘密和主秘密;从流量秘密倒推主秘密要逆转 HKDF,做不到。所以从抓包能核对的只有「秘密 → key、iv → 记录」和「秘密 + 转录 → Finished」这两段;「ECDHE → 各级秘密」这一段要靠上一节的 ACVP 和 rustls 向量。要在真实握手上核对整条链,就得在 provider 里截下 ECDHE 共享秘密,本页没有做。

这个最小实现没有的东西

这里rustls页面
3 个阶梯类型,2 个消耗 self 的转换8 个类型,客户端、服务端在主秘密一级分两条路,派生和装进记录层分开密钥日程
秘密是 Vec<u8>,谁都能读、不清零PRK 存成 Box<dyn HkdfExpander> 取不出原始字节;派生结果 OkmBlock、AeadKey 在 Drop 时 zeroize源码地图
调用方自己拼消息、自己算转录哈希HandshakeHash:套件确定前先缓冲,fork_finish 取中间值,HelloRetryRequest 时折成 message_hash密钥日程
Hash、Aead 两个写死的枚举,直接调 ringTls13CipherSuite 带着 provider 给的 Hkdf、Hash、AEAD 实现,可换后端CryptoProvider
只算 binder_key,不算绑定值用截断的 ClientHello 算 PSK 绑定值并校验会话恢复与 0-RTT
给定序列号打开一条记录两个方向各自记序列号,到上限前主动 KeyUpdate,0-RTT 被拒时试解密加密与序列号
不切记录、不拼跨记录的握手消息DeframerIter 按头切记录,HandshakeDeframer 拼接去帧与缓冲
只有 exporter_master_secretexport_keying_material 按 RFC 8446 §7.5 从它导出密钥日程
只有 TLS 1.3 的 HKDF 日程TLS 1.2 的 PRF 和扩展主密钥、ECH 的确认秘密、QUIC 的 quic key 等标签TLS 1.2 与其他
有 ext binder、e exp master没有:不支持外部 PSK,不提供 0-RTT 阶段的导出密钥日程

rustls 的 tls13/key_schedule.rs 不算测试有 1040 行,hash_hs.rs 还有 347 行;这里 281 行连 HKDF 和打开记录都写了进去。差出来的部分,大多就是上表左列没有的东西。

动脑筋答案

挂两个:acvp_full_chain 和 rustls_openssl_vectors。在 design-mks-mut 里改完重跑:

test acvp_flipped_dhe_breaks_only_later_secrets ... ok
test draft07_traffic_key ... ok
test finished_round_trip_and_tamper ... ok
test rfc5869_a1 ... ok
test rfc5869_a3_empty_salt_and_info ... ok
test rustls_openssl_vectors ... FAILED
test acvp_full_chain ... FAILED
thread 'rustls_openssl_vectors' (7545574) panicked at tests/vectors.rs:126:5:
...
thread 'acvp_full_chain' (7545569) panicked at tests/vectors.rs:91:5:
assertion `left == right` failed: client_handshake_traffic_secret
...
test result: FAILED. 5 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  • acvp_full_chain 挂在第三个秘密:client_early_traffic_secret 和 e exp master 直接从早期秘密派生,不经过 next,所以还对;client_handshake_traffic_secret 是第一个经过「derived 作盐再 Extract」的,从它开始全错。
  • rustls_openssl_vectors 挂在第一个断言:没有 PSK,它检查的第一个值就是握手流量秘密。
  • draft07_traffic_key 不挂:它从一个给定的流量秘密开始,只测 HKDF-Expand-Label,根本不碰阶梯。
  • acvp_flipped_dhe_breaks_only_later_secrets 也不挂:它断言的是「前两个对、后六个错」,而错误的 derived 恰好也让后六个错。这条测试只能证明 ECDHE 被用上了,不能证明它被用对了。

只有从 ECDHE 一路推到流量秘密、再和外部给的答案比的测试,才能抓住这类错误。这也是 rustls 的类型链管不到的地方:顺序对、类型对,值照样可以错(设计取舍)。

小结

  • HKDF 在 HMAC 上二十行:Extract 是一次 HMAC,Expand 是「上一块、info、计数器」的循环;空盐和全零盐等价。rustls 的 HkdfUsingHmac 是同一个写法,给自定义 provider 用。
  • HKDF-Expand-Label 的 info 是六段:长度、标签长度、tls13 前缀、标签、上下文长度、上下文;Derive-Secret 的上下文是转录哈希,derived 用的是空串的哈希。
  • 三级阶梯用三个类型、两次按值转换表达;rustls 用八个类型,多管了两侧不同的路径和装进记录层的时机。
  • 四组离线向量(RFC 5869、draft-07、NIST ACVP、rustls 自带的 OpenSSL 向量)全部通过,包括 rustls 没有的 e exp master;改坏前缀四个测试挂,改坏 derived 两个测试挂。
  • 用 key log 里的秘密打开了本卷抓包的第 4、6、7、8、9 条记录,两条 Finished 的 verify_data 逐字节重算一致;pong 的序列号是 1。
  • 缺的东西(清零、转录维护、套件抽象、绑定值、序列号管理、TLS 1.2 / ECH / QUIC)每一项都有一页讲 rustls 怎么做。

💬 关联讨论

发起讨论
加载中...