iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

前言

在Day 20 我們建立了標準的 Encrypt-then-MAC 範式,並使用 HKDF 實現了嚴謹的金鑰分離。HKDF 表現優異,因為它面對的是由 SecureRandom 產生的 256 位元高熵主金鑰(也就是高度隨機性的金鑰)。

然而,在真實世界的許多場景,例如zip壓縮檔加密、加密 PDF或是Java工程師常使用Jasypt加密資料庫密碼等,加密的起點並非高熵亂數,而是人類設定的文字密碼(Low-Entropy Secret)。

今天我們將深入探討金鑰衍生函數(Key Derivation Function,KDF)與基於密碼的加密(Password-Based Encryption,PBE),學習如何安全地將人類可讀的低熵密碼,轉化為具備密碼學強度的高強度金鑰。

一、 ENC(...) 背後的完整 PBE 運作流程

當你在設定檔中寫下 db.password=ENC(v8X2kL9m...) 時,系統在背後執行的正是標準的 PBE 流程:
是的,這正是 PBE (Password-Based Encryption) 在 Java 企業級開發中最經典、最高頻的落地應用!

你在 config.properties 或 Spring Boot 的 application.yml 中看到的 ENC(...),背後最常見的第三方套件就是 Jasypt (Java Simplified Encryption) 或 Spring Cloud Config Encryption,它們的核心架構完全就是 PBE。


一、 ENC(...) 背後的完整 PBE 運作流程

當你在設定檔中寫下 db.password=ENC(v8X2kL9m...) 時,系統在背後執行的正是標準的 PBE 流程:

[ 開發/維運人員 ] ──提供──> JASYPT_ENCRYPTOR_PASSWORD (Master Password / 主密碼)
                                      │
                                      ▼
[ 隨機 Salt (鹽值) ] ───> [ PBKDF2 / PBE 演算法 ] ───> [ 高熵 AES-256 金鑰 ]
                                                               │
                                                               ▼
[ 明文 DB 密碼 ] ────────────────────────────────────> [ AES 加密引擎 ]
                                                               │
                                                               ▼
                                                    [ 密文 (Base64) ] ──> 包裝為 ENC(...)

  1. 注入 Master Password(主密碼):
    伺服器啟動時,AP人員透過環境變數(export JASYPT_ENCRYPTOR_PASSWORD=MySystemMasterKey)或 JVM 啟動參數(-Djasypt.encryptor.password=...)傳入一串人類可讀的主密碼。
  2. PBKDF 金鑰衍生:
    Jasypt 提取這串主密碼,加上隨機生成並嵌入在密文中的 Salt,透過 PBKDF2 進行數萬至數十萬次的 Hash 迭代,衍生出一把高強度、256-bit 的 AES 對稱金鑰。
  3. 動態攔截與解密:
    Spring / Java 應用程式啟動讀取 properties 時,設定檔元件(如 PropertySourcesPlaceholderConfigurer)攔截到 ENC(...) 標籤,便會在記憶體中利用剛算出的 AES 金鑰將其解密為明文 appuser_db_password 並注入 DataSource。

二、 為什麼這一定要用 PBE,而不能直接用一般 AES?

因為在配置檔加密的場景中,存在一個「金鑰保管」的現實問題:

  • 若用一般 AES 加密:AP人員必須在伺服器上保存一把 32 位元組(256 bits)的全隨機二進位檔 secret.key。這在部署、跨環境傳輸(Dev/Staging/Prod)以及容器化(Docker/K8s Environment Variable)設定時非常不方便。
  • 採用 PBE 加密:維運人員只需要在 CI/CD 流程或 K8s Secret 中注入一串字串(Master Password)。系統就能隨時透過 PBE 將文字密碼還原為強大的 AES 金鑰。

三、 為什麼不能直接拿使用者密碼當作對稱金鑰?

將使用者輸入的文字密碼直接作為 AES 金鑰,會面臨三大核心缺陷:

1. 熵值(Entropy)嚴重不足

AES-256 要求金鑰必須是 32 個完全均勻隨機的 Byte(每個 Bit 為 0 或 1 的機率均為 50%)。
然而,人類設定的密碼通常具備高度可預測性(例如僅包含英文字母、數字與少數符號),且長度往往只有 8~16 個字元。這意味著潛在的金鑰空間非常狹小,攻擊者可以輕易進行字典攻擊或暴力破解。

2. 隨機性與長度限制

使用者輸入的密碼長度是不固定的,而 AES 要求金鑰必須嚴格符合 128-bit、192-bit 或 256-bit。即使你透過單次雜湊(如 SHA-256)將長度補齊,也無法改變原始密碼熵值過低的物理事實。

3. GPU / ASIC 的超高速爆破威脅

現代 GPU 每秒可執行數百萬次至數十億次標準 SHA-256 或 MD5 運算。如果直接使用一次 Hash 來產生金鑰,攻擊者只需要準備好彩虹表(Rainbow Table)或使用 GPU 爆破,幾秒鐘內就能還原出原始密碼。

四、 PBE 的三大核心構件:Password, Salt, Iterations

為了彌補人類密碼的先天缺陷,密碼學家設計了 PBE(Password-Based Encryption) 體系,其核心思想是引入三個要素:

$$
\text{Strong Key} = \text{KDF}(\text{Password}, \text{Salt}, \text{Iteration Count})
$$

[使用者密碼 Password] --+
                        |
[隨機鹽值 Salt] --------+---> [ 金鑰衍生函數 KDF ] ---> [ 256-bit 高強度 Key ]
                        |    (重複疊代 N 次運算)
[疊代次數 Count] -------+

1. Password(使用者密碼)

人類記憶的字串,屬於低熵來源。

2. Salt(鹽值)

  • 作用:由密碼學安全隨機數產生器(SecureRandom)生成的隨機位元組(建議至少 16 Bytes)。
  • 目的:
  • 防範彩虹表(Rainbow Table)攻擊:即使兩個使用者使用了完全相同的密碼 123456,只要 Salt 不同,衍生出來的金鑰就完全不同。
  • 防範跨帳號/跨檔案批量破解:攻擊者無法一次破解所有檔案,必須為每一個 Salt 重新進行計算。
  • 注意:Salt 不需要保密,通常會以明文形式與加密後的密文放在一起儲存。

3. Iteration Count / Work Factor(疊代次數 / 工作因子)

  • 作用:故意將金鑰導出的數學運算過程拉長並變慢(例如執行 600,000 次內部 HMAC 運算)。
  • 目的:
  • 對於合法使用者來說,算出金鑰多花 0.1 秒(100 ms)完全感應不到。
  • 對於攻擊者來說,爆破速度被直接強制放慢 600,000 倍,原本一天能算完的字典檔直接變成需要運算數百年,大幅提升攻擊成本。

五、 KDF 家族比較:PBKDF2 vs. Argon2 vs. HKDF

在設計系統時,我們常會遇到不同的 KDF 演算法,它們各自有著明確的適用場景:

演算法 規範來源 核心特性 主要適用場景
PBKDF2 RFC 8018 (PKCS#5 v2.1) CPU 運算密集型。Java JCA 原生完整支援,相容性最高。 使用者密碼金鑰衍生、傳統跨平臺加密。
Argon2 RFC 9106 記憶體硬性(Memory-hard)。密碼雜湊大賽總冠軍,能有效抵禦 GPU/ASIC 專用晶片爆破。 現代安全等級最高的使用者密碼保護(如 KeePass)。
HKDF RFC 5869 高熵金鑰抽取與擴展。不適合低熵密碼! 處理 Diffie-Hellman (ECDH) 協商後的共享秘密衍生。

觀念釐清:

  • 處理「人類文字密碼」 -> 必須選用包含 Salt 與高強度疊代的 PBKDF2 或 Argon2。
  • 處理「已經具備高熵的密碼學金鑰」 -> 選用運算速度極快且靈活的 HKDF。

六、 Java JCA 實戰:現代最佳實踐 (PBKDF2 + AES-GCM)

雖然 Java 提供了一些傳統的 PBE 包裝字串(如 PBEWithHmacSHA256AndAES_256),但在現代工程開發中,最推薦的「Best Practice」是將「金鑰衍生 (PBKDF2)」與「認證加密 (AES-GCM)」解耦。

這樣不僅架構極度清晰,還能確保同時享有 AES-GCM (AEAD) 的高強度防竄改能力!

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.Base64;

public class ModernPbeExample {

    // 演算法與安全參數設定
    private static final String KDF_ALGORITHM = "PBKDF2WithHmacSHA256";
    private static final int SALT_LENGTH_BYTES = 16;     // 128-bit Salt
    private static final int ITERATION_COUNT = 600_000;   // OWASP 推薦之 PBKDF2-HMAC-SHA256 最低疊代次數
    private static final int KEY_LENGTH_BITS = 256;       // 衍生出 AES-256 金鑰
  
    private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding";
    private static final int GCM_IV_LENGTH_BYTES = 12;   // GCM 標準 96-bit IV
    private static final int GCM_TAG_LENGTH_BITS = 128;   // 128-bit Auth Tag

    /**
     * 1. 核心:使用 PBKDF2 將使用者密碼衍生為高強度對稱金鑰
     */
    public static SecretKey deriveKeyFromPassword(char[] password, byte[] salt) 
            throws NoSuchAlgorithmException, InvalidKeySpecException {
  
        // 使用 char[] 而非 String,方便後續主動擦除記憶體
        PBEKeySpec spec = new PBEKeySpec(password, salt, ITERATION_COUNT, KEY_LENGTH_BITS);
        SecretKeyFactory factory = SecretKeyFactory.getInstance(KDF_ALGORITHM);
        byte[] keyBytes = factory.generateSecret(spec).getEncoded();
  
        // 記憶體清理:使用完畢後立即清除 KeySpec 中的敏感密碼字元
        spec.clearPassword();
  
        return new SecretKeySpec(keyBytes, "AES");
    }

    /**
     * 2. PBE 加密:產生 Salt 與 IV,導出 Key,執行 AES-GCM 加密
     */
    public static PbeCiphertext encrypt(byte[] plaintext, char[] password) throws Exception {
        SecureRandom random = new SecureRandom();

        // 隨機生成 Salt 與 IV
        byte[] salt = new byte[SALT_LENGTH_BYTES];
        byte[] iv = new byte[GCM_IV_LENGTH_BYTES];
        random.nextBytes(salt);
        random.nextBytes(iv);

        // 衍生金鑰
        SecretKey aesKey = deriveKeyFromPassword(password, salt);

        // 執行 AES-GCM 加密
        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
        cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
        byte[] ciphertext = cipher.doFinal(plaintext);

        return new PbeCiphertext(salt, iv, ciphertext);
    }

    /**
     * 3. PBE 解密:利用傳入的 Salt 與 IV 重新生成相同的 Key 並解密
     */
    public static byte[] decrypt(PbeCiphertext pbeCiphertext, char[] password) throws Exception {
        // 使用傳入的同一組 Salt 重新衍生出相同的金鑰
        SecretKey aesKey = deriveKeyFromPassword(password, pbeCiphertext.salt);

        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
        cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, pbeCiphertext.iv));

        // 若密碼錯誤或密文遭竄改,doFinal 會直接拋出 AEADBadTagException
        return cipher.doFinal(pbeCiphertext.ciphertext);
    }

    // 封裝 PBE 密文與附隨參數(Salt、IV 不保密,需與密文一併保存)
    public record PbeCiphertext(byte[] salt, byte[] iv, byte[] ciphertext) {}

    public static void main(String[] args) {
        try {
            char[] userPassword = "UserSecretPassword#2026".toCharArray();
            String secretData = "這是極度敏感的個人備份資料!";

            System.out.println("原始明文: " + secretData);
            System.out.println("開始執行 PBKDF2 金鑰衍生 (600,000 次疊代)...");

            long startTime = System.currentTimeMillis();
            PbeCiphertext encryptedData = encrypt(secretData.getBytes(), userPassword);
            long endTime = System.currentTimeMillis();

            System.out.println("加密完成!耗時: " + (endTime - startTime) + " ms");
            System.out.println("產生的 Salt (Base64): " + Base64.getEncoder().encodeToString(encryptedData.salt()));
            System.out.println("產生的 IV   (Base64): " + Base64.getEncoder().encodeToString(encryptedData.iv()));
            System.out.println("密文+Tag   (Base64): " + Base64.getEncoder().encodeToString(encryptedData.ciphertext()));

            // 解密測試
            byte[] decryptedBytes = decrypt(encryptedData, userPassword);
            System.out.println("解密成功: " + new String(decryptedBytes));

            // 模擬輸入錯誤密碼解密
            System.out.print("嘗試輸入錯誤密碼解密: ");
            char[] wrongPassword = "WrongPassword123".toCharArray();
            decrypt(encryptedData, wrongPassword);

        } catch (javax.crypto.AEADBadTagException e) {
            System.out.println("❌ 解密失敗!密碼錯誤或資料遭竄改 (AEADBadTagException)");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

上一篇
Day21 - 現代認證加密 (AEAD):AES-GCM 與 ChaCha20-Poly1305
下一篇
Day23 - 對稱加密總結與安全避坑指南
系列文
我是Java工程師,關於密碼學我想懂的不多 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言