在Day 20 我們建立了標準的 Encrypt-then-MAC 範式,並使用 HKDF 實現了嚴謹的金鑰分離。HKDF 表現優異,因為它面對的是由 SecureRandom 產生的 256 位元高熵主金鑰(也就是高度隨機性的金鑰)。
然而,在真實世界的許多場景,例如zip壓縮檔加密、加密 PDF或是Java工程師常使用Jasypt加密資料庫密碼等,加密的起點並非高熵亂數,而是人類設定的文字密碼(Low-Entropy Secret)。
今天我們將深入探討金鑰衍生函數(Key Derivation Function,KDF)與基於密碼的加密(Password-Based Encryption,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(...)
export JASYPT_ENCRYPTOR_PASSWORD=MySystemMasterKey)或 JVM 啟動參數(-Djasypt.encryptor.password=...)傳入一串人類可讀的主密碼。properties 時,設定檔元件(如 PropertySourcesPlaceholderConfigurer)攔截到 ENC(...) 標籤,便會在記憶體中利用剛算出的 AES 金鑰將其解密為明文 appuser_db_password 並注入 DataSource。因為在配置檔加密的場景中,存在一個「金鑰保管」的現實問題:
secret.key。這在部署、跨環境傳輸(Dev/Staging/Prod)以及容器化(Docker/K8s Environment Variable)設定時非常不方便。將使用者輸入的文字密碼直接作為 AES 金鑰,會面臨三大核心缺陷:
AES-256 要求金鑰必須是 32 個完全均勻隨機的 Byte(每個 Bit 為 0 或 1 的機率均為 50%)。
然而,人類設定的密碼通常具備高度可預測性(例如僅包含英文字母、數字與少數符號),且長度往往只有 8~16 個字元。這意味著潛在的金鑰空間非常狹小,攻擊者可以輕易進行字典攻擊或暴力破解。
使用者輸入的密碼長度是不固定的,而 AES 要求金鑰必須嚴格符合 128-bit、192-bit 或 256-bit。即使你透過單次雜湊(如 SHA-256)將長度補齊,也無法改變原始密碼熵值過低的物理事實。
現代 GPU 每秒可執行數百萬次至數十億次標準 SHA-256 或 MD5 運算。如果直接使用一次 Hash 來產生金鑰,攻擊者只需要準備好彩虹表(Rainbow Table)或使用 GPU 爆破,幾秒鐘內就能還原出原始密碼。
為了彌補人類密碼的先天缺陷,密碼學家設計了 PBE(Password-Based Encryption) 體系,其核心思想是引入三個要素:
$$
\text{Strong Key} = \text{KDF}(\text{Password}, \text{Salt}, \text{Iteration Count})
$$
[使用者密碼 Password] --+
|
[隨機鹽值 Salt] --------+---> [ 金鑰衍生函數 KDF ] ---> [ 256-bit 高強度 Key ]
| (重複疊代 N 次運算)
[疊代次數 Count] -------+
人類記憶的字串,屬於低熵來源。
SecureRandom)生成的隨機位元組(建議至少 16 Bytes)。123456,只要 Salt 不同,衍生出來的金鑰就完全不同。在設計系統時,我們常會遇到不同的 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 提供了一些傳統的 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();
}
}
}