客户端打出一个 ClientHello 才 538 字节,里面已经塞了密钥协商、版本协商、ALPN、SNI、0-RTT 邀请、扩展挂钩 ten-something things。一来一回之后,HKDF 长出 8 把密钥,证书链被验过、握手被 HMAC 自证完整性、AEAD 接管所有应用字节——这一切要在 1 个 RTT 里干完。
这是 TLS 1.3 协议字节级手册,从 ClientHello 到 ECH、再到抗量子 KEM,每一步都标 RFC 条款。
A single ClientHello is only 538 bytes — yet it carries key share, version negotiation, ALPN, SNI, an optional 0-RTT invitation, and ten-something extension hooks. One round-trip later, HKDF has grown eight keys, the certificate chain has been verified, the handshake has been MAC-sealed by itself, and AEAD has taken over every application byte — all inside one RTT.
This is a byte-level field map of TLS 1.3, from ClientHello to ECH and post-quantum KEM, with every step pinned to the relevant RFC clause.
三个公式,一具协议骨骼
Three formulas, one protocol skeleton
"TLS 是什么"这个问题,30 年来有过四个完全不同的答案。SSL 1.0 时代说"它是 RC4 + RSA";SSL 3.0 / TLS 1.0 时代说"它是一套握手 + 一套 Record";TLS 1.2 时代说"它是一份可配置 cipher suite 列表";TLS 1.3 时代说:"它是一组密钥协商 + 身份认证 + AEAD record,且不再让你选。"
每次回答的演化,背后都是一类攻击在催。第一次是 RSA 出口管制 + RC4 bias;第二次是 CBC 与 padding oracle;第三次是 BEAST/CRIME/Lucky13 把可配置性变成攻击面;第四次是把所有不安全的选项一次性砍掉。这一章用三个公式把 TLS 的骨骼立起来——后面 24 章每一章都会反复用到。
"What is TLS?" has had four completely different answers across 30 years. SSL 1.0 era: "RC4 + RSA." SSL 3.0 / TLS 1.0 era: "a handshake plus a record layer." TLS 1.2 era: "a configurable list of cipher suites." TLS 1.3 era: "an authenticated key exchange plus an AEAD record layer, and we're not letting you pick anymore."
Each rewrite was driven by a new class of attack. RSA export controls + RC4 bias; CBC and padding oracles; BEAST/CRIME/Lucky13 turning configurability into attack surface; and finally, in 1.3, the decision to cut every unsafe option. This chapter sets up three formulas that pin down TLS's skeleton — every subsequent chapter will refer back to them.
这三件事在 TLS 里是正交的:换 AKE(X25519 → ML-KEM)不影响 Record;换 AEAD(AES-GCM → ChaCha20)不影响 AKE;删 Ticket 也只是回到 1-RTT,握手本身照常。正交是 TLS 1.3 最大的设计胜利——它让"加抗量子 KEM" 这件事在 2024 年只动了 key_share 一个字段。
These three are orthogonal in TLS. Swap the AKE (X25519 → ML-KEM) and the record layer doesn't care; swap the AEAD (AES-GCM → ChaCha20) and the AKE doesn't care; remove tickets and you fall back to 1-RTT but the handshake itself is untouched. That orthogonality is TLS 1.3's biggest design win — when post-quantum KEM landed in 2024, only one field (key_share) changed.
TLS 1.2 要 2 RTT 才能开始发 HTTP 字节(先 TCP 1 RTT + TLS 2 RTT);TLS 1.3 用一个简单的设计技巧把它压成 1 RTT——客户端在 ClientHello 里把 key_share 直接塞进去,服务器收到就能立刻派生密钥。TLS 1.2 把 key_share 放在第二个回合的 ServerKeyExchange 里,所以必须多一个 RTT。这个变化看起来微小,但需要重写整个握手状态机——RFC 8446 的设计花了 4 年、28 个 draft 才落地。
TLS 1.2 needs 2 RTTs before the first HTTP byte (TCP 1 RTT + TLS 2 RTTs). TLS 1.3 compresses this to 1 RTT with one simple move: the client stuffs the key_share into ClientHello upfront, so the server can derive the shared secret on first sight. TLS 1.2 hides key_share inside the second-flight ServerKeyExchange, mandating an extra round-trip. This sounds like a small change but it required rewriting the entire handshake state machine — RFC 8446 took 4 years and 28 drafts to land.
这三条在 TLS 1.2 里都是可选的——管理员可以关掉 ECDHE 用 RSA-KEX、可以接受 SSLv3 fallback、可以用 SHA-1 transcript。每一项关闭都对应了一个 CVE 编号(FREAK / Logjam / POODLE / SLOTH)。TLS 1.3 的做法:把选项删掉。RSA-KEX 删了,CBC 删了,renegotiation 删了,compression 删了,SHA-1 删了。"想关也关不掉"成为 1.3 的安全保证。
All three were optional in TLS 1.2 — an admin could disable ECDHE and use RSA-KEX, accept SSLv3 fallback, use SHA-1 transcripts. Each off-switch maps to a CVE: FREAK, Logjam, POODLE, SLOTH. TLS 1.3's answer: delete the options. RSA-KEX deleted. CBC deleted. Renegotiation deleted. Compression deleted. SHA-1 deleted. "Can't turn it off even if you want to" is the security guarantee of 1.3.
Client = 你的浏览器或 curl 进程 · Server = 部署 TLS 证书的那个 HTTPS 服务器 · CA = 颁发服务器证书的 Certificate Authority(Let's Encrypt / DigiCert / GTS …)· Root Store = 客户端预装的"我信这些 CA"白名单(Mozilla NSS / Apple / Microsoft / Android)。Ch15-17 详细讲后两者。
Client = your browser or curl · Server = the HTTPS server with a TLS cert · CA = Certificate Authority that issued the server's cert (Let's Encrypt / DigiCert / GTS …) · Root Store = the "I trust these CAs" allow-list bundled in your client (Mozilla NSS / Apple / Microsoft / Android). Ch15-17 cover the last two.
TLS 1.3 不是优化版的 1.2。
它是一次"哪些选项必须没有"的清算。 David Wong · Real-World Cryptography · 2021
TLS 1.3 isn't an optimised 1.2.
It's a reckoning over "which options must not exist". David Wong · Real-World Cryptography · 2021
每一版本号都对应一次大型攻击
every version number maps to a major attack
SSL 1.0 在 Netscape 1994 年的会议室里只活了三个月——内部测试就被发现"RC4 流密钥没加 nonce",没出门就死。SSL 2.0 撑了 1 年,被 1995 年的 cipher-suite rollback 攻击打死。SSL 3.0 撑了 3 年,被 1998 年发现 padding 漏洞、2014 年彻底死于 POODLE。TLS 1.0/1.1 撑了 18 年,活过了 BEAST,但 2020 年 IETF 正式 deprecate。TLS 1.2 活了 10 年(2008-2018),目前仍然占互联网 60% 以上的连接。TLS 1.3(2018-?)正在替换 1.2。
这条家谱不是技术史,是攻击催生的迭代史。每一次大版本号变化背后都有一个 CVE 编号。下面这张表把它对齐。
SSL 1.0 lived three months in Netscape's 1994 conference room — internal review caught "RC4 stream key without a nonce" and it never shipped. SSL 2.0 lasted a year before the 1995 cipher-suite rollback attack killed it. SSL 3.0 lasted three years, vulnerable from 1998, finally killed by POODLE in 2014. TLS 1.0/1.1 lasted 18 years, survived BEAST, formally deprecated by IETF in 2020. TLS 1.2 has lived 10 years (2008–2018) and still serves >60% of internet connections. TLS 1.3 (2018–?) is replacing it.
This isn't technology history — it's the history of attacks driving iteration. Every major version bump maps to a CVE. The table below aligns them.
| YEAR | VERSION | WHAT'S NEW | WHAT KILLED IT |
|---|---|---|---|
| 1994 | SSL 1.0 | RC4 + RSA-KEX,初代RC4 + RSA-KEX, gen 1 | 内部 review 杀死,没发布internal review, never shipped |
| 1995 | SSL 2.0 | 第一个公开版本first public release | cipher-suite rollback 攻击cipher-suite rollback attack |
| 1996 | SSL 3.0 | CBC 模式,HMAC,握手 vs record 拆分CBC mode, HMAC, handshake/record split | POODLE (2014) |
| 1999 | TLS 1.0 | RFC 2246,IETF 接管,重命名RFC 2246, IETF takes over, rename | BEAST (2011) |
| 2006 | TLS 1.1 | CBC 显式 IV,防 BEAST 预表explicit CBC IV, anti-BEAST | Lucky13 (2013) |
| 2008 | TLS 1.2 | AEAD(AES-GCM),SHA-256 transcript,可商议 hashAEAD (AES-GCM), SHA-256 transcript, negotiable hash | CRIME · Heartbleed · ROBOT · FREAK · Logjam · … |
| 2018 | TLS 1.3 | 1-RTT、0-RTT、删 RSA-KEX/CBC/Compression、HKDF Key Schedule1-RTT, 0-RTT, deleted RSA-KEX/CBC/compression, HKDF key schedule | — (5 years and counting) |
| 2023 | ECH RFC | SNI 加密扩展(RFC 9460 HTTPS RR + draft-ietf-tls-esni)SNI encryption (RFC 9460 + draft-ietf-tls-esni) | — |
| 2024 | X25519MLKEM768 | 混合 PQ KEM,Chrome / CF 默认开启hybrid PQ KEM, default in Chrome / CF | — |
TLS 1.2 在 RFC 5246 里定义了 28 个标准 cipher suite,加上扩展共 ~340 个组合(包括 EXPORT、3DES、CBC、RC4 等历史包袱)。TLS 1.3 在 RFC 8446 §B.4 把可选 cipher suite 砍到 5 个,且全部用 AEAD:
TLS 1.2 defined 28 cipher suites in RFC 5246, growing to ~340 combos via extensions (EXPORT, 3DES, CBC, RC4 — historical baggage). TLS 1.3 in RFC 8446 §B.4 cut the catalogue to 5, all AEAD:
注意命名变了:TLS 1.2 的名字叫 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256——5 个独立可商议字段串联起来,全是攻击面。TLS 1.3 的名字只保留 AEAD + hash,签名算法和密钥协商曲线另起在扩展里商议。"可组合性是攻击面" 是 1.3 设计的核心观察。
The naming changed too. TLS 1.2 names like TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 chain five independently-negotiated fields — five attack surfaces. TLS 1.3 names only carry AEAD + hash; signature algorithm and key-exchange curve are negotiated in separate extensions. "Composability is attack surface" is a core design observation of 1.3.
OpenSSL 1.0.1 实现 Heartbeat 扩展时漏 边界检查——客户端可以请求服务器"回 65535 字节",但实际只发了 1 字节 payload。服务器盲目从堆里复制 65534 字节出去,包括其他连接的私钥、密码、cookies。影响 17% 的互联网 HTTPS 服务器。修复要求所有 cert 重新签发。RFC 8446 干脆把 Heartbeat 扩展从 TLS 1.3 里删掉了——"选项即攻击面" 又一例。
OpenSSL 1.0.1 implemented the Heartbeat extension without a bounds check — the client could ask for "65535 bytes back" while sending only 1 byte of payload. The server blindly memcpy'd 65534 bytes off the heap, including other connections' private keys, passwords, and cookies. 17% of HTTPS servers affected. Fixing meant reissuing every certificate. RFC 8446 simply removed Heartbeat from TLS 1.3 — "options are attack surface" in action.
四个无法在 1.2 框架内修复的洞
four holes you cannot patch inside 1.2
"为什么不增量改 TLS 1.2?" 是 TLS WG 在 2014 年讨论得最多的问题。答案是:1.2 的握手设计有四个结构性死结,每一个都需要破坏向后兼容才能修。既然要破坏兼容,不如重写。
"Why not incrementally patch TLS 1.2?" was the TLS WG's most-debated question in 2014. The answer: 1.2's handshake has four structural deadlocks, each of which needs a backwards-incompatible break to fix. If you're breaking compat anyway, just rewrite.
TLS 1.2 的握手必须先ClientHello → ServerHello(1 RTT)让双方交换 cipher suite 与 random,然后才能ServerKeyExchange / ClientKeyExchange(第 2 个 RTT)交换 DH 公钥。要把它压成 1 RTT,必须让客户端在第一个包里就猜服务器愿意用什么曲线。1.2 不支持"猜"——客户端 ClientHello 里只有 cipher-suite list,没有 key share。
TLS 1.2's handshake mandates ClientHello → ServerHello (RTT 1) to swap cipher suite and random, then ServerKeyExchange / ClientKeyExchange (RTT 2) to swap DH public keys. To squeeze this to 1 RTT the client must guess a curve upfront. 1.2 doesn't support guessing — its ClientHello carries a cipher-suite list, no key share.
RSA 密钥交换(RFC 5246 §7.4.7.1)让客户端用服务器公钥加密 pre-master secret 发回去。如果5 年后服务器私钥被偷——攻击者拿出 5 年前抓的所有 pcap,能解密所有历史会话。这就是没有前向保密。直到 2014 年 Snowden 文件公布"NSA 抓存所有 HTTPS 流量等私钥泄露"才让业界全面警觉。TLS 1.3 直接把 RSA-KEX 删了,只留 ECDHE(每次会话临时密钥对)。
RSA key exchange (RFC 5246 §7.4.7.1) has the client encrypt a pre-master secret with the server's public key. If five years later the server's private key leaks, an attacker holding 5-year-old pcaps can decrypt every historical session. That's no forward secrecy. Only after the 2014 Snowden documents — "NSA harvests HTTPS pcaps waiting for key leaks" — did the industry universally catch on. TLS 1.3 simply deleted RSA-KEX, leaving only ECDHE (ephemeral keys per session).
TLS 1.2 允许在已建立的连接上"重新协商"——中间能切换 cipher suite、要求客户端证书、刷新密钥。CVE-2009-3555 发现:中间人能prepend 任意握手数据到连接里,使得"客户端以为自己刚连上"而服务器以为"客户端在重协商",整段攻击者数据被当作 GET 字段处理。这导致 HTTPS POST 被攻击者注入 cookies。修补方案是 RFC 5746 "renegotiation indication extension",要求双方记住上次的 Finished MAC——但实现复杂、长期没普及。1.3 的解决方法:把 renegotiation 整个删掉。要换密钥用新的 KEY_UPDATE 消息,不是握手而是 record 层升级。
TLS 1.2 lets you "renegotiate" mid-connection — switch cipher, request a client cert, rekey. CVE-2009-3555 showed: an MITM can prepend arbitrary handshake bytes so the client thinks "just connected" while the server thinks "renegotiating," and the attacker's bytes get parsed as a GET parameter — letting attackers inject cookies into HTTPS POSTs. The fix (RFC 5746 "renegotiation indication") had both sides remember the previous Finished MAC — complex, slow to adopt. 1.3's fix: delete renegotiation. Need to rekey? Use the new KEY_UPDATE message — not a handshake, just a record-layer rekey.
TLS 1.2 让客户端发送 cipher-suite 列表,服务器挑一个,但 ClientHello 本身没加密。FREAK / Logjam 都是同一招:中间人把客户端的 ClientHello 改成只列 EXPORT cipher(512-bit DH,可在几小时内破解),服务器以为客户端不支持强 cipher,于是降级。MAC 不能覆盖这次降级——因为 MAC 算的是 handshake transcript,但 handshake 一开始还没密钥。
1.3 的"downgrade sentinel"打破这个循环:服务器在 ServerHello.random 的最后 8 字节嵌入特殊值 "DOWNGRD\01"(如果客户端原本支持 1.3 但被降到 1.2)。Finished MAC 覆盖 ServerHello.random,所以中间人无法在不被发现的情况下降级。
TLS 1.2 has the client send a cipher-suite list, server picks one, but ClientHello itself isn't encrypted. FREAK and Logjam used the same trick: an MITM rewrites the ClientHello to advertise only EXPORT ciphers (512-bit DH, breakable in hours), the server thinks the client can't do strong crypto, downgrades. MAC can't catch this — MAC is over the transcript but there's no key yet at this point in the handshake.
1.3's downgrade sentinel breaks the cycle: the server embeds "DOWNGRD\01" in the last 8 bytes of ServerHello.random if the client originally supported 1.3 but got downgraded. Since Finished MAC covers ServerHello.random, the MITM can't downgrade unseen.
| SIN | 1.2 SYMPTOM | 1.3 FIX | BACKWARDS-COMPAT BREAK |
|---|---|---|---|
| 2 RTT | 必须两个往返才能传应用层app data needs 2 RTTs | CH 直接带 key_shareCH carries key_share | CH 格式改变CH format change |
| RSA-KEX | 无前向保密no forward secrecy | 删 RSA-KEX,只留 ECDHEdelete RSA-KEX, ECDHE only | cipher suite 列表清空cipher-suite catalogue purged |
| Renegotiation | RCE (CVE-2009-3555) | 删 renegotiation,用 KEY_UPDATEdelete renego, use KEY_UPDATE | 客户端证书后置no mid-conn cert request |
| Negotiation | FREAK / Logjam 降级 | downgrade sentinel + ServerHello.randomdowngrade sentinel | 小(向后兼容)small (backwards-compatible) |
四个洞中前三个都要破坏 ClientHello / cipher-suite / handshake 状态机的格式。要破,干脆一次破干净——这就是为什么 TLS 1.3 不叫 "TLS 1.2.1"。
Three of the four require breaking ClientHello, the cipher-suite catalogue, or the handshake state machine. If you're breaking anyway, break cleanly — that's why TLS 1.3 isn't called "TLS 1.2.1".
你不能往一具尸体上贴优化。
你必须把它砍倒,重新种一棵树。 Eric Rescorla · TLS WG · IETF 95 hallway track
You can't keep patching a corpse.
You have to cut it down and plant a new tree. Eric Rescorla · TLS WG · IETF 95 hallway track
本文的主线案例 · 10 个阶段贯穿全书
the through-line · 10 phases across all 25 chapters
从这一章开始,全书所有的字节级讨论都对应这一次具体的握手。客户端是 curl 8.6(链接 OpenSSL 3.2),目标是 https://ursb.me/。服务器是 nginx 1.27 跑在我的阿里云上,证书由 Let's Encrypt 签发(ECDSA-P256,链长 2),开启了 X25519MLKEM768 抗量子 hybrid KEM、ECH(用 cloudflare-ech.com 当 outer SNI)。
10 个阶段全景:
From here on, every byte-level discussion in this book traces back to this one concrete handshake. The client is curl 8.6 (linked against OpenSSL 3.2) targeting https://ursb.me/. The server is nginx 1.27 on my Aliyun box, with a Let's Encrypt ECDSA-P256 cert (chain length 2), X25519MLKEM768 hybrid post-quantum KEM enabled, and ECH (using cloudflare-ech.com as outer SNI).
The 10 phases at a glance:
transcript_hash 标注是 Key Schedule 的"时刻快照"——每往 H 里塞一段消息,下游派生的密钥就会变。注意 client Finished 与 GET / 在同一个 record flight。
Fig ★·1 · Full 1-RTT handshake · blue = plaintext · purple = handshake-encrypted · copper = application-encrypted. The green transcript_hash notes mark Key Schedule "snapshots" — every message folded into H changes downstream key derivation. Client Finished and GET / ship in the same record flight.
每一个阶段在后面会被对应章节字节级展开。下面这张地图标注了哪一段在哪一章详细讲:
Each phase gets a byte-level expansion in the corresponding later chapter. The map below tells you where to look:
| PHASE | EVENT | CHAPTER | BYTE-LEVEL ARTIFACT |
|---|---|---|---|
| 0 | DNS HTTPS RR · ECHConfig | Ch19 | RR type=65 · ipv4hint, ech, alpn |
| 2 | ClientHello · 538 bytes | Ch05 · Ch19 | struct ClientHello { … } |
| 3a | ServerHello | Ch06 | struct ServerHello { … } |
| 3b | EncryptedExtensions | Ch07 | struct EncryptedExtensions { … } |
| 3c | Certificate · ECDSA-P256 chain | Ch15 · Ch16 | CertificateEntry[] (DER × 2) |
| 3d | CertificateVerify · signature | Ch09 | SignatureScheme + signature<0..2^16-1> |
| 3e | Finished (server) | Ch10 | HMAC(finished_key, transcript) |
| 4 | Key Schedule · HKDF tree | Ch08 | 8 secrets from one PSK/ECDHE |
| 5 | Finished (client) + GET / | Ch10 · Ch11 | AEAD(client_application_traffic_secret) |
| 7 | 200 OK body | Ch11 | AEAD record stream |
| 8 | NewSessionTicket × 2 | Ch12 | opaque ticket<1..2^16-1> |
| 9 | Next-visit · 0-RTT | Ch13 · Ch14 | CH.early_data + early_data record |
本书后续每个 byte-dump 都从这次抓包来。具体抓法:SSLKEYLOGFILE=/tmp/keys.log curl -vk --tlsv1.3 --tls13-ciphers TLS_AES_256_GCM_SHA384 -H "Host: ursb.me" https://ursb.me/ & tcpdump -i any -w /tmp/handshake.pcap port 443,然后 Wireshark 接 keylog 解密。Ch24 给完整步骤。
All later byte dumps come from this capture. Repro: SSLKEYLOGFILE=/tmp/keys.log curl -vk --tlsv1.3 https://ursb.me/ & tcpdump -i any -w /tmp/handshake.pcap port 443, then open in Wireshark with the keylog attached. Ch24 has the full recipe.
主线阶段 2 · 第一个字节
Main-line phase 2 · the first byte
ClientHello 是 TLS 协议唯一一条明文消息——之后所有东西都被加密。它要在 1 KB 之内向服务器说清楚:我是谁、我想说什么版本的协议、我支持哪些密钥协商曲线、给你一份我已经为这些曲线生成好的公钥(key_share)、我支持哪些 cipher suite、哪些签名算法、我要去哪个域名(SNI)、应用层用什么协议(ALPN)、要不要恢复上次的会话(PSK)、能不能立刻让我发应用数据(early_data)。
这些事情塞进一个 struct 里,定义在 RFC 8446 §4.1.2。先看结构再看具体字节。
ClientHello is the only plaintext TLS message — everything afterwards is encrypted. In under 1 KB it has to tell the server: who I am, what protocol version I want, which key-exchange curves I support, my pre-generated public key for each (key_share), which AEAD cipher suites I accept, which signature algorithms, which hostname (SNI), which application protocol (ALPN), whether I want to resume a previous session (PSK), and whether I want to send application data immediately (early_data).
All of this lives in a single struct defined in RFC 8446 §4.1.2. Structure first, bytes second.
骨架本身只有四个字段,真正的 TLS 1.3 协议藏在 extensions 里。这是一个深思熟虑的兼容性设计——1.2 时代的中间盒只看前几个字段,看到 legacy_version = 0x0303(TLS 1.2)就放行。"真版本号"放在 supported_versions 扩展里,告诉支持 1.3 的服务器"我其实想说 1.3"。
The skeleton is just four fields — the real TLS 1.3 protocol hides inside the extensions list. This is deliberate middlebox-compat design: 1.2-era middleboxes only inspect the top fields and pass through anything with legacy_version = 0x0303 (TLS 1.2). The "real version" lives in the supported_versions extension, telling 1.3-capable servers "I actually want 1.3".
| EXT (TYPE) | SAYS | DECIDES | CHAPTER |
|---|---|---|---|
supported_versions (43) | "I want TLS 1.3" | 协议版本(绕过 legacy_version)Protocol version (bypasses legacy_version) | 本章 |
supported_groups (10) | "这些曲线我接受I accept these curves" | key_share 的命名空间key_share namespace | Ch08 · Ch21 |
key_share (51) | "这是我已经为它生成的公钥Pre-generated pubkey for the curves" | 省 1 个 RTT 的关键the secret behind 1-RTT | Ch08 |
signature_algorithms (13) | "这些签名算法我能验These sig schemes I can verify" | 证书链 + CertificateVerifycert chain + CertificateVerify | Ch09 · Ch15 |
server_name (0) | "我要去 ursb.meI want ursb.me" | 虚拟主机路由 + 证书选择vhost routing + cert selection | Ch18 · Ch19 |
主线握手里 key_share 占了 1216 字节——是整个 ClientHello 的 70%。原因:客户端为每一个它支持的曲线生成一个公钥。X25519 公钥 32 字节、P-256 公钥 65 字节,加起来才 100 字节左右。但 X25519MLKEM768 混合曲线的 ML-KEM 部分公钥就是 1184 字节。Post-Quantum 把整个 ClientHello 从 ~300 字节膨胀到 ~538 字节,这是 2024 年部署 PQ KEM 时争论得最激烈的开销点。Ch21 会详细讲。
In the main-line handshake, key_share takes 1216 bytes — 70% of the entire ClientHello. Reason: the client pre-generates a public key for each curve it supports. X25519 = 32 B, P-256 = 65 B — about 100 B total. But the ML-KEM part of X25519MLKEM768 is 1184 B by itself. Post-quantum bloats ClientHello from ~300 B to ~538 B — this was the single most-debated cost in 2024 PQ-KEM deployment. Ch21 has the deep dive.
ClientHello 必须 padding 到至少 512 字节(RFC 8446 §D.4)——这是纯兼容性要求,老中间盒看到太小的 TLS 1.3 ClientHello 会以为是 1.2 残包丢弃。RFC 7685 定义了 padding 扩展。
ClientHello must pad to at least 512 bytes (RFC 8446 §D.4) — purely a middlebox-compat requirement; older middleboxes drop ClientHello they perceive as too small. RFC 7685 defines the padding extension.
TLS 1.3 设计期间最痛苦的发现:互联网上有大量"读 TLS 字段但拒绝陌生值"的中间盒。它们在 2014 年的现实抓包里占非小比例——CDN、企业代理、家用路由器、IoT 网关、抗 DDoS 设备。任何一个看到"不熟悉的 record_version" 或"陌生的 cipher 编号"就直接 RST 包。1.3 的协议设计被迫"扮成 1.2",五个伪装字段一个都不能少:
The most painful discovery of TLS 1.3 design: the internet is full of middleboxes that "read TLS fields but reject unknown values". They were a non-trivial fraction of 2014 traffic — CDNs, enterprise proxies, home routers, IoT gateways, anti-DDoS appliances. Any one of them sees an "unfamiliar record_version" or "unknown cipher number" and slams a RST. 1.3's protocol was forced to "pretend to be 1.2", with five disguise fields, all mandatory:
| FIELD | WHAT 1.3 PUTS THERE | WHY | RFC |
|---|---|---|---|
| CH.legacy_version | 0x0303 (TLS 1.2) | 真版本号搬去 supported_versions 扩展real version moved to supported_versions extension | 8446 §4.1.2 |
| CH.legacy_session_id | 32 字节随机(不是空)32 random bytes (not empty) | 中间盒看到空 session_id 以为是异常 1.2 流量middleboxes treat empty session_id as anomalous | 8446 §4.1.2 + Appendix D.4 |
| CH.legacy_compression | [0x00] (1 byte) | 1.3 没 compression,但必须传一个空压缩列表no compression in 1.3, but must include an empty list | 8446 §4.1.2 |
| Record.record_version | 0x0303 always | 所有 record header 都写 TLS 1.2 字面值every record header writes the literal TLS 1.2 value | 8446 §5.1 |
| 假 ChangeCipherSpecDummy ChangeCipherSpec | type=20, len=1, "0x01" | 客户端发完 CH 后塞一个假 CCS,骗中间盒"开始换密钥了"client sends a fake CCS after CH so middleboxes think "rekey starting" | 8446 §D.4 |
第五项最戏剧化:假 CCS 是纯仪式消息。TLS 1.3 没有 ChangeCipherSpec 这个概念(密钥切换由 HKDF 树驱动,不需要单独通知),但不发会让中间盒以为"客户端没正常进入密钥交换阶段"而 RST。所以 RFC 8446 强制双方各发一条空 CCS——纯粹给中间盒看。客户端收到对方的 CCS 立刻丢弃,绝不参与状态机。
The fifth is the most theatrical: dummy CCS is a pure ritual. TLS 1.3 has no ChangeCipherSpec (key changes are driven by the HKDF tree, no separate notification needed), but not sending one makes middleboxes think "client didn't enter key-exchange phase" and RST. So RFC 8446 mandates both sides send one empty CCS — purely for middleboxes to see. The receiver immediately drops it; it never enters the state machine.
Middlebox compat 解决"已经部署的协议如何活下去",但留了个二阶问题:以后任何新增的 cipher suite / 扩展号都可能撞上"陌生值就 RST"的中间盒。怎么让中间盒"训练成接受未知"?
Google 在 2016 年提出 GREASE(Generate Random Extensions And Sustain Extensibility,RFC 8701):客户端在 ClientHello 里故意夹塞一些保留的"假"值——保留的 cipher suite ID(如 0x0A0A, 0x1A1A, …, 0xFAFA)、保留的扩展 type(0x0A0A, 0x2A2A, …)、保留的曲线 group ID。
逻辑:服务器看到这些知道它们是假的(标准里就是写"忽略"),但中间盒以为它们是真的未知值。如果中间盒因此 RST,立刻有人能发现并报告厂商。"故意发一些'未知'让中间盒去适应" 把生态训练成对未知值容忍——这样未来真加新东西就不会被中间盒杀掉。
Chrome 自 2016 年起在每个 ClientHello 里发 GREASE 值。Firefox / Safari 也跟进。BoringSSL 是 GREASE 的参考实现。
Middlebox compat handles "how does a deployed protocol survive", but leaves a second-order problem: any new cipher suite / extension number could collide with "unknown value → RST" middleboxes. How to train middleboxes to accept the unknown?
Google's 2016 answer: GREASE (Generate Random Extensions And Sustain Extensibility, RFC 8701). The client deliberately stuffs reserved "fake" values into ClientHello — reserved cipher suite IDs (0x0A0A, 0x1A1A, …, 0xFAFA), reserved extension types (0x0A0A, 0x2A2A, …), reserved curve group IDs.
The logic: the server sees these and knows they're fake (the spec literally says "ignore"). But a middlebox sees them as real unknown values. If the middlebox RSTs because of them, someone catches it immediately and reports the vendor. "Send some 'unknown' deliberately so middleboxes adapt" trains the ecosystem to be lenient — so when the next genuinely new thing arrives, middleboxes don't kill it.
Chrome has sent GREASE in every ClientHello since 2016. Firefox / Safari followed. BoringSSL is the reference implementation.
GREASE 的副作用:每个 ClientHello 里 GREASE 值的位置是固定的(每会话随机选一个值但位置固定)。这让 JA4 指纹算法能区分 Chrome 的 GREASE 与其他实现,从而被反爬虫 / DDoS 系统当浏览器真伪信号。Cloudflare Bot Management 大量利用这一点。
A GREASE side-effect: the position of GREASE values inside ClientHello is fixed (the value is randomised per-session but the slot is fixed). That makes JA4 fingerprinting distinguish Chrome's GREASE pattern from other implementations, turning into a "real browser?" signal used by anti-scraping / DDoS. Cloudflare Bot Management leans on this heavily.
主线握手开了 ECH,所以网络上实际跑的有两份 ClientHello:
cloudflare-ech.com,是公开的"封皮"。所有中间盒只能看到这一份。ursb.me,整个被 HPKE 加密后塞进 encrypted_client_hello 扩展。只有 Cloudflare 的 ECH frontend 能解。两份大小、字段顺序、扩展顺序都要"看起来一样"——否则流量分析就能区分有没有用 ECH。Ch19 详细讲。
The main-line handshake has ECH on, so two ClientHellos actually traverse the wire:
cloudflare-ech.com, the public "envelope". Middleboxes only see this one.ursb.me, HPKE-encrypted and stuffed into the encrypted_client_hello extension. Only Cloudflare's ECH frontend can decrypt.The two must look identical in size, field order, extension order — otherwise traffic analysis distinguishes ECH-enabled from not. Ch19 covers the details.
主线阶段 3a · 服务器的回应
Main-line phase 3a · server's response
ServerHello 看起来是 ClientHello 的对仗——同样的 random + cipher_suite + extensions——但它只有 ClientHello 的三分之一大。原因很简单:客户端要发列表(我支持 A、B、C),服务器只要发选择(我选 B)。
服务器只回三件事:选了哪个 cipher suite、选了哪个 supported_group、把它自己的 key_share 公钥发给你。其他扩展全部移到了 EncryptedExtensions(下一章)。这是 1.3 的小革命——只有真正影响密钥派生的扩展才能出现在明文 ServerHello 里。
ServerHello looks symmetric to ClientHello — same random + cipher_suite + extensions — but it's a third the size. Reason: the client sends a list ("I support A, B, C"); the server sends a choice ("I pick B").
The server returns three things only: which cipher suite, which supported_group, and its own key_share public key. Every other extension moved to EncryptedExtensions (next chapter). This is one of 1.3's quiet revolutions: only extensions that affect key derivation may appear in plaintext ServerHello.
HRR 长得跟 ServerHello 一模一样,连消息类型都是 ServerHello(2)。区别在 random 字段——HRR 的 random 是一个固定值:
HRR looks identical to ServerHello — even the message type is ServerHello(2). The only difference is the random field, which is a fixed sentinel:
客户端看到这个 random 就知道:"不是真握手,服务器在请我重发 ClientHello。"为什么会发生?两种情况:
selected_group,客户端为这个曲线生成新 key_share 重发。HRR 把握手从 1 RTT 拉到 2 RTT——但绝大多数客户端默认把 X25519 + P-256 都发,命中率极高,真发生 HRR 的不到 0.1%。
The client sees this random and knows: "Not a real ServerHello — the server is asking me to redo ClientHello." Two reasons:
selected_group in HRR; the client generates a fresh key_share for that curve and resends.HRR stretches the handshake from 1 RTT to 2 RTT — but most clients default to sending X25519 + P-256 key_shares, hit rate is very high, and real HRR happens in <0.1% of connections.
CF 边缘 2024 年的统计:99.94% 的 TLS 1.3 握手 1 RTT 完成,0.06% 触发 HRR。其中 HRR 的主要触发因素是企业网代理把 supported_groups 改成只支持 P-384 这种非主流曲线。这给抗量子时代敲警钟——Ch21 会讲:服务器 ML-KEM 部署初期会有 HRR 高峰。
CF edge 2024 stats: 99.94% of TLS 1.3 handshakes complete in 1 RTT, 0.06% trigger HRR. The main HRR trigger is enterprise-proxy middleboxes rewriting supported_groups to non-mainstream curves like P-384. Foreshadowing Ch21: early ML-KEM deployment will spike HRR rates until clients populate ML-KEM in their default key_share list.
主线阶段 3b · 1.3 区别于 1.2 的标志
Main-line phase 3b · the 1.3 vs 1.2 watershed
TLS 1.2 的整个 ServerHello + 扩展 + 证书 + 证书签名都是明文。中间人可以读你访问哪个网站、用什么 ALPN、申请什么客户端证书。TLS 1.3 把这一切搬到 EncryptedExtensions / Certificate / CertificateVerify 三条消息里,用 handshake_traffic_secret 加密。从 ServerHello 写完那一刻 起,所有 TLS 流量都加密了——只剩 ClientHello 一条明文(这就是为什么 Ch19 ECH 要把它也封起来)。
EncryptedExtensions 是这个加密段的第一条消息,专门承载"和密钥派生无关、但握手中需要"的扩展。
In TLS 1.2 the entire ServerHello + extensions + Certificate + signature ride in plaintext. An MITM reads which site you're visiting, which ALPN, which client cert was requested. TLS 1.3 moves all of this into EncryptedExtensions / Certificate / CertificateVerify, protected by handshake_traffic_secret. From the moment ServerHello finishes, the rest of TLS is encrypted — only ClientHello stays plaintext (which is why Ch19 ECH then wraps even that).
EncryptedExtensions is the first message in this encrypted band, carrying extensions that are "needed during handshake but irrelevant to key derivation."
| EXT | WHY HERE NOT IN SH | EXAMPLE |
|---|---|---|
server_name (0) | 没影响密钥派生,但是 server 要告诉 client "你的 SNI 我看到了"no key impact; server confirms "yes I saw your SNI" | empty (just acks) |
alpn (16) | 应用层协议选择app-layer protocol choice | "h2" |
max_fragment_length (1) | 记录层分片大小协商record layer fragment size | 2^14 |
early_data (42) | 如果 server 接受 0-RTT(Ch13)if server accepts 0-RTT (Ch13) | empty |
supported_groups (10) | 告诉 client "我下次还接受这些曲线"tells client "next time I accept these groups too" | [X25519MLKEM768, X25519, P-256] |
EncryptedExtensions 故意不承载 Certificate——证书是一个独立的握手消息(type=11)。这是消息边界的考虑:Certificate 可能很大(链长 3 + OCSP staple 可以到 8 KB),把它做成单独消息让 fragmentation 在 record 层处理更干净。也是为了 PSK 路径:纯 PSK resumption 不发 Certificate,状态机里 EE 后面直接接 Finished。
EncryptedExtensions deliberately doesn't carry Certificate — Certificate is its own handshake message (type=11). Message-boundary reason: certs can be large (chain-of-3 + OCSP staple = 8 KB), so giving it its own message lets the record layer handle fragmentation cleanly. Also matters for the PSK path: pure PSK resumption skips Certificate entirely; the state machine then jumps directly from EE to Finished.
EE 上的 record header 写 ApplicationData(23),不是 Handshake(22)。原因:从这条消息起所有 TLS record 都是 AEAD 加密的,header type 字段也是要隐藏的元数据。RFC 8446 §5 规定加密后所有 record 的 outer type 都是 23,真实的消息类型藏在密文末尾(解密后看最后一个非零字节)。这是 1.3 的另一项静默改动,1.2 时代 record type 是明文的。
The record header says ApplicationData(23), not Handshake(22). Reason: from this message on every TLS record is AEAD-encrypted, and the type byte itself is metadata to hide. RFC 8446 §5 mandates every encrypted record uses outer type = 23; the real type hides at the end of the plaintext (decrypt, then read the last non-zero byte). Another quiet 1.3 change — in 1.2 the record type was plaintext.
TLS 1.2 时代,所有服务器扩展(SNI ACK、ALPN、status_request、heartbeat、…)都在明文 ServerHello 里。中间人能看到全部。TLS 1.3 把它们分成三类,按"是否影响密钥派生"路由:
In TLS 1.2, every server extension (SNI ACK, ALPN, status_request, heartbeat, …) sat in plaintext ServerHello. The MITM saw everything. TLS 1.3 splits them into three classes, routed by "does this affect key derivation":
| EXTENSION | 1.2 LIVED IN | 1.3 LIVED IN | WHY |
|---|---|---|---|
supported_versions | — | SH (plaintext) | 新扩展 · 必须明文new ext · must be plaintext |
key_share | — | SH (plaintext) | 服务器选 group 必须明文给客户端server group choice must be plaintext |
pre_shared_key | — | SH (plaintext) | 服务器选 PSK index 必须明文server PSK index choice must be plaintext |
server_name (ACK) | SH (plaintext) | EE (encrypted) | 不影响密钥,加密no key impact, encrypt |
alpn | SH (plaintext) | EE (encrypted) | 应用层协议选择,藏起来app-layer choice, hide |
supported_groups | SH (plaintext) | EE (encrypted) | 服务器告诉客户端"下次还可以用这些""next time you can also try these" |
early_data | — | EE (encrypted) | 服务器接受 0-RTT 的指示server's 0-RTT acceptance signal |
certificate_authorities | — | EE / CertReq (encrypted) | 客户端证书选择提示client cert selection hint |
heartbeat | SH (plaintext) | DELETED | Heartbleed |
renegotiation_info | SH (plaintext) | DELETED | renegotiation 整个删了renego gone |
规律:"密钥派生需要 → 留 SH","不需要 → 搬 EE"。SH 的明文扩展只剩 3 个,是必要恶;EE 接管了其他所有"中间人本不该看到"的字段。Ch18 SNI 之耻 + Ch19 ECH 是为了把最后那 3 个明文字段也封死。
The rule: "need it for key derivation → SH; not needed → EE". SH's three plaintext extensions are the necessary evil; EE swallowed everything else the MITM had no business seeing. Ch18 SNI shame + Ch19 ECH exist to also seal those last three plaintext fields.
主线阶段 4 · 协议的密码学心脏
Main-line phase 4 · the cryptographic heart of the protocol
到这里双方都掌握了两个秘密:(EC)DHE shared secret(从 key_share 算出的,X25519 是 32 字节,混合 PQ 是 32+32=64 字节)和——如果是 resumption——PSK(从上次 NewSessionTicket 来的)。第一次访问 ursb.me 时只有前者。但 TLS 1.3 不直接用这些做 AEAD 密钥。它走一条密钥派生流水线,把这点根种子展开成 8 把不同用途的密钥。
展开器叫 HKDF (HMAC-based Key Derivation Function),RFC 5869 定义。它的灵感来自 "HMAC-then-expand" 这一对操作:Extract 把弱熵浓缩成均匀的 PRK;Expand 把 PRK 重复扩张成任意长度的输出。TLS 1.3 在此之上加一道 Derive-Secret 装饰,把 transcript-hash 绑进每一把密钥的派生过程——这保证了密钥无法跨握手共享。
By this point both sides hold two secrets: the (EC)DHE shared secret (derived from key_share — 32 bytes for X25519, 32+32=64 for hybrid PQ) and — on resumption — a PSK (from the prior NewSessionTicket). On the first visit to ursb.me, only the former. But TLS 1.3 doesn't feed these directly into AEAD. It runs a key derivation pipeline that expands the root seed into 8 keys for distinct purposes.
The expander is HKDF (HMAC-based Key Derivation Function), RFC 5869. Its core idea: Extract condenses weak entropy into a uniform PRK; Expand grows PRK into arbitrary-length output. TLS 1.3 adds a Derive-Secret wrapper that binds the transcript-hash into every key derivation — guaranteeing keys can never cross handshakes.
HKDF-Expand 的 info 字段在 TLS 1.3 里不是随便填的——RFC 8446 §7.1 规定它必须按 HkdfLabel 这个 struct 编码。这一层包装的目的是防止跨协议复用:用 "tls13 " 前缀让 TLS 派生出的密钥无法被另一个协议(如 QUIC 在没有 RFC 9001 之前)的 HKDF 误解。
HKDF-Expand's info field isn't arbitrary in TLS 1.3 — RFC 8446 §7.1 requires it to encode as the HkdfLabel struct. The "tls13 " prefix is a cross-protocol domain separator: keys derived for TLS can't be accidentally interpreted by another protocol's HKDF (e.g. QUIC before RFC 9001 wired it in).
用主线 ursb.me 握手的实际字节走一遍:派生 client_handshake_traffic_secret。这一步的输入是 handshake_secret + 字面 label "c hs traffic" + transcript_hash(ClientHello ‖ ServerHello)。下面 hex 都来自一次真实抓包,可以拿任意一个 HKDF 库自己跑对。
Walk through one with real bytes from the main-line ursb.me handshake: derive client_handshake_traffic_secret. Inputs: handshake_secret + literal label "c hs traffic" + transcript_hash(ClientHello ‖ ServerHello). The hex below is from an actual capture — paste it into any HKDF library and the output should match.
用 cryptography 库(Python):from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand; HKDFExpand(algorithm=hashes.SHA384(), length=48, info=hkdf_label_bytes).derive(prk)。把上面 PRK 和 hkdf_label 喂进去,输出应该和 T1 完全一致。这是把 RFC 8446 §7.1 翻译成 30 行可运行代码的最小复现。
With Python's cryptography: from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand; HKDFExpand(algorithm=hashes.SHA384(), length=48, info=hkdf_label_bytes).derive(prk). Feed the PRK and hkdf_label above; the output must equal T1. This is RFC 8446 §7.1 translated to 30 lines of runnable code.
| NAME | LABEL | CONTEXT (transcript) | USED FOR |
|---|---|---|---|
| 外部 binder_keyext binder_key | "ext binder" | Hash("") | 外部 PSK 的 Finished MAC(RFC 9258)Finished MAC for external PSK (RFC 9258) |
| resumption binder_keyres binder_key | "res binder" | Hash("") | 主线 resumption PSK 的 Finished MAC · Ch13main-line resumption PSK Finished MAC · Ch13 |
| client_early_traffic_secret | "c e traffic" | Hash(CH) | 0-RTT 数据加密 · Ch13/140-RTT data encryption · Ch13/14 |
| early_exporter_master_secret | "e exp master" | Hash(CH) | 应用层 0-RTT exporter(如 keyless TLS)app-layer 0-RTT exporter (e.g. keyless TLS) |
| client_handshake_traffic_secret | "c hs traffic" | Hash(CH ‖ SH) | 客户端→服务器 加密握手client→server encrypted handshake |
| server_handshake_traffic_secret | "s hs traffic" | Hash(CH ‖ SH) | 服务器→客户端 加密握手server→client encrypted handshake |
| client_application_traffic_secret_0 | "c ap traffic" | Hash(CH..ServerFinished) | 客户端→服务器 应用数据 · Ch11client→server app data · Ch11 |
| server_application_traffic_secret_0 | "s ap traffic" | Hash(CH..ServerFinished) | 服务器→客户端 应用数据server→client app data |
| exporter_master_secret | "exp master" | Hash(CH..ServerFinished) | 应用层 exporter(如 QUIC HP key, OAuth token binding)app-layer exporter (QUIC HP key, OAuth token binding) |
| resumption_master_secret | "res master" | Hash(CH..ClientFinished) | 下次 visit 的 PSK 根种子 · Ch12next-visit PSK root · Ch12 |
每条 traffic_secret 后面还会再派生 key("key" label, 32 B for AES-256)+ iv("iv" label, 12 B)两件套,共 6 套 AEAD 上下文 × 12 字节 = 72 字节 IV 储存。exporter_master_secret 是 TLS 给应用层的"暗号"——应用层调 SSL_export_keying_material(ssl, label, context, length) 就能用 HKDF-Expand-Label 派生任意 secret。QUIC 用它派生 Header Protection key(RFC 9001 §5.4),OAuth Token Binding 用它派生 binding key(RFC 8473)。
Each traffic_secret further derives a pair: key ("key" label, 32 B for AES-256) + iv ("iv" label, 12 B) — 6 AEAD contexts × 12 B = 72 B of IV storage. exporter_master_secret is TLS's "back door" to the application layer — apps call SSL_export_keying_material(ssl, label, context, length) and get any HKDF-Expand-Label-derived secret. QUIC uses it to derive Header Protection keys (RFC 9001 §5.4); OAuth Token Binding derives binding keys from it (RFC 8473).
四个理由让 TLS 必须用 HKDF 树而非"一道 HMAC + 截断":
KEY_UPDATE(Ch11)派生新一代而不重新握手。这要求 master_secret 是一个能持续 expand 的根。Four reasons TLS needs an HKDF tree, not "one HMAC + truncate":
KEY_UPDATE (Ch11) without a new handshake. Requires master_secret to be a long-lived expand-able root.主线阶段 3d · 身份认证的关键一笔
Main-line phase 3d · the authentication move
到这里有两个独立的事实摆在客户端面前:(a)我从 X.509 证书链里读到一个公钥(声称属于 ursb.me),(b)我和这个服务器协商出了一个 ECDHE 共享密钥。但这两件事还没绑在一起——证书可能是中间人拿来的(公钥是真的、私钥不在中间人手上),ECDHE 也可以由任何人执行。中间人需要做的是同时拥有私钥和能做 DH,才能伪装成 ursb.me。
CertificateVerify 把这两件事绑在一起:服务器用证书里公钥对应的私钥,对一段包含 transcript-hash 的固定结构签名。客户端验签:如果通过,则证明握手对面 (1) 拥有私钥(身份)+ (2) 完整看到了从 ClientHello 到 Certificate 这段握手(一致性)。
By this point the client knows two unconnected facts: (a) I read a public key out of an X.509 cert chain (claimed to belong to ursb.me), (b) I negotiated an ECDHE shared secret with this server. But these two haven't been tied together — the cert could have been replayed by an MITM (real pubkey, but the privkey is elsewhere), and ECDHE can be done by anyone. The MITM needs to simultaneously hold the private key and be the ECDHE endpoint to fully impersonate ursb.me.
CertificateVerify ties the two together: the server signs a fixed structure that includes the transcript-hash with the private key matching the cert. The client verifies: if it passes, the handshake peer (1) holds the private key (identity) and (2) saw the full handshake from ClientHello through Certificate (consistency).
这个"64 空格 + 标签"看似古怪,是为了跨协议同型攻击防御。如果 server 在别处(比如 SSH)用同一个私钥签了某段数据,结构里前 64 字节是空格 → 那段数据不能被"装"成 TLS CertificateVerify。RFC 8446 把这一招借鉴自 Apple 的 codesigning 设计。
The "64 spaces + label" looks weird but defends against cross-protocol confusion: if the server reuses the same private key elsewhere (say, SSH) to sign some data, the leading 64 spaces guarantee that data can't be "cast" as a TLS CertificateVerify. RFC 8446 borrowed this idea from Apple's codesigning design.
ECDSA-P256 是主线握手用的签名算法。它在 prime256v1 椭圆曲线上工作,私钥是 32 字节随机整数 d,公钥是 Q = d·G(G 是基点)。签名一段消息 m:
ECDSA-P256 is the main-line signature algorithm. It operates on the prime256v1 elliptic curve; the private key d is a 32-byte random integer; the public key is Q = d·G (G is the base point). To sign a message m:
验证镜像:w = s⁻¹ mod n, u₁ = z·w mod n, u₂ = r·w mod n, 算 (x', y') = u₁·G + u₂·Q,验 x' mod n == r。靠的是椭圆曲线离散对数难题——给定 Q = d·G,求 d 是 ECDLP,在经典计算机上 2^128 强度。
Verification mirrors it: w = s⁻¹ mod n, u₁ = z·w mod n, u₂ = r·w mod n, compute (x', y') = u₁·G + u₂·Q, check x' mod n == r. The security rests on ECDLP — given Q = d·G, recovering d takes 2^128 classical operations.
如果两次签名用了同一个 k 但消息不同:
s₁ = k⁻¹(z₁ + rd), s₂ = k⁻¹(z₂ + rd) → 减得 k = (z₁-z₂)/(s₁-s₂),再代回任一公式解出 d。
历史灾难:
SecureRandom 在某些 Android 版本初始化不当,多个 wallet 用同一个 k 签了不同 transaction → ~55 BTC(当时 $5k)被攻击者扫走。现代实现用 RFC 6979 deterministic k:k = HMAC-SHA256(d, z) 派生而非随机选——既消除 nonce reuse 又消除 RNG 故障风险。OpenSSL 1.1+ / BoringSSL / rustls 全部默认 deterministic ECDSA。
If two signatures share the same k but different messages:
s₁ = k⁻¹(z₁ + rd), s₂ = k⁻¹(z₂ + rd) → subtract: k = (z₁-z₂)/(s₁-s₂), substitute back to recover d.
Historical catastrophes:
SecureRandom was poorly seeded on some Android builds; multiple wallets signed different transactions with the same k → ~55 BTC (~$5k then) drained by attackers.Modern implementations use RFC 6979 deterministic k: k = HMAC-SHA256(d, z) derived rather than randomly chosen — eliminates both nonce reuse and RNG-failure risk. OpenSSL 1.1+ / BoringSSL / rustls all default to deterministic ECDSA.
| SCHEME | CODE | USED IN 1.3? | WHY / WHY NOT |
|---|---|---|---|
| ecdsa_secp256r1_sha256 | 0x0403 | YES (default) | 短签名 + 高性能short sig + fast |
| ed25519 | 0x0807 | YES | 最现代,但 LE 还没普及签发most modern; Let's Encrypt issuance still rare |
| rsa_pss_rsae_sha256 | 0x0804 | YES | RSA cert 必走 PSS(不再 PKCS#1 v1.5)RSA certs must use PSS (no more PKCS#1 v1.5) |
| rsa_pkcs1_sha256 | 0x0401 | cert only | 允许在证书链里,禁用于 CertVerify(防 ROBOT)allowed inside cert chain, banned for CertVerify (anti-ROBOT) |
| SHA-1 anything | — | NO | SLOTH 攻击之后彻底删除removed entirely after SLOTH |
| DSA | — | NO | 不支持 EC,nonce 重用风险no EC, nonce-reuse risk |
常见误解:既然证书里有公钥,为什么不直接拿它和客户端做 ECDHE?两个原因:(1)证书公钥用途不一定是 KEX——RFC 5280 的 KeyUsage 扩展规定 cert 可以是 sig-only。(2)前向保密——证书私钥常常是长寿的(90 天 / 1 年),泄露就重灾。ECDHE 每次握手 fresh keypair(一次一密),即使过 10 年私钥泄露,历史 pcap 也解不开。这是 1.3 强制 ECDHE 的根本原因,1.2 时代的 RSA-KEX 没这个保护。
Common confusion: the cert has a pubkey, why not just use it for ECDHE? Two reasons: (1) cert pubkeys are typed — RFC 5280 KeyUsage may mark a cert as sig-only. (2) Forward secrecy — cert private keys are long-lived (90 days, a year); a leak is catastrophic. ECDHE generates a fresh keypair per handshake (one-time pad), so even if the cert privkey leaks ten years later, past pcaps stay unreadable. That's why 1.3 mandates ECDHE; 1.2-era RSA-KEX had no such protection.
主线阶段 3e + 5 · 握手收尾的两声"OK"
Main-line phase 3e + 5 · the two "OK"s that close the handshake
CertificateVerify 证明了服务器拥有私钥。但是握手过程中还有一类风险:降级攻击 或 篡改——中间人在 ClientHello 把 supported_groups 改了,让双方协商一个弱曲线;中间人在 ServerHello 改 cipher_suite;中间人在 EE 加了/删了扩展。这些篡改如果发生在加密之前的字段里,不会破坏后续 AEAD 解密(密钥派生绑了 transcript,密文解不开,但攻击者可以让篡改后的 transcript 双方都同意)。
Finished 是双方在握手结束前发的最后一条 handshake message,内容是用各自的 finished_key 计算 transcript-hash 的 HMAC。双方独立验证对方的 Finished,如果对方算的 transcript 和我算的 transcript 哪怕差一字节,HMAC 就对不上,握手直接失败。
CertificateVerify proves the server holds the private key. But the handshake faces another risk: downgrade or tampering — an MITM rewrites supported_groups in ClientHello to force a weak curve, rewrites cipher_suite in ServerHello, adds/removes extensions in EE. If the tamper happens to a field that predates encryption, it doesn't break later AEAD (the keys are bound to the transcript, ciphertext won't decrypt — but the attacker can make both parties agree on the tampered transcript).
Finished is the last handshake message each side sends — it's the HMAC of the transcript-hash, keyed by finished_key. Both sides independently verify the peer's Finished. If the transcript they computed differs from the transcript I computed by even one byte, the HMAC won't match and the handshake aborts.
注意 finished_key 是方向独立的——client_finished_key 用 client_handshake_traffic_secret 派生,server 用 server_handshake_traffic_secret 派生。这确保客户端的 Finished 不可能被反射回去当作服务器的。
Note finished_key is direction-specific: client_finished_key derives from client_handshake_traffic_secret, server's from server_handshake_traffic_secret. This guarantees the client's Finished can never be reflected back as the server's.
一个常见误解:"要算 Finished 必须保留从 ClientHello 开始的所有握手消息字节。"实际不需要——SHA-384 是流式 hash,可以一边收消息一边喂给 hash 上下文,不需要保留原始字节。
A common misconception: "computing Finished requires retaining every handshake message byte from ClientHello onward." Not so — SHA-384 is a streaming hash; messages can be fed into the hash context as they arrive, without retaining the raw bytes.
RFC 8446 §4.4.1 把每个"应该取 hash 快照"的时刻都列出来——一共 6 个 transcript_hash 快照点(CH / CH+SH / CH+...+EE+Cert+CV / +ServerFinished / +ClientFinished / +EndOfEarlyData when 0-RTT)。每个快照是把 hash_ctx 当前状态复制一份再 finalize,原 ctx 继续向前喂。SHA-384 内部状态 200 字节,无论握手有多大都是 200 字节——这是 TLS 1.3 服务器内存占用比 1.2 低的根本原因之一。1.2 时代需要把整段 transcript 保留到 Finished 才能算 PRF。
RFC 8446 §4.4.1 enumerates every "snapshot moment" — six transcript_hash snapshots in total (CH / CH+SH / CH+...+EE+Cert+CV / +ServerFinished / +ClientFinished / +EndOfEarlyData for 0-RTT). Each snapshot is a copy of the hash_ctx state, then finalize; the original ctx keeps consuming messages. SHA-384's internal state is 200 bytes — 200 bytes total no matter how big the handshake. This is one of the main reasons TLS 1.3 servers use less memory than 1.2; 1.2 had to retain the whole transcript until Finished to compute the PRF.
| WHO | WHEN | TRANSCRIPT COVERS | VALIDATES |
|---|---|---|---|
| 服务器Server | Phase 3e · T+30ms | CH → CV | "我看到的握手 = 你看到的""My handshake view = yours" |
| 客户端Client | Phase 5 · T+45ms | CH → server Finished | "我也确认你看到的握手是对的""I confirm your view is correct too" |
1-RTT 的关键魔法在这里:客户端验证完服务器 Finished 之后,application_traffic_secret 已经可派生(HKDF 树里能算)。客户端立刻用 client_application_traffic_secret_0 加密 GET /,把它和 client Finished 塞在同一个 record flight 里发出去。服务器先解 Finished、验 MAC,再解后面的应用 record——MAC 验证成功才用 application_secret 解。1 个 RTT 就完成了握手 + 第一个 HTTP 请求字节。
The 1-RTT magic happens here: once the client verifies the server's Finished, the application_traffic_secrets are derivable (the HKDF tree has the inputs). The client immediately encrypts GET / with client_application_traffic_secret_0 and ships it in the same record flight as the client Finished. The server processes Finished first, then decrypts the trailing application record — but only if the MAC verifies. Handshake + first HTTP request byte in one RTT.
主线阶段 5+ · 握手之后的稳态加密
Main-line phase 5+ · the steady-state cipher pump
握手之后,每个 HTTP 字节都包在一个 TLS Record 里发出去。Record 是 TLS 协议唯一的传输单元——上行下行都用它。1.3 里 Record 只剩一种:AEAD 加密的 ApplicationData。Handshake / Alert / ChangeCipherSpec 这些消息类型在 1.3 里只是 ApplicationData 的内层载荷,外层 type 永远是 23。
Post-handshake, every HTTP byte rides inside a TLS Record. Records are TLS's only transport unit, both directions. In 1.3 there's only one record kind: AEAD-encrypted ApplicationData. Handshake / Alert / ChangeCipherSpec messages exist only as inner payload; the outer type is always 23.
一个 TLS 1.3 连接生命周期里其实有三套 AEAD 密钥 + nonce 空间,各自独立从 sequence_number = 0 开始计数:
A TLS 1.3 connection actually has three independent AEAD key + nonce spaces over its lifetime, each counting sequence_number from 0:
| NONCE SPACE | DERIVED FROM | WHEN ACTIVE | SEQ_NUM |
|---|---|---|---|
client_early_traffic_secret | early_secret · "c e traffic" | 0-RTT 数据(CH 之后到 EndOfEarlyData)0-RTT data (CH → EndOfEarlyData) | 从 0 计starts at 0 |
c/s_handshake_traffic_secret | handshake_secret · "c/s hs traffic" | SH 之后所有加密握手消息(EE/Cert/CV/Finished)all encrypted handshake msgs post-SH | 从 0 计starts at 0 |
c/s_application_traffic_secret_0 | master_secret · "c/s ap traffic" | Finished 之后所有应用数据all app data post-Finished | 从 0 计starts at 0 |
c/s_application_traffic_secret_N+1 | 前一代 · "traffic upd"prev gen · "traffic upd" | KEY_UPDATE 之后after KEY_UPDATE | 归 0resets to 0 |
关键不变式:切换 secret = seq_num 归零 + static IV 重派生。这就是为什么 P0-5 Python 解密脚本里我特别强调"如果跑错了第 1 个原因是 seq_num 没归零"。同一个 connection 上看 pcap,外层 record type 都是 23(ApplicationData),但内层实际跨了 4-5 段密钥相位——只有 KEY_UPDATE 这条特殊消息能让客户端知道服务器换密钥了。
The key invariant: switching secret = seq_num resets + static IV re-derives. This is why P0-5's Python decrypt script specifically calls out "if it's wrong, the #1 cause is seq_num not being reset". Inspecting a pcap of one connection, every outer record type is 23 (ApplicationData), but internally the same connection traverses 4-5 key phases — only the KEY_UPDATE message signals the recipient that keys have rotated.
这个设计的关键:"nonce 不在线上"。TLS 1.2 时代每个 record 头部会带一个 explicit nonce(8 字节),白白浪费带宽 + 可能被攻击者操纵。1.3 改成两边都从 traffic_secret 派生一个静态 IV,再 XOR 一个本地维护的 64-bit 序号——双方序号同步靠"成功解密"自然驱动,丢一个 record 立刻 desync。
这也是为什么 TLS 1.3 over TCP 是顺序敏感的:TCP 已经保证字节顺序,sequence_number 隐式同步。QUIC 上跑 TLS 1.3(RFC 9001)就不能这么干——QUIC 包乱序,每个 packet 的 nonce 必须显式带 packet number。
The key design move: "the nonce isn't on the wire". TLS 1.2 sent an explicit 8-byte nonce per record — wasted bandwidth + manipulable. 1.3 has both sides derive a static 12-byte IV from traffic_secret, then XOR a locally-tracked 64-bit sequence number. Both sides stay synchronised through successful decryption alone; lose one record and you desync.
This is why TLS 1.3 over TCP is order-sensitive: TCP guarantees byte order, sequence_number stays implicit. TLS 1.3 over QUIC (RFC 9001) can't do this — QUIC packets arrive out of order, so each packet must carry its packet number explicitly as the nonce input.
AEAD = Authenticated Encryption with Associated Data。AES-GCM 是 TLS 1.3 默认 AEAD(90% 流量),它在内部跑两条独立的线:(1) CTR 模式用 AES 块加密做流加密;(2) GHASH 在 GF(2¹²⁸) 上做 universal hash,认证 AAD + ciphertext。最后用 AES 加密 GHASH 输出当 tag。
AEAD = Authenticated Encryption with Associated Data. AES-GCM is TLS 1.3's default AEAD (~90% of traffic). Internally it runs two parallel lines: (1) CTR mode turns AES block encryption into stream encryption; (2) GHASH does a universal hash over GF(2¹²⁸) to authenticate AAD + ciphertext. The tag is AES of GHASH output.
关键观察:GHASH 子密钥 H 是 AES_K(0^128)——所有用同 key 的 record 都共享同一个 H。这就是为什么 nonce reuse 在 GCM 里是灾难:两个 record 用同一 (K, nonce) 时,攻击者拿到两组 (C, T) 就能解 GHASH 方程恢复 H,进而对任意 record 伪造 tag——AEAD 的认证完全失效。TLS 1.3 用 sequence_number XOR 静态 IV 派生 nonce,结构上保证 nonce 唯一性。1.2 时代 GCM 的 explicit 8-byte nonce 由实现自己选,许多实现选了"counter + random"模式有过 nonce 碰撞事故(Cisco IOS 2016 / NSS 2017)。
Key observation: the GHASH subkey H = AES_K(0^128) — every record under the same key shares the same H. That's why nonce reuse in GCM is catastrophic: two records with the same (K, nonce) let an attacker take two (C, T) pairs, solve the GHASH equation, and recover H; they can then forge tags on arbitrary records, fully bypassing AEAD's auth. TLS 1.3's seq-num XOR static-IV nonce derivation structurally guarantees nonce uniqueness. In 1.2 GCM, the explicit 8-byte nonce was implementation-chosen; "counter + random" implementations have hit nonce collisions in production (Cisco IOS 2016, NSS 2017).
解密走相反方向:先用 GHASH(AAD ‖ ct ‖ lens) 算出 Y,对比 tag XOR E_K(J0);不相等就立刻丢包(不允许部分解密)。这是 AEAD 比"先 MAC 后加密"安全的根本——攻击者不能用"密文已经解了部分"做时序 oracle。
Decryption runs the inverse: compute Y from GHASH(AAD ‖ ct ‖ lens), compare against tag XOR E_K(J0); mismatch → drop immediately (no partial decryption allowed). This is why AEAD is fundamentally safer than "MAC-then-Encrypt" — an attacker can't time the "ciphertext already partly decrypted" path as an oracle.
一条 TLS 1.3 连接如果长时间跑(gRPC 长连接、WebSocket、IoT 长 idle),单一 traffic_secret 加密的字节量会逼近 AES-GCM 的安全边界(~64 GB)。1.3 不允许 renegotiation(Ch03 sin 3),但提供 KEY_UPDATE:
A long-lived TLS 1.3 connection (gRPC long-lived stream, WebSocket, idle IoT) can push enough bytes through a single traffic_secret to approach AES-GCM's security ceiling (~64 GB). 1.3 forbids renegotiation (Ch03 Sin 3), but offers KEY_UPDATE:
注意"_N+1" 部分——traffic secret 一代代向下推,旧的立刻丢弃。这给了"滚动前向保密":每隔 N GB 就换一代密钥,攻击者就算抓到当下密钥,也解不出 N GB 之前的流量。
Notice the "_N+1" — traffic secrets are chained forward; old ones are immediately discarded. This gives rolling forward secrecy: keys rotate every N GB, so an attacker capturing the current key still can't decrypt traffic from N GB ago.
主线阶段 8 · 1-RTT 之后服务器送的礼物
Main-line phase 8 · the gift the server sends after 1-RTT
客户端 Finished 之后握手就结束了。但服务器还会主动发一到几条 NewSessionTicket(NST)——这是给客户端的"下次能省一个 RTT"凭证。NST 的内容是一个 opaque ticket(服务器自己用 STEK 加密的 resumption_master_secret)、一个 ticket_lifetime(秒),加上 0-RTT 的 max_early_data_size。
After the client Finished the handshake is done, but the server proactively sends one or more NewSessionTickets (NSTs) — vouchers for "save one RTT next time". The NST contains an opaque ticket (the server's own STEK-encrypted resumption_master_secret), a lifetime in seconds, and 0-RTT's max_early_data_size.
| MODE | WHAT'S IN TICKET | SERVER MEMORY | USED BY |
|---|---|---|---|
| StatefulStateful | 随机 IDrandom ID | 完整 session 状态(Redis / memcache)full session state (Redis / memcache) | Apple, Google internal |
| Stateless (Session Ticket)Stateless (Session Ticket) | STEK-AES-encrypted blob | 只需 STEK(共享密钥)just the STEK (shared key) | Cloudflare, AWS, nginx |
Cloudflare 这种边缘 CDN 默认用 Stateless——所有边缘节点共享 STEK(Session Ticket Encryption Key),客户端在任何边缘机房 resume 都能命中。代价:STEK 是整个 CDN 的单点密钥,必须定期 rotate。STEK 泄露 = forward secrecy 失效——攻击者能解密所有从 NST 之后到 STEK 轮换之间的会话。这是 Ch22 攻击史里反复出现的话题。
Edge CDNs like Cloudflare default to stateless — every edge node shares the STEK (Session Ticket Encryption Key), so a client can resume from any POP. Cost: the STEK is the entire CDN's single key and must rotate often. A leaked STEK breaks forward secrecy — an attacker decrypts every session from NST issuance to STEK rotation. A recurring theme in the Ch22 attack history.
| VENDOR | ROTATION | RATIONALE | SOURCE |
|---|---|---|---|
| Cloudflare | 6 h | 最激进 · 单 STEK 泄露窗口最多 6 小时流量most aggressive · STEK leak exposes ≤ 6 h of traffic | CF blog 2017 |
| Apple iCloud | 24 h | 移动客户端 1 天 1 次唤醒,匹配 typical session 长度match mobile client wake cycle | Apple PSI 2023 |
| Google GFE | ~24 h | 两层 STEK:内层 24h,外层 1 周two-tier STEK: inner 24h, outer 1 week | Langley 2017 talk |
| AWS ALB | 7 d | 默认 · 用户可调到 1 h-7 ddefault · user-tunable 1 h-7 d | AWS docs |
| nginx (default) | never | ⚠️ 默认 STEK 写死,需要手工配 ssl_session_ticket_key + cron 轮换⚠️ default STEK is hardcoded; admin must wire ssl_session_ticket_key + cron | nginx docs |
2017 年安全研究员发现:约 40% 的 nginx HTTPS 站点从未轮换过 STEK——STEK 是 nginx 启动时随机生成的,进程不重启就不变。这意味着 4 个月的 uptime = 4 个月所有 session ticket 用同一个密钥保护。Mozilla SSL Config Generator 现在不再 推荐启用 session tickets 除非配置了轮换。
2017 security research: ~40% of nginx HTTPS sites never rotated STEK — nginx generates STEK at process start; without restart, it never changes. 4 months of uptime = 4 months of every session ticket protected by the same key. Mozilla's SSL Config Generator no longer recommends enabling session tickets without rotation wired in.
主线握手里服务器发两张 NewSessionTicket(Chrome 默认会接 2-5 张)。原因:每张 ticket 的 PSK 在客户端用过一次后就应该作废(防止重放——见 Ch14),所以并发请求时如果只剩 1 张 ticket 就要 fallback 1-RTT。Chrome 默认每个 origin 一次性请求多张,让 2 个并发 tab 都能命中 0-RTT。RFC 8446 §4.6.1 显式允许"server SHOULD issue multiple tickets"。
The main-line handshake sees the server send two NewSessionTickets (Chrome accepts 2-5 by default). Reason: each ticket's PSK should be invalidated after one use (anti-replay — see Ch14), so concurrent requests with only one ticket left would fall back to 1-RTT. Chrome requests multiple at once per origin, so two concurrent tabs both get 0-RTT. RFC 8446 §4.6.1 explicitly allows "server SHOULD issue multiple tickets".
主线阶段 9 · 第二次访问只需 1-RTT,加上 0-RTT 就是 0
Main-line phase 9 · second visit needs 1 RTT, add 0-RTT and it's zero
客户端拿到 NST 后保存 (ticket_blob, lifetime, resumption_master_secret) 三元组。下一次访问 ursb.me:
pre_shared_key 扩展(PSK identity = ticket_blob)。GET /,和 ClientHello 一起发出去。这就是 0-RTT。The client stores (ticket_blob, lifetime, resumption_master_secret) after the NST arrives. On the next visit to ursb.me:
pre_shared_key extension in the new ClientHello (PSK identity = ticket_blob).GET / with client_early_traffic_secret and ships it alongside ClientHello. That's 0-RTT.PSK identity(ticket)是在线上明文传的——任何中间人都能复制。要防止"我抓到你的 ticket 然后伪装你",1.3 加了 PSK binder:客户端在 CH 里用 PSK 派生 binder_key,再用 binder_key 对 CH(除了 binders 字段本身)算 HMAC。
关键:binder 在每次握手都不一样,因为 binder = HMAC(binder_key, ClientHello[:binders_offset]),而 ClientHello 的 random 是 fresh 的。中间人即便复制 ticket,也无法在新的 ClientHello 上重算 binder(他没有 binder_key,binder_key 派生自 PSK 派生自 resumption_master_secret,他没有)。
The PSK identity (ticket) travels plaintext on the wire — any MITM can copy it. To prevent "I grab your ticket and impersonate you", 1.3 adds PSK binder: the client derives binder_key from PSK, then HMAC's the ClientHello (minus the binders field itself) with binder_key.
The key insight: binder is different every handshake, because binder = HMAC(binder_key, ClientHello[:binders_offset]) and ClientHello.random is fresh. An MITM can copy the ticket but can't recompute binder for a new ClientHello — they don't have binder_key, which derives from PSK from resumption_master_secret, which they don't have.
Binder 的"HMAC over CH minus binders"在字面上是这么意思:把客户端构造的 ClientHello 的字节流切到 binders 字段开始的那个偏移之前,对前面那段算 HMAC。前缀长度天然取决于 ClientHello 之前所有字段的字节数——所以 server 验证时必须从 ClientHello byte 0 开始数到 binders 起点。
"HMAC over CH minus binders" literally means: take the bytes of the ClientHello the client just built, truncate at the byte offset where binders begins, HMAC the prefix. The prefix length naturally depends on every preceding field's byte count — so the server, on verification, must count from byte 0 of ClientHello to the start of binders.
关键不变式:binder HMAC 的输入 = ClientHello 从 byte 0 到 binders 字段开始的偏移(含 00 31 30 这 3 字节长度前缀之前)。RFC 8446 §4.2.11.2 用"PartialClientHello"这个术语描述这块前缀。任何中间人想"复用 ticket + 改 ClientHello 其他字段"都会改变 prefix → HMAC 失配 → 服务器拒绝 PSK 然后 fallback 1-RTT。
The key invariant: binder HMAC's input = ClientHello bytes from offset 0 up to (but excluding) the binders length prefix. RFC 8446 §4.2.11.2 calls this slice PartialClientHello. Any MITM trying to "reuse ticket but tamper with other ClientHello fields" alters the prefix → HMAC fails → server rejects PSK and falls back to a fresh 1-RTT handshake.
用 == 比较 binder 是 RFC 8446 § 9.2 明令禁止的——攻击者重复发"伪造 binder",通过响应延迟 一字节一字节地猜对 binder。OpenSSL 用 CRYPTO_memcmp,rustls 用 subtle::ConstantTimeEq。GnuTLS 1.6 曾因这个原因被 CVE-2017-7507 通报。
RFC 8446 §9.2 forbids == for binder comparison — an attacker repeatedly sending "forged binders" can use response timing to guess byte-by-byte. OpenSSL uses CRYPTO_memcmp; rustls uses subtle::ConstantTimeEq. GnuTLS 1.6 ate CVE-2017-7507 for exactly this.
| MODE | WHAT IT DOES | FORWARD SECRECY? | WHEN |
|---|---|---|---|
| psk_ke | 只用 PSK 派生密钥,不做 DHPSK only, no DH | NO | IoT / 受限设备IoT / constrained devices |
| psk_dhe_ke | PSK + 新 ECDHE 混合PSK + fresh ECDHE | YES | 所有 web 客户端all web clients |
psk_ke 只能给 IoT 用——一台传感器,私钥泄露事故无所谓(设备本来就一次性)。Web 浏览器永远用 psk_dhe_ke,每次 resume 还是会做新的 ECDHE,保证每个会话独立前向保密。
psk_ke is IoT-only: a sensor has no privacy stakes after compromise. Web browsers always use psk_dhe_ke — every resumption still does fresh ECDHE, preserving per-session forward secrecy.
为什么 POST 不能 0-RTT
why POST cannot do 0-RTT
0-RTT 的诱惑:客户端不等服务器就能发应用数据,回访者 TTFB 直接砍 1 个 RTT。但 0-RTT 的根本缺陷是没有新鲜度——0-RTT data 用的密钥(client_early_traffic_secret)派生自客户端单方面就知道的 PSK + transcript。攻击者抓到这一段 UDP 包,可以原封不动重放任意多次,服务器无法区分"是你"还是"录像"。
对查询型 GET 没问题(多查一次结果一样)。但对 POST /transfer/100USD——重放就是 100 次转账。1.3 设计了三道防线,任意两道全失效,第三道兜底。
The temptation of 0-RTT: client sends app data before the server replies, slashing TTFB by 1 RTT for repeat visitors. But the fundamental flaw of 0-RTT is no freshness — the 0-RTT data is encrypted under client_early_traffic_secret, which derives from PSK + transcript that the client knew unilaterally. An attacker who captures that packet can replay it arbitrarily often; the server can't distinguish "you" from "recording".
For idempotent GETs, fine — querying twice yields the same result. But POST /transfer/100USD? A replay = 100 transfers. 1.3 has three defence layers; any two can fail and the third saves you.
| LAYER | RULE | WHERE |
|---|---|---|
| 客户端侧Client side | 只在idempotent 方法启用 0-RTT(GET、HEAD)enable 0-RTT only for idempotent methods (GET, HEAD) | Chrome / Firefox |
| 服务器侧(应用层)Server side (app) | 收到带 Early-Data: 1 header 的请求,应用决定"否决"或"返回 425 Too Early"requests with Early-Data: 1 header — app decides to reject or return 425 Too Early | RFC 8470 |
| TLS 侧TLS layer | 有限时间窗(≤ 10s)+ PSK ID 进 Redis 做去重limited time window (≤ 10s) + PSK-ID dedup in Redis | nginx, CF edge |
CF 边缘对 idempotent 方法(GET、HEAD、OPTIONS)默认接受 0-RTT,对其他方法(POST/PUT/DELETE/PATCH)即使客户端发了也降级到 1-RTT。每个 PSK ID 在 Redis 里有 10s TTL,第二次见到同样 PSK ID 立刻拒绝。回访用户中位 TTFB 降低 ~50 ms(移动网络上更多)。
CF edge accepts 0-RTT for idempotent methods (GET, HEAD, OPTIONS) by default; non-idempotent methods get silently downgraded to 1-RTT even if the client sends 0-RTT data. Every PSK ID lives in Redis with a 10 s TTL; a second sighting is instantly rejected. Repeat-visitor median TTFB drops ~50 ms (more on mobile).
client_early_traffic_secret 在 HKDF 树里只依赖 PSK(早于 ECDHE 输入注入)。所以:
1-RTT 数据是没有这个问题的——它的密钥派生包含了 ECDHE,每次握手 ephemeral。所以"0-RTT 是性能优化的代价就是少一道前向保密"——这是 RFC 8446 §E.5 反复强调的注意事项。
client_early_traffic_secret in the HKDF tree only depends on PSK (before ECDHE input is injected). So:
1-RTT data doesn't have this problem — its derivation includes ECDHE, ephemeral per handshake. "0-RTT trades one layer of forward secrecy for performance" — repeated explicitly in RFC 8446 §E.5.
0-RTT 是一个为知情人提供的工具——它的安全模型和正常 TLS 不一样,
默认不要打开,除非你确信整条链路都准备好了。 RFC 8446 §E.5 · Implementation Pitfalls
0-RTT is a tool for the informed — its security model differs from regular TLS;
do not enable it by default unless every link in the chain is ready. RFC 8446 §E.5 · Implementation Pitfalls
主线阶段 3c · ursb.me 的证书拆开看
Main-line phase 3c · unpacking ursb.me's cert
X.509 证书是 PKIX 体系的核心数据结构,1988 年由 ITU-T 定义,现在的实际版本是 v3(RFC 5280)。它的内部结构用 ASN.1(Abstract Syntax Notation One)描述,编码用 DER(Distinguished Encoding Rules)——一种"TLV"二进制格式。
从直觉上理解,一份证书就是一段被 CA 签名的元数据。元数据里写:"这个公钥属于 CN=ursb.me,从 2026-04-01 到 2026-06-30 有效"。签名是 CA 用它自己的私钥盖的章。客户端的工作:找到 CA 的公钥(在 root store 里),验签——通过则相信这条声明。
X.509 is PKIX's core data structure, defined by ITU-T in 1988, currently at v3 (RFC 5280). Internally it's described in ASN.1 (Abstract Syntax Notation One) and encoded in DER (Distinguished Encoding Rules) — a TLV binary format.
Intuitively, a cert is signed metadata. The metadata says "this public key belongs to CN=ursb.me, valid 2026-04-01 to 2026-06-30." The signature is the CA's stamp made with its private key. The client's job: find the CA's public key (in the root store), verify the signature, then trust the statement.
DER 的规则极简:每个值编为 Tag · Length · Value 三段。Tag 是 1 字节(标识类型),Length 是 1+ 字节(变长),Value 是实际字节。SEQUENCE 的 Tag = 0x30,OCTET STRING = 0x04,UTF8String = 0x0c,INTEGER = 0x02,OID = 0x06,等等。
DER's rule is dead simple: every value encodes as Tag · Length · Value. Tag is one byte (type marker), Length is one-plus bytes (variable), Value is the actual bytes. SEQUENCE = tag 0x30, OCTET STRING = 0x04, UTF8String = 0x0c, INTEGER = 0x02, OID = 0x06, etc.
openssl x509 -text 是解码后的人类视图。要真正看懂证书在线上长什么样,需要直接看 DER 字节。下面是 ursb.me leaf 证书的前 64 字节,每段都标了 ASN.1 类型 + 长度编码方式。
openssl x509 -text shows the decoded human view. To really understand what the cert looks like on the wire, read the DER bytes directly. Below: the first 64 bytes of ursb.me's leaf cert, annotated with each ASN.1 tag + length encoding form.
规律提炼:
30 = constructed universal SEQUENCE (0x10 ‖ tag=16)。a0 = constructed context-specific [0]。02 = INTEGER。04 = OCTET STRING。06 = OID。0x80 | num_bytes,后续 num_bytes 个字节是大端长度。82 04 12 = 0x0412 字节长度(1042)。1.2.840.10045.4.3.3 解出来:40·1+2=2a, 840 = 86 48 (0x86 ∧ 0x7f = 6 + 0x48 = 840), 等等。The pattern:
30 = constructed universal SEQUENCE. a0 = constructed context-specific [0]. 02 = INTEGER. 04 = OCTET STRING. 06 = OID.0x80 | num_bytes, followed by num_bytes big-endian length bytes. 82 04 12 = 0x0412 bytes (1042).1.2.840.10045.4.3.3: 40·1+2=2a, 840 = 86 48, etc.构造的 12 行能 walk 整张证书结构。X.509 解析 bug 的历史教训:openssl 1.0 在 length parsing 处理 long-form 时有过整数溢出(CVE-2016-6303,影响所有 ASN.1 解码路径);GoLang asn1 包 2018 年发现 OID parsing 不当时挂了的 cert 能 panic(CVE-2018-16873)。所有现代 ASN.1 库都用 BER/DER strict mode:拒绝 indefinite length、拒绝任何非最小编码。
12 lines walk the entire cert. The historical lesson of X.509 parsing bugs: openssl 1.0 had an integer overflow in long-form length parsing (CVE-2016-6303, affecting every ASN.1 decode path); Go's asn1 package in 2018 panicked on certain malformed OIDs (CVE-2018-16873). Every modern ASN.1 library uses BER/DER strict mode: reject indefinite length, reject any non-minimal encoding.
主线 ursb.me 证书里那段 CT Precertificate SCTs 不是装饰——它的字节级结构是这样:
That CT Precertificate SCTs block in ursb.me's cert isn't decoration — here's the byte-level layout:
Chrome / Safari 内嵌一份"known CT logs"白名单(Chrome 2026 大约 30 个 log),定期更新。证书必须带至少 2 个独立白名单内 log 的 SCT 才被信任。这把"查 log 的责任" 从客户端推到 CA——CA 签发前必须提交 precert 给 log,拿到 SCT 后才能嵌进真证书。SCT 等于"被 CT 看过"的不可伪造收据。
SCT 嵌入位置可选:(a) X.509 v3 extension(最常见,OID 1.3.6.1.4.1.11129.2.4.2);(b) TLS 扩展 signed_certificate_timestamp(type 18,server 实时在握手里发);(c) OCSP response 内附。Let's Encrypt 走 (a),Cloudflare 边缘还会同时附 (b) 用更新鲜的 log。
Chrome / Safari embed a "known CT logs" allow-list (~30 logs in Chrome 2026), updated periodically. A cert must carry SCTs from at least 2 independent allow-listed logs to be trusted. This shifts the "poll the log" responsibility from client to CA — CAs must submit a precert to the log and embed the returned SCT before issuing. The SCT is the unforgeable receipt of "this has been logged".
SCT delivery has three options: (a) X.509 v3 extension (most common, OID 1.3.6.1.4.1.11129.2.4.2); (b) TLS extension signed_certificate_timestamp (type 18, server sends fresh during handshake); (c) attached to OCSP response. Let's Encrypt uses (a); Cloudflare edges also send (b) with fresher SCTs.
现代证书的"真实主体"在 SubjectAlternativeName (SAN) 扩展里,CN 字段是历史包袱。RFC 6125 之后,浏览器忽略 CN 只看 SAN——这也是为什么 ursb.me 的 CN 字段只写 "ursb.me" 一项,真实域名清单在 SAN 里。如果某个证书只有 CN 没有 SAN,Chrome 80+ 直接拒绝。
The "real subject" in a modern cert is in the SubjectAlternativeName (SAN) extension; CN is historical baggage. Post-RFC 6125, browsers ignore CN and only look at SAN — which is why ursb.me's CN field only carries "ursb.me" while the real hostname list lives in SAN. A cert with CN but no SAN? Chrome 80+ rejects outright.
Let's Encrypt 给所有证书定 90 天有效期。原因:
2024 年 Apple 发起 "45-day cert" 提案,2025 年 CA/Browser Forum 通过——2027 起所有 public TLS 证书最长 47 天,到 2029 年减到 47 天。这是"短证书"运动的延续。
Let's Encrypt sets all certs to 90 days. Reasons:
In 2024 Apple proposed the "45-day cert" rule, ratified by CA/Browser Forum in 2025 — by 2027 all public TLS certs cap at 47 days, dropping to 47 by 2029. The short-cert movement continues.
~150 个 CA · 4 个 root store · 不容置疑的链顶
~150 CAs · 4 root stores · the indisputable top of the chain
客户端验证 ursb.me 的证书要找 issuer "Let's Encrypt E5",发现这是个 intermediate cert(也是签发出来的),又找 E5 的 issuer "ISRG Root X1"——这一次找到了根:ISRG Root X1 是 self-signed 的,存在每一个浏览器/操作系统预装的"根证书库"里。客户端不验证 root 的签名(自签也没法验),而是预先信任它存在。
谁决定哪些 CA 进 root store?四个独立的列表:
The client verifies ursb.me's cert by looking up its issuer "Let's Encrypt E5" — that's an intermediate cert (also signed). It then looks up E5's issuer "ISRG Root X1" — and finds the root: ISRG Root X1 is self-signed, present in every browser/OS root store. The client doesn't verify root signatures (you can't verify a self-signature meaningfully); it simply pre-trusts the root.
Who decides which CAs end up in the root store? Four independent lists:
| STORE | OWNER | ~CAs | USED BY |
|---|---|---|---|
| Mozilla NSS | Mozilla CA Program | ~140 | Firefox, curl, most Linux distros |
| Apple | Apple Root Certificate Program | ~160 | Safari, macOS, iOS |
| Microsoft | Microsoft Trusted Root Program | ~280 | Edge, Windows, .NET |
| Chrome Root Store | ~100 | Chrome 105+(脱离 OS)Chrome 105+ (split from OS) |
| YEAR | CA | WHAT HAPPENED | IMPACT |
|---|---|---|---|
| 2011 | DigiNotar | 被入侵签了 *.google.com 等 ~531 张伪造 certbreached; ~531 fake certs signed (*.google.com etc.) | 立刻从所有 root store 移除 · 公司 1 个月内破产removed from every root store · company bankrupt within a month |
| 2017 | Symantec | ~30,000 张证书 misissuance + 隐瞒中介 CA 转让~30k cert misissuance + concealed sub-CA transfers | Chrome + Mozilla 分阶段 distrust,市场份额从 30% → 0;业务 2018 年卖给 DigiCertChrome + Mozilla phased distrust; market share 30% → 0; sold to DigiCert 2018 |
| 2024-11 | Entrust | 多年 CT 合规缺陷 + 慢响应 + 假冒撤销years of CT non-compliance + slow responses + faked revocations | Chrome distrust after 2024-11-11;~30k 张活跃证书必须迁移Chrome distrust after 2024-11-11; ~30k active certs forced to migrate |
不是花钱就能进。Mozilla 的 CA inclusion process 要求:完整的 WebTrust 审计、连续多年无重大事故、撤销响应机制、CP/CPS 文档、运营透明度。从申请到接收要 1-3 年。被踢出去也很快——2017 年 Symantec 因为多次 misissuance 被 Google + Mozilla 同步剔除,影响 30% 的互联网证书。CA 是一个声誉 + 透明度 + 流程共同支撑的位置。
You can't buy your way in. Mozilla's CA inclusion process requires: full WebTrust audit, multi-year clean record, revocation responsiveness, CP/CPS docs, operational transparency. 1–3 years from application to inclusion. Eviction is fast — in 2017 Symantec was synchronously evicted by Google + Mozilla for serial misissuance, affecting 30% of internet certs. A CA seat rests on reputation + transparency + process all at once.
Chrome 105(2022 年 9 月)开始用自己的根证书库,不再依赖 OS。原因:Google 不想让"某国政府强制 OS 厂商加根"或"OS 厂商懒得撤销" 影响 Chrome 用户。这是信任链根基的去中心化——但代价是 Chrome 要自己维护审计流程。2024 年 Chrome 把 Entrust 的 root 标"distrust after 2024-11-11"(Entrust 多年没好好做 CT),又一次展示这种主动性。
Chrome 105 (Sep 2022) switched to its own root store, no longer trusting the OS. Reason: Google didn't want "a nation-state forcing the OS vendor to add a root" or "OS vendor too slow to evict" to compromise Chrome users. This decentralises the trust anchor, at the cost of Chrome maintaining its own audit pipeline. In 2024 Chrome marked Entrust's root as "distrust after 2024-11-11" (Entrust had years of CT-compliance lapses) — another demonstration of this autonomy.
典型链长 = 3:leaf(ursb.me)→ intermediate(Let's Encrypt E5)→ root(ISRG Root X1)。为什么有 intermediate?三个原因:
Typical chain length = 3: leaf (ursb.me) → intermediate (Let's Encrypt E5) → root (ISRG Root X1). Why intermediates? Three reasons:
证书被偷之后怎么办
what happens after a cert is stolen
ursb.me 的 nginx 配置错了,私钥泄露了。Let's Encrypt 收到撤销请求——但客户端怎么知道?证书在 90 天有效期内仍然语法合法,单靠"查证书内容"区分不出"已撤销"。
解决方案 30 年来出了三套:CRL(1988 年)→ OCSP(1999 年)→ CT log(2012 年)。每一套都解决了上一套的问题,但三套都还在跑。
ursb.me's nginx config is wrong, the private key leaked. Let's Encrypt accepts a revocation request — but how does the client know? The cert is still syntactically valid within its 90-day window; reading the cert alone can't distinguish "revoked".
30 years and three solutions: CRL (1988) → OCSP (1999) → CT log (2012). Each fixed the previous one's problem, but all three still run.
| MECH | HOW | PROBLEM | STATUS 2026 |
|---|---|---|---|
| CRL | CA 定期发"撤销列表",客户端下载CA publishes a periodic list, client downloads | 列表越来越大(GB 级),客户端拒绝下载list grows to GB, clients stop downloading | 基本死亡mostly dead |
| OCSP | 客户端问 CA "这一份还有效吗",CA 实时签名回复client asks CA "is this still valid", CA signs a fresh response | 隐私泄露 + CA 单点故障 + 增加握手延迟privacy leak + CA SPOF + handshake latency | OCSP Stapling |
| OCSP Stapling | 服务器先问 CA,把回答放进 TLS 握手server queries CA, attaches the answer to the TLS handshake | 服务器要主动维护 stapling 缓存server must maintain a stapling cache | ~50% |
| CT log | 所有签发都公开记 log,浏览器拒收 log 缺失的 certall issuances publicly logged; browsers reject unlogged certs | 不是撤销机制,是错误签发可见性not revocation; misissuance visibility | 强制(CB Forum)mandated (CA/B Forum) |
| CRLite (Mozilla) | 用 Bloom filter 压缩 CRL,浏览器内嵌CRL compressed via Bloom filter, baked into browser | 实验性,Firefox 在用experimental, Firefox using | Firefox |
1. 服务器定期(默认 1 小时)向 CA 的 OCSP responder 查询自己的 cert 状态。
2. 收到 CA 签名的 OCSP response(带时间戳)后缓存到内存。
3. TLS 握手时,服务器在 CertificateEntry 的 extensions 里塞 status_request,把 OCSP response 钉进握手。
4. 客户端验证 OCSP response 的签名(由 cert 的 issuer 签),检查 ThisUpdate / NextUpdate 时间窗,确认 cert 没撤销。客户端不直接联系 CA——隐私和延迟都赢。
1. Server periodically (default 1 h) queries its CA's OCSP responder for its own cert's status.
2. Receives a CA-signed OCSP response (timestamped), caches in memory.
3. During TLS handshake, server stuffs status_request into CertificateEntry extensions, stapling the OCSP response into the handshake.
4. Client verifies OCSP response signature (signed by the cert's issuer), checks ThisUpdate / NextUpdate timestamps, confirms not revoked. The client never talks to the CA — privacy and latency both win.
2011 年 DigiNotar 被攻击,伪造的 *.google.com 证书签了出来。问题:Google 不知道。CT log 解决这个——CA 签每张证书必须同时把证书提交到至少 2 个 CT log(默认 3 个)。Log 是 append-only Merkle tree,操作者(Cloudflare、Sectigo、Google)必须公开运营。任何人都能审计 log——"我看到一份 *.google.com 的证书签发了但不是 Google 自己 issue 的,警报!"。这种"watch dog"模式让伪造证书无法不被发现,即使能签出来。
具体证据:每份证书携带 2-3 个 SCT(Signed Certificate Timestamp)。SCT 是 log 签的"我看到这张证书提交了"的承诺。浏览器(Chrome 强制)只接受带 ≥ 2 个独立 log SCT 的证书。这就是 Ch15 ursb.me 证书里那段 X509v3 CT Precertificate SCTs 的来源。
In 2011 DigiNotar was hacked, fake *.google.com certs got issued. Problem: Google didn't know. CT log fixes this — when a CA signs a cert it must simultaneously submit it to ≥ 2 (default 3) CT logs. Logs are append-only Merkle trees operated publicly by Cloudflare, Sectigo, Google. Anyone can audit — "I see a *.google.com cert issuance but Google didn't issue it; alert!". This watchdog model means a fake cert can't escape detection, even if it can be signed.
Concrete artefact: every cert carries 2-3 SCTs (Signed Certificate Timestamps). An SCT is a log's signed promise "I have seen this cert". Browsers (Chrome enforces) only accept certs with ≥ 2 independent-log SCTs. That's the source of the X509v3 CT Precertificate SCTs block in the ursb.me cert from Ch15.
2023 年 6 月,CT log 监控者发现 GoDaddy 给 1.2M 个域名签发的证书都没有 random serial number 的最低熵要求(违反 CA/B Forum BR §7.1)。CT log 让这个问题从 24 小时内被发现到几乎同时被 Mozilla / Microsoft 通报。GoDaddy 被强制 90 天内全部 revoke + 重新签发。没有 CT log,这种事过去能藏 2-3 年。
In June 2023 CT log monitors caught GoDaddy issuing 1.2M certs with serial numbers below the random-entropy minimum (violating CA/B Forum BR §7.1). CT log made the discovery happen within 24 hours, with simultaneous reports from Mozilla / Microsoft monitors. GoDaddy was forced to revoke and reissue within 90 days. Pre-CT, this kind of thing would hide for 2–3 years.
| MECHANISM | WEB ADOPTION 2026 | NOTE |
|---|---|---|
| CT log (mandatory) | ~100% | 浏览器拒绝无 SCT 的 certbrowsers reject SCT-less certs |
| OCSP Stapling | ~50% | CF / Apache 启用率高;自建 nginx 多忘配high on CF / Apache; often missed on self-hosted nginx |
| OCSP Must-Staple | < 1% | 证书显式声明"必须 staple",但 CA 默认不签发cert opts in to "must staple"; CAs don't issue by default |
| CRL (legacy) | < 5% | CRLite / OCSP 已基本取代largely replaced by CRLite / OCSP |
| CRLite (Firefox) | Firefox-only | Bloom filter 压缩 · Chrome 没用Bloom-filter compressed · Chrome doesn't ship it |
"全部加密"的承诺差最后这一步
"encrypt everything" stopped one step short
TLS 1.3 几乎做到了"除了你的存在性外什么都不泄露"——版本、cipher、扩展、证书、签名、应用数据,全部加密。唯一的例外是 ClientHello.server_name(SNI 扩展),它明文写着你要访问的域名。
为什么需要 SNI?一个 IP 上托管多个 HTTPS 网站(Cloudflare 一个 IP 跑 1M+ 个域名),服务器必须在收到 ClientHello 时知道客户端想要哪个域名,才能选对证书发回去。如果加密 SNI,鸡生蛋——服务器还没派生密钥不能解密。
所以 ClientHello 必须明文带 SNI。代价:任何能看 IP 包头的人——你的 ISP、咖啡店 WiFi、国家级防火墙、企业 DPI——都能看见你访问了哪些网站。这就是"SNI 之耻"。
TLS 1.3 almost achieved "reveal nothing except your existence" — version, cipher, extensions, cert, signature, app data, all encrypted. The lone exception: ClientHello.server_name (the SNI extension), which carries the target hostname in plaintext.
Why is SNI needed? A single IP hosts many HTTPS sites (Cloudflare runs 1M+ domains per IP). The server must know which hostname the client wants at ClientHello time, to pick the right cert. Encrypting SNI would be chicken-and-egg — the server can't decrypt without keys it hasn't derived yet.
So ClientHello carries SNI plaintext. The cost: anyone who can see IP packet headers — your ISP, café WiFi, nation-state DPI, enterprise proxies — sees every hostname you visit. That's the SNI shame.
| DATE | ACTOR | WHAT |
|---|---|---|
| 2014-05 | CN GFW | SNI 阻断 facebook.com · 第一次大规模 SNI 过滤部署SNI block facebook.com · first large-scale SNI-filter deployment |
| 2018-09 | Cloudflare | ESNI public draft + 公开实验,覆盖 ~7M 站点 |
| 2020-08 | Iran | 直接封 ESNI 扩展号 0xffce · ESNI 在 Iran 失效block ESNI ext number 0xffce outright · ESNI dies in Iran |
| 2020-09 | CN GFW | 同样封 0xffce · 跟进 Iranalso blocks 0xffce, copying Iran |
| 2020-10 | IETF TLS WG | ESNI 死了,转向 ECH(draft-ietf-tls-esni-09)ESNI dead, pivot to ECH (draft-09) |
| 2023-09 | Cloudflare | ECH 全面上线,draft-17ECH full rollout, draft-17 |
| 2024-Q1 | Firefox 121 | 默认开启 ECH(when DNS supports HTTPS RR)ECH on by default (when DNS supports HTTPS RR) |
| 2025-Q1 | Chrome stable | ECH 默认开启 · 但只对 GREASE-friendly 站点ECH default on · GREASE-friendly sites only |
| USE CASE | WHO | HOW |
|---|---|---|
| 国家级封锁National-scale blocking | CN GFW · IR · TRCN GFW · IR · TR | DPI 看 SNI = "twitter.com" 立刻 RST 包DPI sees SNI = "twitter.com" → RST |
| 企业内 DLPEnterprise DLP | Zscaler / Palo Alto | 按 SNI 路由 + 告警,不解密内容也能"谁访问了什么"route + alert by SNI; "who visited what" without decrypting |
| ISP 跟踪ISP tracking | Comcast, Verizon | "匿名"用户画像(SNI 时间序列)"anonymous" user profiles from SNI time series |
| CDN 路由CDN routing | Cloudflare | 真实合法用法——但是把 SNI 加密了 ECH frontend 也能做the legit use — but ECH frontends can do this even encrypted |
2018 年 Cloudflare 推出 ESNI(Encrypted SNI):把 ClientHello.server_name 字段用一个公钥加密(公钥放在 DNS TXT 记录里)。看起来很美,但有严重缺陷:
IRAN 2020 年索性封掉了 ESNI 协议本身,因为它的扩展号 0xffce 在 ClientHello 里太显眼。这次试错催生了 ECH——把整个内层 ClientHello 加密,外层是个decoy。
In 2018 Cloudflare launched ESNI (Encrypted SNI): encrypt the ClientHello.server_name field with a public key from a DNS TXT record. Looked great but had serious flaws:
Iran simply blocked the ESNI extension itself in 2020 — its extension number 0xffce stood out clearly in ClientHello. That failure spawned ECH: encrypt the entire inner ClientHello, outer is a decoy.
两份 ClientHello,一外一内
two ClientHellos, outer and inner
ECH 的核心想法:客户端发两份 ClientHello。Outer ClientHello 是公开的,SNI 写 "客户端在哪个 ECH frontend 上"(不写真实目的地)。Inner ClientHello 是真正想发的,SNI 写真实域名,被HPKE 加密后塞进 outer 的 encrypted_client_hello 扩展里。
HPKE(Hybrid Public Key Encryption,RFC 9180)是一个统一的"用公钥加密小载荷"接口——融合 KEM + KDF + AEAD。ECH 用 HPKE 把 inner ClientHello 整段加密。公钥从哪来?DNS HTTPS RR(RFC 9460)——一种新型 DNS 记录类型 65,专门承载 TLS 元数据,包括 ECHConfig。
ECH's core idea: client sends two ClientHellos. Outer ClientHello is public, with SNI = "which ECH frontend the client is on" (not the real destination). Inner ClientHello is the real one, with the real SNI, HPKE-encrypted and stuffed into outer's encrypted_client_hello extension.
HPKE (Hybrid Public Key Encryption, RFC 9180) is a unified interface for "encrypt a small payload to a public key" — fuses KEM + KDF + AEAD. ECH uses HPKE to seal the inner ClientHello in one chunk. Where does the public key come from? DNS HTTPS RR (RFC 9460) — a new DNS record type 65 carrying TLS metadata including ECHConfig.
关键设计:kem_context 把双方的公钥都喂进 KDF,防"key compromise impersonation"——攻击者就算拿到 sk_R 也只能解 1 个会话,不能伪造别人的 enc。HPKE 在 ECH 之外也被 MLS、Oblivious DNS-over-HTTPS、Privacy Pass 等多个协议复用——一个统一的非对称加密接口,避免每个协议自己拼 KEM+KDF+AEAD。
The key design trick: kem_context feeds both public keys into the KDF, defending against "key compromise impersonation" — even if an attacker grabs sk_R, they can only decrypt one session, not forge others' enc values. HPKE is reused beyond ECH — by MLS, Oblivious DNS-over-HTTPS, Privacy Pass — as a unified asymmetric-encryption interface, so each protocol doesn't have to stitch KEM+KDF+AEAD itself.
ECH 的隐私保证的关键脆弱性是流量分析:如果 ECH-on 和 ECH-off 的 ClientHello 大小不同、扩展顺序不同、padding 模式不同,DPI 还是能区分。所以 RFC 草案规定:
outer_extensions 引用 inner 同名扩展)。ECH's privacy guarantee has a key fragility: if ECH-on and ECH-off ClientHellos differ in size, extension order, or padding pattern, DPI can still distinguish. So the RFC mandates:
outer_extensions)."harvest now, decrypt later" 是真的
"harvest now, decrypt later" is real
RSA、Diffie-Hellman、ECDH 都建立在大数分解或离散对数困难性上。这两个问题在经典计算机上需要亚指数时间——RSA-2048 用 superhuman 计算资源也要 ~3×10⁷ 年。在量子计算机上呢?Shor 算法(1994 年提出)能多项式时间解这两个问题。
具体数字:解 RSA-2048 需要约 2000 个逻辑量子比特 + 30 亿门(Gidney & Ekerå 2019 的估算)。当前最大量子机器(IBM Condor 2023)有 1121 个物理量子比特,但每个逻辑比特需要 ~1000 个物理比特做纠错——所以实际需要 200 万个物理比特。现在距离这个数字 1000 倍。
问题:什么时候到?NIST 2024 年的官方判断 "敏感数据应在 2030 前完成抗量子迁移"。原因不是 2030 一定有量子机器,而是harvest now, decrypt later——情报机构可以现在抓 TLS 流量,等 2040 量子机器成熟再解。10 年保密期的数据从现在起就已经在风险中。
RSA, Diffie-Hellman, ECDH all rest on the hardness of integer factoring or discrete logarithm. Classically these problems are sub-exponential — RSA-2048 takes ~3×10⁷ years even with superhuman resources. On a quantum computer? Shor's algorithm (1994) solves both in polynomial time.
Concrete numbers: breaking RSA-2048 takes about 2000 logical qubits + 3 billion gates (Gidney & Ekerå, 2019). Today's largest quantum machine (IBM Condor 2023) has 1121 physical qubits, but each logical qubit needs ~1000 physical qubits for error correction — so realistically 2M physical qubits are needed. We're roughly 1000× away.
Question: when? NIST's 2024 official guidance: "sensitive data must complete post-quantum migration by 2030". Not because 2030 has the quantum machine, but because of harvest now, decrypt later — intelligence agencies can record TLS today and decrypt in 2040 when quantum matures. Any data with 10-year confidentiality needs is already at risk.
Shor 算法不是"用量子机暴力试每个因子"。它的核心是把因数分解归约成周期查找问题,然后用量子傅里叶变换在多项式时间内找到周期。骨架:
Shor's algorithm isn't "brute-force every factor on a quantum computer". The core is reducing factoring to period-finding, then using quantum Fourier transform to find the period in polynomial time. Skeleton:
关键洞察:步骤 2 的"周期 r"必须满足 a^r ≡ 1 (mod N)。在 ℤ/Nℤ 的乘法群里,所有元素都有有限阶——这个阶就是周期。经典算法找周期是亚指数的(需要乘 a, a², a³, ... 直到回到 1);QFT 让量子机能叠加地探测所有候选 r 并在一次测量后塌缩到正确值。
同样的归约也能解离散对数(Diffie-Hellman 与 ECDH 的硬度基础)——所以 Shor 一击拿下 RSA + DH + ECDH。但对称密码不在 Shor 的攻击面里:AES、SHA 都不是周期问题,Grover 只给个平方根加速。
The key insight: step 2's "period r" must satisfy a^r ≡ 1 (mod N). In the multiplicative group ℤ/Nℤ, every element has finite order — that order is the period. Classical algorithms find this period in sub-exponential time (multiplying a, a², a³, … until back to 1); QFT lets a quantum machine superpose over all candidate r and collapse to the right one in a single measurement.
The same reduction solves discrete log (Diffie-Hellman's and ECDH's hardness base) — so Shor takes down RSA + DH + ECDH in one stroke. But symmetric crypto is outside Shor's reach: AES, SHA aren't period problems; Grover only gives a square-root speedup.
| YEAR | MILESTONE | WHAT MADE IT |
|---|---|---|
| 2016-12 | NIST PQC project 启动NIST PQC project launched | 公开征集open call for submissions |
| 2017-11 | 收到 82 个提案82 submissions received | 来自全球密码学界global crypto community |
| 2019-01 | Round 2 · 26 candidates | 删掉 56 个明显脆弱的56 obvious weak ones eliminated |
| 2020-07 | Round 3 · 7 finalists + 8 alternates | Kyber, Dilithium, Falcon, SPHINCS+, McEliece, NTRU, SABER, … |
| 2022-07 | 4 个 winner 公布4 winners announced | Kyber (KEM), Dilithium + Falcon + SPHINCS+ (signatures) |
| 2023-08 | FIPS 203/204/205 draft | 改名 ML-KEM / ML-DSA / SLH-DSArenamed ML-KEM / ML-DSA / SLH-DSA |
| 2024-08 | FIPS 203/204/205 final | 正式标准official standards |
| 2024-10 | Round 4 KEM candidates | BIKE, HQC, Classic McEliece (alternates for ML-KEM diversity) |
| 2025-Q1 | CNSA 2.0 实施CNSA 2.0 in force | NSA 命令所有美国政府敏感系统NSA mandates for US gov sensitive systems |
| 2030 | 迁移 deadlinemigration deadline | "敏感数据必须完成 PQ 化sensitive data must be PQ" |
| 2035 | CRQC 中位预估CRQC median estimate | 学界共识;情报机构更激进academic consensus; agencies push earlier |
| ALGO | USE | CLASSICAL | QUANTUM | STATUS |
|---|---|---|---|---|
| RSA-2048 | cert sig + KEX | 3×10⁷ yr | hours (CRQC) | migrate |
| X25519 | ECDHE in TLS | 2^128 | poly-time (Shor) | migrate |
| P-256 ECDSA | cert sig | 2^128 | poly-time (Shor) | migrate |
| Ed25519 | cert sig | 2^128 | poly-time (Shor) | migrate |
| AES-256-GCM | record layer | 2^256 | 2^128 (Grover) | safe (2× key) |
| SHA-384 | transcript hash | 2^192 | 2^96 (Grover) | safe |
| ML-KEM-768 | PQ KEM | 2^196 | 2^196 (lattice) | safe |
| ML-DSA-65 | PQ cert sig | 2^192 | 2^192 (lattice) | safe |
注意 AES 和 SHA 不受 Shor 致命影响——Grover 算法只能给对称加密带来"平方根"的加速。所以 AES-256 还有 128-bit 安全等级,足够。真正危险的是 KEX 和签名,因此 TLS 的 PQ 迁移只动 key_share 和 signature_algorithms。
Note AES and SHA aren't fatally affected by Shor — Grover only gives symmetric crypto a square-root speedup. AES-256 retains 128-bit security; sufficient. The real danger is KEX and signatures, so TLS's PQ migration only touches key_share and signature_algorithms.
NIST 把"能跑 Shor 攻击 RSA-2048 的量子计算机"叫 CRQC。学界对 CRQC 到达年份的中位估计是 2035-2040。但情报机构(NSA / GCHQ)和量子计算公司(IBM / Google Quantum / IonQ)做更激进的判断——可能 2030 早期。这就是为什么 NIST 把"所有政府敏感数据" 2030 前迁移的命令在 2024 年下了。Apple iMessage 2024 年切到 PQ KEM、Signal 2023 年切,都是这条命令的连锁反应。
NIST calls "a quantum computer able to run Shor against RSA-2048" a CRQC. Academic median estimate for CRQC arrival: 2035–2040. Intelligence agencies (NSA, GCHQ) and quantum computing companies (IBM, Google Quantum, IonQ) project more aggressively — possibly early 2030s. That's why NIST issued the "all government-sensitive data" 2030 migration mandate in 2024. Apple iMessage's 2024 PQ switch, Signal's 2023 switch — chain reactions to this mandate.
量子机器还没到。
但抓你的数据已经在发生。 NSA CNSA 2.0 transition memo · 2022
The quantum machine isn't here yet.
But they're already harvesting your data. NSA CNSA 2.0 transition memo · 2022
把 PQ KEM 塞进 1 个 key_share 字段
smuggling PQ KEM into one key_share field
PQ KEM 替换 X25519 的思路有两种:
2024 年 Chrome + Cloudflare 切换的是 hybrid 模式:X25519MLKEM768(IETF 命名)。"768" 是 ML-KEM 的安全等级——NIST level 3(≈ AES-192),稍高于 X25519。
Two ways to replace X25519 with PQ KEM:
Chrome + Cloudflare in 2024 shipped hybrid: X25519MLKEM768 (IETF name). "768" is ML-KEM's security level — NIST level 3 (≈ AES-192), slightly above X25519.
ML-KEM 的硬度来自 module-LWE(Module Learning With Errors)。LWE 原型问题:给你 (A, b),其中 A 是 n × m 矩阵、b = A·s + e (mod q),s 是秘密向量、e 是小噪声。问:能从 (A, b) 还原 s 吗?经典 + 量子复杂度都没有亚指数算法,是后量子时代少数稳的密码假设之一。
"module" 在 module-LWE 里意味着不是单数论而是多项式环上做矩阵——A ∈ R_q^{k×k},其中 R_q = ℤ_q[X] / (X^n + 1)。ML-KEM-768 的具体参数:n=256, q=3329, k=3,噪声 η₁=2, η₂=2。这些参数让 ML-KEM-768 的安全级别等价 AES-192(NIST level 3)。
ML-KEM's hardness comes from module-LWE (Module Learning With Errors). The LWE prototype: you're given (A, b), where A is an n × m matrix, b = A·s + e (mod q), s is a secret vector, e is small noise. Can you recover s from (A, b)? No sub-exponential algorithm exists, classically or quantumly — one of the few PQ-stable assumptions.
"module" in module-LWE means matrix entries are polynomials, not scalars — A ∈ R_q^{k×k}, where R_q = ℤ_q[X] / (X^n + 1). ML-KEM-768's concrete parameters: n=256, q=3329, k=3, noise η₁=2, η₂=2. These put ML-KEM-768 at AES-192 equivalent (NIST level 3).
ML-KEM 的运算瓶颈是多项式乘法——把两个 256 次多项式(在 ℤ_q[X]/(X^256+1) 里)乘起来。朴素做法 O(n²) = 65536 次乘法。但 ML-KEM 的环结构(X^256+1, q=3329)恰好支持数论变换 NTT(Number Theoretic Transform,FFT 的有限域版本),把多项式乘法压到 O(n log n) ≈ 2048 次。
具体原理:取 q = 3329 是因为 256 | (q-1),所以 ℤ_q 里存在第 512 次单位根 ζ = 17(17^512 ≡ 1 mod 3329)。NTT 用 ζ 把多项式表示从"系数域"变换到"点值域",在点值域里相乘是逐元素相乘(O(n)),然后再变回来。
ML-KEM's bottleneck is polynomial multiplication — multiplying two degree-256 polynomials in ℤ_q[X]/(X^256+1). Naive is O(n²) = 65536 multiplications. But ML-KEM's ring structure (X^256+1, q=3329) just happens to support the Number Theoretic Transform (NTT, the finite-field version of FFT), bringing polynomial multiplication down to O(n log n) ≈ 2048 ops.
Concretely: q = 3329 is chosen so that 256 ∣ (q-1), hence ℤ_q has a primitive 512th root of unity ζ = 17 (17^512 ≡ 1 mod 3329). NTT uses ζ to transform polynomials from "coefficient domain" to "point-value domain"; in the point-value domain multiplication is elementwise (O(n)); then transform back.
ML-KEM 公钥(1184 字节)和 ciphertext(1088 字节)都预先存储在 NTT 域——这样 keygen/encap/decap 时不用每次反复变换,省 30-40% CPU。AVX2 / AVX-512 / ARM NEON 上 NTT 一层只需要几个 SIMD 指令——这是 ML-KEM 比 RSA-2048 keygen快 1000 倍的根本原因。
ML-KEM's public key (1184 B) and ciphertext (1088 B) are stored pre-transformed in NTT domain — saving 30-40% CPU by skipping repeated transforms during keygen/encap/decap. On AVX2 / AVX-512 / ARM NEON, one NTT layer is just a few SIMD instructions — the fundamental reason ML-KEM keygen is 1000× faster than RSA-2048 keygen.
2016 年 NIST 启动 PQ 算法竞赛,2022 年最终选了 Kyber(一种 module-LWE KEM)。2024 年 FIPS 203 把它正式名为 ML-KEM(Module-Lattice-based Key Encapsulation Mechanism)。Kyber 是社区名字,ML-KEM 是 NIST 标准名字——两者算法相同但参数不完全一致。生产用都按 ML-KEM 实现。
NIST started the PQ competition in 2016, finalised Kyber (a module-LWE KEM) in 2022. FIPS 203 in 2024 renamed it ML-KEM (Module-Lattice-based Key Encapsulation Mechanism). Kyber is the community name, ML-KEM the NIST standard name — same algorithm, not entirely identical parameters. Production implementations track ML-KEM.
| SCHEME | CLIENT PUBKEY | SERVER (CT) | SHARED | HANDSHAKE BYTES |
|---|---|---|---|---|
| X25519 | 32 B | 32 B | 32 B | ~280 B 总~280 B total |
| ML-KEM-768 | 1184 B | 1088 B | 32 B | ~2500 B 总~2500 B total |
| X25519MLKEM768 | 1216 B | 1120 B | 64 B | ~2500 B 总(主线 ursb.me)~2500 B total (main line) |
Cloudflare 2024 年 4 月对所有 Chrome (124+) 默认启用 X25519MLKEM768。6 个月后报告:
Cloudflare enabled X25519MLKEM768 by default for Chrome 124+ in April 2024. 6-month report:
KEM PQ 化只动 client/server 内存,不动证书链。真正的迁移瓶颈是证书 PQ 化——CA root + intermediate 都要发 PQ-signed 证书。问题:
所以 hybrid KEM 是第一阶段,cert PQ 是第二阶段,目前还在 IETF 草案。完成时间预估 2027-2028。
PQ-ifying KEM touches only client/server memory; cert chain stays the same. The real migration bottleneck is PQ certificates — CA root + intermediates need PQ-signed certs. Problems:
So hybrid KEM is phase 1, cert PQ is phase 2; still in IETF draft. ETA 2027–2028.
每个攻击换一段 RFC
every attack spawned an RFC patch
这是一份 TLS 安全研究的"损失清单"——每个攻击都让 TLS 的某一部分死掉。逆向看,这份清单解释了 TLS 1.3 为什么必须 删那么多东西。
This is the TLS security "kill list" — every attack killed some part of TLS. Reading backwards, this list explains exactly why TLS 1.3 had to delete so much.
| YEAR | ATTACK | HIT | 1.3 RESPONSE |
|---|---|---|---|
| 1995 | SSL 2 rollback | SSL 2.0 | 删 SSL,downgrade sentineldeleted SSL, added downgrade sentinel |
| 1998 | Bleichenbacher | RSA-KEX padding oracle | 删 RSA-KEXdeleted RSA-KEX |
| 2009 | Renegotiation MITM | TLS renegotiation | 删 renegotiationdeleted renegotiation |
| 2011 | BEAST | CBC + predictable IV | 删 CBC,强制 AEADdeleted CBC, AEAD-only |
| 2012 | CRIME | TLS compression | 删 compressiondeleted compression |
| 2013 | Lucky13 | CBC + HMAC timing | 删 CBCdeleted CBC |
| 2014 | Heartbleed | Heartbeat extension | 删 Heartbeat extdeleted Heartbeat ext |
| 2014 | POODLE | SSL 3.0 CBC | SSL 全删SSL fully deleted |
| 2015 | FREAK | EXPORT cipher fallback | 删 EXPORT ciphersdeleted EXPORT ciphers |
| 2015 | Logjam | 512-bit DH params | 最小 2048-bit DH,推 ECDHEmin 2048-bit DH, push ECDHE |
| 2016 | SLOTH | SHA-1 in transcript | 删 SHA-1deleted SHA-1 |
| 2016 | DROWN | SSL 2 cross-protocol | 禁 SSL 2 共用证书ban shared certs with SSL 2 |
| 2018 | ROBOT | Bleichenbacher's return | 删 RSA-KEX(已删过)delete RSA-KEX (already done) |
| 2019 | Raccoon | DH timing | 部分缓解:constant-time DH implpartially mitigated by constant-time DH |
| 2020 | RACCOON-2 | truncated DH share | key_share 强制 full-sizekey_share must be full-size |
2014 年 4 月 7 日,Codenomicon 和 Google Neel Mehta 同日报告:OpenSSL 1.0.1 系列实现 Heartbeat 扩展时漏了边界检查。这个扩展(RFC 6520)让客户端发一个 "ping" record 让服务器回相同 payload,本意是 keep-alive。但 OpenSSL 用客户端声称的 payload 长度作为 memcpy 计数,不验证实际 payload 是不是真有那么长。
攻击 payload 只有 4 字节:"18 03 02 00 03 01 40 00":record header (type=24 heartbeat) + length=3 + 实际 payload "01" + 声称 payload_length=0x4000 (16384)。OpenSSL 看到 length=0x4000 → 从客户端 buffer 复制 16384 字节回客户端——但客户端只发了 1 字节,所以剩下 16383 字节是从同一个进程的堆内存里拿。这些堆内存里可能是别的 TLS 连接的明文请求、私钥、cookies、密码。
影响:受影响版本是 OpenSSL 1.0.1 ~ 1.0.1f,全球 HTTPS 服务器约 17% 中招。Cloudflare 一周内 reissue 了所有客户证书(~10k 张),Yahoo / OkCupid / Imgur / Wikipedia 全部被列入"漏密钥"清单。RFC 8446 直接把 Heartbeat 扩展从 TLS 1.3 删掉——不存在的功能不会有漏洞。Heartbleed 也是 Google 决定 fork BoringSSL 的导火索("OpenSSL 太复杂,我们自己来")。
April 7, 2014. Codenomicon and Google's Neel Mehta filed independent reports the same day: OpenSSL 1.0.1's Heartbeat extension was missing a bounds check. The extension (RFC 6520) lets a client send a "ping" record and have the server echo the payload — keep-alive. OpenSSL used the claimed payload length as the memcpy count, without verifying it matched the actual payload.
The attack payload is 4 bytes: 18 03 02 00 03 01 40 00 — record header (type=24 heartbeat) + length=3 + actual payload "01" + claimed payload_length=0x4000 (16384). OpenSSL sees length=0x4000 → memcpy's 16384 bytes back — but only 1 byte arrived. The remaining 16383 come from the same process's heap: someone else's plaintext request, private keys, cookies, passwords.
Damage: OpenSSL 1.0.1 ~ 1.0.1f, ~17% of HTTPS servers worldwide. Cloudflare reissued ~10k customer certs within a week; Yahoo / OkCupid / Imgur / Wikipedia ended up on the "keys leaked" list. RFC 8446 simply deleted Heartbeat from TLS 1.3 — features that don't exist can't be exploited. Heartbleed also triggered Google's decision to fork BoringSSL ("OpenSSL is too complex, we'll do it ourselves").
2011 年 9 月,Juliano Rizzo 和 Thiago Duong 在 ekoparty 上演示了一段 90 秒的 JavaScript:通过受害者浏览器在同一 TLS 1.0 连接上的多个并发 HTTP 请求,解密出了 HTTPS cookie。
原理在于 TLS 1.0 CBC 的 IV 设计:每个 record 的 IV = 上一条 record 密文的最后一块。这意味着攻击者能预测下一个 IV——CBC 模式下,如果攻击者能选择部分明文 (chosen-plaintext),他可以构造 P = pre_chosen_block XOR IV_predicted XOR guess,让加密结果与某个目标 record 的密文块相同当且仅当 guess 正确。一次猜一字节,256 次试错就能逐字节解出 cookie。
"在同一连接里发多个 record" 这个前提靠 JavaScript 提供:在跨域 iframe 里发 fetch 请求,浏览器复用 TLS 连接。RFC 5246 (TLS 1.1) 已经修复了 IV——每个 record 用显式 IV,但部署滞后到 2014 年才普及。TLS 1.3 直接砍 CBC,"链式 IV" 这个概念在 1.3 里不存在。
September 2011, ekoparty. Juliano Rizzo and Thiago Duong demoed a 90-second JavaScript attack: through the victim browser's concurrent HTTP requests over the same TLS 1.0 connection, they decrypted an HTTPS cookie.
The flaw lay in TLS 1.0 CBC's IV design: each record's IV = the last ciphertext block of the previous record. The attacker can therefore predict the next IV. With chosen plaintext, they can construct P = pre_chosen_block XOR IV_predicted XOR guess so the resulting ciphertext equals a target record's ciphertext block iff the guess is correct. One byte at a time, 256 tries per byte, the cookie peels off.
The "multiple records in one connection" prerequisite was supplied by JavaScript: cross-origin iframes firing fetches reused the TLS connection. RFC 5246 (TLS 1.1) had already fixed the IV — explicit per-record IV — but deployment lagged until ~2014. TLS 1.3 axed CBC outright; "chained IV" doesn't exist in 1.3's vocabulary.
2015 年 5 月 weakdh.org 论文披露:8.4% 的 HTTPS top-million 网站仍支持 DHE_EXPORT cipher suite(512-bit Diffie-Hellman group)。这个出口管制时代的遗物——美国 1996 年前禁止出口"军用强度"加密,限制 DH 不超过 512 位——在 2015 年仍然没死透。
攻击:MITM 把客户端的 cipher list 改成只列 DHE_EXPORT,服务器降级、双方用 512-bit DH 协商。512-bit 离散对数问题在预先做了 group 特定的 number field sieve之后,能在几小时内破——而 Internet 上 80% 的 DHE_EXPORT 实现都用同一个固定 DH group(OpenSSL 默认值),意味着一次预计算就能攻击半个互联网。预计算成本:~$100M GPU-time,2 周。Snowden 文件证实 NSA 确实做了这件事——某段 GCHQ 文档提到他们"解密了万亿密钥"。
TLS 1.3 三道防御:(1) 删 EXPORT cipher suite,(2) ECDHE 强制(替代 DHE,曲线 group 公开标准化),(3) downgrade sentinel + Finished MAC 让任何中间人降级在协议层暴露。
May 2015, the weakdh.org paper: 8.4% of HTTPS top-million sites still supported DHE_EXPORT cipher suites (512-bit Diffie-Hellman group). A relic of US 1996 export-control rules — pre-1996, "military-grade" crypto couldn't leave the US, capping DH at 512 bits — was still alive in 2015.
Attack: MITM rewrites the client's cipher list to only DHE_EXPORT; server downgrades; both sides negotiate 512-bit DH. The 512-bit discrete-log problem, after group-specific number-field-sieve precomputation, falls in hours — and 80% of internet DHE_EXPORT implementations used the same hardcoded DH group (OpenSSL's default), meaning one precomputation breaks half the internet. Precomputation cost: ~$100M GPU-time over 2 weeks. The Snowden documents corroborated NSA actually doing this — a GCHQ slide referenced "decrypting trillions of keys".
TLS 1.3's three defences: (1) delete EXPORT cipher suites, (2) mandate ECDHE (replacing DHE; curve groups are publicly standardised), (3) downgrade sentinel + Finished MAC make any MITM downgrade detectable at the protocol layer.
| CLASS | EXAMPLES | 1.3 DEFENCE |
|---|---|---|
| 降级Downgrade | SSL 2 rollback, FREAK, Logjam, SLOTH | 删旧 cipher · downgrade sentinel · 强签 transcriptdelete legacy · downgrade sentinel · sign transcript |
| OracleOracle | Bleichenbacher, POODLE, Lucky13, ROBOT | 删 CBC + RSA-KEX,强制 AEAD(Mac-then-Encrypt 不存在)delete CBC + RSA-KEX, force AEAD (no MAC-then-Encrypt) |
| 实现错误Implementation | Heartbleed, CCS Injection | 协议简化 → 实现更难错(少一个分支少一个 bug)simpler protocol → fewer bugs (one fewer branch = one fewer CVE) |
TLS 1.2 之前的版本设计完再让学界打——每一次"打中"就是一个 CVE。TLS 1.3 反过来:协议 draft 阶段就有人用形式化验证工具(Tamarin / ProVerif / F* / CryptoVerif)证明"在标准的攻击者模型下,没有可达的安全失败状态"。这是 30 年 TLS 历史的拐点。
Pre-1.2 versions were "design first, let academics break it" — every break is a CVE. TLS 1.3 inverted this: while the draft was still being written, researchers used formal-verification tools (Tamarin / ProVerif / F* / CryptoVerif) to prove "under the standard attacker model, no reachable security-failure state exists". This is the inflection point of TLS's 30-year history.
| TOOL | WHAT IT PROVED | PAPER |
|---|---|---|
| Tamarin | 完整握手 + PSK + 0-RTT 状态机的 secrecy + authenticationhandshake + PSK + 0-RTT state machine secrecy & auth | Cremers et al., S&P 2017 |
| ProVerif | Key Schedule HKDF 树的 key-independencekey-independence of HKDF tree | Bhargavan et al., 2017 |
| F* / miTLS | 完整 record layer 实现的 functional correctness + memory safetyfunctional correctness + memory safety of full record layer | Bhargavan et al., S&P 2017 |
| CryptoVerif | 基于 game-hopping 的密码学安全归约证明game-hopping crypto reduction proofs | Blanchet, INRIA |
| Tamarin (PQ) | X25519MLKEM768 hybrid KEM 在 TLS 1.3 框架内安全性保持X25519MLKEM768 hybrid KEM preserves TLS 1.3 security | Cremers et al., 2024 |
这些工作做了一件事:把 RFC 8446 翻译成形式化模型(每条消息是一个 process,每条密钥派生是一个 rewrite rule),然后让工具自动探索所有可达的协议状态,看有没有"机密性丢失"或"双方对握手内容不一致"的状态。Tamarin 在 2017 年实际找到了一个真问题——一个早期 draft 在 PSK + DHE 组合下能让中间人让两边"接受"不同的 transcript_hash。这个洞在 RFC 8446 final 之前被修了。这是历史上第一次 IETF 协议在出版前就被工具证明过,后续 MLS、Signal、QUIC 都跟进这套流程。
The recipe: translate RFC 8446 into a formal model (each message a process, each key derivation a rewrite rule), then let the tool auto-explore all reachable protocol states, looking for "secrecy lost" or "transcript disagreement" states. Tamarin actually found a real bug in 2017 — an early draft let an MITM make the two sides "accept" different transcript_hashes under a PSK + DHE combination. The bug was fixed before RFC 8446 final. First IETF protocol in history to be tool-proven before publication; MLS, Signal, QUIC all followed this template since.
"我们花了 20 年学会一件事:
能配置的,就能被错误配置;能协商的,就能被降级。" Kenny Paterson · TLS WG · Real World Crypto 2016
"It took us 20 years to learn one thing:
what can be configured can be misconfigured; what can be negotiated can be downgraded." Kenny Paterson · TLS WG · Real World Crypto 2016
同一个协议,六种代码风格
same protocol, six codebases
| STACK | LANG | USED BY | NOTE |
|---|---|---|---|
| OpenSSL | C | nginx, Apache, curl, Python, Ruby | 事实标准。3.0 重写过 Provider API;包袱重de facto. 3.0 rewrote Provider API; baggage-heavy |
| BoringSSL | C++/C | Chrome, Cloudflare quiche, Android | Google 内部 fork。删 EXPORT/SSLv2/Heartbeat。无外部 API 稳定承诺Google's internal fork. EXPORT/SSLv2/Heartbeat all gone. No API-stability promise |
| rustls | Rust | Cloudflare's pingora, Deno, hyper | memory-safe。无 1.2 PSK,无 SSL 3,故意不提供脚枪memory-safe. No 1.2 PSK, no SSL 3, deliberately no footguns |
| s2n-tls | C | AWS internal | "6000 行" 设计目标——审计可读"6000 lines" design target — auditable |
| NSS | C | Firefox, Thunderbird | Mozilla 维护。和 root store 共生Mozilla. Co-evolves with root store |
| Go crypto/tls | Go | Go std lib, Caddy, Vault, Cockroach | stdlib——Go 项目用得多。设计偏保守stdlib — Go projects rely on it. Conservative design |
"代码量越少越安全"在密码学里有铁律——Heartbleed 是 OpenSSL 在 ~500k 行代码里漏的一个边界检查。rustls 在 ~25k 行里至今没出过任何 memory-safety bug。这就是 Cloudflare 把 nginx 替换成 pingora(基于 rustls)的核心动机。
In cryptography "less code = safer" is law — Heartbleed was a single missed bounds-check in ~500k lines of OpenSSL. rustls in ~25k lines has had zero memory-safety bugs to date. That's the core motivation behind Cloudflare swapping nginx for pingora (built on rustls).
下面是"建立一个 TLS 1.3 client,发 GET / 到 ursb.me" 在三个最常用 stack 里的最简代码。同样的逻辑、同样的字节出口,但 API 心智模型差异巨大——这种设计哲学差解释了为什么换 stack 是大工程。
Below is "build a TLS 1.3 client and send GET / to ursb.me" in three of the most common stacks. Same logic, same wire bytes, wildly different API mental models — the design-philosophy gap explains why swapping stacks is a multi-month migration.
| STACK | ROOTS | SNI | CERT VERIFY | FOOTGUN COUNT |
|---|---|---|---|---|
| OpenSSL | 默认 OS · set_default_verify_pathsOS-default · set_default_verify_paths | SSL_set_tlsext_host_name | 必须手动 SSL_set1_host(忘了 = 不验 hostname)must manually SSL_set1_host (forget it = no hostname check) | 多(hostname、verify mode、cipher 选)many (hostname, verify mode, cipher list) |
| rustls | 编译期注入 webpki_rootswebpki_roots compiled in | 由类型系统强制 (ServerName)type-system enforced (ServerName) | 不可关闭 · cert 验证不可绕过cannot disable · cert verify cannot be bypassed | 极少few |
| Go crypto/tls | 默认 OS · 已加载OS-default · auto-loaded | tls.Config.ServerName | 默认开 · 关需 InsecureSkipVerify=trueon by default · disable via InsecureSkipVerify=true | 中(InsecureSkipVerify 是脚枪 #1)moderate (InsecureSkipVerify is footgun #1) |
OpenSSL 的脚枪:忘 SSL_set1_host → 证书签名验过了但没验主机名匹配,攻击者拿任意有效证书就能 MITM。Snyk 2022 扫描 GitHub 1M+ OpenSSL 集成发现 ~8% 项目漏了这一行。这就是 rustls 把 ServerName 做成构造函数必填类型的动机。
OpenSSL's footgun: forget SSL_set1_host → cert signature verifies but hostname match doesn't; any valid cert lets an attacker MITM. Snyk's 2022 scan of 1M+ OpenSSL integrations on GitHub found ~8% of projects missing this one line. That's why rustls makes ServerName a mandatory constructor type parameter.
你能立刻在终端跑的命令
commands you can run right now
这一段输出几乎是整本书的 cross-cut——每一行都对应文里某一章:
16 03 01 02 16 的 record header 解码到 type=Handshake · ver=TLS1.0(middlebox compat 残留)— Ch05Server Temp Key: ML-KEM-768, X25519MLKEM-768 — Ch21 主线握手默认的 PQ hybrid KEXSecure Renegotiation IS NOT supported + Compression: NONE — Ch03 死结 全部对上This single output is a cross-section of the entire article — every line maps to a chapter:
16 03 01 02 16 record header → type=Handshake · ver=TLS1.0 (middlebox compat) — Ch05Server Temp Key: ML-KEM-768, X25519MLKEM-768 — main-line PQ hybrid KEX from Ch21Secure Renegotiation IS NOT supported + Compression: NONE — Ch03 deadlocks, all matched所有理论的终点是能跑。下面这段把 Ch08 (HKDF Key Schedule) + Ch10 (Finished) + Ch11 (AEAD Record) 串成一条完整工作流——不用 Wireshark,纯 Python + cryptography 库,从一个 SSLKEYLOGFILE + 一个 .pcap 解出 ursb.me 实际返回的 HTML 字节。
The end of all theory is "it runs". This snippet wires Ch08 (HKDF Key Schedule) + Ch10 (Finished) + Ch11 (AEAD Record) into a complete workflow — no Wireshark, just Python + cryptography, from one SSLKEYLOGFILE + one .pcap to the actual HTML bytes ursb.me returned.
这 80 行代码替你完成了 Wireshark TLS dissector 的全部工作——验证你真的理解 TLS 1.3 record 的密钥派生 + AEAD + inner type 隐藏。能跑得对说明你能从字节级读懂协议。如果跑错了,三个最常见原因:
This 80-line script does everything Wireshark's TLS dissector does — proving you truly understand TLS 1.3 record key derivation + AEAD + inner-type hiding. If it runs correctly, you can read the protocol byte-by-byte. The three most common bugs:
所有教程的最高目标是用自己的代码连上真实服务器。下面用 rustls(Rust,~25k SLOC,memory-safe)写一个最小可用的 TLS 1.3 client,连 ursb.me,发 GET / 拿到 HTML。完整可运行——cargo new tls-min && cd tls-min 把下面三段贴进去就能跑。这是 Ch24 整章理论的压轴落地,仿照 HTTP/3 那篇 Ch25 「手写 200 行 quiche client」 的同等位置。
The ultimate goal of any tutorial: connect to a real server with your own code. Below, using rustls (Rust, ~25k SLOC, memory-safe), we build a minimal-but-working TLS 1.3 client — connect to ursb.me, send GET /, fetch the HTML. Fully runnable: cargo new tls-min && cd tls-min, paste the three blocks below, you're up. This is the capstone landing for all of Ch24's theory, mirroring HTTP/3 Ch25 "Hand-write 200 lines of quiche".
| DECISION | WHY | WHAT IF YOU REMOVE IT |
|---|---|---|
try_into() for ServerName | 类型系统强制 DNS 名格式type-system enforces DNS-name format | 编译错(rustls 不让你绕开)compile error · rustls won't let you bypass |
enable_early_data = true | 允许 0-RTT 重连allows 0-RTT resumption | 每次都跑 1-RTT 完整握手every visit runs full 1-RTT |
alpn_protocols | 广告 h2 / http/1.1advertise h2 / http/1.1 | 服务器 ALPN 拒绝,连接 abortserver ALPN-rejects, conn aborts |
KeyLogFile | 用 Wireshark 调试解密enables Wireshark decryption | 抓包看到密文没办法解pcap stays opaque |
| 没显式选 cipherno explicit cipher selection | rustls 默认只允许 5 个 AEAD suite,安全配置无需脚枪rustls defaults to the 5 AEAD suites; safe-by-default | OpenSSL 不一样——必须手动 listOpenSSL is different — must list manually |
| PQ 默认PQ default | rustls 0.23 默认提议 X25519MLKEM768rustls 0.23 advertises X25519MLKEM768 by default | 需 config.crypto_provider 切到老 only-ECneed config.crypto_provider to revert to EC-only |
整段 ~70 行(不含 Cargo.toml)。这就是现代 TLS client 的全部模板代码——rustls 把"配 root store / 启 0-RTT / 设 ALPN / 验证 hostname" 这些 1.2 时代每个都是脚枪的步骤用类型系统 + 默认值锁住。同样功能的 C + OpenSSL 版本至少 200 行,且能漏 SSL_set1_host 这种致命 bug(Ch23 Snyk 8% 数据)。这是 Cloudflare 决定 pingora(基于 rustls)替换 nginx + OpenSSL 的根本原因。
~70 lines total (Cargo.toml excluded). This is the entire boilerplate for a modern TLS client — rustls locks down "set root store / enable 0-RTT / set ALPN / verify hostname" (1.2-era footguns each) with types and defaults. The C + OpenSSL equivalent is 200+ lines and can still miss SSL_set1_host for a fatal bug (Ch23, Snyk's 8% finding). This is exactly why Cloudflare's pingora (rustls-based) replaced nginx + OpenSSL.
25 章压缩到 10 条
25 chapters distilled to 10 bullets
握手只发生 1 个 RTT。
但 30 年的攻防压在每个字节里。 Field Note · 07 · Fin
The handshake lasts one RTT.
But 30 years of attack and defence sit in every byte. Field Note · 07 · Fin
RFC · IETF Drafts · 论文 · 引擎源码
RFCs · IETF Drafts · papers · engine source
这一节把全文用到的 外部规范、RFC、论文、源码 归档。每条引用都带 状态徽章(STD = 正式 RFC / PS = Proposed Standard / DRAFT = IETF Internet-Draft)+ 链接 + 你在哪一章会用到它。所有 URL 在 2026 年 5 月有效; TLS 1.3 + ECH + PQ 生态仍在演化, IETF tls / lamps / pq-crypto WG 持续在出新草案。
This section archives every external spec, RFC, paper, or source-code reference the article touches. Each carries a status pill (STD = published RFC / PS = Proposed Standard / DRAFT = IETF Internet-Draft) + link + the chapter that uses it. All URLs valid as of May 2026; the TLS 1.3 + ECH + PQ ecosystem keeps moving — IETF tls / lamps / pq-crypto WGs publish drafts continuously.
Early-Data: 1 header + 425 Too Early。Ch14。Thomson, Sep 2018. Early-Data: 1 header + 425 Too Early. Ch14.RFC 不是终点。
它只是"这一刻全世界同意了"的快照。 Field Note · 07 · Fin
An RFC is not the end.
It's a snapshot of "what the world agreed on, at this moment". Field Note · 07 · Fin
从客户端打出 ClientHello,
到 200 OK 的字节被 AEAD 解开,
TLS 1.3 用 538 字节告诉服务器一切,
用 HKDF 长出 8 把密钥,
在 一个 RTT 里完成 30 年的攻防。
From the client's ClientHello,
to the moment AEAD unwraps 200 OK,
TLS 1.3 says everything in 538 bytes,
grows eight keys from one HKDF root,
and settles 30 years of attack and defence — in one RTT.