LOADING

加载过慢请开启缓存 浏览器默认开启

口令存储为什么要加盐和慢哈希

口令存储为什么要加盐和慢哈希

这篇文章写一个和密码实验直接相关、也很常见的安全问题:用户口令到底应该怎么存。

很多初学者会觉得:

password -> SHA256 -> 存数据库

这样已经不是明文,应该安全了。但在真实系统里,这还不够。原因是普通哈希太快、结果确定,一旦数据库泄露,攻击者可以离线高速尝试常见口令。

更合理的方向是:

password + random salt -> password hashing / KDF -> 存算法、参数、salt、hash

本文用 Python 标准库中的 PBKDF2-HMAC-SHA256 演示。真实新系统更推荐 Argon2id、scrypt、bcrypt 这类专门口令哈希算法;这里用 PBKDF2 是因为它不需要额外依赖,便于复现。

1. 普通 SHA256 的问题

普通哈希有一个特点:

同一个输入永远得到同一个输出

例如:

import hashlib


def sha256_hex(password: str) -> str:
return hashlib.sha256(password.encode("utf-8")).hexdigest()

对同一个口令计算两次:

Correct-Horse-Battery-Staple

结果完全一样。

真实运行截图:

口令存储 VSCode 运行截图

截图里可以看到:

SHA256(password): a8936e06dff4325a005bc4b68d5775bff9b3b05122784af264137aa6cd433ff3
SHA256(password) again: a8936e06dff4325a005bc4b68d5775bff9b3b05122784af264137aa6cd433ff3

这带来两个问题:

两个用户使用同一口令,数据库中的 hash 相同
攻击者可以提前准备常见口令 hash 表,泄露后直接查

2. salt 是什么

salt 是每条口令记录单独生成的一段随机数据。

salt = random bytes
hash = KDF(password, salt)

salt 不需要保密,可以和 hash 一起存储。它的作用不是当作第二个密码,而是让相同口令在不同记录中产生不同结果。

例如两个用户都使用:

Correct-Horse-Battery-Staple

如果 salt 不同,存储记录也不同:

Record 1: pbkdf2_sha256$600000$...$...
Record 2: pbkdf2_sha256$600000$...$...

这能防止攻击者通过“两个 hash 一样”直接看出两个用户口令一样,也能让预计算表失效。

3. 为什么还需要慢哈希

salt 解决的是相同口令、预计算表的问题,但不解决普通哈希太快的问题。

如果攻击者拿到数据库,可以离线尝试:

candidate password -> hash -> compare

普通 SHA256 非常快,这对文件校验很好,但对口令存储不合适。

慢哈希/KDF 会故意提高每次尝试成本。例如 PBKDF2 会重复迭代很多轮:

PBKDF2-HMAC-SHA256(password, salt, iterations=600000)

这样用户登录时多花一点点时间,但攻击者批量猜测时成本会显著上升。

4. 存储记录应该包含什么

数据库里不要只存一个 hash。至少要存:

algorithm$iterations$base64(salt)$base64(hash)

例如:

pbkdf2_sha256$600000$base64_salt$base64_hash

为什么要存算法和参数?

以后可以升级迭代次数
不同用户记录可能使用不同旧参数
验证时需要知道当时用的 salt 和 iterations

5. Python 实现

派生 PBKDF2 hash:

import base64
import hashlib
import hmac
import os


def derive_pbkdf2_hash(
password: str,
salt: bytes,
iterations: int = 600_000,
dklen: int = 32,
) -> bytes:
return hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
dklen=dklen,
)

编码存储记录:

def encode_record(iterations: int, salt: bytes, digest: bytes) -> str:
salt_text = base64.b64encode(salt).decode("ascii")
digest_text = base64.b64encode(digest).decode("ascii")
return f"pbkdf2_sha256${iterations}${salt_text}${digest_text}"

验证口令:

def verify_password(password: str, record: str) -> bool:
algorithm, iteration_text, salt_text, digest_text = record.split("$")
if algorithm != "pbkdf2_sha256":
raise ValueError(f"unsupported algorithm: {algorithm}")

iterations = int(iteration_text)
salt = base64.b64decode(salt_text)
expected = base64.b64decode(digest_text)
actual = derive_pbkdf2_hash(password, salt, iterations, len(expected))
return hmac.compare_digest(actual, expected)

这里使用:

hmac.compare_digest(actual, expected)

而不是普通 ==。它用于常量时间比较,避免比较过程泄露细微时间差。

6. 生成两条记录

演示代码中,每次都使用:

salt = os.urandom(16)

生成随机 salt。

password = "Correct-Horse-Battery-Staple"
iterations = 600_000

for index in range(1, 3):
salt = os.urandom(16)
digest = derive_pbkdf2_hash(password, salt, iterations)
record = encode_record(iterations, salt, digest)
print(f"Record {index}: {record}")
print(f"Verify correct password: {verify_password(password, record)}")
print(f"Verify wrong password: {verify_password('wrong-password', record)}")

真实运行截图里可以看到:

Record 1: pbkdf2_sha256$600000$...$...
Verify correct password: True
Verify wrong password: False

Record 2: pbkdf2_sha256$600000$...$...
Verify correct password: True
Verify wrong password: False

两条记录不同,但都能用正确口令验证通过。

7. VSCode 代码截图

下面是完整脚本在 VSCode 中运行的截图:

口令存储代码运行截图

脚本文件路径:

D:\密码实验\work\blog_password_storage\password_storage_demo.py

8. Argon2id、scrypt、bcrypt、PBKDF2 怎么选

常见选择可以这样理解:

算法 特点 建议
Argon2id 可设置时间成本、内存成本、并行度 新系统优先考虑
scrypt 内存硬算法,增加硬件批量攻击成本 适合口令存储
bcrypt 历史长,库支持广 注意输入长度限制
PBKDF2 标准化时间久,合规和标准库常见 迭代次数要足够高

本文使用 PBKDF2 是教学演示,不是说它永远最优。真实项目应结合语言生态、合规要求和运行成本选择。

9. 常见错误

9.1 salt 写死

错误:

salt = "my-fixed-salt"

salt 必须每条记录随机生成。固定 salt 无法让相同口令产生不同记录。

9.2 把 salt 当秘密

salt 可以公开存储。它的作用是唯一性,不是保密性。

如果需要服务器端秘密,可以讨论 pepper。但 pepper 不应和数据库记录放在一起。

9.3 迭代次数永远不升级

硬件会变快,旧参数会逐渐不够。比较实际的做法:

记录中保存 iterations
用户登录成功后检查参数是否过旧
过旧则用新参数重新计算并更新记录

9.4 忘记登录限速

慢哈希主要提高离线攻击成本。在线登录接口仍然需要:

失败次数限制
IP/账号维度限速
异常登录检测
多因素认证

9.5 使用裸 MD5/SHA1/SHA256 存密码

这些是通用哈希函数,不是口令存储方案。即使用 SHA256,也缺少 salt 和成本参数。

10. 小结

口令存储至少应做到:

每条记录随机 salt
使用专门口令哈希或 KDF
保存算法、参数、salt、hash
验证时重新计算并常量时间比较
支持参数升级
登录接口做限速

不要把:

SHA256(password)

当成完整口令存储方案。它在真实泄露场景下很脆弱。

参考资料