密碼儲存:為什麼你永遠不應該加密密碼
Published: Sat Feb 01 2025 | Modified: Sat Feb 07 2026 , 12 minutes reading.
1. 為什麼要關心這個問題?
你正在建構一個帶有使用者帳戶的應用程式。使用者輸入密碼,你需要在資料庫中儲存某些東西以便之後驗證他們。
如果你的資料庫被洩露(假設它會被洩露),你的使用者密碼會怎樣?
糟糕的密碼儲存已經導致數十億憑證洩露。讓我們理解什麼是「好的」儲存方式。
2. 為什麼不使用加密?
加密密碼的問題
如果你加密密碼:
儲存: AES-GCM(key, "password123") → 密文
驗證: AES-GCM-decrypt(key, 密文) → "password123"
問題:
1. 你有解密金鑰
2. 任何獲得金鑰的人可以獲取所有密碼
3. 你可以看到使用者的實際密碼
4. 金鑰管理成為關鍵弱點
這是錯誤的。你永遠不應該能夠恢復密碼。我們實際需要什麼
密碼儲存的需求:
1. 驗證:可以檢查輸入的密碼是否正確
2. 單向:無法從儲存值恢復原始密碼
3. 唯一:相同密碼 → 不同的儲存值(每個使用者)
4. 慢速:計算成本高(抵抗暴力破解)
5. 面向未來:可以隨時間增加難度3. 為什麼不使用普通雜湊?
簡單的方法(非常危險)
import hashlib
# 錯誤:普通雜湊
def store_password(password):
return hashlib.sha256(password.encode()).hexdigest()
def verify_password(password, stored):
return hashlib.sha256(password.encode()).hexdigest() == stored
# 問題:相同密碼 = 相同雜湊
store_password("password123") # 總是相同的輸出!攻擊 1:彩虹表
預先計算常見密碼的雜湊:
彩虹表:
password123 → ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
123456 → 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
qwerty → 65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5
...數百萬條更多...
攻擊:在表中查找雜湊 → 立即恢復密碼攻擊 2:相同雜湊 = 相同密碼
資料庫洩露:
user1: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
user2: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
user3: 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
攻擊者看到:user1 和 user2 有相同的密碼!
破解一個,獲得兩個。4. 加鹽:每個使用者唯一
添加鹽值
import hashlib
import os
def store_password(password):
salt = os.urandom(16) # 每個使用者隨機
hash_input = salt + password.encode()
password_hash = hashlib.sha256(hash_input).hexdigest()
return salt.hex() + ":" + password_hash
def verify_password(password, stored):
salt_hex, stored_hash = stored.split(":")
salt = bytes.fromhex(salt_hex)
hash_input = salt + password.encode()
computed_hash = hashlib.sha256(hash_input).hexdigest()
return computed_hash == stored_hash
# 現在相同密碼 → 不同雜湊
print(store_password("password123")) # 每次不同!
print(store_password("password123")) # 又不同!鹽值解決了一些問題
有鹽值:
✓ 彩虹表無效(需要每個鹽值一個表)
✓ 相同密碼 → 不同儲存值
✓ 無法識別使用相同密碼的使用者
仍然有問題:
✗ SHA-256 太快了!
✗ GPU 可以每秒計算數十億次雜湊
✗ 暴力破解仍然可行5. 速度問題
現代 GPU 攻擊速度
RTX 4090 上的 Hashcat(大約):
SHA-256: 22,000,000,000 H/s(220億/秒)
MD5: 164,000,000,000 H/s
對於 8 字元小寫密碼(26^8 = 2080億):
SHA-256: 2080億 / 220億 = ~10 秒
MD5: 2080億 / 1640億 = ~1.3 秒
對於 8 字元混合大小寫 + 數字(62^8 = 218兆):
SHA-256: 218兆 / 220億 = ~2.7 小時
MD5: 218兆 / 1640億 = ~22 分鐘
這就是為什麼我們需要慢速雜湊函數!解決方案:工作因子
密碼雜湊演算法包含故意的慢速:
bcrypt: 成本因子(2^cost 次迭代)
scrypt: CPU 成本、記憶體成本、平行化
Argon2: 時間成本、記憶體成本、平行度
目標:使每次雜湊嘗試需要 ~100ms-1s
攻擊者做 10 億次嘗試現在需要 3+ 年6. bcrypt
bcrypt 如何工作
bcrypt 設計:
1. 基於 Blowfish 密碼
2. 昂貴的金鑰設置階段
3. 成本因子控制迭代次數(2^cost)
4. 內建鹽值(22 字元)
5. 輸出:60 字元
格式: $2b$cost$salt(22)hash(31)
範例: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/BoIYq6h.Cg0f3Fy/q
─┬──┬───────────────────────┬─────────────────────────────────
│ │ 鹽值 雜湊
│ └── 成本因子(12 = 2^12 = 4096 次迭代)
└── 演算法版本(2b = 現代 bcrypt)Python 中的 bcrypt
import bcrypt
def hash_password(password: str) -> str:
"""雜湊密碼用於儲存"""
# 產生鹽值和雜湊(成本因子 12 是好的預設值)
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt(rounds=12) # 2^12 = 4096 次迭代
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
def verify_password(password: str, hashed: str) -> bool:
"""驗證密碼與儲存的雜湊"""
password_bytes = password.encode('utf-8')
hashed_bytes = hashed.encode('utf-8')
return bcrypt.checkpw(password_bytes, hashed_bytes)
# 使用
stored = hash_password("my_secure_password")
print(f"儲存: {stored}")
# $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/BoIYq6h.Cg0f3Fy/q
# 驗證
print(verify_password("my_secure_password", stored)) # True
print(verify_password("wrong_password", stored)) # Falsebcrypt 的限制
bcrypt 問題:
- 72 位元組密碼限制(截斷更長的密碼)
- 固定記憶體使用(不是記憶體困難的)
- 可以用專用硬體加速
長密碼的解決方法:
def hash_long_password(password: str) -> str:
# 預雜湊以處理任意長度
import hashlib
pre_hash = hashlib.sha256(password.encode()).digest()
import base64
shortened = base64.b64encode(pre_hash)[:72]
return hash_password(shortened.decode())7. Argon2(推薦)
為什麼選擇 Argon2?
Argon2 贏得了密碼雜湊競賽(2015):
三個變體:
- Argon2d: 最大 GPU 抵抗力,易受側通道攻擊
- Argon2i: 側通道抵抗,用於密碼雜湊
- Argon2id: 混合(推薦),兩者的優點
特點:
- 記憶體困難(可配置記憶體使用)
- 時間可配置(迭代次數)
- 平行度可配置(CPU 執行緒)
- 沒有密碼長度限制Python 中的 Argon2
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# 使用推薦參數建立雜湊器
ph = PasswordHasher(
time_cost=3, # 迭代次數
memory_cost=65536, # 64 MB 記憶體
parallelism=4, # 4 個平行執行緒
hash_len=32, # 輸出長度
salt_len=16 # 鹽值長度
)
def hash_password(password: str) -> str:
"""使用 Argon2id 雜湊密碼"""
return ph.hash(password)
def verify_password(password: str, hashed: str) -> bool:
"""驗證密碼與儲存的雜湊"""
try:
ph.verify(hashed, password)
return True
except VerifyMismatchError:
return False
def needs_rehash(hashed: str) -> bool:
"""檢查雜湊是否需要用新參數更新"""
return ph.check_needs_rehash(hashed)
# 使用
stored = hash_password("my_secure_password")
print(f"儲存: {stored}")
# $argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0$hash...
print(verify_password("my_secure_password", stored)) # True
# 隨時間升級參數
if verify_password("my_secure_password", stored) and needs_rehash(stored):
new_hash = hash_password("my_secure_password")
# 在資料庫中儲存 new_hash選擇 Argon2 參數
OWASP 建議(2024):
最低:
- Argon2id
- m=19456(19 MB),t=2,p=1
推薦:
- Argon2id
- m=65536(64 MB),t=3,p=4
高安全性:
- Argon2id
- m=262144(256 MB),t=4,p=8
調優方法:
1. 設定記憶體為伺服器可以承受的最大值
2. 增加 time_cost 直到雜湊需要 ~0.5-1 秒
3. 設定平行度為可用核心數8. scrypt
何時使用 scrypt
scrypt 優勢:
- 記憶體困難(像 Argon2)
- 自 2009 年以來經過良好研究
- 用於一些加密貨幣
何時使用:
- 當 Argon2 不可用時
- 用於金鑰衍生(類似 HKDF 的用例)
- 與現有系統相容Python 中的 scrypt
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
import os
def hash_password_scrypt(password: str) -> tuple[bytes, bytes]:
"""用 scrypt 雜湊密碼"""
salt = os.urandom(16)
kdf = Scrypt(
salt=salt,
length=32,
n=2**17, # CPU/記憶體成本(必須是 2 的冪)
r=8, # 區塊大小
p=1 # 平行化
)
key = kdf.derive(password.encode())
return salt, key
def verify_password_scrypt(password: str, salt: bytes, stored_key: bytes) -> bool:
"""用 scrypt 驗證密碼"""
kdf = Scrypt(
salt=salt,
length=32,
n=2**17,
r=8,
p=1
)
try:
kdf.verify(password.encode(), stored_key)
return True
except Exception:
return False
# 使用
salt, key = hash_password_scrypt("my_password")
print(verify_password_scrypt("my_password", salt, key)) # True9. 比較
┌─────────────┬──────────┬────────────┬─────────────┬────────────────┐
│ 演算法 │ 記憶體 │ 平行 │ 推薦 │ 備註 │
│ │ 困難 │ 抵抗 │ │ │
├─────────────┼──────────┼────────────┼─────────────┼────────────────┤
│ Argon2id │ ✓ │ ✓ │ ✓✓✓ │ 最佳選擇 │
│ scrypt │ ✓ │ 部分 │ ✓✓ │ 好的備選 │
│ bcrypt │ ✗ │ 部分 │ ✓ │ 仍然可以 │
│ PBKDF2 │ ✗ │ ✗ │ 僅遺留 │ 使用 60萬迭代 │
│ SHA-256 │ ✗ │ ✗ │ ✗ │ 永不使用 │
│ MD5 │ ✗ │ ✗ │ ✗✗✗ │ 永不使用 │
└─────────────┴──────────┴────────────┴─────────────┴────────────────┘
記憶體困難:需要大量 RAM,更難在 GPU 上平行化
平行抵抗:難以用多核/GPU 加速10. 完整實現
"""
生產就緒的密碼雜湊模組
"""
from argon2 import PasswordHasher, Type
from argon2.exceptions import VerifyMismatchError, InvalidHashError
import secrets
import hmac
class PasswordManager:
"""使用 Argon2id 的安全密碼雜湊"""
def __init__(
self,
time_cost: int = 3,
memory_cost: int = 65536, # 64 MB
parallelism: int = 4,
pepper: bytes = None # 伺服器端金鑰
):
self.hasher = PasswordHasher(
time_cost=time_cost,
memory_cost=memory_cost,
parallelism=parallelism,
hash_len=32,
salt_len=16,
type=Type.ID # Argon2id
)
self.pepper = pepper
def _apply_pepper(self, password: str) -> str:
"""在雜湊前添加 pepper 到密碼"""
if self.pepper:
# HMAC 防止長度擴展攻擊
peppered = hmac.new(
self.pepper,
password.encode(),
'sha256'
).hexdigest()
return peppered
return password
def hash(self, password: str) -> str:
"""雜湊密碼用於儲存"""
if not password:
raise ValueError("密碼不能為空")
peppered = self._apply_pepper(password)
return self.hasher.hash(peppered)
def verify(self, password: str, hash: str) -> bool:
"""驗證密碼與雜湊"""
if not password or not hash:
return False
peppered = self._apply_pepper(password)
try:
self.hasher.verify(hash, peppered)
return True
except (VerifyMismatchError, InvalidHashError):
return False
def needs_rehash(self, hash: str) -> bool:
"""檢查雜湊是否需要用新參數更新"""
try:
return self.hasher.check_needs_rehash(hash)
except InvalidHashError:
return True
def verify_and_rehash(self, password: str, hash: str) -> tuple[bool, str | None]:
"""驗證密碼並在參數更改時返回新雜湊"""
if not self.verify(password, hash):
return False, None
if self.needs_rehash(hash):
return True, self.hash(password)
return True, None
# 使用範例
def example_usage():
# 使用可選的 pepper 初始化(儲存在環境變數中,不是程式碼!)
pepper = secrets.token_bytes(32) # 生產中:從環境獲取
pm = PasswordManager(pepper=pepper)
# 註冊
password = "user_password_123"
hashed = pm.hash(password)
print(f"儲存的雜湊: {hashed[:50]}...")
# 登入
is_valid = pm.verify(password, hashed)
print(f"密碼有效: {is_valid}")
# 檢查是否需要重新雜湊(升級參數後)
is_valid, new_hash = pm.verify_and_rehash(password, hashed)
if new_hash:
print("雜湊已升級,在資料庫中儲存 new_hash")
if __name__ == "__main__":
example_usage()11. 常見錯誤
錯誤 1:不安全地比較雜湊
# 錯誤:時序攻擊漏洞
def verify_bad(password, stored_hash):
computed = hash_password(password)
return computed == stored_hash # 字串比較洩露時間資訊
# 正確:使用常量時間比較
import hmac
def verify_good(password, stored_hash):
computed = hash_password(password)
return hmac.compare_digest(computed, stored_hash)
# 最佳:使用函式庫的內建驗證函數
# (bcrypt.checkpw,argon2.verify 已經處理這個問題)錯誤 2:硬編碼參數
# 錯誤:程式碼中的參數
def hash_password(pwd):
return argon2.hash(pwd, time_cost=2, memory_cost=32768)
# 正確:可配置,允許升級
class PasswordConfig:
TIME_COST = int(os.environ.get('ARGON2_TIME_COST', 3))
MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_KB', 65536))
PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4))錯誤 3:不處理升級
# 始終在成功登入後檢查是否需要重新雜湊
def login(username, password):
user = get_user(username)
if not verify_password(password, user.password_hash):
return False
# 如果使用舊參數則升級雜湊
if needs_rehash(user.password_hash):
user.password_hash = hash_password(password)
save_user(user)
return True12. 本章小結
三點要記住:
永遠不要加密密碼,永遠不要使用普通雜湊。 加密是可逆的,普通雜湊太快了。使用專門建構的密碼雜湊演算法。
Argon2id 是最佳選擇。 它是記憶體困難的、可配置的,並贏得了密碼雜湊競賽。如果 Argon2 不可用則使用 bcrypt。
調整參數使雜湊時間約為 0.5-1 秒。 這使暴力破解不切實際,同時保持登入可接受。隨著硬體改進,隨時間增加參數。
13. 下一步
我們可以安全地雜湊密碼。但 pepper 儲存在哪裡?我們如何管理加密金鑰?當金鑰需要輪換時會發生什麼?
在下一篇文章中:金鑰管理——安全地產生、儲存、輪換和銷毀密碼學金鑰。
