iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Software Development

我是Java工程師,關於密碼學我想懂的不多系列 第 21 篇

Day20 - 手動打造安全的經典模式:Encrypt-then-MAC 結合 HKDF 金鑰分離

  • 分享至 

  • xImage
  •  

前言

在前面的章節中,我們揭示了傳統組合模式(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的金鑰衍生函數) 登場的時刻。

一、 為什麼 EtM 必須搭配 HKDF 進行金鑰分離?

密碼學安全的前提建立在「金鑰獨立性假設(Key Independence Assumption)」之上:用於加密的 key (K_enc) 與用於 MAC 認證的 key (K_mac) 必須在數學上完全無關。
若工程師圖方便,直接拿同一把 Master Key 同時傳給 AES-CBC 加密與 HMAC-SHA256 認證:

  • 破壞獨立性假設:加密演算法的內部狀態轉移,可能與 MAC 運算產生不可預期的相互作用(Cross-algorithm Interaction)。
  • 密碼分析漏洞:攻擊者若透過 MAC 運算推導出某些中間狀態(State Leakage),可能同時威脅到 AES 的資料機密性。

一、 什麼是 HKDF(基於HMAC的金鑰衍生函數)?

HKDF(HMAC-based Extract-and-Expand Key Derivation Function) 是由密碼學家 Hugo Krawczyk 於 RFC 5869 標準中定義的金鑰衍生函數。它的核心任務是:將一串原始秘密(如 Diffie-Hellman 共享秘密、Master Secret 或隨機位元組)轉換為一把或多把具備最高密碼學強度、統計上均勻且相互獨立的對稱金鑰。

二、HKDF 的兩階段核心架構 (Extract & Expand)

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

1. 第一階段:Extract(萃取)

  • 數學算式:PRK = HMAC-Hash(Salt, IKM)
  • 目的:將輸入的原始秘密材料(Input Keying Material, IKM)中分佈不均勻的熵(Entropy)萃取出來,濃縮生成一把固定的、具備最高隨機性的偽隨機金鑰(Pseudo-Random Key, PRK)。
  • 說明:若傳入的原始秘密已經是高度均勻且隨機的硬體亂數,在此階段可跳過 Extract,直接使用該秘密作為 PRK 進入 Expand 階段。

2. 第二階段:Expand(展開)

  • 數學算式:OKM = HKDF-Expand(PRK, Info, L)
  • 目的:將 PRK 展開為指定長度 L 的輸出金鑰材料(Output Keying Material, OKM),並透過 Context 標籤(Info)實現領域隔離(Domain Separation)。
  • 說明:傳入不同的 Info 字串(例如 "TLS 1.3, client_handshake_traffic_secret"),即可用同一把 PRK 衍生出數學上完全不相干的獨立金鑰(如加密金鑰、MAC 金鑰、IV 等)。

四、JDK版本對於HKDF的支援

1. JDK 8–21

在 JDK 23 以前的長年版本中,JCA 標準庫內部雖然在 TLS 實作裡有私有的 HKDF,但官方確實沒有提供公開的 KDF 類別或 HKDF 標準 API。

  • 第三方元件庫:引進 Bouncy Castle(如 HKDFBytesGenerator)。
  • 純 JCA 自寫:利用 JCA 內建的 Mac.getInstance("HmacSHA256") 依照 RFC 5869 規格自己編寫程式碼。

2.JDK 24+ 正式引進 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);

五、 EtM 完整資料流與封包結構

[傳輸封包結構 Payload]
┌──────────────────┬───────────────────────────────┬─────────────────────────────┐
│   IV (16 Bytes)  │  Ciphertext (AES-CBC Payload) │  Auth Tag (HMAC-SHA256)     │
└──────────────────┴───────────────────────────────┴─────────────────────────────┘
▲                                                  ▲
└───────────────────── 納入 HMAC 計算 ───────────────┘
                     Tag = HMAC(K_mac, IV || Ciphertext)

六、 Java JCA 完整工程範例

在 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);
    }
}


上一篇
Day19 - 訊息鑑別碼 (Message Authention Code,MAC)
下一篇
Day21 - 現代認證加密 (AEAD):AES-GCM 與 ChaCha20-Poly1305
系列文
我是Java工程師,關於密碼學我想懂的不多 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言