一支支付 App 裡,使用者可能用 6 位數付款密碼、九宮格圖形,或 指紋/臉 來確認付款。這三種東西很容易被當成「三種密碼」,一起丟進同一張表用同一種方式存——那是一個很貴的誤會。
假設那張表是可逆的:有金鑰就能解回原文。只要資料庫和金鑰有一次同時外洩,所有人的付款密碼就是明文。更冤的是——你根本不需要解得回來。
讀完這篇,你要能用自己的話回答:PIN/圖形為什麼跟生物辨識是兩類東西、雜湊與鹽與「慢」各在防什麼、為什麼 6 位數光靠慢雜湊救不了、以及那張表裡到底該存哪幾欄。
這幾題就是本文的重點,也是讀完你該能用自己的話答出來的問題。先想想現在答不答得出來,然後帶著它們往下讀——遇到答案所在的段落就特別留意;讀完再回來點「看答案」對照。
一句話:PIN 和圖形是「使用者記在腦裡的秘密」,指紋和臉不是——後者伺服器根本拿不到。
這個分法是後面所有結論的骨架,先看清楚:
使用者背在腦裡、每次輸入給你比對。圖形密碼本質上就是「經過哪幾個點、什麼順序」的一串資料,跟一串數字沒兩樣。
手機的安全晶片幫使用者保管一把私鑰,指紋只是「解鎖那把私鑰」的開關。伺服器驗的是簽章,不是指紋。
所以存法天差地遠:第一類要把秘密「壞掉」再存;第二類在伺服器這一側根本沒有秘密可存,只存一把公鑰。這篇只講第一類,生物辨識那一側留給系列的第二篇。
這段一句話:分不清「比對秘密」和「證明你握有鑰匙」,就會把三種憑證塞進同一張表——錯的不是欄位,是分類。
一句話:可逆=有金鑰就能解回原文,而你根本不需要這個能力。
驗證 PIN 時,你要回答的問題只有一個:「使用者這次輸入的,跟他當初設的是不是同一個?」
這件事用單向的雜湊就能做到,不需要知道原文。存得比需要的多,就是多背一個風險。
誤解一:「金鑰有好好保管就沒事」。問題是資料庫和金鑰只要有一次同時外洩,或某個內部角色同時拿得到,所有人的付款密碼就一起裸奔。
誤解二:「反正沒規定不能這樣存」。NIST SP 800-63B 講得很直白:驗證方必須把密碼存成「能抵抗離線攻擊」的形式,也就是加鹽並用密碼雜湊方案處理。
這段一句話:驗證只需要知道「對不對」,不需要知道「是什麼」。能單向就別可逆。
一句話:hash 讓它壞掉、salt 讓每筆不一樣、慢讓每次猜都很貴。
一個單向函數。丟 PIN 進去吐出一串固定長度的亂碼,但從亂碼算不回 PIN。驗證時用同一個函數再算一次,比對兩串亂碼。
每筆密碼配一個不同的隨機值,跟 PIN 一起丟進雜湊。兩個人都設 123456,存下來也不一樣。鹽不是秘密,直接跟雜湊放同一列。
一般雜湊(SHA-256)設計成越快越好,攻擊者一秒能猜幾十億次。密碼雜湊刻意做得很慢、很吃記憶體,讓每一次猜測都貴。
OWASP 對鹽的定義就是「一段獨一無二、隨機產生、加進每個密碼裡一起雜湊的字串」;NIST 要求鹽至少 32 bits、且要避免撞號。
至於「慢」要多慢,NIST 的說法是:cost factor 應該在不拖垮驗證效能的前提下盡量調高,並隨硬體進步逐年調升。
OWASP Password Storage Cheat Sheet 目前的建議(2026-09 查閱之現行版本):
| 演算法 | OWASP 建議參數 | 什麼時候選它 |
|---|---|---|
| Argon2id | m=19456 KiB(19 MiB)、t=2、p=1 為最低配置 | 首選 |
| scrypt | N=2^17、r=8、p=1 | 沒有 Argon2id 時 |
| bcrypt | work factor 至少 10;輸入上限 72 bytes | 舊系統相容 |
| PBKDF2-HMAC-SHA256 | 600,000 次迭代 | 需要 FIPS-140 合規、或只想用內建 API |
圖形密碼怎麼套進來?先把「畫了哪幾個點、什麼順序」序列化成固定格式的字串(例如 0-4-8-5),再當成 PIN 一樣加鹽慢雜湊。
(序列化這一步是實作上的常見做法,不是任何規範規定的格式;重點是規則要固定、不可歧義。)
這段一句話:這三個詞分別在賣三件事——不可逆、每筆不同、每次很貴。少了任何一個都有明顯的破口。
一句話:慢雜湊是在賣「單價」,但 PIN 的「數量」太少,總價還是便宜。
先算數。這兩個數字決定了一切:
106。對比之下,一組 8 碼英數隨機密碼有 1014 級的組合——PIN 少了一億倍。
比 PIN 還少。研究還發現使用者實際選的圖形集中在極少數幾種,比理論值更好猜。
(九宮格畫直線跳過一個還沒連的點時,中間那點會被自動連進去,所以很多看似不同的畫法其實是同一個圖形,合法組合因此大幅減少。)
把數字代進去:假設每次 Argon2id 要 50 毫秒,100 萬次 × 50 ms ≈ 14 小時單核,開 8 核就是不到 2 小時。
(50 ms 是量級估算,實際看你的硬體,我沒有實測。)而且鹽在這裡幫不上忙——鹽只防「一次撞多人」,攻擊者針對某一個帳號慢慢算,鹽擋不住。
所以不能只靠一道。要三道一起上,各擋一種攻擊:
防預先算好的對照表、防一次撞多人,把每次猜測的單價拉高。
→ 擋對照表攻擊一把整個系統共用、絕不放進資料庫的秘密金鑰。沒有它,100 萬種候選也算不出正確答案。
→ 擋離線窮舉防攻擊者沒拿到資料庫、直接對 API 一直試。超過次數就鎖定該驗證器。
→ 擋線上猜測OWASP 列了兩種做法:先雜湊再 HMAC(post-hashing,把慢雜湊結果再用 HMAC-SHA256 包一層、pepper 當金鑰),或先加 pepper 再雜湊(pre-hashing)。
前者的好處很實際:可以直接套在既有的雜湊上,不用動慢雜湊本身。(HMAC =「帶金鑰的雜湊」:同一份資料,沒有那把金鑰就算不出同樣的結果。)
代價是不好換。資料庫裡存的是 HMAC(pepper, 慢雜湊),內層拿不回來,沒有原 PIN 就算不出新的外層。
OWASP 明講:pepper「無法在不知道使用者密碼的情況下更換……需要強制所有使用者重設」。務實的做法是等使用者下次成功輸入時順手重算,並在紀錄裡記下 pepper 的 key id/版本。
pepper 本身用密碼學安全的隨機數產生器產生,至少 32 bytes。放哪裡?OWASP 說要「跟密碼資料庫分開儲存」、放在密鑰保管庫或 HSM。
NIST 說得更硬:這把密鑰必須與雜湊分開存放、應該「儲存並使用」於 HSM 或 TEE 等硬體保護區——意思是理想上連 HMAC 這一步都交給 KMS/HSM 代算,pepper 根本不進 App 的記憶體。
OWASP 也提醒:pepper 是縱深防禦,單靠它本身沒有額外的安全性質。三道要一起上,不是挑一道做。
這段一句話:三道防線分別擋三種攻擊——慢雜湊擋對照表、pepper 擋離線窮舉、次數限制擋線上猜測。少一道就破一個口。
一句話:存「版本 + 鹽 + 雜湊 + 失敗計數」,不存原文、不存 pepper。
每一列存這四樣,缺一不可:
v1 = PBKDF2-SHA256/600k + pepper#1)——NIST 建議存下所用方案與 cost factor,之後才能升級;pepper 的 key id 也記在這裡。絕對不存:PIN 原文、任何可解回原文的加密結果、pepper。
有了版本欄位,升級 work factor 就有路可走(OWASP 的做法):等使用者下次成功登入、手上有原文時,用新參數重算一次存回去。
前面講的「雜湊 + 額外資料 + 金鑰放 HSM」不只是最佳實務,在台灣的電子支付情境裡有對應的法規要求。
要求的文字大致是:使用者身分識別資料中的固定密碼,儲存時應先做不可逆運算(如雜湊);為防止用預先產製的雜湊值推測密碼,還應加密保護或加入不可得知之資料一起運算;採加密者,金鑰應存於 HSM 內並限制匯出。
對照一下就會發現,這三句話跟本文的三件事一模一樣:
| 法規的說法 | 本文的說法 |
|---|---|
| 不可逆運算 | 雜湊,不要可逆加密 |
| 加入不可得知之資料 | pepper(伺服器側密鑰) |
| 金鑰存於 HSM 並限制匯出 | pepper 不能跟資料庫放一起 |
誠實提醒(這點請你們法遵確認):這段文字原本在某部辦法的第 14 條,該辦法已於民國 112 年 4 月 25 日廢止;目前對應的是由同業公會擬訂、報主管機關核定的作業基準(我查到的是民國 113 年 10 月 24 日版,相同內容落在第 17 條)。法規會改版、條號會移動,不要直接拿我這段當合規依據——我能確定的只有「要求的實質內容」跟三道防線完全吻合。
Rfc2898DeriveBytes.Pbkdf2 這組靜態方法從 .NET 6 開始提供;string 版會用 UTF-8 轉 bytes。下面同時示範 OWASP 的「先雜湊再 HMAC」pepper 作法:
using System.Security.Cryptography;
public sealed record PinRecord(int Version, int Iterations, byte[] Salt, byte[] Hash);
public static class PinHasher
{
const int Iterations = 600_000; // OWASP:PBKDF2-HMAC-SHA256 建議 600,000 次
const int SaltSize = 16; // 128 bits(NIST 最低 32 bits,多給無妨)
const int HashSize = 32;
// pepper:系統共用的秘密金鑰(CSPRNG 產生、至少 32 bytes)。環境變數是退而求其次;
// 理想是連下面的 HMAC 都改呼叫 KMS / HSM 的 MAC 操作。絕不進資料庫。
static byte[] Pepper => Convert.FromBase64String(
Environment.GetEnvironmentVariable("PIN_PEPPER") ?? throw new InvalidOperationException("PIN_PEPPER 未設定"));
public static PinRecord Enroll(string pin)
{
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
return new PinRecord(Version: 1, Iterations, salt, Derive(pin, salt, Iterations));
}
public static bool Verify(string pin, PinRecord rec)
{
byte[] candidate = Derive(pin, rec.Salt, rec.Iterations);
// 固定時間比較,避免用回應時間差猜出前幾個 byte
return CryptographicOperations.FixedTimeEquals(candidate, rec.Hash);
}
static byte[] Derive(string pin, byte[] salt, int iterations)
{
byte[] slow = Rfc2898DeriveBytes.Pbkdf2(pin, salt, iterations, HashAlgorithmName.SHA256, HashSize);
return HMACSHA256.HashData(Pepper, slow); // post-hashing:pepper 當 HMAC 金鑰
}
}
內建沒有 Argon2id,可用 NuGet 套件 Konscious.Security.Cryptography.Argon2(MemorySize 單位是 KiB)。把 Derive 裡的 PBKDF2 那行換掉即可,但 PinRecord 要能裝下 m/t/p 三個參數(或用 Version 對應一組固定參數):
using Konscious.Security.Cryptography;
using System.Text;
static byte[] Argon2idHash(string pin, byte[] salt)
{
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pin))
{
Salt = salt,
DegreeOfParallelism = 1, // p=1
Iterations = 2, // t=2
MemorySize = 19 * 1024 // 19456 KiB = 19 MiB(OWASP 最低建議)
};
return argon2.GetBytes(32);
}
(套件我只查了 README 的 API 形狀,沒有實際跑過效能;上線前請自己量一次每次雜湊的耗時。)
這段一句話:版本欄位是給未來的自己留的後路——沒有它,你之後想升級參數或換 pepper 都動不了。
使用者記在腦裡的秘密要「壞掉」再存,而且要三道防線一起上。慢雜湊擋對照表、pepper 擋離線窮舉、次數限制擋線上猜測——因為 6 位數只有 100 萬種、九宮格只有 389,112 種,單靠演算法選得好是救不了的。
Rfc2898DeriveBytes.Pbkdf2(.NET 6+ 的一次性 API 與支援的雜湊演算法)