在對稱加密(如 AES-GCM)中,通訊雙方必須持有相同的金鑰才能進行加解密。然而,在零信任的網際網路中,我們面臨著一個世紀難題——「如何讓兩台從未見過面的機器,在滿是駭客監聽的開放通道上,安全地產生出一把只有他們兩人知道的對稱金鑰?」
若直接傳送金鑰,竊聽者即可輕鬆攔截;若採用 RSA 加密傳送,則會有金鑰過長、效率低下以及缺乏前向安全性(Forward Secrecy)等問題。
1976 年,Whitfield Diffie 與 Martin Hellman 提出了劃時代的 Diffie-Hellman (DH) 金鑰交換協定,允許雙方各自產生私鑰,透過交換公鑰,在不傳遞任何秘密的前提下,共同計算出相同的「共享秘密(Shared Secret)」。本章將帶領讀者從經典 DH 演進至現代 TLS 1.3 的核心——ECDH / ECDHE,並以 Java JCA 打造生產級的金鑰協商與 ECIES 混合加密系統。
在數學上,DH 利用了 Day 28 介紹的離散對數難題(DLP):
因為在同餘代數中 (g^b)^a ≡ (g^a)^b ≡ g^{ab} (mod p,雙方算出的共享秘密 S 完全一致)!
經典 DH 的安全性建立在大整數離散對數難題(DLP)上。由於數域篩法(GNFS)的發展,要達到現代 128-bit 安全強度,經典 DH 的模數 p 必須高達 3072 bits。這導致:
為了解決經典 DH 的效能瓶頸,現代密碼學將運算舞台從「整數模運算」轉移至 Day 29 學到的「橢圓曲線點群」,升級為 ECDH(Elliptic Curve Diffie-Hellman)。
ECDH 完全繼承了 DH 的邏輯,只是將「模次方運算」替換為「橢圓曲線純量乘法」:
經典 DH: g^a mod p => ECDH: k_A * G
推導結論:S_{Alice} = S_{Bob} = S = (x_S, y_S)。雙方成功在不傳遞私鑰的情況下,推導出曲線上的同一個點 S!
| 比較維度 | 經典 Diffie-Hellman (DH) | 橢圓曲線 Diffie-Hellman (ECDH) |
|---|---|---|
| 底層數學難題 | 離散對數難題 (DLP) | 橢圓曲線離散對數難題 (ECDLP) |
| 私鑰形式 | 隨機大整數 a | 隨機純量 k_A |
| 公鑰形式 | 大整數 A ≡ g^a (mod p | 曲線座標點 Q_A = k_A * G ) |
| 共享秘密計算 | S ≡ B^a (mod p | 共享曲線點 S = k_A * Q_B ) |
| 128-bit 強度金鑰長度 | 3072 bits | 256 bits (如 secp256r1 / X25519) |
| 傳輸封包與計算效能 | 封包大、CPU 負擔重 | 金鑰極小、計算速度快數十倍 |
理解了 DH/ECDH 的運算後,我們必須建立現代資安架構的最核心認知:現代系統「幾乎不直接使用非對稱加密」來加密實際的明文訊息。
網際網路傳輸普遍採用 「非對稱金鑰協商 + 對稱資料加密」 的混合加密架構(Hybrid Cryptography):
【非對稱金鑰協商階段 (Asymmetric Phase)】
Alice Bob
│ │
├───── 1. 透過 DH/ECDH 交換公鑰 ──────────────────────┤
│ │
▼ ▼
算得相同 Shared Secret 算得相同 Shared Secret
│ │
└───── 2. 透過 HKDF 鍛造出對稱金鑰 (如 AES Key) ───────┘
│
▼
【對稱資料加密階段 (Symmetric Phase)】
Alice Bob
│ │
├───── 3. 使用 AES-GCM 加密大規模資料 (Ciphertext) ───>│
│ │
│ ▼
│ 使用 AES-GCM 解密
儘管 ECDH 在數學上非常優雅,但在實際網路工程中,它面臨兩個致命威脅:
ECDH 協定本身是未經身分驗證(Unauthenticated)的。若駭客 Eve 潛伏在網路節點中:
[ Alice ] ──> (傳送 Q_A) ──> [ Eve 截獲 ] ──> (傳送 Q_Eve) ──> [ Bob ]
[ Alice ] <── (傳送 Q_Eve) <── [ Eve 攔截 ] <── (傳送 Q_B) <─── [ Bob ]
致命資安禁忌:
ECDH 算出的共享點 S = (x_S, y_S),其 x 座標點的分佈在密碼學上並不具備完全均勻的隨機性。絕不能直接把 x 座標位元組拿來當作 AES 金鑰!
正確做法(ECIES 範式):必須將 ECDH 產生的原始共享秘密(Raw Shared Secret)輸入至 HKDF(HMAC-based Key Derivation Function) 衍生出指定長度且高熵的 AES 金鑰。
以下演示 Alice 與 Bob 透過 Java JCA KeyAgreement 完成 ECDH 金鑰協商、HKDF 金鑰衍生與 AES-GCM 混合加密的完整流程:
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.spec.ECGenParameterSpec;
import java.util.Arrays;
import java.util.Base64;
public class EcdhEciesProductionExample {
private static final String EC_CURVE = "secp256r1";
private static final int GCM_TAG_LENGTH_BITS = 128;
private static final int GCM_IV_LENGTH_BYTES = 12;
public static void main(String[] args) throws Exception {
System.out.println("=== 模擬 Alice 與 Bob 的 ECDH 金鑰協商與 ECIES 加密過程 ===");
// Step 1: Alice 與 Bob 分別生成各自的 ECDH 暫態金鑰對 (ECDHE)
KeyPair aliceKeyPair = generateEcKeyPair();
KeyPair bobKeyPair = generateEcKeyPair();
// Step 2: 雙方在不安全通道上公開交換公鑰 (Alice 傳 Q_A 給 Bob,Bob 傳 Q_B 給 Alice)
PublicKey alicePublicKey = aliceKeyPair.getPublic();
PublicKey bobPublicKey = bobKeyPair.getPublic();
// Step 3: Alice 使用自己的私鑰 + Bob 的公鑰,計算 Shared Secret
byte[] aliceSharedSecret = computeEcdhSharedSecret(aliceKeyPair.getPrivate(), bobPublicKey);
// Step 4: Bob 使用自己的私鑰 + Alice 的公鑰,計算 Shared Secret
byte[] bobSharedSecret = computeEcdhSharedSecret(bobKeyPair.getPrivate(), alicePublicKey);
// 驗證雙方算出的 Shared Secret 是否完全一致
boolean isMatch = Arrays.equals(aliceSharedSecret, bobSharedSecret);
System.out.println("Alice 與 Bob 算出的 ECDH Raw Shared Secret 一致: " + isMatch);
// Step 5: 透過 HKDF-SHA256 將 Raw Shared Secret 衍生為強化的 AES-256 對稱金鑰
byte[] hkdfSalt = "ECDH-Day29-Salt".getBytes(StandardCharsets.UTF_8);
byte[] hkdfInfo = "ECIES-AES-256-GCM-Key".getBytes(StandardCharsets.UTF_8);
SecretKey aliceAesKey = deriveAesKeyViaHkdf(aliceSharedSecret, hkdfSalt, hkdfInfo);
SecretKey bobAesKey = deriveAesKeyViaHkdf(bobSharedSecret, hkdfSalt, hkdfInfo);
// Step 6: Alice 使用導出的 AES 金鑰加密訊息 (ECIES 加密)
String plaintext = "Hello Bob! This is an ECIES protected message from Day 29.";
byte[] iv = new byte[GCM_IV_LENGTH_BYTES];
new SecureRandom().nextBytes(iv); // 生成隨機 IV
byte[] ciphertext = encryptAesGcm(plaintext.getBytes(StandardCharsets.UTF_8), aliceAesKey, iv);
System.out.println("\nAlice 傳送的 AES-GCM 密文 (Base64): " + Base64.getEncoder().encodeToString(ciphertext));
// Step 7: Bob 使用導出的 AES 金鑰解密訊息
byte[] decryptedBytes = decryptAesGcm(ciphertext, bobAesKey, iv);
String decryptedMessage = new String(decryptedBytes, StandardCharsets.UTF_8);
System.out.println("Bob 成功解密訊息: " + decryptedMessage);
}
/**
* 生成 secp256r1 (NIST P-256) 金鑰對
*/
private static KeyPair generateEcKeyPair() throws Exception {
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("EC");
keyPairGen.initialize(new ECGenParameterSpec(EC_CURVE));
return keyPairGen.generateKeyPair();
}
/**
* 使用 JCA KeyAgreement 計算 ECDH 共享秘密
*/
private static byte[] computeEcdhSharedSecret(PrivateKey privateKey, PublicKey partnerPublicKey) throws Exception {
KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH");
keyAgreement.init(privateKey);
keyAgreement.doPhase(partnerPublicKey, true);
return keyAgreement.generateSecret();
}
/**
* 輕量級 HKDF-SHA256 金鑰衍生 (HKDF-Extract & Expand)
*/
private static SecretKey deriveAesKeyViaHkdf(byte[] ikm, byte[] salt, byte[] info) throws Exception {
// 1. HKDF-Extract: PRK = HMAC-SHA256(Salt, IKM)
Mac hmacExtract = Mac.getInstance("HmacSHA256");
hmacExtract.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = hmacExtract.doFinal(ikm);
// 2. HKDF-Expand: OKM = HMAC-SHA256(PRK, info || 0x01)
Mac hmacExpand = Mac.getInstance("HmacSHA256");
hmacExpand.init(new SecretKeySpec(prk, "HmacSHA256"));
hmacExpand.update(info);
hmacExpand.update((byte) 0x01); // Counter = 1
byte[] okm = hmacExpand.doFinal();
// 截取前 32 bytes (256 bits) 作為 AES-256 金鑰
byte[] aesKeyBytes = Arrays.copyOf(okm, 32);
return new SecretKeySpec(aesKeyBytes, "AES");
}
/**
* AES-GCM 加密
*/
private static byte[] encryptAesGcm(byte[] plaintext, SecretKey key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
return cipher.doFinal(plaintext);
}
/**
* AES-GCM 解密
*/
private static byte[] decryptAesGcm(byte[] ciphertext, SecretKey key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
return cipher.doFinal(ciphertext);
}
}