迷你密钥日程
主线位置:握手主线里密钥日程那条链的缩小版,外加记录主线的最后一步。从 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_info | RFC 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_key | draft-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_chain | NIST ACVP「TLS-v1.3-KDF-RFC8446」,同一个 Go 文件的 TestACVPVectors | 有 PSK 的整条链,8 个秘密,含 rustls 没有的 e exp master |
rustls_openssl_vectors | rustls 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
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 做四件事,全部只用上面的函数:
- 用
SERVER_HANDSHAKE_TRAFFIC_SECRET派生 key 和 iv,打开第 4 条记录(服务端加密的那一批),按 4 字节握手头切出各条消息; - 转录从第 1、2 条明文记录的正文(ClientHello、ServerHello)开始,逐条追加;遇到服务器 Finished 时,用到 CertificateVerify 为止的转录哈希重算
verify_data,和收到的比; - 打开第 6 条记录(客户端 Finished),用到服务器 Finished 为止的转录哈希、
CLIENT_HANDSHAKE_TRAFFIC_SECRET重算并比较;再故意少放服务器 Finished 比一次; - 用两个
*_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 打不开。这正是一次握手的动脑筋。
问: 抓包里有了握手流量秘密和应用流量秘密,能不能顺便验证应用流量秘密是从主秘密正确推出来的?
答: 不能。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 两个写死的枚举,直接调 ring | Tls13CipherSuite 带着 provider 给的 Hkdf、Hash、AEAD 实现,可换后端 | CryptoProvider |
只算 binder_key,不算绑定值 | 用截断的 ClientHello 算 PSK 绑定值并校验 | 会话恢复与 0-RTT |
| 给定序列号打开一条记录 | 两个方向各自记序列号,到上限前主动 KeyUpdate,0-RTT 被拒时试解密 | 加密与序列号 |
| 不切记录、不拼跨记录的握手消息 | DeframerIter 按头切记录,HandshakeDeframer 拼接 | 去帧与缓冲 |
只有 exporter_master_secret | export_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 怎么做。