4、密码存储安全基础:哈希算法对比(MD5/SHA256/bcrypt)、加盐策略、加密与解密

你看,咱们做 DLP 场景的时候,最头疼的往往不是接口怎么调,而是密码存哪、怎么存。我接手过一个遗留系统,用户表里的密码居然还是明文存的……直接给我看愣了。后来重构时我特意把这一块做扎实了,今天就聊聊这个话题。

4.1 为什么说哈希是存储密码的正道?

先说个定义:哈希(Hash)是一种单向的数学变换。你给它任意长度的数据,它吐出一个固定长度的摘要。但反过来,你几乎不可能从摘要还原原始输入——这就是“不可逆”。

那加密呢?加密是双向的,你拿密钥加密,别人拿密钥或配对密钥就能解密。如果把加密用在密码存储上,一旦密钥泄露,所有密码秒变裸奔。我见过一个项目用 AES 加密存密码,结果密钥硬编码在代码里……那是真踩坑。

所以结论很简单:存密码,只哈希,不加密。

4.2 MD5、SHA256、bcrypt 到底差在哪?

我当年刚入行时,大部分项目还在用 MD5。后来查资料发现 MD5 的碰撞漏洞早在 2004 年就公开了——一个 128 位的输出,已经被证明可以构造出两个不同输入产生相同哈希。这对密码存储来说是致命伤。你再想想,攻击者只要能找到一个相同哈希的字符串,就等于绕过了密码验证。

那 SHA256 呢?这是 SHA-2 家族的一员,256 位输出。目前没有公开的碰撞攻击。很多人觉得“那就用 SHA256 吧,足够安全了”。嗯,理论上没错,但实际应用中 SHA256 是“快速哈希”——它的设计目标是计算快、碰撞难。可在密码存储场景里,快恰恰是缺点。攻击者可以用 GPU 或者 ASIC 每秒跑上亿次 SHA256,暴力破解弱密码只是时间问题。

这里就要请出 bcrypt 了。bcrypt 是一种自适应慢速哈希,它内部会反复迭代,还内置了加盐(Salt)机制。最关键的是,你可以通过调整 “cost factor”(工作因子)来增加计算难度。假设你设 cost=12,那么一次 bcrypt 大约需要 250 毫秒(具体看硬件);对合法用户来说多等 0.25 秒没感觉,但攻击者想遍历 10 万个常见密码,那就得花 7 个多小时——这还不算每个密码都要单独跑一次 bcrypt 的迭代。

我把这三个算法的主要差异列个表:

特性 MD5 SHA256 bcrypt
输出长度(位) 128 256 可变(通常 184)
是否可逆 不可逆 不可逆 不可逆
是否抗碰撞 ❌ 已攻破 ✔ 目前安全 ✔ 安全
运算速度 极快(≈ 2 亿次/秒 GPU) 快(≈ 6000 万次/秒 GPU) 慢(cost=12 时 ≈ 4 次/秒 GPU)
内置加盐 ❌ 需要手动加 ❌ 需要手动加 ✔ 自动加盐
抗 GPU/ASIC 加速 非常差 较差 优秀(因内存和迭代开销)
推荐场景 不应使用 校验完整性(如文件摘要) 密码存储首选

看到这个表格,你大概心里有数了。我个人习惯:密码存储一律用 bcrypt,也不去折腾手动加盐的事,bcrypt 已经帮你干了。

4.3 加盐策略:别让两个相同密码的哈希一样

“加盐”是什么?就是在哈希之前,往密码后面拼一段随机字符串(叫 Salt)。每个用户拥有一个独立的 Salt,存的时候把 Salt 和哈希结果一起存。下一次验证时,用用户输入的密码 + 存储的 Salt 再算一遍哈希,对比即可。

为什么要加盐?你想想,假如没用盐,攻击者拿到你的用户表,看到两个哈希值一样,立马就知道这两个用户用的是同一个密码。更糟的是,他可以预先计算常见密码的哈希表(叫彩虹表),一比对就能还原大批密码。加了盐之后,每个密码都混入了不同的随机数,彩虹表直接失效。攻击者必须对每个盐值单独跑一遍全量计算——计算量直接翻几万倍。

避坑指南(我的亲身经历)
我曾经在一个项目里用了全局固定盐……就是写在配置文件里的一个字符串。后来安全审计时被吐槽:固定盐跟没加盐区别不大,攻击者拿到配置文件后照样能建彩虹表。正确的做法是:每个用户生成一个随机盐,长度至少 16 字节,最好 32 字节,用密码学安全的随机数生成器(比如 Python 的 os.urandom)来产。

说到长度,我给出一个常用方案:

  • 盐长度:16 ~ 32 字节(128 ~ 256 bit)
  • 生成方式:os.urandom(16).hex() 或 secrets._hex(32)
  • 存储格式:把 Salt 和 Hash 拼接起来存,例如:$2b$12$SaltBase64HashBase64(bcrypt 默认格式)
⚠️ 注意
盐不要用用户名、用户 ID、时间戳、递增数字这类可预测的玩意。理由很简单:攻击者能猜出规律,那跟没加盐没什么两样。

4.4 加密 ≠ 解密,这是两码事

很多新手会把“加密”和“解密”当成对称操作。其实在密码存储语境下,他们说的是两套完全不同的机制:

  • 加密(Encryption):使用密钥将明文变成密文,以后可以用同一个密钥(对称)或配对密钥(非对称)还原。适用于数据传输和存储时的机密性保护,比如 HTTPS 的 TLS 握手、数据库字段加密等。
  • 哈希(Hash):单向函数,不可还原。适用于密码验证、数据完整性校验等不需要还原原始值的场景。

所以“加密与解密”这个标题容易让人误解。正确的关系是:
存密码 → 用哈希;保护其他敏感数据(如信用卡、身份证)→ 用加密。

但在 DLP 场景里,我们有时候需要“解密”吗?比如某个接口要取出用户的临时凭证做校验——那是对称加密的场景,而不是密码存储。记住:密码永远不应该被解密。

4.5 框架图:密码存储安全的核心逻辑

我把这一块的知识结构画成一张图,方便你理解整体流程:

密码存储安全知识体系 密码存储 哈希算法对比 MD5 ❌ SHA256 (快速但不适合密码) bcrypt ✅ 慢+抗GPU 加盐策略 每个用户独立随机盐 最少 16 字节 不可预测(非用户名/ID) 加密 vs 哈希 加密:双向,需密钥 哈希:单向,不可逆 密码存 → 哈希 实现建议 优先使用 bcrypt + 自动盐;非密码场景用 SHA256

4.6 代码实战:Python 里到底怎么写?

纸上谈兵没用,咱们直接上代码。我用 Python 的 bcrypt 库演示注册和登录流程:

import bcrypt

def register_user(username, password):
    # 1. 生成盐(bcrypt 内部自动生成,长度为 16 字节)
    # 2. 哈希密码,指定 cost factor(推荐 ≥12)
    # 注意:这里盐已经打包在结果里了
    salt = bcrypt.gensalt(rounds=12)
    hashed = bcrypt.hashpw(password.encode('utf-8'), salt)

    # 存储到数据库时只需要存 hashed(它包含了盐和哈希)
    print(f"用户名: {username}, 哈希: {hashed.decode()}")

def verify_user(username, password, stored_hash):
    # 从数据库取出 stored_hash,它已经包含盐
    # 直接用用户输入的密码校验
    if bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')):
        print("验证成功")
    else:
        print("密码错误")

看到没?bcrypt 的 gensalt 自动给你生成了随机盐,还让你指定工作因子 rounds。我一般建议 cost=12,低于 10 就太快了,高于 14 对用户端体验影响较大(比如移动端登录等半秒到一秒)。具体情况可以压测后调整。

4.7 避坑清单(这部分我踩过)

  • 千万别用 MD5 存密码——哪怕你加盐,MD5 的碰撞漏洞也让攻击者有机可乘。
  • 固定盐 ≈ 不加盐——一定要每个用户单独随机盐。
  • 不要自己实现哈希函数——你用 Python 的 hashlib.md5 或者 hashlib.sha256 然后再手动拼接盐,容易出错(比如漏了编码、换了分隔符导致校验失败)。直接用成熟库。
  • 成本因子别设太低——我见过有人用 bcrypt 但 rounds=4,跟 SHA256 一样快,白费功夫。
  • 永远不要解密密码——如果有人问你要“忘记密码时把明文发回”,直接告诉他这是安全红线。只能重置,不能还原。

4.8 小结一下

密码存储的核心三件事:

  • 只用慢哈希算法(bcrypt 为首选)
  • 每个用户独立加随机盐
  • 绝不加密、绝不解密

你把这个基础打牢,后面聊 DLP 场景里的密码同步优化才不会跑偏。好,这一章就到这。

一句话记牢:
“存密码用 bcrypt,存其他敏感数据用加密。别把两者搞混,否则你的库哪天被拖了,哭都来不及。”