在前面的章節中,我們揭示了傳統組合模式(Encrypt-and-MAC 與 MAC-then-Encrypt)的致命缺陷,並確立了 Encrypt-then-MAC (EtM) 是唯一在密碼學理論上被證明安全的傳統組合模式。
然而,理論安全不等於工程安全。要在沒有 AEAD(如 AES-GCM)的環境下手動實作安全的 Encrypt-then-MAC (EtM),第一個遭遇的核心課題就是:「如何產生兩把獨立的金鑰?」 這正是 HKDF(HMAC-based Extract-and-Expand Key Derivation Function,基於HMAC的金鑰衍生函數) 登場的時刻。
密碼學安全的前提建立在「金鑰獨立性假設(Key Independence Assumption)」之上:用於加密的 key (K_enc) 與用於 MAC 認證的 key (K_mac) 必須在數學上完全無關。
若工程師圖方便,直接拿同一把 Master Key 同時傳給 AES-CBC 加密與 HMAC-SHA256 認證:
HKDF(HMAC-based Extract-and-Expand Key Derivation Function) 是由密碼學家 Hugo Krawczyk 於 RFC 5869 標準中定義的金鑰衍生函數。它的核心任務是:將一串原始秘密(如 Diffie-Hellman 共享秘密、Master Secret 或隨機位元組)轉換為一把或多把具備最高密碼學強度、統計上均勻且相互獨立的對稱金鑰。
HKDF 的設計採用了靈活且嚴謹的「兩階段(Two-Stage)」模組化架構:
Input Keying Material (IKM) + Salt
│
▼
┌─────────────────┐
│ Stage 1: Extract│ <-- 萃取熵 (Entropy Extraction)
└─────────────────┘
│
Pseudo-Random Key (PRK)
│
┌─────────────┴─────────────┐
│ │ + Info ("App_Mac_Key")
│ + Info ("App_Enc_Key") │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Stage 2: Expand │ │ Stage 2: Expand │ <-- 領域隔離與衍生 (Expansion)
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
Encryption Key MAC Key
Info)實現領域隔離(Domain Separation)。Info 字串(例如 "TLS 1.3, client_handshake_traffic_secret"),即可用同一把 PRK 衍生出數學上完全不相干的獨立金鑰(如加密金鑰、MAC 金鑰、IV 等)。在 JDK 23 以前的長年版本中,JCA 標準庫內部雖然在 TLS 實作裡有私有的 HKDF,但官方確實沒有提供公開的 KDF 類別或 HKDF 標準 API。
HKDFBytesGenerator)。Mac.getInstance("HmacSHA256") 依照 RFC 5869 規格自己編寫程式碼。javax.crypto.KDF為了徹底解決這個長期的 API 缺口,Java 社群發布了 JEP 510 (Key Derivation Function API),正式在標準庫中新增了 javax.crypto.KDF 類別與專屬的 HKDFParameterSpec。
在現代 JDK 中,你可以直接使用官方內建的 HKDF API:
import javax.crypto.KDF;
import javax.crypto.SecretKey;
import javax.crypto.spec.HKDFParameterSpec;
// 1. 直接取得官方 HKDF 實體
KDF hkdf = KDF.getInstance("HKDF-SHA256");
// 2. 設定 HKDF Extract & Expand 參數
HKDFParameterSpec params = HKDFParameterSpec.ofExtract()
.addIKM(masterSecret)
.addSalt(salt)
.thenExpand("App_Encryption_Key_AES256".getBytes(), 32);
// 3. 一鍵衍生出 AES 金鑰物件
SecretKey aesKey = hkdf.deriveKey("AES", params);
[傳輸封包結構 Payload]
┌──────────────────┬───────────────────────────────┬─────────────────────────────┐
│ IV (16 Bytes) │ Ciphertext (AES-CBC Payload) │ Auth Tag (HMAC-SHA256) │
└──────────────────┴───────────────────────────────┴─────────────────────────────┘
▲ ▲
└───────────────────── 納入 HMAC 計算 ───────────────┘
Tag = HMAC(K_mac, IV || Ciphertext)
在 JDK 25(正式納入 JEP 510: Key Derivation Function API)中,Java 終於引進了原生的 javax.crypto.KDF 與 javax.crypto.HKDFParameterSpec。
我們不再需要手動用 Mac 撰寫 hkdfExpand() 輔助函式,直接調用 JDK 25 標準庫即可實現簡潔且高效的金鑰衍生。
import javax.crypto.Cipher;
import javax.crypto.HKDFParameterSpec;
import javax.crypto.KDF;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
public class EncryptThenMacJdk25 {
private static final String CIPHER_ALGO = "AES/CBC/PKCS5Padding";
private static final String MAC_ALGO = "HmacSHA256";
private static final int AES_KEY_SIZE = 32; // 256 bits
private static final int HMAC_KEY_SIZE = 32; // 256 bits
private static final int IV_LENGTH = 16; // AES Block Size (16 Bytes)
private static final int MAC_LENGTH = 32; // HMAC-SHA256 Tag Length (32 Bytes)
public static void main(String[] args) throws Exception {
byte[] masterSecret = "ThisIsAMasterSecretKeyForEtM123!".getBytes(StandardCharsets.UTF_8);
String plaintext = "Day 20: Encrypt-then-MAC with JDK 25 Native KDF API";
System.out.println("原始明文: " + plaintext);
// 1. 發送端:加密並計算 MAC
byte[] payload = encryptThenMac(plaintext, masterSecret);
System.out.println("打包封包長度: " + payload.length + " bytes");
// 2. 接收端:驗證 MAC 後解密
String decryptedText = verifyThenDecrypt(payload, masterSecret);
System.out.println("解密成功: " + decryptedText);
}
/**
* 發送端:Encrypt-then-MAC (使用 JDK 25 KDF API)
*/
public static byte[] encryptThenMac(String plaintext, byte[] masterSecret) throws Exception {
// A. 取得 JDK 25 原生 HKDF 實體
KDF hkdf = KDF.getInstance("HKDF-SHA256");
// B. 透過 HKDF-Extract-then-Expand 衍生獨立的加密與 MAC 金鑰
SecretKey keyEnc = hkdf.deriveKey("AES",
HKDFParameterSpec.ofExtract()
.addIKM(masterSecret)
.thenExpand("App_Encryption_Key_AES256".getBytes(StandardCharsets.UTF_8), AES_KEY_SIZE));
SecretKey keyMac = hkdf.deriveKey("HmacSHA256",
HKDFParameterSpec.ofExtract()
.addIKM(masterSecret)
.thenExpand("App_MAC_Key_HMAC256".getBytes(StandardCharsets.UTF_8), HMAC_KEY_SIZE));
// C. 生成隨機 IV
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
// D. 先加密 (Encrypt)
Cipher cipher = Cipher.getInstance(CIPHER_ALGO);
cipher.init(Cipher.ENCRYPT_MODE, keyEnc, new IvParameterSpec(iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// E. 後計算 MAC (MAC over IV || Ciphertext)
Mac mac = Mac.getInstance(MAC_ALGO);
mac.init(keyMac);
mac.update(iv); // 防範 IV 被篡改
mac.update(ciphertext);
byte[] tag = mac.doFinal();
// F. 打包:Payload = IV || Ciphertext || Tag
return ByteBuffer.allocate(iv.length + ciphertext.length + tag.length)
.put(iv)
.put(ciphertext)
.put(tag)
.array();
}
/**
* 接收端:Verify-then-Decrypt
*/
public static String verifyThenDecrypt(byte[] payload, byte[] masterSecret) throws Exception {
if (payload.length < IV_LENGTH + MAC_LENGTH) {
throw new IllegalArgumentException("無效的封包:長度小於標頭與認證標籤總和!");
}
// A. 利用 JDK 25 KDF 衍生相同金鑰
KDF hkdf = KDF.getInstance("HKDF-SHA256");
SecretKey keyEnc = hkdf.deriveKey("AES",
HKDFParameterSpec.ofExtract()
.addIKM(masterSecret)
.thenExpand("App_Encryption_Key_AES256".getBytes(StandardCharsets.UTF_8), AES_KEY_SIZE));
SecretKey keyMac = hkdf.deriveKey("HmacSHA256",
HKDFParameterSpec.ofExtract()
.addIKM(masterSecret)
.thenExpand("App_MAC_Key_HMAC256".getBytes(StandardCharsets.UTF_8), HMAC_KEY_SIZE));
// B. 拆解 Payload (IV | Ciphertext | Tag)
int ciphertextLength = payload.length - IV_LENGTH - MAC_LENGTH;
byte[] iv = Arrays.copyOfRange(payload, 0, IV_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(payload, IV_LENGTH, IV_LENGTH + ciphertextLength);
byte[] receivedTag = Arrays.copyOfRange(payload, IV_LENGTH + ciphertextLength, payload.length);
// C. 先驗證 MAC (Verify-then-Decrypt)
Mac mac = Mac.getInstance(MAC_ALGO);
mac.init(keyMac);
mac.update(iv);
mac.update(ciphertext);
byte[] expectedTag = mac.doFinal();
// 常數時間比對防範時序攻擊
if (!MessageDigest.isEqual(expectedTag, receivedTag)) {
throw new SecurityException("MAC 驗證失敗!資料遭篡改或金鑰不符,直接丟棄!");
}
// D. MAC 驗證通過後執行解密
Cipher cipher = Cipher.getInstance(CIPHER_ALGO);
cipher.init(Cipher.DECRYPT_MODE, keyEnc, new IvParameterSpec(iv));
byte[] decryptedBytes = cipher.doFinal(ciphertext);
return new String(decryptedBytes, StandardCharsets.UTF_8);
}
}