密码学基础:哈希、加盐与密钥派生

作者:Yolo 发布时间: 2026-06-05 阅读量:6

密码学基础:哈希、加盐与密钥派生

网络安全系列 · 认证与授权篇

密码学是现代网络安全的基石。从登录密码的存储到 HTTPS 握手,从数字签名到区块链,密码学无处不在。但很多开发者对密码学的理解停留在"用 MD5 哈希一下"的层面,这恰恰是安全漏洞的温床。

本文将深入讲解密码学的三大核心概念:哈希函数加盐密钥派生函数(KDF),帮助你理解为什么 MD5 不能用于密码存储,以及现代系统应该如何正确处理用户凭证。


一、哈希函数:从任意数据到固定指纹

1.1 什么是哈希函数

哈希函数(Hash Function)是一种将任意长度的输入数据映射为固定长度输出的算法。这个输出通常被称为哈希值摘要指纹

理想的哈希函数具有以下特性:

  • 确定性:相同输入必定产生相同输出
  • 快速计算:计算哈希值的速度要快
  • 单向性:从哈希值无法反推出原始数据(不可逆)
  • 抗碰撞性:很难找到两个不同的输入产生相同的哈希值
  • 雪崩效应:输入微小的改动会导致输出完全改变

1.2 常见哈希算法

算法输出长度安全性适用场景
MD5128 bit❌ 已破解文件校验(不推荐)
SHA-1160 bit❌ 已破解旧系统兼容
SHA-256256 bit✅ 安全数字签名、区块链
SHA-3可变✅ 安全高安全场景
BLAKE2/3可变✅ 安全高性能场景

1.3 为什么 MD5 不能用于密码存储

MD5 在密码存储场景中有两个致命缺陷:

第一,速度太快。

MD5 的设计初衷是快速计算文件校验和,而不是安全存储密码。现代 GPU 可以以每秒数十亿次的速度计算 MD5 哈希。这意味着一个 8 位纯数字密码的 MD5 值,在普通显卡上几分钟就能穷举完毕。

第二,碰撞攻击已实际可行。

2004 年,王小云教授团队首次在可计算时间内找到 MD5 的碰撞。2013 年,研究人员利用 MD5 碰撞伪造了符合 CA 标准的 TLS 证书。

# 错误示范:直接用 MD5 存储密码
import hashlib

def hash_password_wrong(password):
    return hashlib.md5(password.encode()).hexdigest()

# 攻击者拿到数据库后,用彩虹表秒破
# 或者直接用 hashcat -m 0 暴力破解

1.4 哈希的正确使用场景

哈希函数在以下场景中是安全的:

  • 文件完整性校验:下载文件后验证 SHA-256 哈希
  • 数字签名:对文档哈希后签名,而非签名整个文档
  • 数据去重:快速判断两个大文件是否相同
  • HMAC 消息认证:结合密钥生成消息认证码
但绝不包括:直接存储用户密码。

二、加盐:让相同的密码产生不同的哈希

2.1 彩虹表攻击

即使使用 SHA-256 这样的安全哈希算法,直接存储密码哈希仍然存在风险。

攻击者可以预先计算大量常见密码的哈希值,建立一个彩虹表(Rainbow Table)。当拿到数据库后,直接查表即可还原密码。

密码SHA-256 哈希
1234568d969e...
password5e8848...
qwerty65e84b...

由于哈希的确定性,所有使用 "123456" 的用户,其哈希值完全相同。攻击者只需计算一次,就能破解所有使用该密码的账户。

2.2 什么是盐(Salt)

是一个随机生成的字符串,在哈希密码前与密码拼接。每个用户使用不同的盐,即使密码相同,最终的哈希值也完全不同。

import os
import hashlib
import base64

def hash_password_better(password):
    # 生成 16 字节的随机盐
    salt = os.urandom(16)
    # 盐 + 密码 一起哈希
    hash_value = hashlib.sha256(salt + password.encode()).digest()
    # 存储:盐 + 哈希值
    return base64.b64encode(salt + hash_value).decode()

def verify_password(stored, password):
    decoded = base64.b64decode(stored.encode())
    salt = decoded[:16]
    hash_value = decoded[16:]
    new_hash = hashlib.sha256(salt + password.encode()).digest()
    return new_hash == hash_value

2.3 盐的设计原则

  • 长度足够:至少 16 字节(128 bit),推荐 32 字节
  • 完全随机:使用密码学安全的随机数生成器(CSPRNG)
  • 每个用户独立:绝不复用盐,即使密码相同
  • 明文存储:盐不需要保密,可以明文存储在数据库中

2.4 盐解决了什么问题,没解决什么

盐解决了


  • 彩虹表攻击(攻击者需要为每个盐重新计算)

  • 相同密码的哈希值相同问题


盐没解决

  • 哈希速度太快(GPU 仍然可以快速暴力破解单个密码)

  • 弱密码本身的安全问题


这就是为什么我们还需要密钥派生函数


三、密钥派生函数:让哈希计算变慢

3.1 为什么需要慢哈希

密码存储的核心矛盾:

  • 合法用户登录:需要快速验证,用户体验要好
  • 攻击者暴力破解:需要尽量慢,增加破解成本
密钥派生函数(Key Derivation Function, KDF)通过故意增加计算时间和内存消耗,让单次哈希计算变慢。对于合法用户,登录时多等 100 毫秒完全可以接受;但对于攻击者,每秒只能尝试几次到几千次,而不是几十亿次。

3.2 PBKDF2:经典的密钥派生

PBKDF2(Password-Based Key Derivation Function 2)是 RSA 实验室提出的标准算法。

import hashlib
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

def hash_password_pbkdf2(password, salt=None):
    if salt is None:
        salt = os.urandom(16)
    
    kdf = PBKDF2HMAC(
        algorithm=hashlib.sha256(),
        length=32,          # 输出 32 字节
        salt=salt,
        iterations=600000,  # 迭代次数(2023 年 OWASP 推荐)
    )
    key = kdf.derive(password.encode())
    return salt, key

关键参数:迭代次数。

  • 2010 年:推荐 10,000 次
  • 2020 年:推荐 100,000 次
  • 2023 年:OWASP 推荐 600,000 次
迭代次数需要根据硬件性能逐年增加。

3.3 bcrypt:自适应的密码哈希

bcrypt 由 Niels Provos 和 David Mazières 在 1999 年设计,是密码存储的经典选择。

import bcrypt

def hash_password_bcrypt(password):
    # 自动生成盐,work factor = 12(2^12 轮迭代)
    salt = bcrypt.gensalt(rounds=12)
    hashed = bcrypt.hashpw(password.encode(), salt)
    return hashed

def verify_password_bcrypt(stored, password):
    return bcrypt.checkpw(password.encode(), stored)

bcrypt 的优势:


  • 自适应:可以通过增加 work factor 随着硬件升级而变"慢"

  • 内置盐:无需手动管理盐

  • 抗 GPU:基于 Blowfish 密码,使用 4KB 查找表,对 GPU 不太友好

  • 广泛支持:几乎所有语言都有成熟库


3.4 scrypt:内存困难的密钥派生

scrypt 由 Colin Percival 设计,不仅计算密集,还内存困难(memory-hard)。

import pyscrypt

def hash_password_scrypt(password):
    salt = os.urandom(32)
    # N=2^14, r=8, p=1 是标准参数
    hash_value = pyscrypt.hash(
        password.encode(),
        salt,
        N=16384,  # CPU/内存成本
        r=8,      # 块大小
        p=1,      # 并行化
        dkLen=32
    )
    return salt, hash_value

scrypt 的内存困难特性意味着:


  • 攻击者无法用 ASIC 或 GPU 大规模并行破解(内存带宽成为瓶颈)

  • 合法服务器通常有足够的内存


3.5 Argon2:现代密码哈希之王

Argon2 是 2015 年密码哈希竞赛的获胜者,被推荐为密码存储的首选算法。

Argon2 有三个变体:


  • Argon2d:数据依赖型,抗 GPU 攻击最强,但存在侧信道风险

  • Argon2i:数据独立型,抗侧信道攻击,推荐用于密码哈希

  • Argon2id:混合模式,兼顾两者优势,推荐默认使用


from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=3,      # 迭代次数
    memory_cost=65536,  # 64 MB 内存
    parallelism=4,    # 4 个并行线程
    hash_len=32,
    salt_len=16
)

def hash_password_argon2(password):
    return ph.hash(password)

def verify_password_argon2(stored, password):
    try:
        ph.verify(stored, password)
        return True
    except:
        return False

Argon2 的优势:


  • 同时控制时间内存并行度三个维度

  • 抵抗 GPU、ASIC、FPGA 等专用硬件攻击

  • 2016 年被推荐为密码哈希的首选算法

  • 赢得密码哈希竞赛,经过广泛审查



四、算法对比与选择建议

4.1 各算法特性对比

特性bcryptscryptArgon2idPBKDF2
自适应✅(手动调参数)
内存困难
抗 GPU中等最强
抗 ASIC中等
侧信道安全✅ (Argon2i/id)
标准支持广泛较广增长中最广泛

4.2 选择建议

新项目(2024+):


  • 首选 Argon2id,参数:time_cost=3, memory_cost=64MB, parallelism=4

  • 如果库支持有限,使用 bcrypt(cost=12+)


现有系统升级:

  • 如果正在用 MD5/SHA1/SHA256 + 盐:立即迁移到 bcrypt/Argon2

  • 迁移策略:用户下次登录时,用新算法重新哈希并替换旧值


def migrate_password(user, password):
    if user.hash_type == 'legacy':
        # 验证旧哈希
        if verify_legacy(user.stored_hash, password):
        # 用新算法重新哈希
            user.new_hash = hash_password_argon2(password)
            user.hash_type = 'argon2id'
            user.save()
            return True
    return verify_argon2(user.new_hash, password)


五、常见错误与最佳实践

5.1 ❌ 错误示范

# 错误 1:无盐哈希
hashlib.sha256(password.encode()).hexdigest()

# 错误 2:使用快速哈希
hashlib.md5(salt + password).hexdigest()

# 错误 3:盐太短或可预测
salt = username[:8]  # 可预测!

# 错误 4:自定义哈希组合
sha256(md5(password) + salt)  # 不增加安全性,反而可能引入漏洞

# 错误 5:硬编码盐
SALT = "myapp_salt_2024"  # 所有用户共享!

5.2 ✅ 最佳实践

  1. 永远不要自己实现密码哈希算法,使用经过审查的库
  2. 默认使用 Argon2id,其次 bcrypt
  3. 参数要足够强:随着硬件升级定期调整
  4. 盐必须随机且独立:每个用户、每次改密码都重新生成
  5. 添加 pepper(可选):一个全局密钥存储在代码/环境变量中,即使数据库泄露也增加破解难度
# pepper 的使用(可选增强)
PEPPER = os.environ.get('PASSWORD_PEPPER')  # 32 字节随机密钥

def hash_with_pepper(password, salt):
    # pepper 不存储在数据库,而是放在配置/密钥管理系统
    return argon2.hash(password + PEPPER, salt)
  1. 实施登录速率限制:即使哈希很强,也要防止在线暴力破解
  2. 监控异常登录:同一 IP 大量失败尝试时告警/封禁

六、总结

密码存储不是"哈希一下"那么简单,而是一个需要精心设计的系统工程:

层次措施目的
算法选择Argon2id / bcrypt让哈希计算变慢
每个用户独立随机防御彩虹表
Pepper全局密钥(可选)数据库泄露后的额外保护
速率限制登录失败限制防御在线暴力破解
监控异常登录告警及时发现攻击

密码学没有"差不多安全",只有"安全"和"不安全"。当你犹豫用 MD5 还是 bcrypt 时,选择 bcrypt;当你犹豫 bcrypt 的 cost 用 10 还是 12 时,选择 12。在密码存储这件事上,过度谨慎永远不是错误。


系列索引:



本文属于「网络安全系列」认证与授权篇。如有疑问或补充,欢迎在评论区留言讨论。