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。但還缺少一個關鍵部分:我們如何知道我們在和我們認為的人通訊?
在下一節中,我們將探討:數位簽章和憑證——我們如何在網際網路上證明身分和建立信任。
