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。但還缺少一個關鍵部分:我們如何知道我們在和我們認為的人通訊?

在下一節中,我們將探討:數位簽章和憑證——我們如何在網際網路上證明身分和建立信任。