密码存储原理:哈希、加密与盐值

聊到密码迁移,我们首先得把「密码是怎么存的」这件事彻底搞明白。

说来惭愧,我刚入行那会儿,有个项目直接把用户密码明文塞进了数据库。后来被安全审计抓个正着,差点被拉去写检讨。那之后我才真正开始认真研究密码存储的门道。

说白了,密码存储就三个核心:哈希、加密、盐值。搞懂它们,你才能理解什么叫真正的「安全存储」。

密码存储的演进:从裸奔到全副武装

我们来看看密码存储的几个「时代」。你想想看,你的系统现在处在哪个阶段?

阶段 存储方式 风险等级 典型特征
石器时代 明文存储 极高 数据库一泄露,所有账号全完蛋
青铜时代 简单哈希(MD5/SHA-1) 高 抵抗不了彩虹表,也扛不住暴力破解
铁器时代 哈希 + 盐值 中 相同密码,存储结果不同,防彩虹表
现代标准 慢哈希 + 盐值(bcrypt/Argon2) 低 计算慢,破解成本高,是目前的最佳实践

我在一个遗留系统里就见过「青铜时代」的产物。几万用户,密码全是用 MD5 存的。迁移的时候,我第一件事就是让用户全部强制重置密码——因为原来的密码根本不能叫「安全」。

注意! 不要用 MD5、SHA-1、甚至 SHA-256 来直接存密码。这些是快速哈希,设计初衷是数据完整性校验,不是密码存储。现代 GPU 一秒可以算几十亿次 SHA-256,你的密码在它们面前基本等于裸奔。

核心概念:哈希 vs 加密

很多新手搞混这两个概念。我习惯用一个比喻来解释:加密是双开门,哈希是碎纸机。

  • 加密(Encryption):有密钥就能打开。加密后的数据可以通过解密还原出原文。
  • 哈希(Hashing):单向过程。你永远没办法从哈希值反推出原文。

为什么密码存储不能用加密?因为加密就意味着有人拿着密钥能把你所有用户的密码恢复成明文。说白了,这个密钥就是系统最大的单点故障。一旦密钥泄露,全线崩溃。

哈希不一样。即便数据库被拖库了,攻击者拿到也只是一堆看起来毫无规律的字符串。他没办法直接知道你的密码是「123456」。

关键原则: 密码存储只能用哈希,不能用加密。系统本身也不应该有能力「解密」用户的密码。

盐值:为什么不能少?

好,现在你已经知道要用哈希了。但问题来了:如果两个用户密码相同,他们的哈希值会不会一样?

会! 这就是「彩虹表」攻击能生效的原因。攻击者可以先算好常见密码的哈希值,做成一张表,然后直接查。

盐值就是用来解决这个问题的。它是一个随机生成的字符串,在哈希之前拼在密码后面。这样即使密码相同,因为盐值不同,最终的哈希值也完全不一样。

我曾经维护过一个系统,所有用户共用一个固定的「盐值」——说白了这就是个心理安慰,根本起不到真正的防护作用。真正的盐值必须满足三个条件:

  1. 每个用户独立:甚至每次密码变更都要重新生成
  2. 足够长:至少 16 字节,我习惯用 32 字节
  3. 密码学安全随机:不能用 random(),要用 secrets._hex() 这类安全随机函数
最佳实践: 盐值不需要保密,它可以和哈希值一起存到数据库里。攻击者拿到盐值也没用,因为他仍然需要为每个密码+每个盐值的组合做暴力破解——计算量被指数级放大了。

慢哈希:给攻击者「戴上镣铐」

光有哈希和盐值还不够。我们还得控制哈希的计算速度。

你想想看,如果哈希函数算得太快,攻击者一秒能试几百万个密码,那你的盐值保护很快就会失效。所以我们需要「慢哈希」——故意让计算变慢。

我目前团队使用的标准是 bcrypt,它的 cost factor 参数控制了哈希的运算轮数。在我个人的项目里,我一般设成 12,这样一次哈希大约需要 250 毫秒。对用户来说毫无感觉,但对攻击者来说,破解成本直接翻了几千倍。

常见的慢哈希算法对比:

算法 特点 建议场景
bcrypt 内置盐值,自适应,行业标准 通用推荐,新项目首选
Argon2id 抗 GPU 和 ASIC,新一代标准 追求极致安全,对性能有要求
PBKDF2 可调迭代次数,传统方案 兼容旧系统,不建议新项目
scrypt 内存密集型,抗硬件破解 资源充足,硬件攻击防护优先
// Python 示例:普通哈希 vs 慢哈希
import hashlib
import bcrypt

# ❌ 错误:普通 SHA-256 + 盐值
password = "my_secure_password"
salt = "固定盐值"
wrong_hash = hashlib.sha256((password + salt).encode()).hexdigest()
# 计算速度:纳秒级

# ✅ 正确:bcrypt 慢哈希 + 内置盐值
password_bytes = password.encode('utf-8')
salt_bcrypt = bcrypt.gensalt(rounds=12)
correct_hash = bcrypt.hashpw(password_bytes, salt_bcrypt)
# 计算速度:约 250ms,慢了上亿倍

嗯,这里要注意:rounds 也不是越高越好。太高会导致正常的用户登录也变慢,影响体验。我一般建议从 10 开始,在开发环境测试,然后逐步提高到 12 或 13。

最佳实践与避坑指南

我曾经踩过的坑: 使用自定义的哈希组合方式,比如「MD5(密码 + 盐值)」。这种「自己发明轮子」的做法非常危险。攻击者一眼就能认出你的算法,然后针对性优化破解。永远使用标准的、经过社区验证的库,比如 bcrypt、Argon2。

总结一下密码存储的黄金法则:

  • 使用慢哈希算法(bcrypt 或 Argon2id)
  • 每个用户使用独立的密码学安全随机盐值
  • 不要在客户端做哈希,服务器端接收原始密码后立即哈希
  • 不要自己实现密码学算法,用成熟的库
  • 定期评估 cost factor,跟上硬件发展

知识体系总览

下面这张图把本章的核心脉络串起来了。从用户注册到密码存储,每一步都不能省略。

密码存储核心逻辑图 密码存储核心逻辑:从明文到安全哈希 用户输入密码 生成随机盐值 密码 + 盐值 拼接 慢哈希函数 存储哈希值 + 盐值 流程说明 1. 用户输入密码后,系统生成一个唯一的、足够长(≥16字节)的密码学安全随机盐值。 2. 将密码与盐值拼接,然后通过慢哈希算法(如 bcrypt/Argon2id)进行哈希。 3. 最终只存储哈希值和盐值,原始密码在任何地方都不再出现。

以上就是密码管理最核心的三大块。搞明白了这些,你就能理解为什么迁移密码时需要特别小心——因为哈希过的密码已经不是「密码」了,它是一种不可逆的凭证。

接下来的章节,我们就要动手解决「怎么安全地把这些哈希值搬过去」这个实际操作问题了。