Luke a Pro

Luke Sun

Developer & Marketer

🇺🇦
EN||

Diffie-Hellman:陌生人如何协商共享密钥

| , 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. 本章小结

三点要记住:

  1. Diffie-Hellman 让陌生人创建共享密钥。 两方可以在公开信道上协商密钥,这看起来不可能但因为离散对数问题而成为可能。

  2. 始终使用临时 DH (ECDHE) 以获得前向保密。 每个会话使用新密钥意味着即使密钥之后被泄露,过去的会话仍然安全。

  3. DH 需要认证。 没有签名/证书,中间人攻击很简单。这就是为什么 HTTPS 需要 TLS 证书。

14. 下一步

我们已经介绍了非对称密码学:RSA、ECC 和 Diffie-Hellman。但还缺少一个关键部分:我们如何知道我们在和我们认为的人通信?

在下一节中,我们将探讨:数字签名和证书——我们如何在互联网上证明身份和建立信任。