💡 先搞懂問題
虛構的「晴空咖啡」會員系統有一份每晚自動產生的資料庫備份,某天發現這份備份被放在一個沒有設權限的雲端資料夾裡好幾個月。事後檢討時,大家最關心的問題是:會員的密碼還安全嗎?答案完全取決於當初怎麼存。如果存的是明文,所有人的密碼當下就曝光了,而且很多人在別的網站用同一組密碼,攻擊者會拿去其他服務逐一嘗試登入,這叫撞庫(credential stuffing)。如果當初「加密」存放,金鑰往往就在同一台伺服器或同一份設定裡,一起外洩的機率很高。
新手常見的下一個想法是「改存 SHA-256,雜湊不能還原,應該就安全了」。問題在於攻擊者根本不需要還原:他手上有一份常見密碼清單,對每個候選密碼算一次 SHA-256,再拿去和外洩的值比對,對上了就知道原密碼。這種在自己電腦上慢慢試、不會觸發任何登入限制的方式叫離線猜測(offline guessing)。SHA-256 的設計目標是快,這對檔案完整性是優點,對存密碼卻是缺點,顯示卡每秒能算的次數非常可觀。再加上沒有加鹽時,兩位會員用了同一個密碼,存下來的值就一模一樣,攻擊者猜中一個就等於猜中一群,甚至可以事先把常見密碼的雜湊全部算好,外洩後直接查表。
所以存密碼要同時解決兩件事:讓每一次猜測都變貴,以及讓攻擊者無法「一次猜、多人中」或事先算好。前者靠刻意變慢、甚至刻意吃記憶體的密碼雜湊(password hashing),後者靠每個密碼搭配一段隨機的鹽(salt)。這兩件事現代的密碼雜湊函式都已經內建,開發者真正要做的,是選對函式、用對 API,並把登入流程的其他環節一起顧好。
生活比喻:一整櫃被搬走的保險箱
想像晴空咖啡把每位會員的貴重物品各放在一個小保險箱裡,結果有天整櫃保險箱被小偷搬回家。小偷在自己家裡要試多久都沒人管,所以店門口的保全、試錯三次就鎖住的規則,這時完全派不上用場。如果保險箱用的是按一下就回應的電子鎖,小偷一秒可以試上萬組號碼;如果每試一次都必須轉一圈很重的轉盤,還得先在桌上攤開一大張對照圖才能轉,那麼主人一天開一次感覺不到差別,小偷想試一百萬組卻要花上很久,而且家裡的桌子有限,沒辦法同時攤開幾百張圖一起試。
另一個細節是轉盤的刻度。如果每個保險箱的刻度排列都一樣,小偷只要做一張「常見號碼對應的轉法」對照表,就能拿著同一張表開所有箱子;如果每個箱子出廠時刻度都隨機打亂、排列方式就貼在箱子背面,小偷就得一個箱子一個箱子重新算。更謹慎的店家還會規定,開箱除了轉盤號碼,還要插上一把放在總公司保管庫的鑰匙,這把鑰匙沒有跟著櫃子被搬走。
time_cost、bcrypt 的 rounds、PBKDF2 的迭代次數;必須先攤開的大張對照圖是 Argon2id 的 memory_cost,也就是記憶體困難(memory-hard)的設計。每個箱子隨機打亂、排列方式貼在背面的刻度就是鹽:PasswordHasher().hash() 每次都自動產生新的鹽,並把它寫在結果字串裡,跟雜湊值存在一起。放在總公司保管庫的那把鑰匙則是 pepper,存在資料庫以外的地方。
比喻有三個地方要修正。第一,真實的轉盤再重,小偷也只能一個人慢慢轉;攻擊者卻能租用大量顯示卡平行運算,所以「慢」要慢到乘上平行數量之後仍然昂貴,這正是記憶體困難設計要對付的。第二,保險箱的刻度貼在背面看似洩密,但鹽本來就不需要保密,它的工作是讓每筆紀錄都得分開計算,而不是藏住什麼。第三,比喻裡小偷總要轉到「對的號碼」才打得開,現實中攻擊者會優先試最常見的密碼,所以使用者如果選了 123456,再好的密碼雜湊也只能拖延極短的時間;禁止常見與已外洩的密碼、多因素驗證,仍然是必要的另一半。
🎮 互動實驗室一:同一個密碼,兩筆紀錄
兩位虛構會員林小晴與陳大川剛好都用了 sunshine2024。先在「無 salt」模式看資料庫裡存的是什麼,再切到「有 salt」,看同一個密碼怎麼變成兩筆不同的紀錄。你可以改任何一人的密碼,或按「重新註冊」換一組新的鹽。下方的「預先算好的對照表」模擬攻擊者事先準備的常見密碼雜湊,存的值一旦命中就會標紅。這裡用瀏覽器的 SHA-256 示意,只是為了讓你看清楚鹽的效果;真實系統請用 Argon2id,它會自動加鹽,而且刻意變慢。
晴林小晴
川陳大川
攻擊者預先算好的對照表(未加鹽的 SHA-256,示意 5 筆)
| 常見密碼 | SHA-256(前 24 字元) |
|---|
🎮 互動實驗室二:猜測成本計算器
假設攻擊者拿到外洩的雜湊,要把某個密碼的所有可能組合試一遍。選擇密碼使用的字元集、拉動長度,再選攻擊者每秒能試幾次,右邊會算出組合數與平均要試多久(平均只要試一半)。三種速度是示意的量級,用來比較「快速雜湊、慢雜湊、線上登入加上速率限制」的差距,不是實測值;實際速度依硬體、演算法參數而定,可以用滑桿自己調整。這裡假設密碼是隨機產生的,人自己想的密碼通常規律得多,會被字典清單更快試到。
一、密碼長什麼樣子
二、攻擊者每秒能試幾次(示意)
三、結果
同一個密碼,三種情境並排(對數刻度)
🎮 互動實驗室三:程式判斷卡
每張卡是登入、註冊或重設密碼流程裡的一小段 Python,請判斷它最主要的問題;有幾張其實寫得沒問題,要選「這段寫法沒問題」。答完會說明原因並附上安全寫法。卡片裡用到的 argon2-cffi、bcrypt、hashlib 與 secrets 行為,都用 python3 實際執行核對過(argon2-cffi 25.1、bcrypt 5.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=1 | hashlib.scrypt |
| Argon2id | 否 | 否 | 可調,吃運算與記憶體 | 首選;至少 m=19 MiB、t=2、p=1(或等效組合) | argon2.PasswordHasher |
表格裡「SHA-256 加鹽」那一列最容易被誤選:鹽確實讓相同密碼存成不同值、讓預先算好的表失效,但攻擊者對每一筆紀錄仍然能用極高的速度逐一猜測。鹽解決「一次猜、多人中」,慢雜湊解決「每次猜都很便宜」,兩者缺一不可。
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 格式的字串,把演算法、版本、參數、鹽和雜湊值全部寫在一起,資料庫只需要一個欄位。
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 也用同樣的思路:保留舊的驗證路徑,登入成功時改存新格式。
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 另外要求每一次計算都佔用一大塊記憶體,平行跑一萬份就要一萬份記憶體,攻擊者無法單靠堆核心數來加速,這就是記憶體困難設計。
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 外洩後很難輪替,因為你沒有使用者的原密碼可以重算,通常只能等使用者下次登入時更新或要求重設密碼。
登入流程的其他環節
密碼雜湊保護的是「資料庫外洩之後」。在那之前,攻擊者更常直接對登入頁下手,這時要靠登入流程本身。第一,失敗訊息不要洩漏帳號是否存在:「此帳號不存在」和「密碼錯誤」分開顯示,等於替攻擊者確認哪些帳號值得繼續試,統一回「帳號或密碼錯誤」即可,連回應時間也盡量一致(範例裡對不存在的帳號也算一次雜湊,就是這個用意)。第二,限制嘗試頻率:依帳號與來源 IP 計數、逐步拉長等待時間,或在可疑時要求額外驗證;直接永久鎖帳號會讓攻擊者能故意把別人鎖住,要權衡。第三,提供多因素驗證,並在設定密碼時擋掉常見或已知外洩的密碼。這些措施對應的是實驗室二的第三根長條,它們和慢雜湊分工,不能互相取代。
重設密碼的 token 用 secrets
忘記密碼流程會寄出一個重設連結,連結裡的 token 等於一把臨時鑰匙。用 random.randint 或 random.choice 產生是常見錯誤:random 模組是為模擬設計的偽亂數,觀察到足夠多的輸出就能推算後續的值。安全用途要用 secrets 模組,例如 secrets.token_urlsafe(32) 產生 32 位元組、約 43 個字元的 token。token 也要有短的有效期限、只能使用一次,資料庫裡最好只存它的 SHA-256:因為 token 本身是 256 位元的隨機值,不像人選的密碼可以被字典猜中,所以這裡用快速雜湊就足夠,這也是和存密碼不同的地方。
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 可被預測,不能拿來產生重設碼
判斷步驟
- 確認存的是「密碼」:只需要驗證、不需要讀回,就不要用任何可逆加密。
- 選函式:新系統用 Argon2id(argon2-cffi 的
PasswordHasher);不能用時選 scrypt;既有系統的 bcrypt 維持 cost 10 以上並注意 72 位元組;需要 FIPS 用 PBKDF2-HMAC-SHA256 至少 600,000 次。 - 確認鹽:每筆紀錄各自隨機產生,和雜湊存在一起;不要全系統共用同一段鹽,也不要拿帳號名稱當鹽。
- 寫驗證:用
verify或checkpw,不要自己重算再用 == 比;argon2-cffi 失敗會丟例外,所有例外都導向登入失敗;成功後用check_needs_rehash升級參數。 - 補齊流程:統一的失敗訊息、速率限制、多因素驗證、擋常見密碼;重設 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