🗺️ 程式互動式地圖
資安線・密碼學函式庫

密碼要怎麼存:salt、慢雜湊與 argon2

系統其實不需要知道使用者的密碼,只需要能確認「這次輸入的對不對」。這一頁從資料庫外洩那一刻往回推,說明為什麼要加鹽、為什麼要刻意變慢,以及在 Python 裡該怎麼寫。

salt 實驗:同一個密碼 猜測成本計算器(示意) 程式判斷卡:13 張

💡 先搞懂問題

虛構的「晴空咖啡」會員系統有一份每晚自動產生的資料庫備份,某天發現這份備份被放在一個沒有設權限的雲端資料夾裡好幾個月。事後檢討時,大家最關心的問題是:會員的密碼還安全嗎?答案完全取決於當初怎麼存。如果存的是明文,所有人的密碼當下就曝光了,而且很多人在別的網站用同一組密碼,攻擊者會拿去其他服務逐一嘗試登入,這叫撞庫(credential stuffing)。如果當初「加密」存放,金鑰往往就在同一台伺服器或同一份設定裡,一起外洩的機率很高。

新手常見的下一個想法是「改存 SHA-256,雜湊不能還原,應該就安全了」。問題在於攻擊者根本不需要還原:他手上有一份常見密碼清單,對每個候選密碼算一次 SHA-256,再拿去和外洩的值比對,對上了就知道原密碼。這種在自己電腦上慢慢試、不會觸發任何登入限制的方式叫離線猜測(offline guessing)。SHA-256 的設計目標是快,這對檔案完整性是優點,對存密碼卻是缺點,顯示卡每秒能算的次數非常可觀。再加上沒有加鹽時,兩位會員用了同一個密碼,存下來的值就一模一樣,攻擊者猜中一個就等於猜中一群,甚至可以事先把常見密碼的雜湊全部算好,外洩後直接查表。

備份檔外洩 存明文 小晴 sunshine2024 大川 sunshine2024 當下全部曝光 接著被拿去撞庫 (可逆加密而金鑰 一起外洩也一樣) 存 SHA-256(無鹽) 小晴 a353b0de… 大川 a353b0de… 相同密碼值相同 離線猜測非常快 常見密碼可直接查表 存 Argon2id 小晴 $argon2id$…Xk 大川 $argon2id$…pQ 每筆的鹽都不同 每猜一次都要花 時間與記憶體 弱密碼仍有風險
三欄是同一份備份外洩時的三種結局(雜湊值只顯示開頭,為示意)。中間那欄的兩筆值一模一樣,這就是沒有鹽的代價;右邊那欄即使密碼相同也存成不同字串。注意右下角那句:慢雜湊只是讓猜測變貴,太常見的密碼仍然可能在清單前幾名就被試中。

所以存密碼要同時解決兩件事:讓每一次猜測都變貴,以及讓攻擊者無法「一次猜、多人中」或事先算好。前者靠刻意變慢、甚至刻意吃記憶體的密碼雜湊(password hashing),後者靠每個密碼搭配一段隨機的鹽(salt)。這兩件事現代的密碼雜湊函式都已經內建,開發者真正要做的,是選對函式、用對 API,並把登入流程的其他環節一起顧好。

生活比喻:一整櫃被搬走的保險箱

想像晴空咖啡把每位會員的貴重物品各放在一個小保險箱裡,結果有天整櫃保險箱被小偷搬回家。小偷在自己家裡要試多久都沒人管,所以店門口的保全、試錯三次就鎖住的規則,這時完全派不上用場。如果保險箱用的是按一下就回應的電子鎖,小偷一秒可以試上萬組號碼;如果每試一次都必須轉一圈很重的轉盤,還得先在桌上攤開一大張對照圖才能轉,那麼主人一天開一次感覺不到差別,小偷想試一百萬組卻要花上很久,而且家裡的桌子有限,沒辦法同時攤開幾百張圖一起試。

另一個細節是轉盤的刻度。如果每個保險箱的刻度排列都一樣,小偷只要做一張「常見號碼對應的轉法」對照表,就能拿著同一張表開所有箱子;如果每個箱子出廠時刻度都隨機打亂、排列方式就貼在箱子背面,小偷就得一個箱子一個箱子重新算。更謹慎的店家還會規定,開箱除了轉盤號碼,還要插上一把放在總公司保管庫的鑰匙,這把鑰匙沒有跟著櫃子被搬走。

回到程式:小偷把整櫃搬回家,對應的是資料庫外洩後的離線猜測,登入次數限制管不到它。很重的轉盤是工作因子(work factor):Argon2id 的 time_cost、bcrypt 的 rounds、PBKDF2 的迭代次數;必須先攤開的大張對照圖是 Argon2id 的 memory_cost,也就是記憶體困難(memory-hard)的設計。每個箱子隨機打亂、排列方式貼在背面的刻度就是鹽:PasswordHasher().hash() 每次都自動產生新的鹽,並把它寫在結果字串裡,跟雜湊值存在一起。放在總公司保管庫的那把鑰匙則是 pepper,存在資料庫以外的地方。
保險箱(比喻) 密碼儲存的設計 整櫃搬回家慢慢試 外洩後的離線猜測 每試一次轉一圈重轉盤 工作因子:time_cost、rounds 先攤開大張對照圖 記憶體困難:memory_cost 每箱刻度隨機排列 鹽:每筆不同、和雜湊存一起 總公司保管庫的鑰匙 pepper:存在資料庫以外
左欄是比喻,右欄是對應的設計。前四列都是密碼雜湊函式內建的功能,開發者只要選對函式;最後一列的 pepper 是額外的一層,需要自己規劃金鑰存放位置。

比喻有三個地方要修正。第一,真實的轉盤再重,小偷也只能一個人慢慢轉;攻擊者卻能租用大量顯示卡平行運算,所以「慢」要慢到乘上平行數量之後仍然昂貴,這正是記憶體困難設計要對付的。第二,保險箱的刻度貼在背面看似洩密,但鹽本來就不需要保密,它的工作是讓每筆紀錄都得分開計算,而不是藏住什麼。第三,比喻裡小偷總要轉到「對的號碼」才打得開,現實中攻擊者會優先試最常見的密碼,所以使用者如果選了 123456,再好的密碼雜湊也只能拖延極短的時間;禁止常見與已外洩的密碼、多因素驗證,仍然是必要的另一半。

🎮 互動實驗室一:同一個密碼,兩筆紀錄

兩位虛構會員林小晴與陳大川剛好都用了 sunshine2024。先在「無 salt」模式看資料庫裡存的是什麼,再切到「有 salt」,看同一個密碼怎麼變成兩筆不同的紀錄。你可以改任何一人的密碼,或按「重新註冊」換一組新的鹽。下方的「預先算好的對照表」模擬攻擊者事先準備的常見密碼雜湊,存的值一旦命中就會標紅。這裡用瀏覽器的 SHA-256 示意,只是為了讓你看清楚鹽的效果;真實系統請用 Argon2id,它會自動加鹽,而且刻意變慢。

晴林小晴

鹽(16 位元組,每人隨機)
資料庫裡存的值

川陳大川

鹽(16 位元組,每人隨機)
資料庫裡存的值

攻擊者預先算好的對照表(未加鹽的 SHA-256,示意 5 筆)

常見密碼SHA-256(前 24 字元)
畫面說明:載入中。

🎮 互動實驗室二:猜測成本計算器

假設攻擊者拿到外洩的雜湊,要把某個密碼的所有可能組合試一遍。選擇密碼使用的字元集、拉動長度,再選攻擊者每秒能試幾次,右邊會算出組合數與平均要試多久(平均只要試一半)。三種速度是示意的量級,用來比較「快速雜湊、慢雜湊、線上登入加上速率限制」的差距,不是實測值;實際速度依硬體、演算法參數而定,可以用滑桿自己調整。這裡假設密碼是隨機產生的,人自己想的密碼通常規律得多,會被字典清單更快試到。

一、密碼長什麼樣子

每多一個字元,組合數就乘上字元集大小

二、攻擊者每秒能試幾次(示意)

滑桿以 10 的次方移動,從 0.1 到 10¹² 次/秒

三、結果

組合數 = 字元集 ^ 長度
平均窮舉時間 = 組合數 ÷ 2 ÷ 每秒次數
一秒內
一天內
一年內
千年內
百萬年內
更久

同一個密碼,三種情境並排(對數刻度)

快速雜湊 10¹⁰
慢雜湊 10⁴
線上限速 0.1
畫面說明:載入中。

🎮 互動實驗室三:程式判斷卡

每張卡是登入、註冊或重設密碼流程裡的一小段 Python,請判斷它最主要的問題;有幾張其實寫得沒問題,要選「這段寫法沒問題」。答完會說明原因並附上安全寫法。卡片裡用到的 argon2-cffi、bcrypt、hashlib 與 secrets 行為,都用 python3 實際執行核對過(argon2-cffi 25.1、bcrypt 5.0)。

答對 0 / 0
連續答對 0
畫面說明:先看密碼經過什麼函式、比對用的是什麼方法。

📘 原理補完

八種存法的比較

實驗室一看的是「相同密碼會不會存成相同值」,實驗室二看的是「每次猜測要花多少」。把這兩個問題套到常見的存法上,就得到下表。參數欄依 OWASP Password Storage Cheat Sheet 的現行建議整理:新系統首選 Argon2id,無法使用時用 scrypt;bcrypt 用於既有系統;需要符合 FIPS-140 時用 PBKDF2。

存法外洩即曝光相同密碼同值每次猜測成本定位與最低參數(OWASP)Python
明文是是不用猜不可接受—
可逆加密(Fernet 等)金鑰一起外洩就是否(有 IV)有金鑰就不用猜不適合密碼:系統不需要讀回原密碼—
SHA-256 無鹽否是極低,可查表不適合hashlib.sha256
SHA-256 加鹽否否極低不適合:鹽擋查表,擋不住快速猜測同上
PBKDF2否否可調,只吃運算FIPS 情境;HMAC-SHA256 至少 600,000 次、HMAC-SHA512 至少 220,000 次hashlib.pbkdf2_hmac
bcrypt否否可調,只吃運算既有系統;cost 至少 10,輸入上限 72 位元組bcrypt.hashpw/checkpw
scrypt否否可調,吃運算與記憶體Argon2id 不可用時;N=2¹⁷、r=8、p=1hashlib.scrypt
Argon2id否否可調,吃運算與記憶體首選;至少 m=19 MiB、t=2、p=1(或等效組合)argon2.PasswordHasher

表格裡「SHA-256 加鹽」那一列最容易被誤選:鹽確實讓相同密碼存成不同值、讓預先算好的表失效,但攻擊者對每一筆紀錄仍然能用極高的速度逐一猜測。鹽解決「一次猜、多人中」,慢雜湊解決「每次猜都很便宜」,兩者缺一不可。

密碼 sunshine2024 + 小晴的鹽 8f1c2a9b… + 大川的鹽 3e70d541… 8b56d2d4… (另一串完全不同) 對照表:sunshine2024 → a353b0de… 表裡是沒加鹽的值,兩筆都查不到
中間兩欄各自把鹽接在密碼前面再算雜湊(數值為示意,左邊那串用 python3 的 hashlib 依實驗室同一算法核對過)。下方紅框是攻擊者事先準備的表,裡面只有沒加鹽的值,所以兩筆都對不上;他必須針對每一筆紀錄的鹽重新計算。

argon2-cffi:鹽與參數都寫在字串裡

Python 用 Argon2 最常見的套件是 argon2-cffi。PasswordHasher() 預設使用 Argon2id,參數採 RFC 9106 的低記憶體建議:time_cost=3、memory_cost=65536(KiB,也就是 64 MiB)、parallelism=4,比 OWASP 的最低建議更嚴格。hash(password) 每次都會用作業系統的安全亂數產生 16 位元組的新鹽,所以同一個密碼呼叫兩次會得到兩個不同字串;結果是 PHC 格式的字串,把演算法、版本、參數、鹽和雜湊值全部寫在一起,資料庫只需要一個欄位。

argon2id v=19 m=65536,t=3,p=4 BHjxjXMO… ziLvGu0P… 演算法 版本 記憶體 KiB、次數、平行度 鹽 16 位元組 雜湊 32 位元組 Base64 約 22 字元 Base64 約 43 字元 每段之間用 $ 分隔,整串約 97 個字元,存在同一個欄位 verify() 從字串讀出鹽與參數重算,所以舊參數的紀錄照樣能驗證 check_needs_rehash() 比較字串裡的參數和目前設定,判斷要不要升級
這是 python3 實際執行 PasswordHasher().hash() 得到的一串結果(鹽與雜湊截短顯示)。鹽就躺在字串中間,完全公開,這是設計上的刻意安排:驗證時一定要用同一段鹽重算,而鹽的價值在於「每筆不同」,不在於保密。

驗證時用 verify(hash, password),順序是先放存好的雜湊、再放使用者輸入的密碼。它成功時回傳 True,失敗時不是回傳 False,而是丟出例外:密碼不符丟 VerifyMismatchError,其他驗證失敗丟它的父類別 VerificationError,存的字串根本不是合法的 Argon2 雜湊時丟 InvalidHashError。所以登入程式一定要用 try/except 包起來,而且任何例外都要導向「登入失敗」,不能吞掉之後繼續放行。千萬不要寫成 ph.hash(輸入) == 存的值,因為每次 hash() 都產生新的鹽,這個比較永遠是 False。

check_needs_rehash(hash) 用來升級工作因子。硬體每年變快,參數也該逐步調高;但你拿不到使用者的原密碼,無法一次把整個資料庫重算。做法是在使用者登入成功、手上剛好有正確密碼的那一刻,檢查字串裡的參數是否落後目前設定,落後就重新 hash() 並寫回資料庫。從 bcrypt 或 PBKDF2 遷移到 Argon2id 也用同樣的思路:保留舊的驗證路徑,登入成功時改存新格式。

註冊 使用者輸入新密碼 ph.hash(password) 存入 $argon2id$… 字串 登入 取出該帳號存的字串 ph.verify(stored, password) 丟例外 登入失敗一律同一句錯誤訊息 True check_needs_rehash? 需要 重新 hash 寫回然後登入成功 不需要 直接成功
左欄是註冊,只有一個步驟。右欄是登入:verify 失敗會丟例外(紅色),成功後才檢查參數要不要升級(綠色)。注意紅色那條:不論是密碼錯、帳號不存在,還是雜湊字串損壞,都走到同一個「登入失敗」。
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError

ph = PasswordHasher()                    # 預設 Argon2id:t=3、m=64 MiB、p=4(RFC 9106 低記憶體建議)
db = {}                                  # 示範用的「資料庫」:帳號 → 雜湊字串

def register(user: str, password: str) -> None:
    db[user] = ph.hash(password)         # 自動產生隨機鹽;演算法、參數、鹽都寫在字串裡

def login(user: str, password: str) -> bool:
    stored = db.get(user)
    if stored is None:
        ph.hash(password)                # 帳號不存在也算一次,讓回應時間差不多
        return False
    try:
        ph.verify(stored, password)      # 成功回傳 True;失敗丟例外,不是回傳 False
    except VerifyMismatchError:
        return False                     # 密碼不符
    except (VerificationError, InvalidHashError):
        return False                     # 其他驗證失敗或雜湊格式損壞,一律拒絕
    if ph.check_needs_rehash(stored):    # 目前設定比字串裡的參數新 → 趁現在升級
        db[user] = ph.hash(password)
    return True

register("lin", "correct horse battery staple")
print(db["lin"].split("$")[1:4])          # ['argon2id', 'v=19', 'm=65536,t=3,p=4']
print(login("lin", "wrong"), login("lin", "correct horse battery staple"), login("nobody", "x"))
# 輸出:False True False

為什麼要慢,而且要吃記憶體

密碼雜湊的「慢」是可以調整的工作因子。對網站來說,一次登入花幾十到幾百毫秒,使用者幾乎感覺不到;對攻擊者來說,每個候選密碼都要付同樣的代價,速度和快速雜湊比起來會差上好幾個數量級。實驗室二的三根長條用對數刻度畫,就是因為差距大到線性刻度畫不下。不過只靠運算次數還不夠:顯示卡和專用晶片擅長同時跑成千上萬份「只吃運算」的計算,PBKDF2 與 bcrypt 都屬於這一類。Argon2id 和 scrypt 另外要求每一次計算都佔用一大塊記憶體,平行跑一萬份就要一萬份記憶體,攻擊者無法單靠堆核心數來加速,這就是記憶體困難設計。

8 個隨機小寫字母:26⁸ ≈ 2.09 × 10¹¹ 種(示意速度) 快速雜湊10¹⁰ 次/秒 約 10 秒 慢雜湊10⁴ 次/秒 約 121 天 線上限速0.1 次/秒 數萬年 長條為對數刻度;速度是比較量級用的示意值,不是實測值 人自己想的密碼常在字典清單前段,實際會快得多
同一個密碼,只因為存法不同,攻擊者窮舉的時間就從十秒左右變成好幾個月。第三根是線上猜測:攻擊者沒有拿到資料庫,只能透過登入頁一次一次試,這時速率限制和帳號保護才派得上用場。
只吃運算(PBKDF2、bcrypt) 每格=一份同時進行的猜測 核心多就能塞很多份 記憶體困難(Argon2id、scrypt) ≥ 19 MiB ≥ 19 MiB ≥ 19 MiB 記憶體用完 每份都要一大塊記憶體 能同時跑的份數被記憶體卡住 示意圖:格子數量與記憶體大小只表示相對關係
左邊每個小格是一份只需運算的猜測,晶片核心越多就能同時跑越多份;右邊每份都要先佔住一大塊記憶體,同一張卡能同時跑的份數少得多。OWASP 的 Argon2id 最低設定 19 MiB 就是每份猜測必須付出的記憶體。

bcrypt、scrypt 與 PBKDF2 的定位

bcrypt 已經存在二十多年,很多既有系統仍在使用,OWASP 建議 cost(rounds)至少 10,Python 的 bcrypt 套件 gensalt() 預設是 12。它有一個常被忽略的限制:只處理前 72 個位元組。中文在 UTF-8 裡通常每個字 3 個位元組,24 個中文字就到上限;bcrypt 4.x 以前超過的部分會被默默截掉,5.0.0 起改成直接丟 ValueError。另外它只接受位元組,傳字串會丟 TypeError。有人會先用 SHA-256 把長密碼壓短再交給 bcrypt,但 OWASP 提醒這種「預先雜湊」有空位元組截斷與雜湊重用等風險,新系統直接選 Argon2id 比較單純。

scrypt 也是記憶體困難的函式,Python 標準庫有 hashlib.scrypt,OWASP 最低建議是 N=2¹⁷、r=8、p=1(每次約需 128 MiB 記憶體,呼叫時要把 maxmem 調高)。PBKDF2 只吃運算,但它是 FIPS-140 認可的演算法,在需要合規的環境裡常是唯一選項,hashlib.pbkdf2_hmac("sha256", pw, salt, 600_000) 就是 OWASP 的最低次數。標準庫的這兩個函式不會幫你管理鹽和參數,鹽要用 os.urandom(16) 或 secrets.token_bytes(16) 產生,並和次數、結果一起存下來。

import bcrypt, hashlib, hmac, os

pw = "晴空咖啡 2024 冬季限定".encode()           # bcrypt 只吃位元組;這串是 30 位元組
if len(pw) > 72:                                  # 只處理前 72 位元組;5.0.0 起超過會丟 ValueError
    raise ValueError("密碼超過 72 位元組")
h = bcrypt.hashpw(pw, bcrypt.gensalt(rounds=12))   # cost 12;OWASP 最低建議 10
print(h[:7], len(h))                              # b'$2b$12$' 60:鹽與 cost 也寫在字串裡
print(bcrypt.checkpw(pw, h))                      # True(bcrypt 的比對回傳布林值)

# 需要 FIPS 時的標準庫寫法:鹽與次數要自己保存
salt = os.urandom(16)
iters = 600_000                                   # OWASP:PBKDF2-HMAC-SHA256 至少 600,000 次
dk = hashlib.pbkdf2_hmac("sha256", pw, salt, iters)
record = (salt.hex(), iters, dk.hex())            # 三樣都要存,驗證時才算得回來
check = hashlib.pbkdf2_hmac("sha256", pw, bytes.fromhex(record[0]), record[1])
print(hmac.compare_digest(check.hex(), record[2]))  # True:比對用固定時間比較

pepper:放在資料庫以外的那一層

鹽和雜湊存在一起,資料庫外洩時鹽也一起外洩,這沒有關係,因為鹽本來就不負責保密。pepper 則是一個全系統共用的秘密值,存在資料庫以外,例如秘密管理服務或硬體安全模組(HSM)。OWASP 描述的常見做法是先用密碼雜湊算出結果,再以 pepper 當金鑰做一次 HMAC 才存入資料庫。這樣即使攻擊者只拿到資料庫,沒有 pepper 就連開始猜都沒辦法。pepper 是「多一層」,不是取代鹽或慢雜湊;它的代價是金鑰管理:pepper 外洩後很難輪替,因為你沒有使用者的原密碼可以重算,通常只能等使用者下次登入時更新或要求重設密碼。

資料庫(含備份) 小晴|鹽 8f1c…|雜湊 … 大川|鹽 3e70…|雜湊 … 鹽不需要保密,和雜湊放一起 秘密管理服務/HSM pepper(全系統一個) 應用程式執行時才讀取 有存取紀錄與權限控管 備份檔外洩只帶走左邊 沒有 pepper 連第一個候選密碼都驗證不了
左邊的資料庫外洩時,鹽與雜湊一起被帶走;右邊的 pepper 放在另一個系統,備份檔裡沒有它。要注意 pepper 一旦外洩就很難更換,所以它要和其他金鑰一樣嚴格保管。

登入流程的其他環節

密碼雜湊保護的是「資料庫外洩之後」。在那之前,攻擊者更常直接對登入頁下手,這時要靠登入流程本身。第一,失敗訊息不要洩漏帳號是否存在:「此帳號不存在」和「密碼錯誤」分開顯示,等於替攻擊者確認哪些帳號值得繼續試,統一回「帳號或密碼錯誤」即可,連回應時間也盡量一致(範例裡對不存在的帳號也算一次雜湊,就是這個用意)。第二,限制嘗試頻率:依帳號與來源 IP 計數、逐步拉長等待時間,或在可疑時要求額外驗證;直接永久鎖帳號會讓攻擊者能故意把別人鎖住,要權衡。第三,提供多因素驗證,並在設定密碼時擋掉常見或已知外洩的密碼。這些措施對應的是實驗室二的第三根長條,它們和慢雜湊分工,不能互相取代。

登入請求 速率限制依帳號+來源計數 verify 密碼Argon2id 多因素驗證 超過次數 延遲或加驗避免永久鎖死帳號 失敗 「帳號或密碼錯誤」不區分哪一個錯 記錄事件:帳號、時間、來源、結果(不要記錄輸入的密碼) 設定密碼時另外擋掉常見與已外洩的密碼
由左到右是一次登入會經過的關卡。藍色的速率限制擋的是線上猜測,綠色的 Argon2id 保護的是外洩後的資料,兩者分工。紅框那句錯誤訊息刻意不區分帳號錯還是密碼錯,日誌裡也絕對不要寫進使用者輸入的密碼。

重設密碼的 token 用 secrets

忘記密碼流程會寄出一個重設連結,連結裡的 token 等於一把臨時鑰匙。用 random.randint 或 random.choice 產生是常見錯誤:random 模組是為模擬設計的偽亂數,觀察到足夠多的輸出就能推算後續的值。安全用途要用 secrets 模組,例如 secrets.token_urlsafe(32) 產生 32 位元組、約 43 個字元的 token。token 也要有短的有效期限、只能使用一次,資料庫裡最好只存它的 SHA-256:因為 token 本身是 256 位元的隨機值,不像人選的密碼可以被字典猜中,所以這裡用快速雜湊就足夠,這也是和存密碼不同的地方。

secrets.token_urlsafe(32)約 43 字元、不可預測 寄到會員信箱 資料庫只存 token 的 SHA-256 到期時間(例如 15 分鐘) 是否已使用 會員點連結送回 token 雜湊相符+未過期+未使用 ❌ random.randint(100000, 999999)
上排是寄送:token 由 secrets 產生,只出現在信件裡;資料庫存的是它的雜湊與兩個限制條件。右下角是驗證時三個條件都要成立。左下紅框是要避免的寫法:可預測的亂數加上只有 90 萬種可能,等於讓別人有機會猜中重設碼。
import hashlib, secrets, time

token = secrets.token_urlsafe(32)                 # 放進重設連結寄給會員(約 43 字元)
record = {"token_sha256": hashlib.sha256(token.encode()).hexdigest(),  # 資料庫只存雜湊
          "expires": time.time() + 15 * 60,       # 15 分鐘內有效
          "used": False}                          # 只能用一次

def check(t: str) -> bool:
    h = hashlib.sha256(t.encode()).hexdigest()    # 高熵 token 用快速雜湊即可,和存密碼不同
    return (secrets.compare_digest(h, record["token_sha256"])
            and time.time() < record["expires"] and not record["used"])

print(len(token), check(token), check("123456"))  # 43 True False
# ❌ random.randint(100000, 999999):random 可被預測,不能拿來產生重設碼

判斷步驟

  1. 確認存的是「密碼」:只需要驗證、不需要讀回,就不要用任何可逆加密。
  2. 選函式:新系統用 Argon2id(argon2-cffi 的 PasswordHasher);不能用時選 scrypt;既有系統的 bcrypt 維持 cost 10 以上並注意 72 位元組;需要 FIPS 用 PBKDF2-HMAC-SHA256 至少 600,000 次。
  3. 確認鹽:每筆紀錄各自隨機產生,和雜湊存在一起;不要全系統共用同一段鹽,也不要拿帳號名稱當鹽。
  4. 寫驗證:用 verify 或 checkpw,不要自己重算再用 == 比;argon2-cffi 失敗會丟例外,所有例外都導向登入失敗;成功後用 check_needs_rehash 升級參數。
  5. 補齊流程:統一的失敗訊息、速率限制、多因素驗證、擋常見密碼;重設 token 用 secrets、設期限、只能用一次。需要時再加上存在資料庫以外的 pepper。

容易寫錯或考錯的地方

「加鹽的 SHA-256 就夠了」是常見干擾選項。鹽解決的是相同密碼同值與查表,解決不了每次猜測太便宜;題目若問「外洩後最能降低密碼被還原的風險」,答案是加鹽的慢速密碼雜湊,而不是只加鹽、只換成 SHA-512,或把雜湊再加密一次但金鑰放在同一台主機。

帳號鎖定擋不住離線猜測。選項寫「登入失敗五次就鎖帳號」來回應資料庫外洩,是把線上與離線搞混了。反過來,問登入頁被大量嘗試時,答案才是速率限制與多因素驗證。

鹽不需要保密。「鹽要和雜湊分開存、加密保存」是錯的說法;需要保密、存在資料庫以外的是 pepper。

verify 的參數順序與回傳方式。argon2-cffi 是 verify(hash, password),失敗丟 VerifyMismatchError;bcrypt 是 checkpw(password, hashed),回傳 True 或 False。兩個套件的順序剛好相反,程式題常在這裡設陷阱。

hash() 每次結果都不同。ph.hash("abc") == ph.hash("abc") 是 False,所以不能用「重新雜湊再比對字串」的方式驗證密碼。

bcrypt 的 72 位元組與型別。傳字串會丟 TypeError;超過 72 位元組在 5.0.0 起丟 ValueError,舊版則默默截斷,兩段前 72 位元組相同的長密碼會被視為同一個。

secrets 的參數是位元組數。secrets.token_hex(16) 產生 32 個字元,token_urlsafe(32) 約 43 個字元;產生驗證碼、重設 token、臨時密碼都用 secrets,不用 random 或 numpy.random。

✅ 自我檢測

以下 6 題都是原創的程式閱讀題,選完會立即顯示對錯與解析,全部作答後會出現總分。程式輸出都用 python3(argon2-cffi 25.1、bcrypt 5.0)實際執行確認過。目前得分:0 / 6