哈希函数到底是不是加密
1. 为什么要关心这个问题?
一个开发者这样存储用户密码:
hashed = sha256(password)
database.store(hashed)“这很安全,“他们说。“我用的是 SHA-256,一个强大的哈希函数。”
然后他们的数据库泄露了。几小时内,攻击者恢复了 80% 的密码。
哪里出错了?
这个开发者把”哈希”和”安全密码存储”混淆了。这两者不是一回事。SHA-256 是一个哈希函数,不是密码存储解决方案。直接用它来存密码就像用锤子当螺丝刀——用错了工具。
2. 定义
哈希函数接受任意大小的输入,产生固定大小的输出(“哈希值”或”摘要”)。它被设计成单向的:你可以从输入计算哈希,但无法从哈希计算出输入。
密码学哈希函数的关键特性:
- 确定性: 相同输入总是产生相同输出
- 固定输出大小: SHA-256 总是输出 256 位,无论输入多大
- 单向性: 计算上不可逆
- 抗碰撞性: 难以找到两个产生相同哈希的不同输入
- 雪崩效应: 输入的微小变化导致输出剧烈变化
3. 根本区别
加密:设计上双向
明文 ──[用密钥加密]──► 密文 ──[用密钥解密]──► 明文加密是可逆的。有了密钥,你总能恢复原始数据。
哈希:设计上单向
输入 ──[哈希函数]──► 哈希值
↓
(无法返回)哈希是不可逆的。没有密钥。没有解密。原始数据在数学上被销毁了——只留下一个指纹。
为什么会混淆?
两者都从可读的输入产生”乱码输出”。但目的完全不同:
| 特性 | 加密 | 哈希 |
|---|---|---|
| 目的 | 临时隐藏数据 | 永久创建指纹 |
| 可逆 | 是(有密钥) | 否 |
| 需要密钥 | 是 | 否 |
| 输出大小 | 随输入变化 | 固定 |
| 使用场景 | 保护传输/存储中的数据 | 验证完整性,存储密码 |
4. 哈希函数如何工作
高层流程
┌─────────────────────────────────────────────────────────────┐
│ 1. 填充 │
│ - 添加比特使输入成为块大小的整数倍 │
├─────────────────────────────────────────────────────────────┤
│ 2. 分块处理 │
│ - 分割成固定大小的块 │
│ - 每个块通过压缩函数处理 │
│ - 每个块的输出馈入下一个块 │
├─────────────────────────────────────────────────────────────┤
│ 3. 最终化 │
│ - 输出最终内部状态作为哈希值 │
└─────────────────────────────────────────────────────────────┘雪崩效应
这是哈希对完整性检查有用的原因:
import hashlib
text1 = "Hello, World!"
text2 = "Hello, World." # 只是把 ! 改成了 .
print(hashlib.sha256(text1.encode()).hexdigest())
# dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
print(hashlib.sha256(text2.encode()).hexdigest())
# f8c3bf62a9aa3e6fc1619c250e48abe7519373d3edf41be62eb5dc45199af2ef一个字符的变化 → 完全不同的哈希。这使得”猜测”原始输入变得不可能。
5. 为什么你无法”解密”哈希
信息丢失
哈希函数将任意长度的输入压缩成固定长度的输出。信息在数学上丢失了。
"Hello"(5 字节) → 256 位哈希
"战争与和平"(3MB) → 256 位哈希
所有可能的文件 → 256 位哈希无限的输入映射到有限的输出。多个输入会产生相同的哈希(碰撞)。你无法逆转这个过程,因为你不知道使用了无限可能输入中的哪一个。
没有密钥,就没有解密
加密在没有密钥时是安全的,因为找到密钥在计算上不可行。
哈希没有密钥。没有什么可找的。“逆转”需要反转一个被设计成不可逆的数学函数。
“破解”哈希意味着什么
当我们说哈希被”破解”时,我们指的是:
- 碰撞攻击: 找到了两个具有相同哈希的不同输入
- 原像攻击: 给定一个哈希,找到某个产生它的输入(不一定是原始的)
两者都不意味着”解密”。即使是被破解的哈希函数也不会变得可逆。
6. MD5:为什么死而不僵
MD5 是 1991 年设计的。它从 2004 年就被”破解”了。但你仍然到处能看到它。
为什么 MD5 是破碎的
- 碰撞攻击是实用的: 你可以创建两个具有相同 MD5 哈希的不同文件
- 选定前缀攻击有效: 给定任意两个前缀,你可以向每个追加数据,使结果具有相同的哈希
- 它太快了: 现代 GPU 每秒 95 亿个 MD5 哈希
为什么 MD5 死而不僵
# 在生产系统中仍然能看到:
file_checksum = hashlib.md5(file_content).hexdigest() # "只是用于完整性"
cache_key = hashlib.md5(query).hexdigest() # "只是用于缓存键"人们辩称:“我不是用它来做安全,只是校验和。”
问题在于: 需求会变。今天的”只是校验和”会变成明天的安全控制。而且 MD5 太快了,即使非安全用途也会启用攻击。
MD5 实际上没问题的场景
- 比较你完全控制的文件
- 封闭系统中的非安全校验和
- 遗留系统兼容性(完全了解风险的情况下)
MD5 不行的场景
- 任何安全敏感的应用
- 面向用户的文件验证
- 密码哈希(绝对不行!)
- 数字签名
- 证书验证
7. 密码存储:正确的哈希方式
以下是为什么 sha256(password) 会失败:
问题 1:速度
SHA-256 被设计成很快。非常快。
SHA-256: ~85 亿次哈希/秒 (GPU)
bcrypt: ~71,000 次哈希/秒 (同样的 GPU)
Argon2: ~1,000 次哈希/秒 (同样的 GPU,经过调优)快速哈希意味着快速破解。一个 8 字符的密码大约有 6 千万亿种可能。以每秒 85 亿次的速度,全部尝试需要 8 天。
问题 2:没有盐
没有盐,相同的密码有相同的哈希。
数据库泄露:
user1: 5e884898da28047d9... ← "password"
user2: 5e884898da28047d9... ← 也是 "password"
user3: 5e884898da28047d9... ← 也是 "password"攻击者预先计算常见密码的哈希(彩虹表)。一次查找,数千个账户被攻破。
问题 3:彩虹表
预计算的表,将常见密码映射到它们的哈希。仅使用 SHA-256,一个 10GB 的彩虹表可以即时破解大多数弱密码。
解决方案:密码哈希函数
import bcrypt
import argon2
# bcrypt:久经考验,广泛支持
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
# argon2:密码哈希竞赛的现代赢家
ph = argon2.PasswordHasher(
time_cost=2,
memory_cost=102400, # 100 MB
parallelism=8
)
hashed = ph.hash(password)这些函数是:
- 故意慢: 可配置的工作因子
- 内存密集: (Argon2)需要大量 RAM,击败 GPU 攻击
- 自动加盐: 即使密码相同,每个哈希也是唯一的
正确的密码存储流程
注册:
password → [盐 + 慢哈希] → stored_hash
验证:
input_password + stored_salt → [相同的慢哈希] → 与 stored_hash 比较8. 哈希函数选择指南
| 使用场景 | 推荐 | 避免 |
|---|---|---|
| 密码存储 | Argon2id, bcrypt, scrypt | SHA-*, MD5 |
| 文件完整性 | SHA-256, SHA-3, BLAKE3 | MD5, SHA-1 |
| 数字签名 | SHA-256, SHA-3 | MD5, SHA-1 |
| HMAC | SHA-256, SHA-3 | MD5 |
| 非安全校验和 | CRC32, xxHash | (都可以) |
| 内容寻址存储 | SHA-256, BLAKE3 | MD5 |
9. 代码示例:正确的密码处理
import argon2
from argon2 import PasswordHasher, exceptions
# 配置:根据你的服务器能力调整
ph = PasswordHasher(
time_cost=2, # 迭代次数
memory_cost=65536, # 64 MB 内存使用
parallelism=4, # 并行线程数
hash_len=32, # 输出哈希长度
salt_len=16 # 盐长度
)
def hash_password(password: str) -> str:
"""哈希密码用于存储。"""
return ph.hash(password)
def verify_password(stored_hash: str, password: str) -> bool:
"""验证密码与存储的哈希。"""
try:
ph.verify(stored_hash, password)
return True
except exceptions.VerifyMismatchError:
return False
except exceptions.InvalidHashError:
# 哈希格式无效
return False
def needs_rehash(stored_hash: str) -> bool:
"""检查密码是否需要重新哈希(参数已更改)。"""
return ph.check_needs_rehash(stored_hash)
# 使用
password = "user_password_here"
# 注册
hashed = hash_password(password)
print(f"存储的哈希: {hashed}")
# $argon2id$v=19$m=65536,t=2,p=4$...
# 登录
if verify_password(hashed, password):
print("登录成功")
# 检查是否应该升级哈希
if needs_rehash(hashed):
new_hash = hash_password(password)
# 用 new_hash 更新数据库10. 常见误区
| 误区 | 现实 |
|---|---|
| ”有足够的计算能力就能解密哈希” | 不能。哈希会销毁信息。没有东西可解密。 |
| “SHA-256 适合用于密码存储” | SHA-256 太快了。使用 bcrypt/Argon2。 |
| “更长的哈希 = 更安全” | 安全性取决于算法,而不仅仅是长度。SHA-512 不是 SHA-256 的”两倍安全”。 |
| “我把密码哈希两次来增加安全性” | 这没有帮助,在某些情况下实际上可能降低安全性。 |
| “MD5 用于非安全目的没问题” | 直到需求改变。即使”只是校验和”也使用 SHA-256。 |
11. 本章小结
三点要记住:
哈希不是加密。 哈希函数设计上是单向的。你不能也不应该期望”解密”一个哈希。没有密钥,没有逆转,只有指纹。
速度是密码存储的敌人。 像 SHA-256 这样的通用哈希函数被设计成快速的。像 Argon2 这样的密码哈希函数被设计成慢速的。为工作使用正确的工具。
MD5 和 SHA-1 在安全方面已被弃用。 即使”只是校验和”,也优先使用 SHA-256 或 BLAKE3。需求会变,你不希望在它们变化时被抓到使用了破碎的密码学。
12. 下一步
我们已经介绍了加密、哈希及其区别。但我们忽略了一些关键的东西:密钥和盐从哪里来?
在下一篇文章中,我们将探索:随机数——密码学系统中最被低估的组件,以及为什么 rand() 会杀死你的安全性。
