4. Diffie-Hellman 密钥交换原理

聊到 TLS 1.3 的密钥交换,绕不开 Diffie-Hellman。说实话,我第一次接触 DH 时觉得它像魔术——两个人公开交换几个数字,最后能算出只有他俩才知道的秘密。后来亲手实现了两遍,才明白背后其实就靠一个数学难题:离散对数问题。

4.1 离散对数问题:DH 的安全根基

先看一个简单场景。我们有一个素数 \(p\) 和一个生成元 \(g\)(\(g\) 是模 \(p\) 的原根)。给定 \(a\),计算 \(g^a \bmod p\) 很容易。但反过来,给定 \(g^a \bmod p\),要找出 \(a\) 就极其困难。这就是离散对数问题。

我当年在学校里觉得这抽象得很,直到做了实际项目才理解:“嗯,这里的难点不是正向计算,而是单向性。” 说白了,DH 安全依赖的就是这个不对称的难度。

核心结论:离散对数问题(DLP)是目前公认的“计算上不可行”的问题。只要素数 \(p\) 足够大(比如 2048 位),攻击者无法在合理时间内逆向推进。

一个小提醒:并不是所有 \(g\) 和 \(p\) 都安全。我曾在遗留系统里见过有人用了 \(g=2\) 且 \(p\) 是安全素数,结果没问题;但如果你自己随便挑一个组合,搞不好会留下弱点。建议直接用标准化的 DH 参数组(RFC 7919 或 RFC 3526)。

4.2 DH 参数:选对才能防坑

DH 参数就三个东西:素数 \(p\)、生成元 \(g\)、模运算。看起来简单,但参数选不好,安全归零。

参数说明推荐值 / 注意事项
\(p\)安全素数(形式 \(2q+1\),\(q\) 为素数)至少 2048 位;TLS 1.3 固定使用 FFDHE 组
\(g\)模 \(p\) 的生成元或循环子群生成元常用 2;需验证 \(g^{(p-1)/2} \neq 1 \bmod p\)
私钥 \(a, b\)通信方各自随机选取的整数长度与 \(p\) 相当;必须使用安全随机数

曾经踩过的坑:我在一个嵌入式设备中发现固件写死了 \(p=512\) 位,且 \(g=5\)。用现代硬件几分钟就能破解。所以——DH 参数不能自己瞎编,用标准组是最省心的选择。

4.3 共享密钥计算:一步步拆解

现在看 DH 的具体流程。假设 Alice 和 Bob 想协商一个共享密钥。

  1. 约定参数:双方预先知道 \(p\) 和 \(g\)(比如从证书或配置中获取)。
  2. 生成私钥:Alice 选随机数 \(a\),Bob 选 \(b\)。私钥始终保密。
  3. 计算公钥:Alice 算 \(A = g^a \bmod p\),Bob 算 \(B = g^b \bmod p\)。公钥公开传输。
  4. 交换公钥:Alice 收到 \(B\),Bob 收到 \(A\)。中间人可以看见 \(A\) 和 \(B\)。
  5. 计算共享密钥:Alice 算 \(S = B^a \bmod p\),Bob 算 \(S = A^b \bmod p\)。结果相同。

为什么相同?因为 \(B^a = (g^b)^a = g^{ab} \bmod p\),而 \(A^b = (g^a)^b = g^{ab} \bmod p\)。数学保证了两边一致。

举个例子,参数很小只是为了演示:

p = 23, g = 5
Alice 选 a = 6, 计算 A = 5^6 mod 23 = 15625 mod 23 = 8
Bob 选 b = 15, 计算 B = 5^15 mod 23 = 30517578125 mod 23 = 19
共享密钥:Alice 算 19^6 mod 23 = 47045881 mod 23 = 2
Bob 算 8^15 mod 23 = 35184372088832 mod 23 = 2

最后双方都得到 2,而中间人只能看到 \(p=23, g=5, A=8, B=19\)。要算出 2 就必须知道 \(a\) 或 \(b\),而这等价于解离散对数问题。

实际工程注意:示例中的参数太小,几秒钟就能攻破。生产环境必须用 2048 位以上。TLS 1.3 强制使用 FFDHE 或 ECDHE(椭圆曲线版本),后者效率更高。

4.4 知识体系总览

Diffie-Hellman 密钥交换知识体系 DH 密钥交换 离散对数问题 DH 参数 (p, g) 共享密钥计算 单向性 困难性 素数长度 私钥保护 密钥确认 TLS 1.3 使用 ECDHE 或 FFDHE,均基于此原理 私钥永不被传输,只有公钥在网络上可见

4.5 常见误区与避坑

  • 重用私钥:DH 私钥最好每次连接都重新生成。我见过一个项目把私钥写死,结果重放攻击直接攻破。
  • 不验证公钥:如果中间人篡改了公钥,双方计算出的共享密钥就不同。TLS 1.3 通过签名和握手哈希解决了这一点。
  • 参数太弱:500 位的 \(p\) 在 2010 年后就不可靠了。现在至少 2048 位,或者直接用椭圆曲线。
  • 忽略随机数质量:随机数不随机,私钥就能被猜出。用 /dev/urandom 或系统提供的 CSPRNG。

曾经踩过的坑:调试一个 DH 库时,输出总对不上。查了半天发现是字节序问题——大端小端不统一导致共享密钥不同。记得在协议层面明确编码约定。