HTTPS 到底解决了什么:证书、密钥交换和 TLS 握手

「HTTP + TLS」说明了 HTTPS 使用的协议,却还没解释它怎样保护数据。要理解握手,得先看明文 HTTP 暴露了什么,以及 TLS 怎样确认服务器身份、协商密钥和发现篡改。

HTTP 本身不是不能传数据。请求行、Header、Body、响应状态码、响应体都很清楚,TCP 也会保证字节流可靠、有序地到达。问题在于普通 HTTP 默认相信中间网络:路由器、代理、公共 Wi-Fi、运营商链路、被入侵的网关都站在传输路径上,只要内容是明文,它们就可能读取、篡改,甚至冒充目标服务器。

下面沿着 TCP 上的 HTTPS 连接说明:常规握手先建立 TLS 安全通道,再把 HTTP 报文放进受保护的 TLS 记录。恢复连接时的 0-RTT 有例外,后面单独讨论。

HTTP 明文和 HTTPS 加密通道的区别

RFC 8446 将 TLS 的目标定义为在可靠、有序的传输之上提供安全通道,保障身份认证、机密性和完整性。MDN 的 TLS 说明也从这三类能力解释 HTTPS 对中间人攻击的防护。

TLS 要解决的三类安全问题

身份认证解决「正在连接的服务器是不是目标域名背后的服务器」。机密性解决「中间网络能不能读到 HTTP 内容」。完整性解决「内容在路上被改了,接收方能不能发现」。

只加密但不认证,客户端可能把秘密交给冒充者;只认证但不加密,中间人仍然能读请求;不检查完整性,接收方就无法可靠拒绝被篡改的数据。这几件事缺一项,安全通道都不完整。

编码、加密、签名和完整性校验

HTTPS 里会同时出现编码、散列、对称加密、非对称加密、签名、证书、MAC、AEAD。它们名字都和「安全」沾边,但职责完全不一样。

编码只是表示形式转换,比如 URL 编码、Base64、字符集编码。编码后的内容可能不好直接读,但不提供保密性,知道规则就能还原。

散列会把输入压成固定长度摘要,比如 SHA-256,常用来做指纹、完整性校验或派生材料。它不是加密:摘要通常反推不出原文,同一份输入也应该总是得到同一份摘要。

对称加密才是 HTTPS 后续传 HTTP 数据的主力。客户端和服务器拿到同一类会话密钥后,用 AES-GCM、ChaCha20-Poly1305 这类算法保护大量应用数据。对称算法速度快,适合持续传输。

公钥密码使用公钥和私钥,但加密、签名和密钥协商是不同用途。历史 TLS 1.2 的 RSA 密钥交换用公钥加密 pre-master secret;使用 ECDHE 的连接则协商临时共享秘密,再用证书对应的私钥签名认证。不能把这些用途合成「服务器公钥加密所有 HTTP 内容」。

签名也不是加密。签名用来证明「这个摘要确实由某个私钥持有者确认过」。证书链、CertificateVerify 都在用这个思路。

MAC 和 AEAD 用来保护消息完整性。TLS 1.2 里常能看到独立的 MAC 讲法;TLS 1.3 保留下来的记录层算法都是 AEAD,也就是加密和认证一起完成。看到这些词时,先问它在解决身份、保密、完整性里的哪一件事,整条链路就不会乱。

同一份消息,编码、加密、散列和签名改变什么

证书证明公钥归属

服务器把一个公钥发给浏览器,这件事本身不难。难的是浏览器要知道:这个公钥是不是属于正在访问的域名。

如果中间人能把服务器公钥替换成自己的公钥,后面的加密就没有意义。客户端会以为自己在和目标服务器建立安全通道,实际是在和中间人建立通道,中间人再另起一条连接去访问真正的服务器。两段连接都可能是「加密」的,但用户的数据已经被中间人看见了。

证书就是为了解决这个绑定关系。站点证书里会包含域名、公钥、有效期、用途、签发者等信息,也包含 CA 对这些信息的数字签名。RFC 5280 定义的 X.509 证书字段里,tbsCertificate 承载 subject、issuer、公钥、有效期和扩展信息;签名值证明 CA 认可这份证书里「主体和公钥」的绑定关系。

浏览器验证证书时,不是简单地「用 CA 公钥解密一下」。更准确地说,是从本地信任的根证书出发,验证中间证书,再验证站点证书,最后检查域名、有效期、证书用途、吊销状态和本地策略。

浏览器如何验证 HTTPS 证书链

按照 MDN 的证书颁发机构说明,浏览器预置了一批受信任的根证书。服务器通常发送站点证书和中间证书,浏览器沿着这条链逐级验证,直到信任库中的根证书;根证书一般不需要由服务器发送。

所以自签名证书不一定「技术上不能用」,而是默认不在浏览器信任链里。访问 BadSSL 的自签名测试站点 时,Chrome 会直接停在证书错误页,因为这条链无法回到浏览器信任的根。

自签名证书触发浏览器证书错误

开发环境里可以把自建根 CA 安装到本机信任库,再签发本地域名证书。公司代理也可能通过安装企业根证书来解密 HTTPS 流量。这里的本质没有变:浏览器信任了那个根,后续由它签出来的站点证书就可能通过校验。真正要小心的是,不要随便把来源不明的根证书加入系统信任库。

现代握手的主线是 ECDHE

很多 HTTPS 入门资料会把握手讲成这样:服务器发送证书,客户端拿到服务器公钥,生成一个随机密钥,用服务器公钥加密后发回去,之后双方用这个密钥加密 HTTP。

这描述的是 TLS 1.2 里的 RSA 密钥交换主线,而且还经常把 pre-master secret、master secret、traffic keys 混成一个「主密钥」。它能帮助理解历史流程,但不适合当成现代 HTTPS 的默认模型。

RSA 的算法原理:用乘法造一把带陷门的锁

Matrix67 的《跨越千年的 RSA 算法》好读,是因为它没有一上来就把公式堆满,而是先把问题讲成「公开锁和私钥开锁」,再一点点引入同余、欧拉函数和模逆。HTTPS 里也可以沿着这条线看:先理解 RSA 这个盒子为什么能锁住一个小秘密,再看 TLS 为什么后来不再把会话秘密交给它运输。

RSA 的公钥可以写成 (n, e),私钥可以写成 (n, d)。n 来自两个大素数 p 和 q 的乘积。e 是公开指数,d 是和 e 配对的私有指数。它们要满足 e * d ≡ 1 (mod φ(n)),其中 φ(n) = (p - 1) * (q - 1)。知道 p 和 q 很容易算出 φ(n),再求出 d;如果攻击者能把公开的 n 分解回 p 和 q,也能这样得到私钥。真实参数下,大整数分解很困难。

用很小的数字跑一遍会更清楚。选 p = 5、q = 11,得到 n = 55,φ(n) = 40。再选 e = 3,找出 d = 27,因为 3 * 27 = 81,除以 40 余 1。于是公钥是 (55, 3),私钥是 (55, 27)。

RSA 玩具数字例子如何加密和解密

如果明文数字是 12,用公钥加密就是 12^3 mod 55 = 23。网络上看到的是 23。服务器用私钥解密,计算 23^27 mod 55 = 12,原来的数字回来了。

为什么能回来?因为 e * d = 1 + k * φ(n)。对和 n 互素的消息,欧拉定理保证 m^φ(n) mod n = 1,所以 m^(e*d) 会绕回 m。知道 d 就能高效解密,公开 (n, e) 则不会直接给出这个私有指数。

RFC 8017里的 RSA 加密原语也使用这个模幂操作:公钥加密、私钥解密都是对整数做指数和取模,只是输入的钥匙不同。真实世界不能裸用这个玩具算法。实际 RSA 还要处理填充、随机性、消息长度、错误处理和旁路攻击;TLS 1.2 历史上的 RSA 密钥交换也正是因为填充 oracle 这类风险,才有很多防御细节。

TLS 1.2 的 RSA 密钥交换更像是 key transport,也就是「把一个秘密运到服务器」。客户端先生成 48 字节的 pre-master secret,再用服务器证书里的 RSA 公钥加密,放进 ClientKeyExchange 发给服务器。服务器用自己的 RSA 私钥解密,拿到同一份 pre-master secret。随后两边再结合 client_random、server_random 派生 master secret 和后续记录层密钥。RFC 5246 的 RSA-Encrypted Premaster Secret描述的就是这条路径。

这条路径的直觉很好懂:公钥锁盒子,私钥开盒子。客户端把秘密放进盒子里,网络上的人只能看到上锁后的盒子。问题在于,服务器证书私钥是长期身份密钥,一旦未来泄露,过去抓到的那些「上锁盒子」就有机会被重新打开。只要攻击者保存了当年的握手包,就可能从 RSA 加密的 pre-master secret 推回当年的会话密钥。

ECDHE 的算法原理:两边各自算出同一个秘密

ECDHE 属于 key agreement,也就是「双方各自算出同一个秘密」。它继承了 Diffie-Hellman 的主意:秘密不直接经过网络,网络上只交换公开参数。

先用普通 Diffie-Hellman 的小数字看一次。公开约定 p = 23、g = 5。客户端偷偷选 a = 6,发出 A = 5^6 mod 23 = 8。服务器偷偷选 b = 15,发出 B = 5^15 mod 23 = 19。客户端拿到 B 后算 B^a mod 23 = 19^6 mod 23 = 2,服务器拿到 A 后算 A^b mod 23 = 8^15 mod 23 = 2。两边得到同一个 2。

旁观者能看到 23、5、8、19,却缺少 a 或 b。想从 5^6 mod 23 = 8 反推出 6,在小数字里可以试出来;换成真实参数后,这就是离散对数问题,靠暴力枚举已经不可行。

ECDHE 把普通 DH 里的 g^a mod p 换成椭圆曲线上的点乘 aG。这里的「乘」指曲线点群上的反复加法:2G = G + G,3G = 2G + G。给定私钥 a 和公开基点 G,算出公开点 A = aG 很快;只看 G 和 A,反推出 a 很难,这叫椭圆曲线离散对数问题。RFC 6090把椭圆曲线群定义在有限域上,并把 ECDH 作为椭圆曲线版 Diffie-Hellman 来描述。

普通 DH 和椭圆曲线 ECDH 的计算对应关系

图里的椭圆曲线例子仍然是玩具数字:曲线是 y^2 = x^3 + 2x + 2 (mod 17),基点 G = (5, 1)。客户端私钥选 a = 6,公开点就是 A = 6G = (16, 13);服务器私钥选 b = 11,公开点就是 B = 11G = (13, 10)。客户端计算 aB = 6 * (13, 10) = (7, 6),服务器计算 bA = 11 * (16, 13) = (7, 6)。两边走的是不同路径,最后落到同一个曲线点。

真实 TLS 不会使用这种小曲线。常见组是 X25519、secp256r1 这类经过标准化和长期分析的参数。玩具曲线只用来说明一件事:shared secret 没有被发出去。网络上只有双方的 key share,也就是 A 和 B。临时私钥 a、b 留在各自机器里,用完就丢。

再单独看一遍 ECDHE 的计算路径,会更容易理解它和证书之间的边界。

ECDHE 如何协商共享密钥

E 是 ephemeral,意思是临时。使用 ECDHE 的握手会生成临时私钥和公开参数,用完销毁。即使服务器证书私钥以后泄露,攻击者也拿不到当年已经销毁的 ECDHE 临时私钥;过去抓包里只有 key share,无法仅凭这些材料重算 shared secret。

ECDHE 为什么替换 RSA 密钥交换

shared secret 也不会直接拿去加密 HTTP。TLS 会把 ClientHello、ServerHello、随机数、握手 transcript 等材料纳入派生过程,得到不同方向、不同阶段使用的流量密钥。TLS 1.2 里会看到 PRF、pre-master secret、master secret、key block 这些名字;TLS 1.3 改成了基于 HKDF 的 key schedule。RFC 8446 的 TLS 1.3 差异说明明确提到,TLS 1.3 移除了静态 RSA 和静态 DH 密码套件,公钥密钥交换都提供前向安全。

这就是前向安全:服务器证书私钥未来泄露,也不足以解开过去抓包中的会话。历史 RSA 密钥交换没有这层隔离;客户端用服务器长期 RSA 公钥加密 pre-master secret,拿到对应私钥的人就有机会从旧抓包中恢复会话秘密。

RSA 密钥传输和 ECDHE 密钥协商的本质区别

这套流程把身份认证和密钥协商分开了。证书私钥证明服务器身份,ECDHE 协商本次连接的共享秘密,HKDF / PRF 再结合 shared secret 和握手 transcript 派生流量密钥。长期身份密钥不再直接用于解开每次连接的会话秘密。

被移除的是 RSA key transport,不是所有 RSA 用法。RSA 仍可用于证书认证,TLS 1.3 也支持 RSASSA-PSS 签名;区别在于长期身份私钥不再用来解开每条连接的会话秘密。

证书私钥在 ECDHE 流程里仍然重要。服务器需要用证书对应的私钥证明自己确实持有这个身份。TLS 1.3 里的 CertificateVerify 会对握手 transcript 签名;浏览器用证书里的公钥验证签名,确认「这条临时 ECDHE 协商」和「这个域名证书」被绑定在一起。RFC 8446 的协议概览也把这两件事分开:ClientHello、ServerHello 和 key_share 确定共享密钥,Certificate、CertificateVerify、Finished 负责认证和握手完整性。

TLS 1.3 握手怎么走

下面是使用服务器证书认证、不要求客户端证书的 TLS 1.3 完整握手。恢复连接的 PSK 握手会省略部分消息,不能把这张图套到所有连接上。

第一段是 Key Exchange。客户端发 ClientHello,里面带支持的 TLS 版本、密码套件、扩展、随机数和 key_share。服务器回 ServerHello,选定版本、密码套件,并返回自己的 key_share。这两条消息确定握手后续需要的共享密钥材料。

第二段是 Server Parameters 和 Authentication。服务器发送加密后的 EncryptedExtensions,再发送证书、CertificateVerify 和 Finished。CertificateVerify 证明服务器拥有证书私钥;Finished 是对整个握手 transcript 的确认,能发现中间人是否篡改过前面的协商内容。

第三段由客户端验证服务器消息,再发送自己的 Finished。随后客户端可以用派生出的应用流量密钥发送 HTTP 请求。

TLS 1.3 完整握手主线

RFC 8446 的协议概览给出的 TLS 1.3 完整握手图,也正是这条主线:ClientHello 和 ServerHello 负责确定密钥材料,Certificate、CertificateVerify、Finished 负责身份和握手完整性,握手结束后应用层数据用认证加密保护。

TLS 1.2 不是不能用 ECDHE。TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 这种套件就是 TLS 1.2 里很典型的 ECDHE 路线。真正要区分的是:TLS 1.2 同时保留了更多历史组合,密码套件名称也把密钥交换、证书认证、记录层算法和哈希都塞在一起;TLS 1.3 则把密钥交换和认证从密码套件名称里拆出去了。

TLS 1.2 和 TLS 1.3 密码套件命名差异

看到 TLS_AES_128_GCM_SHA256 这种 TLS 1.3 名字时,不要再按 TLS 1.2 的方式问「这里怎么没有 ECDHE 或 RSA」。它只描述记录层保护算法和 HKDF 使用的哈希。ECDHE 的组、证书签名算法、ALPN、SNI 等信息都通过扩展和握手消息协商。

HTTPS 到底加密了哪些内容

握手完成后,HTTP 请求和响应会放在 TLS Application Data 里。按照 MDN 的 TLS 握手说明,后续消息包括 HTTP Header 和 Body 都会用协商出的密钥保护。

因此,路径、查询参数、Cookie、Authorization、请求体、响应体都不该被普通中间网络直接看见。抓包工具如果没有拿到会话密钥,通常只能看到 TCP 连接、TLS 握手和加密后的 Application Data。

不过 HTTPS 没有隐藏所有元数据。对方 IP、端口、连接时间、流量大小、记录长度仍然在链路层面可见。DNS 查询如果没有走 DoH、DoT 或其他加密通道,访问的域名也可能先在 DNS 层暴露。传统 SNI 还会把要访问的主机名放进 ClientHello,方便同一个 IP 上的服务器选择正确证书。RFC 6066 定义的 server_name 扩展就是这个用途。

TLS 1.3 已经加密了 ServerHello 之后的大部分握手内容,但 ClientHello 里仍有敏感元数据。2026 年发布的 RFC 9849 定义了 Encrypted Client Hello,目标是把 ClientHello 里的 SNI、ALPN 等字段也保护起来。它能减少域名元数据暴露,但是否生效取决于浏览器、DNS 记录、CDN 或服务器部署情况,不能把它当成所有 HTTPS 连接已经默认具备的能力。

所以 HTTPS 更准确的边界是:保护 HTTP 内容和 TLS 记录层数据,不等于隐藏所有连接元数据;保护访问某个域名时的传输过程,不等于证明这个网站本身可信。

TLS 记录怎么保护应用数据

握手结束后,TLS 会先把应用层数据切成一条条记录,再分别加密和保护。它不需要攒完整个 HTTP 响应;大响应可以边传边解,错误也更容易定位在记录层。

TLS 1.2 的记录保护取决于密码套件。传统 CBC 等套件会分别使用加密密钥和 MAC 密钥,客户端与服务器各有一组;记录序列号也参与 MAC 计算,用于检测记录被重排或重放。GCM 则属于 AEAD,不使用独立 MAC 密钥。RFC 5246 的记录层定义明确区分了这些形态,不能把「密文 + 独立 MAC」套到所有 TLS 1.2 连接上。

TLS 1.3 的记录层主线更简洁:保留的套件都使用 AEAD。AEAD 把加密和认证合在一起,记录序列号会参与 nonce 构造,记录头等关联数据会纳入认证。接收方解不开或认证失败,就不能把这条记录交给 HTTP 层。

同一密钥下,AEAD 记录不能重复使用 nonce,否则可能破坏保密性和认证保证。TLS 用记录序列号、派生出的 IV 和算法规则构造 nonce,应用不需要为每条 HTTP 消息自行管理这些细节。

False Start 和 0-RTT 不要混着讲

TLS 1.2 时代经常讨论 False Start。客户端发送自己的 Finished 后,不等服务器 Finished 回来,就提前发送加密的应用数据,以减少等待。这个优化需要浏览器和服务器满足特定条件,也和前向安全、ALPN/NPN 等能力相关。RFC 7918 后来把 TLS False Start 标准化成独立文档。

TLS 1.3 之后,常规完整握手本身已经变成典型 1-RTT。对于曾经连接过的站点,还可以通过 PSK resumption 做更短的恢复握手,甚至发送 0-RTT early data。

0-RTT 的代价是重放风险。RFC 8446 的 0-RTT 章节明确把它的安全属性和普通 1-RTT 数据区分开:early data 没有跨连接的不可重放保证。适合放幂等请求,不适合支付、下单、状态变更这类重复执行会出问题的操作。

False Start 优化的是 TLS 1.2 的等待过程,TLS 1.3 则直接缩短了常规握手。0-RTT 还要求恢复连接,并接受 early data 的重放风险;不能把它当成任意 HTTPS 请求都能提前发送的通用流程。

排查 HTTPS 时看哪些字段

浏览器里最直观的是安全面板和证书详情。证书错误页通常已经把失败类型说得很清楚:未知 CA、域名不匹配、证书过期、证书被吊销、证书链不完整,处理方向都不一样。

命令行里可以先用 curl 看协议、证书和响应头。-I 发出 HEAD 请求,-v 打印连接细节。--tlsv1.3 设置最低 TLS 版本;如果要只测试 TLS 1.3,再加 --tls-max 1.3。curl 手册说明了最低版本和最高版本的区别。

curl -Iv --tlsv1.3 --tls-max 1.3 https://shengsheng.fun/

如果要看站点证书字段,用 openssl s_client 取得证书,再交给 openssl x509 展开关键信息。下面只显示证书内容,不能把命令有输出当成证书验证通过:

openssl s_client \
  -connect shengsheng.fun:443 \
  -servername shengsheng.fun \
  </dev/null 2>/dev/null |
  openssl x509 \
    -noout \
    -subject \
    -issuer \
    -dates \
    -ext subjectAltName \
    -fingerprint \
    -sha256

用 openssl 检查证书字段

这里最常看的字段有几个。

subject 是证书主体。现在浏览器判断域名主要看 SAN,也就是 subjectAltName,不要只盯着老的 CN 字段。

issuer 是签发者。它应该能通过服务器给出的中间证书链,最终回到本机信任库里的某个根证书。

notBefore 和 notAfter 是有效期。客户端本机时间不准,也会造成看起来莫名其妙的证书过期或尚未生效。

subjectAltName 要覆盖正在访问的域名。访问 www.example.com 时,只有 example.com 不一定够,除非证书里有对应通配符或明确列出 www.example.com。

指纹是证书内容的哈希,适合人工比对「是不是同一张证书」,不是浏览器建立信任链的主要依据。生产环境不要指望用户手工看指纹来完成 HTTPS 信任。

如果要看协商出的协议和密码套件,可以让 openssl s_client 多打印连接信息。

openssl s_client \
  -connect shengsheng.fun:443 \
  -servername shengsheng.fun \
  -verify_hostname shengsheng.fun \
  -verify_return_error \
  -tls1_3 \
  </dev/null

输出里的 Protocol、Cipher、证书链、验证结果都值得看。这里的 -servername 用来发送 SNI,-verify_hostname 才明确要求检查域名;-verify_return_error 会在验证失败时中止,而不是显示错误后仍继续握手。OpenSSL 的 s_client 文档说明了这些选项。

Verify return code: 0 (ok) 表示按此次配置的信任库和验证规则通过。OpenSSL 与浏览器的信任库、吊销检查和策略可能不同,因此仍要结合浏览器错误判断,不能拿它替代全部浏览器验证。

排查时,我会先判断失败发生在哪一层:证书身份校验、握手协商,还是 HTTP 请求。证书有效不等于请求成功,握手成功也不等于网站业务可信;把这几件事分开,才知道接下来该查证书、协议配置还是应用代码。