4. 密钥协商基础:Diffie-Hellman密钥交换

说白了,没有密钥协商,TLS就是个空壳子。

你想想看,通信双方从来没有见过面,怎么就能商量出一个只有你我知道的共享密钥?早期的对称加密需要提前把密钥分发给对方,这在互联网上根本不现实。直到1976年,Diffie和Hellman提出了一个颠覆性的方案——利用数学难题在公开信道上建立共享秘密。嗯,这就是我们今天要聊的DH密钥交换。

核心思想

DH的核心不复杂,就是利用了离散对数问题的“单向性”。把它的流程用大白话说:

  • 双方先约定两个公开参数:一个大素数 p 和一个生成元 g(这两个谁都能看)
  • 各自在本地生成一个随机私钥(这个保密)
  • 通过私钥算出一个公钥,发给对方
  • 收到对方的公钥后,结合自己的私钥做一次幂运算,得到完全相同的共享密钥

我在项目中第一次亲眼看到这个流程时,觉得像变魔术。但实际上它就是利用了指数运算和模运算的交换律:(ga)b ≡ (gb)a (mod p)。两边都算gab mod p,结果当然一样。

Diffie-Hellman 密钥交换流程图 Alice Bob 共享参数 p, g 生成私钥 a 计算公钥 A = ga mod p 生成私钥 b 计算公钥 B = gb mod p 发送公钥 A 发送公钥 B 计算共享密钥 s = Ba mod p 计算共享密钥 s = Ab mod p 相同的共享密钥 s 说明:两个箭头交换公钥,注意这是公开的,谁都能看到 而私钥永远不离开本地,保证了安全性

这就是整个交换流程。我画这张图的时候想了半天,怎么把“公开信道”和“私有计算”同时表现出来——你注意看箭头都是虚线,代表传输内容是公开的;而私钥计算那几步,全都锁在方框里,没有任何传输。

安全性基础

为什么DH安全?核心在于离散对数难题:你能拿到 p、g 和 A = ga mod p,但想从 A 反推出私钥 a,在计算上几乎不可能。这就好比你有一张照片,但想从照片里精确还原出相机镜头的焦距、光圈和快门速度的组合——理论上可行,但实际操作量太大,大到没有意义。

安全强度关键:选择的素数 p 必须足够大。我建议不要低于2048位,最好用4096位以上。曾经有人用1024位参数,后来在学术界激起了不少争议——并非完全没有被攻破的风险。

DH的主要变体

我自己做TLS项目时用过的DH变体主要有三种,这里给你整理一下:

变体名称 描述 密钥类型 安全级别 使用场景
静态DH (DH) 双方使用固定私钥,固定公钥可提前分发 长期密钥对 较低(无前向安全) 早期TLS、某些VPN
临时DH (DHE) 每次握手重新生成临时密钥对 临时短期密钥 高(完美前向安全) TLS 1.2、TLS 1.3
椭圆曲线DH (ECDHE) 椭圆曲线上的DH,同等安全下更短密钥 临时短期密钥 极高(完美前向安全+高效) 现代TLS 1.3首选

看到表格了吗?静态DH基本被淘汰了。原因很简单:如果私钥泄露,之前所有会话都会被回溯解密。我特别推荐ECDHE,它几乎成了现代TLS的实际标准。

如何实现一个简单的DH

口说无凭,写段代码让你感受一下。这是最简化的Python实现:

import os
import hashlib

# 生成大素数 p 和生成元 g
# 实际场景不应该自己写生成器,用专业库
p = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
g = 2

# Alice 生成私钥和公钥
alice_private = int.from_bytes(os.urandom(32), 'big')
alice_public = pow(g, alice_private, p)

# Bob 生成私钥和公钥
bob_private = int.from_bytes(os.urandom(32), 'big')
bob_public = pow(g, bob_private, p)

# 双方交换公钥(这里模拟交换)
# 收到对方公钥后计算共享密钥
alice_shared = pow(bob_public, alice_private, p)
bob_shared = pow(alice_public, bob_private, p)

assert alice_shared == bob_shared

# 将共享密钥导出为对称密钥(通常需要KDF处理)
shared_key = hashlib.sha256(alice_shared.to_bytes(32, 'big')).digest()
print("共享密钥(十六进制):", shared_key.hex()[:64])
提示:上面代码用的 pow(g, private, p) 比先算 g ** private 再取模效率高得多,而且内置支持大数运算。我曾见过同事直接用 ** 然后 % p,在密钥很大时直接内存溢出了……老实用三层参数的 pow() 吧。

一个关键避坑:不要用错q值

我曾经在集成一个安全库时,发现DH协商出来的密钥老是跟对端不一致。排查了半天发现,两边选的生成元g不一样——我把生成元和参数搞混了。是的,虽然是同一个p,但只要g一个不同,结果天差地别。

还有一点:p必须是一个安全素数,即(p-1)/2也是素数。否则可能会有小子群攻击的风险。我看过一些旧协议库的代码,这一点检查得不够严格。建议直接用标准化的参数组,别自己发明创造。

特别提醒:永远不要用同一个私钥进行多方DH协商!有些人为了省事,试图让服务器同时与两个客户端共享同一个临时私钥——这会让数学计算变得可预测,聪明的攻击者可以间接算出另一方的私钥。不骗你,真有线上事故源于此。

到这里,DH的基础已经讲完了。核心就两点:利用离散对数难题让双方在公开信道协商密钥,以及临时密钥才能保证前向安全。接下来实际看TLS 1.3是怎么用ECDHE来完成握手的——嗯,那将是一次更精妙的配合。