# 6 位數 PIN 與九宮格圖形，到底該怎麼存

Chance Lu

一支支付 App 裡，使用者可能用 **6 位數付款密碼**、**九宮格圖形**，或 **指紋／臉** 來確認付款。這三種東西很容易被當成「三種密碼」，一起丟進同一張表用同一種方式存——那是一個很貴的誤會。

💣

假設那張表是**可逆**的：有金鑰就能解回原文。只要資料庫和金鑰有一次同時外洩，所有人的付款密碼就是明文。更冤的是——**你根本不需要解得回來**。

🎯

讀完這篇，你要能用自己的話回答：PIN／圖形為什麼跟生物辨識是**兩類東西**、雜湊與鹽與「慢」各在防什麼、為什麼 6 位數光靠慢雜湊救不了、以及那張表裡到底該存哪幾欄。

## ✎就愛考你

這幾題就是本文的重點，也是讀完你該能**用自己的話**答出來的問題。先想想現在答不答得出來，然後**帶著它們往下讀**——遇到答案所在的段落就特別留意；讀完再回來點「看答案」對照。

1. 6 位數 PIN 已經加了鹽、也用 Argon2id 慢雜湊了，為什麼資料庫一外洩還是撐不了多久？
    
**因為 6 位數字只有 100 萬種可能，攻擊者把 100 萬種全算一遍就好，慢雜湊只是讓「全算一遍」從幾秒變幾小時。**
    - 鹽讓攻擊者**不能一次撞很多人**，但對「針對某一個人」的窮舉完全沒幫助。
    - 所以還需要**伺服器側密鑰（pepper）**，以及**錯誤次數限制**。
2. 鹽（salt）到底防的是什麼？它防不了什麼？
    
**防「一份算好的對照表打全部人」，防不了「針對單一帳號慢慢猜」。**
    - 每筆密碼配一個**不同的隨機值**，同樣的 PIN 在不同帳號會算出不同雜湊。
    - 攻擊者因此不能先算好「123456 → 某雜湊」再掃全表，也看不出哪兩個人用同一組 PIN。
    - 鹽**不是秘密**，就跟雜湊一起放在同一列。
3. pepper 跟 salt 差在哪？為什麼 pepper 不能跟雜湊放同一張表？
    
**salt 是每筆不同、公開放在表裡的隨機值；pepper 是整個系統共用、絕對不能跟表放一起的秘密金鑰。**
    - pepper 的價值全在「表被偷了但 pepper 沒被偷」——所以要放進 **HSM／KMS／密鑰保管庫**，跟資料庫分開。
    - NIST 明講：只要這把密鑰還是秘密，對雜湊表的暴力破解就變得不切實際。
4. 綜合題：舊表是「可逆存」的，現在要重新設計，PIN 和圖形怎麼搬？
    
**解回原文 → 加鹽慢雜湊 ＋ pepper 存回去，補上失敗計數與鎖定，最後把舊欄位和舊金鑰銷毀。**
    - 正因為舊表可逆，PIN／圖形**可以批次轉換**，不用逼使用者重設。
    - 每列都要存**演算法與參數版本**（含 pepper 的 key id），之後才升級、輪替得了。

## 1先分家：這三把「密碼」其實是兩種東西

**一句話：PIN 和圖形是「使用者記在腦裡的秘密」，指紋和臉不是——後者伺服器根本拿不到。**

這個分法是後面所有結論的骨架，先看清楚：

🧠

    **比對秘密（PIN、九宮格）**
    
使用者背在腦裡、每次輸入給你比對。圖形密碼本質上就是「經過哪幾個點、什麼順序」的一串資料，跟一串數字沒兩樣。

🔑

    **證明你握有鑰匙（指紋、臉）**
    
手機的安全晶片幫使用者保管一把私鑰，指紋只是「解鎖那把私鑰」的開關。伺服器驗的是簽章，不是指紋。

所以存法天差地遠：第一類要把秘密「壞掉」再存；第二類**在伺服器這一側根本沒有秘密可存**，只存一把公鑰。這篇只講第一類，生物辨識那一側留給系列的第二篇。

💡

這段一句話：分不清「比對秘密」和「證明你握有鑰匙」，就會把三種憑證塞進同一張表——**錯的不是欄位，是分類**。

## 2「可逆存」為什麼是災難

**一句話：可逆＝有金鑰就能解回原文，而你根本不需要這個能力。**

驗證 PIN 時，你要回答的問題只有一個：「使用者這次輸入的，跟他當初設的是不是同一個？」

這件事用**單向**的雜湊就能做到，不需要知道原文。**存得比需要的多，就是多背一個風險。**

🔓

**誤解一：「金鑰有好好保管就沒事」。**問題是資料庫和金鑰只要有**一次**同時外洩，或某個內部角色同時拿得到，所有人的付款密碼就一起裸奔。

📜

**誤解二：「反正沒規定不能這樣存」。**NIST SP 800-63B 講得很直白：驗證方**必須**把密碼存成「能抵抗離線攻擊」的形式，也就是加鹽並用密碼雜湊方案處理。

💡

這段一句話：**驗證只需要知道「對不對」，不需要知道「是什麼」。**能單向就別可逆。

## 3三個詞一次拆開：雜湊、鹽、慢

**一句話：hash 讓它壞掉、salt 讓每筆不一樣、慢讓每次猜都很貴。**

**雜湊（hash）**
一個單向函數。丟 PIN 進去吐出一串固定長度的亂碼，但從亂碼算不回 PIN。驗證時用同一個函數再算一次，比對兩串亂碼。

**鹽（salt）**
每筆密碼配一個**不同的隨機值**，跟 PIN 一起丟進雜湊。兩個人都設 `123456`，存下來也不一樣。鹽不是秘密，直接跟雜湊放同一列。

**慢（cost factor）**
一般雜湊（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 一樣**加鹽慢雜湊。

（序列化這一步是實作上的常見做法，不是任何規範規定的格式；重點是規則要固定、不可歧義。）

💡

這段一句話：這三個詞分別在賣三件事——**不可逆、每筆不同、每次很貴**。少了任何一個都有明顯的破口。

## 4三道防線：為什麼光靠慢雜湊救不了 6 位數

**一句話：慢雜湊是在賣「單價」，但 PIN 的「數量」太少，總價還是便宜。**

先算數。這兩個數字決定了一切：

🔢

    **6 位數字＝1,000,000 種**
    
10^6^。對比之下，一組 8 碼英數隨機密碼有 10^14^ 級的組合——PIN 少了**一億倍**。

⌗

    **九宮格＝389,112 種**
    
比 PIN 還少。研究還發現使用者實際選的圖形集中在極少數幾種，**比理論值更好猜**。

（九宮格畫直線跳過一個還沒連的點時，中間那點會被自動連進去，所以很多看似不同的畫法其實是同一個圖形，合法組合因此大幅減少。）

把數字代進去：假設每次 Argon2id 要 50 毫秒，100 萬次 × 50 ms ≈ **14 小時單核**，開 8 核就是不到 2 小時。

（50 ms 是量級估算，實際看你的硬體，我沒有實測。）而且鹽在這裡**幫不上忙**——鹽只防「一次撞多人」，攻擊者針對某一個帳號慢慢算，鹽擋不住。

所以不能只靠一道。要三道一起上，各擋一種攻擊：

① 加鹽慢雜湊
    
防預先算好的對照表、防一次撞多人，把每次猜測的單價拉高。

    → 擋對照表攻擊

② pepper（伺服器側密鑰）
    
一把整個系統共用、**絕不放進資料庫**的秘密金鑰。沒有它，100 萬種候選也算不出正確答案。

    → 擋離線窮舉

③ 錯誤次數限制
    
防攻擊者沒拿到資料庫、直接對 API 一直試。超過次數就鎖定該驗證器。

    → 擋線上猜測

### pepper 怎麼加、代價是什麼

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 是**縱深防禦**，單靠它本身沒有額外的安全性質。三道要一起上，不是挑一道做。

### 次數限制要設多少

- NIST 的硬規定：同一帳號同一驗證器的**連續失敗次數不得超過 100 次**，超過就停用該驗證器；並明說 100 是上限、可以更低。
- 另一個可參考的數字：NIST 對裝置端「活化密碼」（解鎖驗證器用的 PIN）要求連續失敗**不超過 10 次**。
- 支付情境大可設得更嚴，例如 **5 次就鎖**並走身分重驗；也可以加「失敗越多、等待越久」的漸進延遲，NIST 也列為建議手段。

💡

這段一句話：三道防線分別擋三種攻擊——**慢雜湊擋對照表、pepper 擋離線窮舉、次數限制擋線上猜測**。少一道就破一個口。

## 5那張表裡到底要存什麼

**一句話：存「版本 ＋ 鹽 ＋ 雜湊 ＋ 失敗計數」，不存原文、不存 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 條**）。法規會改版、條號會移動，**不要直接拿我這段當合規依據**——我能確定的只有「要求的實質內容」跟三道防線完全吻合。

想看程式碼？.NET 的 PBKDF2 ＋ pepper 範例（也附 Argon2id 版）

`Rfc2898DeriveBytes.Pbkdf2` 這組靜態方法從 **.NET 6** 開始提供；`string` 版會用 UTF-8 轉 bytes。下面同時示範 OWASP 的「先雜湊再 HMAC」pepper 作法：

```csharp
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` 對應一組固定參數）：

```csharp
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 種，單靠演算法選得好是救不了的。

## 🔗這系列的另外兩篇

[系列 2／3**生物辨識：為什麼伺服器只存一把公鑰** — 印章與印鑑卡的比喻、challenge 在防什麼、FIDO2 是機制而 WebAuthn 只是其中一種介面。](https://docs.smartech.tw/n/生物辨識-為什麼伺服器只存一把公鑰)
  [系列 3／3**「簽得出來就等於刷過臉」嗎？這條推論的前提** — 兩個旗標、attestation、加一張臉金鑰就死，以及兩個平台的差異。](https://docs.smartech.tw/n/簽得出來就等於刷過臉嗎-這條推論的前提)

## 📚參考來源

- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)（Argon2id／scrypt／bcrypt／PBKDF2 參數、salt 與 pepper 定義、post-hashing、work factor 升級策略）
- [NIST SP 800-63B rev.4（2025-08）](https://pages.nist.gov/800-63-4/sp800-63b.html)（§3.1.1.2 密碼儲存：鹽 ≥ 32 bits、keyed hash 密鑰分開存放並放 HSM／TEE；§3.2.2 速率限制 ≤ 100 次、活化密碼 ≤ 10 次）
- [Microsoft Learn — `Rfc2898DeriveBytes.Pbkdf2`](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.rfc2898derivebytes.pbkdf2)（.NET 6+ 的一次性 API 與支援的雜湊演算法）
- [Konscious.Security.Cryptography](https://github.com/kmaragon/Konscious.Security.Cryptography)（Argon2id 的 .NET 套件 README；僅查 API 形狀，未實測效能）
- [Uellenbeck 等，“Quantifying the security of graphical passwords: the case of Android unlock patterns”, ACM CCS 2013](https://dblp.org/rec/conf/ccs/UellenbeckDWH13.html)（389,112 種合法圖形、使用者選擇高度集中；論文全文未抓到，該數字另以程式列舉驗證過）
- [全國法規資料庫 — 電子支付機構資訊系統標準及安全控管作業基準辦法 §14](https://law.moj.gov.tw/LawClass/LawSingle.aspx?pcode=G0380243&amp;flno=14)（已於民國 112-04-25 廢止）；現行版本：[公會擬訂之作業基準（113-10-24 版，相同內容在 §17）](https://www.rootlaw.com.tw/LawArticle.aspx?LawID=A040390041071500-1131024)（**條號與版本請以法遵確認為準**）
