4、TLS 1.3 密钥交换:ECDHE 原理,DH 参数

说到 TLS 1.3 的密钥交换,我第一个想到的就是 ECDHE。

为什么?因为它是整个握手的“安全地基”。没有它,后面的加密都是白搭。

你想想看,客户端和服务器要在不安全的信道上协商出一个只有他俩知道的共享密钥。而且这个密钥还得是“前向安全的”——就算以后私钥泄了,以前的会话记录也解不出来。

TLS 1.3 搞定了这件事。它只用了一套方案:ECDHE。

从 DH 到 ECDHE:数学原理

先说传统 Diffie-Hellman(DH)。

它的核心就是一个模幂运算:
g^a mod p

通信双方选一个大素数 p 和一个生成元 g。各自选一个随机数 a(私钥),算出公钥 A = g^a mod p 发给对方。对方也发来 B = g^b mod p。然后自己算 B^a mod p = g^(ab) mod p,对方算 A^b mod p = g^(ab) mod p。共享密钥就这么诞生了。

嗯,这个叫“有限域上的 DH”(FFDHE)。数学上没问题,但在实践中,p 往往很大(2048 bit 甚至 3072 bit),运算慢,而且 p 一旦选不好就会出安全问题。

椭圆曲线 Diffie-Hellman(ECDH)用的是另一套代数结构——椭圆曲线上的点群。私钥是一个随机整数 d,公钥是 Q = d * G,其中 G 是曲线上的基点。ECDHE 里的“E”代表“Ephemeral”,临时,每次握手都重新生成密钥对。

说白了,ECDHE 就是把 DH 从“整数乘法群”搬到了“椭圆曲线点群”上。同样能交换密钥,但运算更快、密钥更短。举个例子,256 bit 的椭圆曲线安全性≈ 3072 bit 的 RSA,但计算量少了一个数量级。

我在早期的一个嵌入式项目里遇到过坑:设备用的老旧 DH 参数,p 是 1024 bit,计算结果慢得离谱。后来换成 ECDHE,握手时间直接降了 70%。

ECDHE 的工作流程

在 TLS 1.3 中,ECDHE 是握手的第一步。具体流程如下:

  1. Client 生成临时密钥对
    客户端随机选一个私钥 sk_c,算出公钥 pk_c = sk_c * G。
  2. Client 发送公钥和密码套件
    在 ClientHello 里带上自己支持的和首选的一个椭圆曲线(Named Group),以及对应的公钥。
  3. Server 生成临时密钥对
    服务器也从自己支持的曲线里选一个(通常和客户端相同),生成自己的 sk_s 和 pk_s。
  4. Server 发送公钥
    在 ServerHello 里返回选中的曲线和服务器公钥。
  5. 各自计算共享密钥
    客户端:shared_secret = sk_c * pk_s
    服务器:shared_secret = sk_s * pk_c
    因为椭圆曲线上的点乘法满足结合律,结果一致。

共享密钥算出来之后,后续的会话密钥就靠它派生。注意,这里的“临时”意味着每次握手都会生成新的密钥对——前向安全性就靠这一点保证。

// 伪代码示意
// 客户端
sk_c = random_scalar()
pk_c = sk_c * G

// 服务器
sk_s = random_scalar()
pk_s = sk_s * G

// 共享密钥
client_shared = sk_c * pk_s
server_shared = sk_s * pk_c  // 结果相同

TLS 1.3 中的 DH 参数(Named Groups)

TLS 1.3 不再允许随便选 p 和 g。它指定了一组“命名的群(Named Groups)”,我习惯叫它们“标准曲线”。这些曲线都经过密码学界严格审查,性能和安全性有保证。

常用的曲线如下:

Named Group 曲线类型 位安全强度 备注
x25519 Curve25519 128 bit 由 Daniel J. Bernstein 设计,安全且高性能
secp256r1 NIST P-256 128 bit 最广泛的兼容曲线,几乎所有平台都支持
secp384r1 NIST P-384 192 bit 兼容高安全需求场景
x448 Goldilocks 224 bit 比 x25519 更安全但更慢,少用

我个人习惯优先用 x25519。为什么?它在实现上难以被侧信道攻击,而且运算速度奇快。除非遇到老旧设备不兼容,否则我基本不选 secp256r1。

经验之谈:在服务端配置中,建议把 x25519 放在列表最前面。客户端发来的列表如果包含 x25519,服务器就会首选它。我曾经测过,x25519 比 secp256r1 快 2~3 倍。
踩过的坑:有一次我调试一个跨平台的 TLS 握手,客户端只支持 secp256r1,服务器却只配了 x25519。结果握手直接失败,客户端报 "no shared cipher"。从那以后,我养成了习惯:服务器至少配两种曲线,x25519 和 secp256r1 必须都有。

ECDHE 密钥交换流程图

下图展示了 ECDHE 的一次完整交换过程:

ECDHE 密钥交换流程 Client 生成临时密钥对 pk_c = sk_c * G 共享密钥 = sk_c * pk_s Server 生成临时密钥对 pk_s = sk_s * G 共享密钥 = sk_s * pk_c ClientHello + pk_c ServerHello + pk_s 共享密钥协商完成

为什么 TLS 1.3 选择 ECDHE 作为唯一密钥交换?

在 TLS 1.2 时代,密钥交换选项五花八门:RSA、DH、DHE、ECDHE、PSK……光密钥交换就够你头疼的。

到了 1.3,IETF 直接砍掉了 RSA 和静态 DH。为什么?因为 RSA 密钥交换没有前向安全性——一旦服务器私钥泄露,之前所有的会话记录都能被解密。静态 DH 同理。

DHE(有限域上的临时 DH)虽然安全,但速度慢,而且参数选择容易出错。我之前就见过一个库用 1024 bit 的 DHE 组,直接被爆攻击破。

所以最后只剩下 ECDHE 和 PSK(预共享密钥)。PSK 用于 0-RTT 等场景,但不是主流。ECDHE 成了默认且几乎必选的那一个。

要点:TLS 1.3 的核心密钥交换只有 ECDHE(兼容量有限的可选 PSK)。它保证了每次握手都有独立密钥,服务器私钥泄露不影响过去会话——这就是“完美前向安全性”(PFS)。

避坑指南:Named Groups 协商

我曾经维护过一个旧版 Nginx 服务器,当时只配了 secp384r1,觉得 384 位肯定比 256 位安全。结果客户端全是移动端,只支持 x25519 和 secp256r1,握手全失败。

后来我发现,很多开源库对 secp384r1 的实现有 bug,运算效率也低。从那以后,我的最佳实践是:

  • 服务器至少配 2~3 个曲线:x25519、secp256r1、secp384r1
  • 客户端兼容时优先选 x25519
  • 不要手工写死曲线参数,让库自动协商

另外,TLS 1.3 还支持 FFDHE(有限域 DH)作为可选组,但实际使用率极低。除非你对接的设备不支持椭圆曲线,否则完全不用考虑它。