← 返回文章列表

一次 HTTPS 请求全链路:从证书链到 TLS 1.3 握手

2026年8月3日网络安全
HTTPSTLS密码学AESECDHE

笔者因为工作变动,开始接触量子通信相关的知识。了解了一堆诸如 QKD、PQC、量子比特 之类的概念以后,笔者突然发现,当我试图把这些看起来前沿又晦涩的内容串起来,一股熟悉的感觉涌上心头!这个妹妹是我在传统密码学中见过的!

笔者同时又尴尬地发现,我甚至连 HTTPS 工作机制都说不清了。还好笔者请了束脩一个月 1500 的 Codex 老师(这里顺带推荐一个代充平台BeWild),笔者决定在 Codex 老师的帮助下,通过捋清楚一次 HTTPS 请求的过程,让自己复习这些 17 年前就在济南高新区的某教室里学过的知识(当然 Codex 老师告诉笔者的第一个知识就是:17 年前学的那些知识已经过时了,小朱你确实应该重新学,现在的学费不白交)。

好吧,那就让我们从打开浏览器开始,在地址栏里敲下回车,浏览器发出的是这样一段字节:

GET /index.html HTTP/1.1
Host: byz.me

一个居心叵测的窃听者在我们的浏览器和 byz.me 服务器之间安装了窃听设施,拿到的却是一串看不出结构的密文。这个窃听者的行为叫做 中间人攻击,而使用 HTTPS 就是非常传统且有效的防范中间人攻击的措施,下面笔者把 HTTPS 的工作机制整理出来。

基础知识

很多人都听说过,有对称加密和非对称加密,也就仅此而已了。实际上这不仅是 是 与 非 的问题,非对称的也不仅仅只有加密,广义上的非对称密码学中,签名算法和密钥协商算法也是同样重要的内容。在 HTTPS 中,这两类算法各有分工:对称加密算法负责保护数据本身,非对称密码学负责身份认证和密钥协商。

对称加密的加密和解密用同一个 key,所以 对称。对称加密速度快,适合保护大量数据。AES 家族是最常见的对称加密算法,比如在现代 TLS 套件常使用的 AES-GCM;此外还有 ChaCha 家族比如 ChaCha20-Poly1305。HTTP 报文的保密性和完整性最终由这类记录保护机制承担。

非对称加密的 非对称,体现在加密和解密的过程使用不同的 key,同属非对称密码学的签名算法和密钥协商算法也是一样。

现在,我们第一次访问 byz.me,麻烦的是,此时双方并不共享任何 key。直接把它发到公网,监听者就拿到了。这个时候就该 ECDHE 出场了,双方各生成一对临时密钥,交换公钥后,各自用「自己的私钥 + 对方的公钥」算出同一个秘密,再从这个秘密派生通信密钥。秘密本身从不上网。

仅仅只是交换密钥,却不证明对方是谁,攻击者也能生成一对密钥,分别与两端交换。证书和握手签名解决这件事——客户端验证证书链及域名等条件,再检查服务器用对应私钥生成的签名,确认握手中的关键材料来自预期服务器。

总结非对称密码学里最常见的三种算法和用途如下:

用途怎么用解决什么问题常见例子
加密 / 解密公钥加密,私钥解密让别人安全地告诉我秘密RSA 加密
签名 / 验签私钥签名,公钥验证证明消息确实由对方发出且未被篡改Ed25519
密钥交换各出一对临时密钥,算出同一个秘密在被监听的网络中协商密钥ECDHE

至于量子相关的内容,无论是量子计算对传统密码学的冲击,还是基于量子通信的 QKD 和基于传统经典计算的 PQC 带来的量子安全,笔者自己暂时还是小白一枚。先挖个坑,等笔者好好学习以后再来填。

不是只有一种 HTTPS

HTTPS 和更基础的 SSL/TLS 技术已经发展了很多年,当前主流的 TLS 有 1.2 和 1.3 两个版本(笔者读大学时 1.2 才刚刚发布,课本上学的应该是更早的 1.0,而现在 1.0 和 1.1 都早已被主流软件弃用了),而 HTTPS 也并不是只有一种,HTTP/2 和 HTTP/3 走的不是同一条栈。

HTTP/1.1和HTTP/2运行于TLS和TCP之上;HTTP/3使用集成TLS1.3握手的QUIC

HTTP/1.1 和 HTTP/2 的 HTTPS,可以理解成 HTTP over TLS over TCP,TLS 夹在应用层和传输层中间,给应用层的字节流提供安全保护。

HTTP/3 换了思路,是 QUIC over UDP。QUIC 把 TLS 1.3(1.3 ONLY!1.2 都要被弃用了)的握手和密钥派生直接集成进来了,但应用数据是由 QUIC 自己的包保护机制处理的,没有照搬 TLS Record Layer。所以右边这部分不能画成「HTTP → TLS → UDP」,RFC 9001专门规定了这部分集成。

TLS 握手到底在解决什么

在钻进具体报文之前,先记住一件事:TLS 握手的一堆消息,从头到尾只在回答三个问题。

  1. 对面是谁——怎么确认我在跟 byz.me 的服务器说话,而不是中间那位窃听者?
  2. 秘密是什么——怎么在一条被监听的线路上,商量出一个只有双方知道的密钥?
  3. 对上了吗——怎么确认双方算出来的密钥真的一样,中途没人改过东西?

加密算法本身只解决「怎么加密」,这三个问题一个都不解决:算法再强,密钥给错了人也没用。握手阶段不传输业务数据,它专门用来解决这三个问题,解决完了,才轮到对称加密上场干活。

TLS 1.2 和 1.3 的差别,是这三个问题怎么答、答得多快,而不是要不要答。带着这三个问题往下看,报文名就不再是一串要背的单词,而是每个问题的具体答案。

证书链与信任根

第一个问题「对面是谁」,落到实现上就是一张证书。这一环值得单独说清楚,因为后面两节的握手图里都会出现一个叫 Certificate 的消息,而它凭什么可信,是很多人卡住的地方。

为什么需要证书? 因为光有一个公钥,你没法知道它是谁的。攻击者完全可以把自己的公钥发给你,说「我是 byz.me」。所以需要一个第三方来担保:这张公钥确实属于 byz.me。

证书里装了什么? 它至少包含四样东西:域名(这张证书给谁用)、公钥、有效期、以及 一个签名 ——由某个证书颁发机构(CA,Certificate Authority)用自己的私钥对上面这些内容签的。

那 CA 的公钥又从哪来? 这就是证书链要解决的问题。CA 通常不会直接给网站签证书,中间还隔着一层:CA 有一张「中间证书」,网站的证书是中间证书签的,而中间证书又是根证书签的。验证的时候一路往上推——用中间证书的公钥验网站的签名,再用根证书的公钥验中间证书的签名。任何一层对不上,整条链就断了。

最顶上那张根证书,是谁签的? 没人签,它是自签名的。这就是整条链的起点:信任根不是被推导出来的,而是预先装好的。你的操作系统和浏览器出厂时就带了一份受信任的根证书列表,浏览器厂商、操作系统厂商会审核谁有资格进这个列表。链上每一环的信任,最终都来自「我本来就信这几张根证书」。

还有几个细节值得知道:

  • 服务器一般不把根证书发给你。根证书你本地已经有了,再发一遍纯属浪费,所以 Certificate 消息里通常只有网站证书和中间证书,客户端自己去找对应的根。
  • 证书是有范围的。它写死了给哪个域名用、什么时候过期。所以「证书可信」不等于「这张证书可以为所欲为」——一张给 a.com 签的证书,拿来冒充 byz.me 是过不了域名校验的。
  • 过期的证书要重新签。这也是为什么网站要定期换证书,以及为什么换证书出问题时会直接访问不了。

把这些串起来,第一个问题的答案就完整了:客户端拿到证书,先从它一路验到某张预装的根证书,确认「这条链是可信机构签下来的」,再检查域名、有效期、用途对不对,最后才敢相信里面那把公钥确实属于 byz.me。

TLS 1.2:先交换密钥材料,再切换记录保护

TLS 1.2 完整握手:灰色消息明文可见,紫色消息已加密;左侧是它回答的问题,右侧是握手耗时

第一个消息 ClientHello,客户端先摊牌:我支持哪些 TLS 版本、哪些加密套件,再附上一个自己生成的随机数 R₁。

服务器从里面挑一组,用 ServerHello 告诉客户端选了什么,也附上自己的随机数 R₂。随后服务器把证书链发过来——Certificate 里有公钥,没有私钥。

注意图中灰色的部分:证书是明文发的。TLS 1.2 要等密钥交换做完才切换加密,在此之前,一个被动的监听者连服务器用的是哪张证书都看得见。一开始那位窃听者能看出点眉目,靠的就是这个阶段的报文。

证书里的公钥用来回答第一个问题——对面是谁。但它不能拿去加密业务数据,那太慢了。真正要商量密钥,靠的是紧接着的 ServerKeyExchange:服务器发一个临时公钥,并用证书对应的私钥给它签个名。

**这个签名是整场握手里最关键的一环,它把第一个问题和第二个问题拴在了一起。**客户端先检查证书的信任链、域名、有效期、用途——也就是上一节那条一路验到根证书的链——确认「这张证书确实是 byz.me 的」;再验证签名,确认「这份临时公钥确实是持有该证书私钥的人发的」。两件事都过了,才能确信临时公钥来自真的 byz.me。少任何一步,两个问题就脱节了——攻击者完全可以拿别人的证书,配上自己的临时公钥。

客户端接着用 ClientKeyExchange 发出自己的临时公钥。到这里,双方各自捏着自己的私钥、手里拿着对方的公钥。ECDHE 的数学性质保证了一件事:两边各做一次运算,会得到同一个数,而这个数从来没有在网络上传输过。也就是说,第二个问题里最麻烦的那部分——「怎么把密钥安全地送过去」——根本不用送。

但算出来的这个数还不能直接当密钥。双方再把它和 R₁、R₂ 一起,通过一个伪随机函数派生出主秘密,再派生出真正用来加密的密钥。RFC 5246里的原始推导就是这样;如果协商了 Extended Master Secret 扩展,还会把握手摘要也算进去,用来锁死整场握手。这一差别见 RFC 7627,两种推导不能写成一样。

**那两个随机数是干什么的?**它们保证每次连接算出来的密钥都不一样。就算客户端和服务器的长期私钥都不变,只要 R₁、R₂ 每次都是新的,派生出的密钥就是新的。少了它们,同一个客户端每次连同一台服务器都会得到同一把 key,这把 key 一旦泄露,历史流量就全完了。

最后一步是回答第三个问题。客户端发送 ChangeCipherSpec,从这条消息起切换到加密;然后用新密钥保护一条 Finished 发给服务器。Finished 的内容是整场握手的摘要,客户端等于在说:我看到的是这些消息,我用这些消息算出了这把密钥。服务器用同样的方式算一遍,对得上就说明双方看到的内容一致、算出的密钥也对得上。服务器验证通过后,照做一遍。对不上,这条连接直接作废。

整套下来要两个往返。原因就写在图里:客户端必须先听完服务器说的(选了什么参数、临时公钥是什么),才能算出密钥、才能回话。

TLS 1.3:更早产生握手密钥

TLS 1.3 完整握手:与上图对照,客户端提前给出临时公钥,服务器认证消息全部加密

和上图对照着看,TLS 1.3 的核心改动只有一句话:把临时公钥挪到了最前面。

TLS 1.2 里,客户端得等服务器说完 ServerHello 才知道选了什么参数,然后才能生成临时公钥发过去——这就是第二个往返的由来。TLS 1.3 不等了:客户端在 ClientHello 里就顺手带上自己的临时公钥 key_share,赌服务器大概率会接受那组参数。服务器在 ServerHello 里回一个自己的 key_share。

一次来回,双方就凑齐了材料,立刻能算出共享秘密并派生握手密钥。省下来的那个往返就是这么来的。

接下来的变化很直观:钥匙已经商量好了,服务器再开口说话就可以用上它。EncryptedExtensions、Certificate、CertificateVerify、Finished 这四条消息全部受握手密钥保护,证书不再明文发了——开头那位窃听者到这一步就彻底抓瞎了。

三个问题在这里是这样落的:Certificate 和 TLS 1.2 一样回答「对面是谁」;CertificateVerify 是服务器用证书私钥对握手记录签名,等于在说「这些参数确实是我这台服务器选的」——因为 1.3 没有 ServerKeyExchange 了,它接替了把身份和密钥拴在一起的角色;Finished 回答「对上了吗」。

RFC 8446给出了握手和密钥派生的具体时序。有一点容易被绕进去:应用流量密钥的派生已经包含服务器 Finished 的摘要,不需要理解成「等客户端的 Finished 到了才算密钥」。服务器发完自己的 Finished 就能发应用数据,客户端则是验证完服务器、发完自己的 Finished 之后。

两个版本放在一起看:

TLS 1.2TLS 1.3
客户端什么时候给出临时公钥等服务器说完之后ClientHello 里就给
服务器证书明文ServerHello 之后受保护
完整握手往返2-RTT1-RTT
握手后半段用什么保护还没加密握手密钥

图上画的是参数匹配时的常规流程。客户端赌的那组参数服务器有可能不接受,这时会经过 HelloRetryRequest 让客户端重猜一次,多跑一轮——这是 1.3 用「赌一把」换速度的代价。常说的 1-RTT 指的是 TLS 握手本身的往返,不含 DNS 和 TCP 建连——这一点在估首屏时间的时候特别容易算错。TCP 这一侧也不止三次握手:握手完成之后,连接还要排进 accept 队列等应用来取,队列满了新的 SYN 会被直接丢掉——这一段等待在Java I/O:阻塞、Reactor 与虚拟线程的等待成本里写过。

TLS 1.3 也支持 PSK 会话恢复,适合回头再来一次的场景。NewSessionTicket 为后续恢复提供关联材料,不是让下次连接直接复用这次的 AES key。0-RTT 提前数据还有重放风险,和普通完整握手不是一回事——不是「越快越好」,而是拿安全性换了一次往返。

HTTP 明文在哪一步变成密文

绕了这么远,终于能回答开头那个问题了:GET /index.html 那段明文,到底在哪一步变成了密文?

答案在 TLS 握手的最后一步。密钥派生完成、ChangeCipherSpec 一发,从这一刻起,所有交给 TLS 的字节都会被加密。HTTP 自己完全不知道这件事发生了。

以 HTTP/1.1 请求为例,客户端内部仍然会构造这样的明文:

GET /index.html HTTP/1.1
Host: byz.me
Accept: text/html

这些明文字节被原样交给 TLS Record Layer,由它分段、加密、算出认证标签,才发到网络上。

HTTP明文字节经TLS记录保护后在网络上传输,接收端验证并解密后交给HTTP服务

这里有组概念容易混:一个 HTTP 请求可能被切成好几个 TLS record,一个 record 也可能只是某次响应的一部分,两者没有一对一的关系。record 是加密层的切法,不是 HTTP 的切法。

对于 AEAD 套件,加密这一步概念上可以写成:

ciphertext, tag = AEAD_Encrypt(key, nonce, plaintext, additional_data)

这只是接口示意,TLS 1.2 与 1.3 的 nonce、附加认证数据和明文封装细节并不相同。接收端要先验证认证标签,通过了才把还原的数据交给上层——未经认证的数据不能当成可信的 HTTP 内容。这一步很重要:加密只保证别人看不见,认证标签才保证别人改不了。

两个方向的流量密钥是分开的:请求用客户端发送方向的密钥,响应用服务器发送方向的密钥。TLS 1.3 的 KeyUpdate 可以更新长连接的流量密钥,但这不意味着每个 HTTP 请求都要换一次 key。

HTTP/3:为什么换成了 UDP

前面说的都是 TCP 上的 TLS。HTTP/3 换到了 UDP 上跑,这件事经常让人困惑:TCP 不是更可靠吗,怎么反而退回 UDP 了?

要回答这个,得先看 TCP 给 HTTP/2 带来了什么麻烦。TCP 把一条连接上的所有字节当成一个有序的整体,前一个丢了,后面的都得等着——哪怕后面那些属于完全不相干的另一个请求。这就是队头阻塞。HTTP/2 虽然允许在一个连接上并发跑很多请求,但这些问题最终都要挤进同一条 TCP 连接,一个丢包就能把大家全卡住。并发越多,这个放大效应越明显。

QUIC 的做法是:在一条连接里提供多条互相独立的流,某条流的数据丢了,只影响这条流,其他流照常交付。可靠性和有序性这些 TCP 帮着做的事,QUIC 挪到了用户态自己实现,同时按「每条流」而不是「整条连接」来做重传和排序。

安全部分直接集成 TLS 1.3(QUIC 只支持 1.3,不支持 1.2)。这个选择不只是省事:TCP 上的 TLS 得先完成三次握手建好连接,再从头跑一遍 TLS 握手,两套握手各占一个往返。QUIC 把传输层握手和安全握手揉在一起,省掉了一次来回。

看起来是「退回到不可靠的 UDP」,但可靠这件事并没有被放弃,只是从内核搬到了 QUIC 自己手里。好处是它可以在用户态快速迭代,不必等操作系统升级。代价是这一切都要求浏览器和服务器两边都支持。

QUIC 虽然不是新鲜玩意儿了,但是笔者并没有仔细了解过这个协议,笔者甚至兴趣并不是很大。这里再挖个坑吧,这个坑就不一定会填了:P

抓包工具为什么也能看明文

故事说到这本来要结束了,突然做 Web 前端和客户端开发的小伙伴举起了小手:我手里的 Charles 或者 Fiddler,为啥能抓到 HTTPS 的包呢?

抓包工具做的事,和开头那位窃听者一模一样,唯一的区别是你主动授予了信任。

抓包工具第一次运行时,会往你的系统里装一张自己的根证书,并请求你信任它。之后它截住浏览器的请求,不给浏览器看真服务器的证书,而是现场签发一张「byz.me」的证书给它。因为这张证书的签发者在你的系统信任列表里,浏览器认可它,于是把浏览器和工具之间的这段 TLS 建了起来。工具再去和真正的服务器建一条自己的 TLS 连接,两边解密、转发、再加密。

所以 HTTPS 防的一直是你不知情的中间人,而不是你亲手请进来的中间人。装那张根证书,就是你把中间人请进门的那一下——按前面那节的说法,你亲手往信任列表里塞了一张根证书,那么由它签出来的任何证书,浏览器都会一路验到底、全部放行。

那如果万恶的窃听者通过一些手段诱骗你信任了他用于中间人攻击的根证书怎么办?也不是无解,客户端可以在系统信任之外,自己再加一道校验。常见做法叫证书固定,把可接受的证书或公钥范围直接写在应用里,只认那几张。这时候系统信不信任抓包工具的根证书已经不重要了,应用自己这道校验就过不去。双向认证则更进一步,服务端也要验客户端的证书。

说到这了就再补充一句,这里有个容易被误解的地方:证书固定不是「更安全」的通用答案,它是个设计取舍。想让用户能调试、能审计流量,就不该固定;想让流量不容易被分析,就固定。而且固定有维护成本——证书轮换时应用没跟上,用户直接就连不上了。

← 返回文章列表