TLS 与安全 入门

curl https://api.example.com/user/42 的 TCP 握手刚一完成,第二次握手就开始了。这一页证明关于它的 3 件事:光有加密几乎什么也买不到;一次被猜出来的 key share 就是 1 个往返和 2 个往返的全部差别;以及 TLS 里贵的那部分不是加密。7 个 section:没保护的字节、两个密码家族、在公开场合约定密钥、握手、证书、记录层,以及它怎么坏。

01

没有保护的字节长什么样

curl 和服务器之间有 6 台机器能读到线路。TLS 藏不住任何一台,它只是让它们读到的东西没用。

每一跳都终结一条链路再转发一个包,转发时那些字节就躺在它自己的内存里。这不是攻击,这就是路由本身。唯一的问题是:字节到那儿的时候写着什么。

沿路径拖动观察者,再切换协议。走普通 http:// 时每一跳都读到整个请求;走 https:// 时同一跳读到的是一个头加一堆噪声:

咖啡店 Wi-Fi —— 整个请求。沿路径拖动观察者;方向键一次移动一跳,Home 键复位
咖啡店 Wi-Fi —— 整个请求

注意观察者从来没离开线路。TLS 没有改变谁能看到这个包 ——咖啡店的 AP照样转发它的每个字节。变的是URL、cookie 和 body 压根就不在这个包里了。

机密性只是 3 个保证里的第 1 个,单独拿出来几乎一文不值。翻转密文的一个字节,看看应用收到什么 —— 先用只加密的密码,再用带 AEAD tag 的:

翻转字节 7 —— 明文对应位被翻转,记录被接受

看字节 7。在没有 tag 的流密码下,翻密文的一个 bit 就正好翻了明文的那个 bit:amount=10.00 变成amount=90.00,接收方分辨不出来。攻击者从头到尾没拿到密钥,也不需要。换成 AEAD,同样的改动产出 bad_record_mac,记录被丢掉。

剩下真实性,而这正是大家最爱丢的那个。把拦截者在证书校验关掉的情况下走一遍,再打开校验重走一遍:

第 1 步 / 共 4 步

因为客户端从来不问证书是谁签的,攻击者能完成一次完美加密的握手 —— 跟它自己 —— 再转发给真服务器。打开校验后它在第 3 步被unknown_ca (48) 挡住。加密到错的端点不是弱一点的保证,那是没有保证。

TLS 用一层就把这 3 个都买下来了,这层坐在应用之下、TCP 之上。往下走一遍栈,看同一个 176 字节的请求在每一层上称出多重:

应用层

记录层加 22 字节:5 字节头、1 字节藏在密文里的内容类型、16 字节 tag。它不知道自己搬的是什么。HTTP、IMAP、gRPC、AMQP 待遇完全一样,这就是为什么 stunnel 能把一个从没听说过 TLS 的协议包起来。

一个实际后果,它决定了你的故障从哪来。TLS 在私钥所在的地方终止,而生产上那几乎从来不是处理请求的那个进程 —— 拖滑块走过负载均衡器对后端说话的 3 种方式:

VPC 内明文 HTTP

中间那个盒子拿着证书,所以版本策略、密码套件列表、过期时间都住在那里,几乎所有 TLS 事故也都在那里。它后面,明文 HTTP 留下一段明文,赌的是没人会把包路由错、也没人能在邻居主机上拿到 shell。

02

两个密码家族,各自便宜在哪

一个快,但要求双方已经共享一把密钥;另一个慢,但不要求。它们之间的比值决定了协议的形状。

对称密码用同一把密钥正着跑和反着跑。从 Westmere 开始 AES 就是一条指令而不是一个库,所以在服务器核上它更像是内存带宽问题,不是计算问题。公钥运算不是。

拖动载荷大小,3 个值读在同一根对数轴上 ——批量加密对上一次 X25519 交换和一次 P-256 签名:

载荷 1 KB

在起手的 1 KB 上,批量的活是 0.20 µs,签名是 29 µs —— 147 倍,而字节数少了 400 倍。往上拖,两个标记要到 128 KB 和 256 KB 之间才碰头。整个设计就是这句话:公钥的钱每连接只付一次,之后再也不付。

非对称慢,是因为每一位密钥买到的安全更少。走过各个安全级别,比较RSA 和曲线各自需要多少:

80 位安全强度

注意差距是越爬越大的。112 位时 RSA 要 2,048 位,曲线要 224 位 —— 9 倍;256 位时要 15,360 对 512 —— 30 倍。这就是为什么现在新签的服务器证书都是 ECDSA P-256:和 RSA-3072 同样的 128 位强度,密钥材料只有十二分之一。(NIST SP 800-57 Part 1 Rev. 5,表 2。)

在能检测篡改之前,这两个家族都还不能用,而现代 TLS 把检测折进密码本身。记录起手是 16 字节载荷 —— 往上拖,看tag什么时候不再要钱:

载荷 16 B

载荷 16 字节时,记录在线路上是 38 字节 —— 开销 58%。到 16 KB 上限时是 0.13%。 AEAD 把加密和认证做成一次调用,于是没有顺序可以搞错,解密和检查之间也没有分支。下一张图就是这句话值多少钱。

AEAD 之前这是两次调用,而顺序至关重要。让接收方把两种顺序各走一遍,看哪一种在检查 MAC 之前就碰了攻击者选的字节:

第 1 步 / 共 3 步

因为先 MAC 后加密先解密再剥 padding, 拒绝一条伪造记录花多长时间取决于攻击者选的 padding。这个时间差就是一个解密预言机,它就是 BEAST、POODLE 和 LUCKY13。先加密后 MAC 在解密跑起来之前就把伪造扔掉; AEAD 干脆没有顺序可以搞错。

AEAD 唯一扛不住的是 nonce 重复。挪到第一条之后的任意一条记录,再把 nonce 从序列号切成固定值:

记录 1 —— 别处没用过的密钥流

一旦同一把密钥下两条记录共用一个 nonce,它们就共用一段密钥流,把两段密文异或起来密钥流就抵消了 —— 攻击者拿到 P₀ ⊕ Pᵢ, 全程没碰过密钥。GCM 下更糟:认证子密钥也一起掉出来,伪造变成免费的。TLS 1.3 直接取消了这个选择,nonce 由连接内不可能重复的序列号推导。

03

在公开场合约定一把密钥

两端需要同一把对称密钥, 而它们说的每句话整条路径都读得到。一次往返解掉这件事,是 TLS 其余部分的那个戏法。

显而易见的答案 —— 用服务器公钥加密一个随机密钥再发过去 —— 正是 TLS 1.2 的做法,也正是 TLS 1.3 直接删掉的东西。它被删不是因为慢。

先看替代它的东西。分别设定客户端的私密值和服务器的私密值:只有两个公开值上线,而双方仍然落在同一个数上:

客户端私密值 a = 6 · 服务器私密值 b = 15

注意共享值只出现在两侧的盒子里,中间那个从来没有。拿着两个公开值的观察者必须先还原出a或b 才能到那里 —— 把手上的两个乘起来给出的是 5a+b,不是 5ab。真的 TLS 跑在 Curve25519 上而不是 mod 23, 但形状就是这个。

全部安全性就是这次还原的代价,而它在仍在使用的各个群之间相差 5 个数量级。走一遍,在 log₂ 轴上读出这份工作量:

DH-1024

因为 DH-1024 坐在 280,而数域筛法里大部分工作每个素数只做一次,一个针对某个被广泛共用的 1024 位素数做预计算的对手,能读走所有用了它的连接。这就是 Logjam(Adrian 等,CCS 2015), 也就是 TLS 1.3 砍掉有限域 Diffie-Hellman 的原因。X25519 没有可以被预计算的共享参数。

能在事后失窃里活下来的性质叫前向保密,它也是密钥传输必须走人的全部理由。选一种会话密钥的协商方式,然后拖动服务器私钥泄漏的那一天:

第 0 天密钥被偷

看标记后面的那些条。用 RSA 密钥传输时,一路回到第 0 天的每一次录制的会话都能解开,因为会话密钥本身是用一把比它活得久的密钥加密的。用 ECDHE 时一次也解不开: 临时私密值哪儿都没写下来,被偷的那把密钥从头到尾只签过名。「先收割、后解密」防的就是这个。

单独一个 32 字节的共享密钥还不够 —— 连接需要按方向、按阶段分开的密钥,否则一个方向的消息能被重放进另一个方向。沿密钥调度往下走:

ECDH 共享密钥

每个箭头是一次 HKDF-Expand-Label 调用,label 字符串不同,而每个输出与它的兄弟在计算意义上独立。这就是为什么攻破一把应用流量密钥不会连带交出恢复 secret,也是为什么 KeyUpdate 能不重新握手就轮换流量密钥。

这根轴上最后的压力,来自一台能跑 Shor 算法的机器。修复已经上线了,代价用字节付 —— 走过各个群,看ClientHello 怎么越过分段上限:

X25519

X25519MLKEM768 带一个 1,216 字节的 key share,把 ClientHello 顶到 1,528 字节 —— 越过以太网 MTU 剩下的 1,460,于是这个 hello 变成 2 个包,第 2 个包到之前它无法完成。 Chrome 从 131 版(2024 年 11 月)起设成默认。多一个包就是全部代价 —— 因为今天录下的会话到 2040 年仍然值得解密。

04

一次往返到加密记录

TLS 1.2 要 2 个往返第一个 HTTP 字节才能动。TLS 1.3 只要 1 个,差别就是第一条消息里的一次猜测。

猜的是 key share。客户端挑一个它预期服务器也支持的群,生成一对临时密钥,立刻把公开的那半发出去 —— 在它知道服务器同不同意之前,猜错就多付一个往返。

单步或播放这次握手,看每条消息是被哪把密钥保护的:明文,然后是握手密钥, 再然后是应用密钥:

带 key share 的 ClientHello

注意加密开始得有多早。只有两个 hello 是明文的;到第 4 条消息服务器已经在加密了,这也是为什么 TLS 1.3 里证书根本不出现在线路上 —— 相对 1.2 的这个改动,对审查和对隐私一样重要。客户端的 Finished 关掉握手,请求就搭它后面那个包。

省下的那个往返是这次重设计买到的唯一东西,而它的价值完全由网络决定。拖动往返时间,读出到响应第一个字节的时间:

往返 80 毫秒。左右拖动改变往返时间;方向键一次 5 毫秒,Home 键复位
往返 80 毫秒

在起手的 80 毫秒上,TLS 1.3 240 毫秒够到第一个字节,TLS 1.2 要 320 —— 在一段什么有用的事都还没发生的等待里,省下 80 毫秒。这里数的是:TCP 1 个往返,TLS 1 到 2 个,请求和它的回答 1 个。在 300 毫秒的卫星链路上,省下的就是 300 毫秒。

恢复可以更进一步,把请求塞进第一个包里,代价是一个只能靠设计绕开、不能靠配置关掉的问题。选early data 里装的方法,再重放那个被截获的包:

重放 0 次 —— 每次答案都一样

因为服务器没有任何按连接的状态可以对照,它分辨不出重放和原件:拖到 4 次重放,这个账户已经被扣了 5 次。 TLS 1.3 没有修这个 —— 真正的防重放需要在 CDN 每个边缘节点之间共享状态。所以 0-RTT 只对 GET 放行,任何会改状态的动作都被强制走完整握手。

握手还必须扛住的另一件事,是攻击者在飞行中改写它来逼出更弱的选择。挑一个字段改掉,再比较两个版本:

什么都不改

因为 CertificateVerify 是对目前为止每一个握手字节的哈希做的签名,任何改动都会让客户端的记录和服务器的对不上,签名验不过 ——decrypt_error (51),而且是在一个应用字节动之前。TLS 1.2 只签了密钥交换参数,没被签的那一部分就是 FREAK 和 Logjam 走进去的洞。

最后,这一切让应答的那台机器付多少钱。设定连接速率,再比较完整握手和恢复握手:

每秒 1,000 次握手

拖到每秒 1 万次握手:完整握手要 1.09 个核的公钥运算,恢复要 0.80。恢复省下的是签名,不是密钥交换,因为 TLS 1.3 默认的 psk_dhe_ke 照样跑 ECDHE 保住前向保密。psk_ke 跳过它,几乎免费,代价就是把前向保密扔了。

05

把密钥绑到一个名字上

一条通往错误机器的完美加密通道一文不值,所以必须有东西把公钥拴在主机名上。PKI 的每一块都是为了让这根绳子可被检查。

证书是一份被签名的声明:这把密钥属于这些名字,到这个日期为止,用于这些用途。客户端把每一条都查一遍,而这些条款彼此不能互换 —— 挂在这一条和挂在那一条是不同的 bug。

走一遍 TLS 客户端真正校验的 6 件事,读出每一件拒绝的是什么:

Subject Alternative Name

注意 Common Name 不在列表上。Chrome 2017 年就不读它了,其他跟进;主机名住在Subject Alternative Name 扩展里,别处没有。一张 CN 写对、SAN 列表却是空的证书会挂,而报错点的是运维从没编辑过的字段 —— 所以每次都要搭进去一个下午。

主机名这一条最常被搞错,因为通配符的含义和它看起来的不一样。选一个主机名,拿它和SAN 条目比一比:

连接 example.com

通配符正好匹配一个标签。*.example.com 罩得住 api.example.com,罩不住 a.b.example.com, 而且 —— 这条最出人意料 —— 罩不住 example.com 本身。第 4 个主机名是攻击:api.example.com.evil.net 结尾是 evil.net,这正是匹配要从右往左按标签走、而不是找子串的原因。

叶子证书单独什么也证明不了;它必须够得到客户端已经持有的一把密钥。沿证书链往上走,再把中间证书拿掉:

第 1 环 / 共 3 环

因为客户端只自带根证书,服务器必须把每一张中间证书都发出来。少发一张是最常见的 TLS 配置错误,而且它挂得不对称:浏览器通常能靠 AIA 扩展去抓签发者救回来,curl、Java 和 Go 不会。于是站点在 Chrome 里好好的、在 CI 里挂掉,这是它能挂掉的最坏方式。

中间证书存在,是因为根的私钥离线锁在保险库里、大概十年才签一次。叶子正相反 —— 在一张 90 天的证书上拖动今天:

第 0 天 / 共 90 天。左右拖动移动今天;方向键一次一天,Home 键复位
第 0 天 / 共 90 天

看续期窗口在第 60 天打开。留 30 天余量是因为续期是一个无人值守、且会安静失败的任务 —— 一次 DNS 变更、一个失效的 ACME 账户、一条防火墙规则 —— 而 30 天大约就是有人注意到所需的时间。过了第 90 天客户端用certificate_expired (45)拒绝,而且是大声拒绝,这已经是好情况了。

短有效期不是讲卫生,它就是撤销这件事本身的答案。挑一种机制,拖动自密钥被报告失窃以来的小时数:

吊销后 0 小时

注意上面两根条一样长。裸 OCSP 是软失败的:响应者够不着时客户端照样继续,所以能拦下一个 HTTP 请求的攻击者也就拿到了完整的 90 天。 Stapling 把这次抓取挪到服务器上从而堵住它;6 天的证书则是根本不需要撤销来堵住它,这也是 CA/Browser Forum 2025 年投票决定到 2029 年把最长有效期砍到 47 天的原因。

最后一个变体,是把同一套检查在反方向也跑一遍。切到双向,把两半都走一遍:

客户端校验服务器

只认服务器的 TLS 里,服务器知道连接是私密的,却不知道对面是谁,于是退回去用 bearer token —— 日志文件里的 bearer token 就是一份凭据。mTLS 把客户端私钥变成凭据:它没法从 header 里被抄走,网格 sidecar 每小时轮换一次。代价是多运维一套 PKI。

06

记录层,字节真正住的地方

握手一毫秒就结束。此后每个字节都走记录层, 它的两个参数其实是伪装成配置的延迟和吞吐决策。

记录是加密的单位。发送方装满一条、封上、写进 socket; 接收方在它全部到齐之前对它什么也做不了。最后这半句是一份延迟预算,不是细节。

先看一条记录在线路上长什么样,以及发送方能藏什么、藏不了什么。加padding,盯着长度字段:

0 字节 padding

注意头从来不变色。类型、版本、长度坐在 AEAD 外面,所以观察者永远知道每条记录搬了多少字节 —— padding 能挪动这个数字,消不掉它。 TLS 1.3 确实把真正的内容类型藏进了密文里,所以外层类型连握手消息都写着 application_data。

发送方挑的大小是一笔交易,而这笔交易的两端同时在屏幕上。拖动记录大小,读它挪动的 3 个数:

16 KB 记录。左右拖动改变记录大小;方向键折半或加倍,Home 键复位
16 KB 记录

16 KB 时,1 MB 的响应要 64 条记录、1.4 KB 开销 —— 0.13%。往下拖到 64 字节记录,同一个响应要 16,384 条记录、352 KB,税率 34%。但真正疼的是大记录:在 10 Mbps 链路上,一条 16 KB 记录里的任何东西都要等13.1 毫秒的数据到齐才能解密,这就是为什么流媒体服务器用小记录、批量传输用最大记录。

一把密钥也不能永远用下去,而这个上限不是政策,是算术。选 AEAD 和记录大小,读出这把密钥最多能保护多少数据:

16 KB 记录

RFC 8446 §5.5 把 AES-GCM 限制在每把密钥 224.5 条记录 —— 约 2,370 万条 —— 因为再往上计数器的生日界就不再可忽略了。16 KB 记录下这是362 GB,一条 10 Gbps 的连接大约 5 分钟就跑到。KeyUpdate 不重新握手就能轮换一个方向;ChaCha20-Poly1305 没有这种记录数上限。

这些都藏不住流量的形状。走一遍观察者仍然学得到的东西,分别看有没有 Encrypted Client Hello:

目的 IP

6 项里有 4 项在 TLS 1.3 下活着,有意思的是主机名: SNI 是明文走的,好让握着很多证书的服务器在还没有密钥时就挑对那一张,于是它成了每个国家级防火墙过滤的字段。ECH 用一把发布在 DNS 里的密钥加密它,只留下目的 IP —— 而在 CDN 后面,一个 IP 服务几百万个站点。字节数和时序两种情况下都还在。

07

它怎么坏,以及它花多少钱

两件值得闭着眼推的事:TLS 的失败怎么到达调用方,以及 CPU 到底花在哪。

几乎所有失败都是大声的,这是设计出来的 —— 协议里没有「忽略然后继续」。危险的是它根本看不见的那些失败,而它们全是配置。

走一遍真实服务会撞上的 6 种,读出每一种返回什么:

主机名不在 SAN 列表里

最后一行是 code review 时要找的那个。curl -k、verify=False、InsecureSkipVerify: true —— 握手完成,流量确实是加密的,没有 alert 也没有日志行。其他每一行都在一个应用字节动之前就中止了。

第 2 类沉默失败,是没人回头看过的版本策略。拖动服务器接受的最低版本, 看已发表的攻击一个个熄灭:

SSL 3.0 — 还够得到 6 个攻击

注意 TLS 1.2 还剩 2 个够得到 —— 经 CBC 套件的 LUCKY13, 和经小有限域群的 Logjam —— 因为 1.2 只是让你把它们配置掉,不是删掉。到TLS 1.3 计数归零是另一个理由:这些算法根本不在规范里。

还有两种不产生 alert,因为它们压根没走到 TLS。HTTP 到 HTTPS 的跳转把第一个请求连同 cookie 留在明文里,除非 HSTS 是预加载的。而明文 HTTP 上套一层应用级 AES,会把 §02 走过的每个 bug 重造一遍,而且一次审计都没有。

成本这个问题比它的名声简单。设定链路速率,再选这个核有没有 AES 指令:

1 Gbps 流量

1 Gbps 时 AES-128-GCM 要 0.02 个核;拖到 10 Gbps 是 0.24。把指令拿掉,同样的流量要 6.94 个核,而 ChaCha20-Poly1305 只要 1.14 —— 这正是手机把 ChaCha 排在密码列表第一、而服务器把 AES 排第一的原因。