HTTPS 的锁是怎么挂上的?TLS 1.3 握手 6 步实测

0 阅读8分钟

地址栏那把锁,背后只谈了 6 句话。

我在浏览器里把 TLS 1.3 握手拆成 6 步,一步一步跑完:50ms 延迟下总耗时 63.6ms,密码学计算只占 2.6ms,双方最终拿到同一把会话密钥。

把其中一条消息里的公钥换掉,第 5 步立刻报 OperationError,握手停住。

图 1:TLS 1.3 握手实验室初始界面,左侧是六步时间轴,右侧是密钥、证书、指标和自检四个面板

在线案例与源码:gitcode.com/OPCTeam/dem… 成品只有一个 index.html,双击就能打开,不连网、不装依赖。

一、先看结果:6 步、1004 字节、63.6 毫秒

这 6 步不是画出来的流程图,每一步都真的生成了随机的密钥和报文。

步骤消息实测耗时字节数
1ClientHello0.2 ms254 B
2ServerHello0.3 ms254 B
3EncryptedExtensions + Certificate1.3 ms182 B
4CertificateVerify + Finished0.5 ms140 B
5客户端 Finished0.7 ms76 B
6应用数据0.4 ms98 B

6 步加起来 1004 字节。

页面顶部的 RTT 滑块放在 50ms,总耗时读数 63.6ms。

50ms 是模拟的一次往返,2.6ms 是 ECDH、HKDF、AES-GCM、ECDSA 四种运算加起来的真实耗时,剩下约 11ms 是 setTimeout 和页面渲染的固定开销。

这三个数对不上才奇怪,因为浏览器里的计时器本来就有几毫秒的粒度。

二、为什么值得看:能跑的文字教程太少

搜“TLS 1.3 握手”,出来的大多是时序图和一大段名词解释。

图能看懂,但没人告诉你 ClientHello 到底装了多少字节,也没人让你亲手把 key_share 换掉看它怎么失败。

我把这件事做成了一个单页应用,规则很简单:页面上出现的每一个十六进制字符串,都必须由 WebCrypto 当场算出来。

不是预埋的假数据,刷新一次,公钥和共享密钥全变。

这就是它和普通演示页的区别。

三、密钥到底怎么换:ECDH 只负责一半

第一步,客户端和服务端各生成一对 ECDH P-256 密钥。

双方交换公钥,各自用私钥做一次 deriveBits,得到 32 字节的共享秘密。

页面里两行十六进制必须一模一样,旁边会亮一个“一致”的绿标。

但共享秘密不能直接拿去当 AES 密钥。

TLS 1.3 中间还夹了一层 HKDF-SHA256:把共享秘密、双方随机数和固定的 info 字符串喂进去,派生出四条方向的密钥。

  • 客户端→服务端 Handshake Key
  • 服务端→客户端 Handshake Key
  • 客户端→服务端 Application Key
  • 服务端→客户端 Application Key

每条 16 字节,对应 AES-128-GCM。

为什么分方向?因为两端用的密钥必须错开,否则一方加密、另一方拿同一把钥匙解密,重放和反射攻击就有了空间。

图 2:握手完成后,ECDH 公钥、共享密钥、四条派生密钥和 AES-GCM 参数全部落位,三项一致性检查都通过

第三步是证书链。

页面现场生成 Root CA、Intermediate CA 和站点证书三对 ECDSA P-256 密钥。

Root 自签名,Intermediate 由 Root 签,站点证书由 Intermediate 签。

验签也是真的:用上一级公钥去 verify 下一级的签名,三条全过,面板显示 3/3。

图 3:三级证书链,每一级的签名都由上一级公钥验证通过

四、验证与踩坑:两个真 bug

第一版跑起来时,前四步都绿,第 5 步直接红。

日志只有一行:服务端 Finished 解密失败,OperationError。

原因不复杂:客户端拿自己的 Handshake Key 去解服务端加密的数据。

TLS 1.3 的双向密钥是分开的,客户端加密用 c2s,解密要用 s2c。

第一版只派生了两把钥匙,方向一错,AES-GCM 的认证标签必然对不上。

修法是把四条密钥都派生出来,加密用自己方向的那条,解密用对方方向的那条。

第二版改完,握手跑通。

第二个坑更隐蔽。

自检里有一条“篡改签名后验签失败”,结果它连原签名都判失败。

排查后发现,代码里写的是 new Uint8Array(sig)。

这不是复制,是视图。

后面的 tampered[flipPos] ^= 0xFF 改的是同一块内存,原签名跟着一起坏了。

改成 sig.slice(0) 先复制一份,再翻转字节,原签名通过、篡改签名失败,断言才真正成立。

这两个 bug 都不是排版问题,是密码学代码里最容易骗过眼睛的那种:界面看起来正常,逻辑已经反了。

改完后页面内 9 条自检全过:共享密钥一致、HKDF 两端一致、证书链 3/3、篡改验签失败、每次 ECDH 密钥不同、AES-GCM 往返恢复原文、篡改密文认证失败、正常签名通过、不同共享密钥派生结果不同。

五、三种模式的账:1 RTT、2 RTT、0 RTT

页面右上角可以切三种模式,RTT 滑块从 10ms 到 200ms。

同一个页面、同一套密码学运算,只改往返次数。

模式RTT 次数50ms 下总耗时150ms 下总耗时计算耗时(50ms 档)
TLS 1.3163.6 ms160.8 ms2.6 ms
TLS 1.22113.4 ms313.1 ms2.8 ms
会话复用010.1 ms10.4 ms2.2 ms

50ms 这一档,TLS 1.3 比 TLS 1.2 少 49.8ms,差不多正好省下一次往返。

150ms 这一档差距拉到 152.3ms,因为往返越贵,少一次往返越值钱。

0-RTT 那栏看着最夸张,10ms 出头,但它量的是复用连接后的数据阶段,不是第一次访问。

这里有个边界要说清楚:三种模式共用同一套密钥交换和证书链演示,所以“握手总字节”都是 1004 字节。

真实的 0-RTT 不会重做证书链,字节数会小得多;页面里的 1004 是演示实际构造的报文总量,不是线上 0-RTT 的握手大小。

图 4:50ms 延迟下三种模式的耗时对比,TLS 1.2 的柱子几乎是 TLS 1.3 的两倍

六、失败路径:换掉公钥,握手就断

点“中间人攻击”,再点“开始握手”,页面会模拟攻击者替换 ServerHello 里的 key_share。

客户端用自己的私钥和攻击者的公钥算,服务端用另一套,两个共享秘密当场对不上。

第 5 步解密服务端 Finished 时抛 OperationError,时间轴变红,状态栏写“握手失败 — 中间人攻击”,总耗时停在 61.4ms。

再点“篡改密文”,页面会翻转应用数据密文中间的一个字节。

这次是第 28 字节,0x16 改成 0xe9。

AES-GCM 解密直接报认证失败,第 6 步标红,页面提示“篡改检测成功 — AES-GCM 认证失败”。

一个断在密钥协商,一个断在数据完整性,两条失败路径都能复现。

图 5:中间人攻击下共享密钥不一致,第 5 步红框标出 OperationError,状态栏显示握手失败

七、准备环境:进入码道 Web 与本地复现

下载仓库里的 index.html,双击打开即可。

本地复跑 9/9 通过,9 条断言全绿。

也可以在当前目录起一个静态服务:python -m http.server 8000,再打开 http://localhost:8000/index.html。

不需要 npm,也不会发出任何外部网络请求。

按这个顺序点,能复现本文所有数字:

  1. RTT 滑块保持 50ms,点“开始握手”,看 6 步耗时和总耗时 63.6ms。
  2. 切到“指标对比”,依次跑 TLS 1.2 和会话复用,看三行对比。
  3. 点“运行自检”,9 条断言应全绿。
  4. 点“中间人攻击”再点“开始握手”,第 5 步应报 OperationError。
  5. 点“篡改密文”再点“开始握手”,第 6 步应报 AES-GCM 认证失败。

浏览器用 Chrome 或 Edge 90 以上都行,WebCrypto 的 ECDH、HKDF、AES-GCM、ECDSA 都是原生支持。

想改需求重新生成,可以打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source…

八、它不是什么

这不是一个 TLS 实现,只是一间把协议拆开看的实验室。

页面没有真实网络、没有 TCP、没有抓包,也没有解析真实的 X.509 证书;证书对象是页面按字段构造的,签名和验签用的是真实 ECDSA。

RTT 是 setTimeout 模拟的,不是线上测量值。

CPU 调度和垃圾回收也会影响读数,所以同一档位多跑几次,总耗时会有几毫秒浮动。

0-RTT 只做了时间账,没有实现会话票据和 PSK 复用。

把它当成理解握手顺序和密钥方向的教具可以,别拿它当安全评估工具。

九、使用码道体会

码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。

需求里我把“必须用 WebCrypto 真算”写死了,生成出来的第一版确实用了真的 ECDH 和 AES-GCM,但密钥方向错了。

有意思的是,它自己跑测试时发现了这个错,日志里写着“客户端用 clientHandshakeKey 解密服务端数据,key 不匹配必然失败”,然后整份重写。

第二个视图和副本的坑,也是它自己定位到 new Uint8Array(sig) 这一行。

我要做的不是替它写代码,而是把验收条件写清楚:哪些数必须一致,哪条路径必须失败,失败时页面要显示什么。

条件写清楚了,返工就少。

十、下一步

这份页面还缺三块:TLS 1.2 的真实报文结构、会话票据的完整复用流程、以及从 pcap 文件导入一次真实握手做对照。

把这三块补上,它就从“教具”更接近“观察工具”。

如果你也在做类似的协议可视化,欢迎拿这份单文件改,仓库地址在文章开头。