Diffie-Hellman:陌生人如何协商共享密钥
Published: Sat Feb 01 2025 | Modified: Sat Feb 07 2026 , 10 minutes reading.
1. 为什么要关心这个问题?
这是密码学的根本问题:
Alice 想给 Bob 发送加密消息。
他们从未见过面。
他们发送的所有内容都是公开的。
他们如何协商一个密钥?在 1976 年之前,答案是:他们做不到。你需要安全信道来交换密钥,但你需要密钥来创建安全信道。这是个鸡生蛋蛋生鸡的问题。
Diffie-Hellman 打破了这个悖论。你每次建立的 HTTPS 连接都在使用它。
2. 定义
Diffie-Hellman 密钥交换(DH) 是一种让两方在不安全信道上共同建立共享密钥的方法,而无需任何预先共享的密钥。
其安全性基于 离散对数问题:给定 g 和 g^a,在某些数学群中找出 a 在计算上是困难的。
魔法:
Alice 知道:a(私密)
Bob 知道: b(私密)
双方计算: g^(ab)(共享密钥)
窃听者看到:g, g^a, g^b
无法计算: g^(ab)3. 混合颜料类比
想象可以混合但无法分离的颜料:
公开:黄色颜料(所有人都知道)
1. Alice 选择秘密的红色
混合黄色 + 红色 → 橙色
把橙色发送给 Bob
2. Bob 选择秘密的蓝色
混合黄色 + 蓝色 → 绿色
把绿色发送给 Alice
3. Alice 把她的红色加到绿色中:
绿色 + 红色 = 棕色
4. Bob 把他的蓝色加到橙色中:
橙色 + 蓝色 = 棕色
两人都有棕色了!
窃听者有:
- 黄色(公开)
- 橙色(黄色 + 红色)
- 绿色(黄色 + 蓝色)
不知道红色或蓝色就无法算出棕色!4. 数学原理(模幂运算)
设置
公开参数(所有人都知道):
- p:一个大质数
- g:模 p 乘法群的一个生成元
这些可以标准化并重复使用。交换过程
步骤 1:生成私钥
Alice 选择随机数 a(私密)
Bob 选择随机数 b(私密)
步骤 2:计算公钥
Alice 计算:A = g^a mod p
Bob 计算: B = g^b mod p
步骤 3:交换公钥
Alice 把 A 发送给 Bob(公开)
Bob 把 B 发送给 Alice(公开)
步骤 4:计算共享密钥
Alice 计算:s = B^a mod p = (g^b)^a mod p = g^(ab) mod p
Bob 计算: s = A^b mod p = (g^a)^b mod p = g^(ab) mod p
双方得到相同的密钥 s = g^(ab) mod p!为什么窃听失败
窃听者 Eve 看到:
- p, g(公开参数)
- A = g^a mod p
- B = g^b mod p
要计算 s = g^(ab) mod p,Eve 需要 a 或 b。
要从 A = g^a mod p 找出 a:
这是离散对数问题。
对于合适的参数,这在计算上是不可行的。5. 数字示例
# 小数字用于理解(真正的 DH 使用 2048+ 位的质数)
# 公开参数
p = 23 # 质数
g = 5 # 生成元
# Alice 的私钥
a = 6
# Alice 的公钥
A = pow(g, a, p) # 5^6 mod 23 = 8
# Bob 的私钥
b = 15
# Bob 的公钥
B = pow(g, b, p) # 5^15 mod 23 = 19
# 交换公钥 (A=8, B=19)
# Alice 计算共享密钥
s_alice = pow(B, a, p) # 19^6 mod 23 = 2
# Bob 计算共享密钥
s_bob = pow(A, b, p) # 8^15 mod 23 = 2
print(f"Alice 的密钥: {s_alice}") # 2
print(f"Bob 的密钥: {s_bob}") # 2
assert s_alice == s_bob # 相同!6. ECDH:基于椭圆曲线的 Diffie-Hellman
为什么使用曲线?
经典 DH:
- 需要 2048+ 位的质数才能保证安全
- 运算较慢
ECDH(椭圆曲线 DH):
- 256 位曲线就能达到同等安全性
- 运算更快
- 密钥更小ECDH 如何工作
不再使用模幂运算:
A = g^a mod p
而是使用曲线上的标量乘法:
A = a × G
其中 G 是椭圆曲线上的生成点。
共享密钥变成:
s = a × B = a × (b × G) = ab × G
s = b × A = b × (a × G) = ab × G
同一个点!ECDH 代码示例
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
# 生成密钥对
alice_private = ec.generate_private_key(ec.SECP256R1())
alice_public = alice_private.public_key()
bob_private = ec.generate_private_key(ec.SECP256R1())
bob_public = bob_private.public_key()
# 计算共享密钥
alice_shared = alice_private.exchange(ec.ECDH(), bob_public)
bob_shared = bob_private.exchange(ec.ECDH(), alice_public)
assert alice_shared == bob_shared
# 派生实际加密密钥
key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b'encryption key'
).derive(alice_shared)
print(f"共享密钥长度: {len(alice_shared)} 字节")
print(f"派生密钥长度: {len(key)} 字节")X25519:现代选择
from cryptography.hazmat.primitives.asymmetric import x25519
# 更简单的 API
alice_private = x25519.X25519PrivateKey.generate()
alice_public = alice_private.public_key()
bob_private = x25519.X25519PrivateKey.generate()
bob_public = bob_private.public_key()
# 交换
shared_secret = alice_private.exchange(bob_public)
# Bob 得到相同的结果
assert shared_secret == bob_private.exchange(alice_public)7. 临时 DH vs 静态 DH
静态 DH(已废弃)
Alice 和 Bob 有长期的 DH 密钥对。
他们对所有会话使用相同的密钥。
问题:没有前向保密!
如果 Alice 的私钥之后泄露,
所有过去的会话都可以被解密。临时 DH(DHE/ECDHE)
对于每个会话:
1. 生成新的临时密钥对
2. 执行 DH 交换
3. 派生会话密钥
4. 丢弃临时私钥
好处:
- 前向保密:过去的会话保持安全
- 即使长期密钥之后被泄露
- TLS 1.3 要求使用这种方式TLS 1.3 密钥交换
客户端 服务器
│ │
│─── ClientHello ───────────────────>│
│ supported_groups: x25519, p256 │
│ key_share: x25519 公钥 │
│ │
│<─── ServerHello ───────────────────│
│ key_share: x25519 公钥 │
│ │
│ [双方计算共享密钥] │
│ [派生握手密钥] │
│ │
│<═══ 使用 AEAD 加密 ═══════════════>│
key_share 值是临时的。
每个连接都使用新的密钥对。8. 常见攻击和防御
中间人攻击
基本 DH 的根本漏洞:
Alice Mallory Bob
│ │ │
│── g^a ────────────>│ │
│ │── g^m ────────────>│
│ │ │
│<───────────── g^m ─│<───────────── g^b ─│
│ │ │
Alice 以为她在和 Bob 通信:共享 g^(am)
Bob 以为他在和 Alice 通信:共享 g^(bm)
Mallory 可以解密、阅读、重新加密所有内容!
解决方案:认证
- 对公钥进行数字签名
- 证书(PKI)
- 这就是为什么 HTTPS 需要 TLS 证书!小子群攻击
针对实现不当的 DH 的攻击:
攻击者发送来自小子群的 B'。
共享密钥只有有限的可能性。
可以暴力破解密钥。
防御:
- 验证收到的公钥
- 检查 B^q = 1(其中 q 是群的阶)
- 使用安全质数:p = 2q + 1
- 使用设计良好的曲线(X25519 处理了这个问题)Logjam 攻击(2015)
研究人员发现:
- 许多服务器使用相同的 512 位 DH 参数
- 预计算使这些参数变得脆弱
- 可以在几分钟内破解 512 位 DH
教训:
- 使用至少 2048 位的 DH 群
- 不要跨服务器共享 DH 参数
- 更好的选择:使用 ECDH (X25519)9. DH 在实际协议中的应用
TLS (HTTPS)
TLS 1.2:
- DHE_RSA、ECDHE_RSA、ECDHE_ECDSA 密码套件
- 静态 RSA 是一个选项(没有前向保密)
TLS 1.3:
- 只有 ECDHE(x25519 或 P-256)
- RSA 密钥交换完全移除
- 强制要求前向保密Signal 协议
双棘轮算法:
1. 使用长期身份密钥进行 ECDH(认证)
2. 使用临时密钥进行 ECDH(前向保密)
3. 棘轮:定期更换 DH 密钥
结果:
- 前向保密
- 泄露后安全性
- 每条消息都有唯一密钥SSH
初始密钥交换:
- curve25519-sha256(首选)
- ecdh-sha2-nistp256
- diffie-hellman-group-exchange-sha256
DH 之后:
- 派生会话密钥
- 认证服务器(主机密钥)
- 认证客户端(密码或密钥)WireGuard VPN
使用 Noise 协议框架:
1. 静态 DH:客户端和服务器的长期密钥
2. 临时 DH:每次握手使用新密钥
结合静态 + 临时:
- 双向认证
- 前向保密
- 最少的往返次数10. 实现检查清单
安全 DH 实现:
□ 使用临时密钥 (ECDHE)
- 每个会话生成新密钥
- 使用后删除私钥
□ 使用强曲线
- X25519(首选)
- P-256(备用)
- 避免自定义或奇特的曲线
□ 验证公钥
- 检查点在曲线上
- 检查点不是单位元
- 库通常会处理这个问题
□ 认证交换
- 使用签名证书
- 在计算密钥前验证签名
- 这可以防止中间人攻击
□ 使用正确的密钥派生
- 不要直接使用 DH 输出作为密钥
- 使用 HKDF 或类似方法
- 在派生中包含上下文11. 代码:完整的类 TLS 交换
from cryptography.hazmat.primitives.asymmetric import x25519, ed25519
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes, serialization
class SecureKeyExchange:
def __init__(self):
# 用于签名的长期身份密钥
self.identity_key = ed25519.Ed25519PrivateKey.generate()
self.identity_public = self.identity_key.public_key()
def initiate(self):
"""客户端:创建临时密钥并签名"""
ephemeral_private = x25519.X25519PrivateKey.generate()
ephemeral_public = ephemeral_private.public_key()
# 签名临时公钥
public_bytes = ephemeral_public.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
signature = self.identity_key.sign(public_bytes)
return ephemeral_private, ephemeral_public, signature
def respond(self, peer_ephemeral_public, peer_signature, peer_identity_public):
"""服务器端:验证、创建临时密钥、计算密钥"""
# 验证对方的签名
public_bytes = peer_ephemeral_public.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
peer_identity_public.verify(peer_signature, public_bytes)
# 创建我们的临时密钥
ephemeral_private = x25519.X25519PrivateKey.generate()
ephemeral_public = ephemeral_private.public_key()
# 签名我们的临时公钥
our_public_bytes = ephemeral_public.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
signature = self.identity_key.sign(our_public_bytes)
# 计算共享密钥
shared_secret = ephemeral_private.exchange(peer_ephemeral_public)
return ephemeral_public, signature, shared_secret
def complete(self, our_ephemeral_private, peer_ephemeral_public,
peer_signature, peer_identity_public):
"""客户端:验证并计算密钥"""
# 验证对方的签名
public_bytes = peer_ephemeral_public.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
peer_identity_public.verify(peer_signature, public_bytes)
# 计算共享密钥
shared_secret = our_ephemeral_private.exchange(peer_ephemeral_public)
return shared_secret
def derive_keys(shared_secret, context):
"""从共享密钥派生加密和 MAC 密钥"""
return HKDF(
algorithm=hashes.SHA256(),
length=64, # 32 用于加密 + 32 用于 MAC
salt=None,
info=context
).derive(shared_secret)
# 使用
alice = SecureKeyExchange()
bob = SecureKeyExchange()
# Alice 发起
alice_eph_priv, alice_eph_pub, alice_sig = alice.initiate()
# Bob 响应(验证 Alice、创建临时密钥、计算密钥)
bob_eph_pub, bob_sig, bob_secret = bob.respond(
alice_eph_pub, alice_sig, alice.identity_public
)
# Alice 完成(验证 Bob、计算密钥)
alice_secret = alice.complete(
alice_eph_priv, bob_eph_pub, bob_sig, bob.identity_public
)
assert alice_secret == bob_secret
print("安全密钥交换成功!")
# 派生实际密钥
keys = derive_keys(alice_secret, b"my protocol v1")
encryption_key = keys[:32]
mac_key = keys[32:]12. 常见误区
| 误区 | 现实 |
|---|---|
| 「DH 加密消息」 | DH 只建立共享密钥;你需要 AES 等来加密 |
| 「DH 提供认证」 | 基本 DH 容易受到中间人攻击;需要签名 |
| 「静态 DH 没问题」 | 临时 DH 提供前向保密;始终使用 ECDHE |
| 「更大的 DH 群总是更好」 | 到一定程度后,ECDH 更高效 |
| 「DH 是量子安全的」 | Shor 算法可以破解 DH;需要后量子替代方案 |
13. 本章小结
三点要记住:
Diffie-Hellman 让陌生人创建共享密钥。 两方可以在公开信道上协商密钥,这看起来不可能但因为离散对数问题而成为可能。
始终使用临时 DH (ECDHE) 以获得前向保密。 每个会话使用新密钥意味着即使密钥之后被泄露,过去的会话仍然安全。
DH 需要认证。 没有签名/证书,中间人攻击很简单。这就是为什么 HTTPS 需要 TLS 证书。
14. 下一步
我们已经介绍了非对称密码学:RSA、ECC 和 Diffie-Hellman。但还缺少一个关键部分:我们如何知道我们在和我们认为的人通信?
在下一节中,我们将探讨:数字签名和证书——我们如何在互联网上证明身份和建立信任。
