在任何具備使用者登入系統的 Web 應用中,密碼儲存是整個資料安全鏈條中最脆弱也是最關鍵的一環。
歷史上,包含 LinkedIn、Adobe、Yahoo 等知名企業皆曾因密碼儲存機制不當,在資料庫洩漏事故中導致數億名使用者的密碼被攻擊者在短時間內離線還原。
許多非資安背景的工程師直覺認為:「系統用了 SHA-256 甚至 SHA-512,難道還不夠安全嗎?」
答案是:這存在極大安全風險。SHA-256 是為高速校驗而設計的演算法,一張現代消費級 GPU(如 RTX 4090)每秒可計算數十億次 SHA-256 雜湊,暴力破解 8 位純英數密碼僅需數分鐘。
本文將系統化解析密碼雜湊的核心威脅、對比 PBKDF2、Bcrypt、Scrypt 與 Argon2id 四大演算法,並深入探討 Pepper 縱深防禦、Bcrypt 72 位元組截斷陷阱、高併發 DoS 治理,以及主流語言的落地實踐與無痛平滑遷移架構。
1. 為什麼快速雜湊無法保護密碼?
快速雜湊演算法(如 MD5、SHA-1、SHA-256)的核心設計目標是極致效能與資料完整性驗證。在每秒需要校驗數萬筆資料區塊傳輸的場景中,演算法越快越好;然而,這項優勢在密碼儲存情境下卻成為致命弱點。
威脅核心:彩虹表與硬體暴力枚舉
- 未加鹽的彩虹表攻擊(Rainbow Tables):若密碼僅經過單純雜湊,攻擊者可預先計算常見密碼的雜湊映射表,在取得資料庫時透過查表瞬間反解。
- GPU 與專用 ASIC 平行算力碾壓:SHA-256 不耗費記憶體,運算邏輯極簡,GPU 內數千個計算核心可完全平行並行運作。面對一組雜湊值,每秒數百億次的運算能力能輕易覆蓋常規長度字元空間。
防禦核心哲學:可調工作因子與記憶體硬性
安全密碼雜湊演算法的核心思想是讓運算故意變慢,並強制消耗特定實體資源:
- 正常使用者登入:耗費 100 至 200 毫秒完成單次計算,人類使用者並無延遲感知。
- 攻擊者離線暴力破解:若驗證單一候選密碼需消耗 200 毫秒與 64 MB 記憶體,枚舉 1 億個密碼需要花費數月甚至數年,且硬體記憶體頻寬會徹底封鎖 GPU 的平行擴展優勢。
2. 現代密碼防禦體系架構
為了兼顧安全性與線上服務可用性,現代密碼防護並非僅在資料庫前執行單一函式,而是建構一套涵蓋入口流量、金鑰管理、背景計算與平滑升級的縱深防禦架構。
前置限流與長度驗證
透過 Redis Token Bucket 在入口層限制嘗試次數,並在應用層強制校驗密碼長度上限(如 128 位元組),直接抵擋惡意爆發並消除計算資源枯竭風險。
Pepper 與 KMS / HSM 金鑰託管
將獨立高熵金鑰(Pepper)託管於 AWS KMS 或 Vault,透過 HMAC 與密碼結合。即使資料庫遭到 SQL 注入整庫外洩,攻擊者在無 Pepper 下完全無法啟動離線暴力破解。
Worker Pool 隔離與 Argon2id 計算
將高耗能雜湊計算移至獨立背景執行緒池(Worker Thread),避免阻塞主伺服器事件循環;配置 64 MB 記憶體硬性,瓦解 GPU 與專用 ASIC 的平行運算優勢。
標準格式持久化與無痛平滑遷移
將內嵌演算法識別碼、鹽與參數的標準雜湊字串寫入資料庫;配合 needsRehash 檢查,在使用者每次成功登入時無感升級舊演算法與安全工作因子。
3. 四大現代密碼雜湊演算法技術對決
不同演算法在運算瓶頸與防禦維度上具備不同特性:
| 演算法 | 誕生時間與標準規範 | 計算瓶頸維度(Hardness) | 抗 GPU / ASIC 專用硬體能力 | 建議使用場景 |
|---|---|---|---|---|
| PBKDF2 | 2000 (RFC 2898 / NIST SP 800-132) | 純 CPU 計算密集型(多次迭代 HMAC) | 偏低(GPU 高度平行加速門檻低) | 嚴格受限於 FIPS-140 等美國聯邦合規審計系統 |
| Bcrypt | 1999 (Eksblowfish 衍生) | CPU + 固定小記憶體(4 KB L1 快取) | 中等(已存在專用 FPGA 破解叢集,但具備足夠通用安全性) | 既有成熟系統維護、主流語言生態預設整合 |
| Scrypt | 2009 (RFC 7914) | CPU + 記憶體硬性(Memory-Hard) | 極高(大記憶體消耗大幅限制並行度) | 密碼貨幣儲存、早期防 ASIC 高安全性系統 |
| Argon2id | 2015 (PHC 密碼大賽冠軍 / RFC 9106) | CPU + 記憶體硬性 + 多執行緒 + 抗時序側信道 | 業界最高防禦上限 | 所有現代新系統與新服務第一優先推薦 |
4. 架構防禦升級:Pepper 與 KMS/HSM 整合
許多團隊實作了加鹽(Salt),但鹽的作用僅在於保證全域唯一性以粉碎預計算彩虹表。鹽隨同雜湊值明文儲存於資料庫內,一旦資料庫發生整庫外洩(如 SQL 注入、備份檔遺失),攻擊者依然能直接取得鹽與雜湊值,立即展開離線運算。
Salt vs. Pepper
- Salt(鹽):由 CSPRNG(加密安全隨機數產生器)動態生成,每個使用者各自獨立,公開儲存於資料庫中。
- Pepper(胡椒):全系統共用的高熵機密值(例如 256 位元隨機字串),絕對不與雜湊值存放於同一個資料庫。
縱深防禦實踐:HMAC 與 KMS 託管
若採用 Pepper,可將金鑰託管於 AWS KMS、GCP Cloud KMS 或 HashiCorp Vault:
密碼輸入 ──> HMAC-SHA256(password, KMS_Pepper) ──> Argon2id 雜湊計算 ──> 儲存 DB
- SQL 注入防護:攻擊者拖庫取得雜湊資料,但無法取得應用程式運行時記憶體或受嚴格 IAM 控管的 KMS 金鑰,完全無法在攻擊者本機啟動離線暴力破解。
- 權限分離與稽核:每次密碼雜湊或驗證均需經由受控服務存取金鑰,提供存取日誌稽核能力。
5. 既有系統盲區:Bcrypt 的 72 位元組截斷陷阱
儘管 Bcrypt 應用廣泛,但其底層繼承自 Eksblowfish 區塊加密演算法,存在一個廣為人知卻常被忽略的先天限制:密碼有效輸入長度上限為 72 位元組。
72-byte 截斷機制與安全隱患
在標準 Bcrypt 實作中,若傳入超過 72 位元組的字串,超過的字元將被直接截斷並靜默忽略:
SuperSecretPassword...(長度 72 位元組)SuperSecretPassword...ExtraCharactersHere(長度 100 位元組)
上述兩組密碼在標準 Bcrypt 計算下產生的雜湊值完全相同。這意味著使用者設定更長的密碼並未提升安全性,甚至可能造成不同密碼驗證通過的非預期行為。
因應策略比較
-
SHA-256 預雜湊(Pre-hashing): 在送入 Bcrypt 之前,先以 SHA-256 計算密碼的十六進位字串(Digest 長度固定為 64 字元),確保輸入長度永遠小於 72 位元組。
-
升級至 Argon2id: Argon2id 原生支援最大
2³²-1位元組的密碼長度,完全不存在 72 位元組截斷問題,徹底根除此限制。
6. 伺服器端的可用性挑戰:高併發與 DoS 防禦
慢速記憶體硬性雜湊是一把雙面刃:它拉高了攻擊者的算力成本,同時也佔用了伺服器的寶貴運算資源。
威脅情境:記憶體與 CPU 資源耗盡
假設系統 Argon2id 設定為 m=65536(64 MB RAM)、單次運算 150 毫秒:
- 若惡意流量在 1 秒內發起 1,000 個併發登入請求,後端將在短瞬間需要配置超過 60 GB 的記憶體與巨量 CPU 核心。
- 在 Node.js、Go 或 Python 單體應用中,這極易導致作業系統觸發 OOM Killer 強制結束服務,或使主執行緒 Event Loop 嚴重阻塞,造成正常使用者無法連線。
工程防護措施
- 執行緒池或獨立微服務隔離(Worker Isolation): 將雜湊驗證與計算任務移至獨立的 Worker Thread 池或專門的認證微服務,避免高計算負載剝奪主 Web 伺服器處理 HTTP I/O 的資源。
- 前置限流(Rate Limiting): 在入口 API Gateway 或反向代理層實施 Token Bucket / Leaky Bucket 限流,針對單一 IP 與單一使用者帳號設定嚴格登入頻率限制(例如每分鐘最多 5 次嘗試)。
- 應用層長度硬性校驗: 在密碼進入雜湊運算前,於驗證層強制拒絕異常超長字串(例如上限 128 位元組),直接防止攻擊者以數十 MB 的畸形字串消耗伺服器記憶體。
7. 多語言生態系實作範例
7.1 Python (argon2-cffi)
OWASP 推薦的 Argon2id 基準配置(兼顧高強度防護與 200 毫秒以內伺服器延遲):
import argon2
hasher = argon2.PasswordHasher(
time_cost=3, # 迭代輪數 (Iterations)
memory_cost=65536, # 記憶體消耗 (64 MB RAM)
parallelism=4, # 平行執行緒數 (4 Threads)
hash_len=32, # 輸出雜湊長度 (Bytes)
salt_len=16 # 隨機鹽長度 (16 Bytes CSPRNG 生成)
)
# 1. 密碼加密
hashed_str = hasher.hash("MySuperSecurePassword123!")
# 輸出標準格式: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
# 2. 登入驗證
try:
hasher.verify(hashed_str, "MySuperSecurePassword123!")
print("驗證成功")
except argon2.exceptions.VerifyMismatchError:
print("密碼錯誤")
7.2 Node.js / Bun (@node-rs/argon2)
在 JavaScript / TypeScript 環境中,建議優先選用基於 Rust 綁定的高效能函式庫 @node-rs/argon2,避免純 JS 實作效能貧弱或 C++ Addon 建置相容性問題:
import { hash, verify, argon2id } from "@node-rs/argon2";
const hashingOptions = {
algorithm: argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 輪迭代
parallelism: 4, // 4 執行緒
outputLen: 32,
};
// 1. 產生雜湊
export async function hashPassword(password: string): Promise<string> {
return await hash(password, hashingOptions);
}
// 2. 驗證密碼
export async function verifyPassword(
password: string,
hashString: string,
): Promise<boolean> {
return await verify(hashString, password);
}
7.3 Go (golang.org/x/crypto/argon2)
Go 官方延伸庫提供了標準底層實作,搭配安全隨機鹽生成:
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2
)
type Params struct {
Memory uint32
Iterations uint32
Parallelism uint8
SaltLength uint32
KeyLength uint32
}
var DefaultParams = &Params{
Memory: 64 * 1024,
Iterations: 3,
Parallelism: 4,
SaltLength: 16,
KeyLength: 32,
}
func HashPassword(password string, p *Params) (string, error) {
salt := make([]byte, p.SaltLength)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, p.KeyLength)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, p.Memory, p.Iterations, p.Parallelism, b64Salt, b64Hash)
return encoded, nil
}
func VerifyPassword(password, encodedHash string) (bool, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("不相容的雜湊格式")
}
var version int
var memory, iterations uint32
var parallelism uint8
_, err := fmt.Sscanf(parts[2], "v=%d", &version)
if err != nil {
return false, err
}
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism)
if err != nil {
return false, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, err
}
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, err
}
computedHash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(expectedHash)))
// 使用常數時間比對防止時序攻擊
if subtle.ConstantTimeCompare(computedHash, expectedHash) == 1 {
return true, nil
}
return false, nil
}
8. 無痛平滑遷移(Zero-downtime Migration)
將既有系統從舊演算法(如 Bcrypt 或 Legacy SHA-256)遷移至 Argon2id,或升級 Argon2id 的成本參數時,絕不能要求全體使用者重設密碼,而應採用登入時觸發的漸進式重新雜湊(Re-hash on Login)模式。
使用者提交密碼
│
▼
驗證演算法版本
├─ Legacy SHA-256 ──> SHA-256 校驗 ──> 驗證成功 ──> 標記需要升級
├─ Bcrypt ──────────> Bcrypt 校驗 ──> 驗證成功 ──> 標記需要升級
└─ Argon2id ────────> Argon2 校驗 ──> 檢查參數 ──> 若低於現行標準則標記需要升級
│
▼
驗證通過且需要升級?
├─ 是 ──> 使用最新 Argon2id 參數重新計算雜湊 ──> 寫回 DB 覆蓋舊資料
└─ 否 ──> 完成登入
遷移邏輯實作模式
interface UserRecord {
id: string;
passwordHash: string;
}
const TARGET_ARGON2_PARAMS = {
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
};
async function authenticateAndMigrate(
user: UserRecord,
plainPasswordInput: string,
): Promise<boolean> {
const currentHash = user.passwordHash;
let isValid = false;
let needsUpgrade = false;
if (currentHash.startsWith("$argon2id$")) {
// 1. 現行 Argon2id 驗證
isValid = await verify(currentHash, plainPasswordInput);
if (isValid) {
// 檢查工作因子是否需要隨硬體演進調高
needsUpgrade = checkIfArgon2ParamsOutdated(
currentHash,
TARGET_ARGON2_PARAMS,
);
}
} else if (currentHash.startsWith("$2a$") || currentHash.startsWith("$2b$")) {
// 2. 舊版 Bcrypt 驗證相容
isValid = await bcryptVerify(plainPasswordInput, currentHash);
needsUpgrade = true;
} else {
// 3. 歷史遺留 SHA-256 驗證相容
isValid = legacySha256Verify(plainPasswordInput, currentHash);
needsUpgrade = true;
}
if (isValid && needsUpgrade) {
// 成功登入後無感升級,將最新雜湊寫入資料庫
const newHash = await hash(plainPasswordInput, TARGET_ARGON2_PARAMS);
await db.users.update({
where: { id: user.id },
data: { passwordHash: newHash },
});
}
return isValid;
}
透過此機制,活躍使用者的憑證將在日常登入中自動完成無感升級;一段時間後未登入的冷資料,可進一步配合強制信件重設機制完成收尾。
9. 密碼儲存的工程檢核清單
- 加鹽(Salt)具備全域唯一性:由 CSPRNG 生成,長度至少 16 位元組,並隨雜湊值妥善保存以防禦彩虹表。
- 引進 Pepper 建立縱深防禦:全域金鑰託管於 KMS 或 HSM,與資料庫物理隔離,有效封鎖整庫外洩後的離線破解。
- 前置長度與頻率限制:應用層限制密碼長度上限(如 128 位元組),入口層落實 Token Bucket 限流,抵禦運算耗盡型 DoS 攻擊。
- 防範 Bcrypt 72 位元組截斷:若使用 Bcrypt,必須謹慎處理 Pre-hashing 編碼;新系統優先採用無長度限制的 Argon2id。
- 落實自動重新雜湊架構:系統保留
needsRehash介面,支援演算法跨代演進與工作因子的平滑升級。 - 嚴禁自創密碼學實作:永遠採用經過嚴格同行評審的標準加密函式庫。
10. 結論與選型指引
- 全新系統規劃:優先選用 Argon2id(RFC 9106),依伺服器負載配置適當記憶體硬性(建議
m=65536,t=3,p=4)。 - 現行 Bcrypt 系統:若已穩定運行,可維持使用並確保工作因子不低於 12,同時注意 72 位元組邊界;有長密碼需求或重構計畫時再行規劃遷移。
- 絕對禁止情境:切勿在任何正式環境中採用 MD5、SHA-1、SHA-256 或 SHA-512 單向雜湊直接儲存使用者密碼。
