密码存储:为什么你永远不应该加密密码
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 存储在哪里?我们如何管理加密密钥?当密钥需要轮换时会发生什么?
在下一篇文章中:密钥管理——安全地生成、存储、轮换和销毁密码学密钥。
