密码学哈希函数:完整指南
密码学哈希函数是现代安全体系中默默无闻的基石。每当你登录网站、向 Git 推送代码、下载文件,或者发起一笔比特币交易,哈希函数都在幕后默默运转。然而,大多数开发者每天都在与它们打交道,却并不真正理解它们的原理——也不清楚当使用不当时会出现多大的安全漏洞。
本指南涵盖了一切内容:数学原理、发展历史、已被破解的算法、现代标准,以及正确使用哈希函数所需的实用代码。
1. 什么是密码学哈希函数?
密码学哈希函数接受任意大小的输入,并产生一个固定大小的输出,称为摘要或哈希值。例如,无论输入是单个字符还是整部电影文件,SHA-256 始终输出 256 位(64 个十六进制字符)。
核心属性
确定性:相同的输入总是产生相同的输出。SHA-256("hello") 永远返回 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824。
计算快速:计算哈希值应该只需要毫秒级时间。这种高效性对文件完整性校验和数字签名至关重要,尽管对于密码哈希来说却成了一个弱点(后文会详细介绍)。
单向性(预像抗性):给定哈希输出 H,在计算上不可行找到任何输入 m,使得 hash(m) = H。你无法仅从哈希值逆向推导出原始数据。
第二预像抗性:给定输入 m1,在计算上不可行找到不同的输入 m2,使得 hash(m1) = hash(m2)。即使攻击者知道你的原始数据,也无法找到产生相同哈希值的不同输入。
碰撞抗性:在计算上不可行找到任意两个不同输入 m1 和 m2,使得 hash(m1) = hash(m2)。这比第二预像抗性要求更强。
雪崩效应:输入的微小变化——哪怕只改变一个比特——会导致输出完全不同。将 "hello" 改为 "hellp" 会产生与原来没有任何表面关联的完全不同的哈希值。
SHA-256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
SHA-256("hellp") = 9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca7
这两个哈希值之间没有可预测的关联——这就是雪崩效应的体现。
2. 哈希算法简史
MD5(1991 年)
Ronald Rivest 设计了 MD5,作为 MD4 的改进版本。它产生 128 位摘要,在整个 20 世纪 90 年代被广泛用于校验和与密码存储。在超过十年的时间里,MD5 是许多安全应用的默认选择。
SHA-1(1995 年)
美国国家安全局(NSA)将 SHA-1(安全哈希算法 1)设计为数字签名标准的一部分。它产生 160 位摘要。SHA-1 成为 TLS/SSL 证书、代码签名以及 Git 对象存储的主导哈希算法。
SHA-2 家族(2001 年)
同样由 NSA 设计,SHA-2 实际上是六个函数的家族:SHA-224、SHA-256、SHA-384、SHA-512、SHA-512/224 和 SHA-512/256。SHA-256 和 SHA-512 是最常用的,分别产生 256 位和 512 位摘要,至今仍然安全。
SHA-3 / Keccak(2015 年)
在 SHA-1 的弱点变得明显之后,美国国家标准与技术研究院(NIST)举办了一场公开竞赛(2007–2015 年),寻找完全独立于 NSA 的 SHA-2 设计的全新哈希标准。获胜者是 Keccak,由 Guido Bertoni、Joan Daemen、Michaël Peeters 和 Gilles Van Assche 设计。与使用 Merkle–Damgård 构造的 SHA-2 不同,SHA-3 使用海绵构造,提供了根本不同的安全特性。
BLAKE2(2012 年)
BLAKE2 是一种密码学哈希函数,速度比 MD5 更快,同时提供 SHA-3 级别的安全性。它由 Jean-Philippe Aumasson、Samuel Neves、Zooko Wilcox-O'Hearn 和 Christian Winnerlein 设计,是 SHA-3 竞赛决赛入围者 BLAKE 的改进版本。BLAKE2b 针对 64 位平台优化;BLAKE2s 针对 32 位平台优化。
3. SHA-256 的工作原理
SHA-256 使用 Merkle–Damgård 构造:消息被分割成固定大小的块,压缩函数被迭代应用,将一个块的输出作为下一个块的输入。
第一步:填充
输入消息被填充,使其总长度是 512 位的倍数。具体方法是追加一个 1 比特,然后是若干个零,最后是原始消息长度的 64 位大端整数表示。
第二步:消息调度
每个 512 位的块被扩展为 64 个 32 位字,使用一个混合和旋转比特的调度方法。W[0] 到 W[15] 直接来自消息块;W[16] 到 W[63] 按如下方式计算:
W[i] = σ1(W[i-2]) + W[i-7] + σ0(W[i-15]) + W[i-16]
其中 σ0 和 σ1 是特定的比特旋转和移位操作。
第三步:压缩——64 轮运算
SHA-256 维护 8 个工作变量(a 到 h),初始化为前 8 个素数平方根的小数部分。对于 64 轮中的每一轮,算法应用:
T1 = h + Σ1(e) + Ch(e,f,g) + K[i] + W[i]
T2 = Σ0(a) + Maj(a,b,c)
h = g; g = f; f = e; e = d + T1
d = c; c = b; b = a; a = T1 + T2
轮常数 K[i] 是前 64 个素数立方根的小数部分——这一设计选择通过使常数可公开验证,防止了"袖中藏物"的批评,证明其中没有隐藏后门。
第四步:输出
处理完所有块后,将 8 个工作变量与初始哈希值相加,产生最终的 256 位摘要。这种"前馈"机制确保每个块的输出都依赖于所有之前的块。
4. MD5 和 SHA-1 为何已被破解
MD5 碰撞(2004 年)
2004 年,王小云等人证明了对 MD5 的实际碰撞攻击——找到两个不同的输入产生相同的 MD5 哈希值。到 2008 年,研究人员利用 MD5 碰撞伪造了来自真实 CA 的虚假 SSL 证书,展示了对 HTTPS 基础设施的现实世界攻击。
该攻击使用复杂的差分密码分析,可以在现代硬件上几秒内生成 MD5 碰撞。
SHA-1 "SHAttered" 攻击(2017 年)
谷歌 Project Zero 团队和荷兰数学与计算机科学研究学会(CWI Amsterdam)在 2017 年产生了第一个实际的 SHA-1 碰撞,被称为 SHAttered。他们生成了两个具有相同 SHA-1 哈希值的不同 PDF 文件。该攻击需要大约 9.2 × 10¹⁸ 次 SHA-1 计算——相当于单 CPU 运行 6500 年,但只相当于 GPU 运行约 110 年,这对于民族国家和大型组织来说完全可行。
实际影响
MD5 和 SHA-1 对于以下用途不安全:
- 数字签名
- 证书指纹
- 密码存储
- 任何安全敏感的应用
对于以下用途仍然可接受:
- 非密码学校验和(通过可信渠道验证文件下载完整性)
- 哈希表查找
- 非安全性去重
- 遗留系统兼容性(附带适当的注意事项)
5. 真实世界中的使用场景
密码存储
绝不要以明文存储密码——甚至不能以普通哈希形式存储。如果你的数据库被泄露,攻击者可以在数小时或数天内使用字典攻击或彩虹表破解普通哈希。
正确的方法是使用专为密码设计的慢速加盐哈希函数:bcrypt、scrypt 或 Argon2。
文件完整性验证
当你下载软件时,开发者会提供一个 SHA-256 校验和。下载后,你计算文件的哈希值并进行比较。如果匹配,说明文件在传输过程中没有被损坏或篡改。
sha256sum downloaded-file.tar.gz
# 与开发者发布的校验和进行比较
数字签名
哈希函数是数字签名的基础。与其对整个文档(可能有几个 GB)进行签名,不如对其哈希值进行签名。接收方独立地对文档进行哈希,并对照该哈希值验证签名。
区块链
比特币对工作量证明挖矿和对交易块的哈希使用 SHA-256 双重哈希(SHA-256d)。矿工必须找到一个输入(随机数),使其哈希后产生带有特定数量前导零的输出——这个过程需要巨大的计算工作量,提供了区块链的安全保证。
Git 对象存储
Git 使用 SHA-1 对每个提交、树和 blob 对象进行哈希。哈希值既作为对象的标识符,也作为完整性检查。由于 SHA-1 的弱点,Git 正在积极迁移到 SHA-256。
存储系统去重
备份系统和内容寻址存储(如 IPFS)使用哈希来识别重复内容。如果两个文件具有相同的哈希值,它们只存储一次。
6. 实际计算哈希值
JavaScript (Node.js)
const crypto = require('crypto');
// SHA-256
const sha256 = crypto.createHash('sha256')
.update('Hello, World!')
.digest('hex');
console.log(sha256);
// 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3
// MD5(仅用于非安全目的)
const md5 = crypto.createHash('md5')
.update('Hello, World!')
.digest('hex');
console.log(md5);
// 65a8e27d8879283831b664bd8b7f0ad4
// SHA-512
const sha512 = crypto.createHash('sha512')
.update('Hello, World!')
.digest('hex');
console.log(sha512);
Python
import hashlib
# SHA-256
h = hashlib.sha256(b"Hello, World!").hexdigest()
print(h)
# 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3
# SHA-512
h512 = hashlib.sha512(b"Hello, World!").hexdigest()
print(h512)
# 多种算法
for algo in ['md5', 'sha1', 'sha256', 'sha512']:
h = hashlib.new(algo, b"Hello, World!").hexdigest()
print(f"{algo}: {h}")
Bash / Shell
# SHA-256
echo -n "Hello, World!" | sha256sum
# 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3 -
# MD5
echo -n "Hello, World!" | md5sum
# 65a8e27d8879283831b664bd8b7f0ad4 -
# SHA-1
echo -n "Hello, World!" | sha1sum
# 对文件进行哈希
sha256sum /path/to/file.iso
7. HMAC:基于哈希的消息认证码
普通哈希只验证数据完整性——它告诉你数据是否被损坏。但它不验证真实性——它不能证明是谁创建了数据。任何人都可以计算哈希值。
HMAC(RFC 2104)通过将密钥与哈希函数结合来解决这个问题:
HMAC(K, m) = hash((K' ⊕ opad) || hash((K' ⊕ ipad) || m))
其中 K' 是填充到块大小的密钥,opad/ipad 是特定的填充常数。如果底层哈希函数是安全的,这种构造就是可证明安全的。
常见用途
API 认证:REST API 使用 HMAC-SHA256 对请求进行签名。服务器和客户端共享一个密钥。客户端用密钥对请求体进行签名;服务器验证签名。
JWT 签名:JSON Web Token 使用 HMAC-SHA256(HS256)对头部和载荷进行签名,确保令牌未被篡改。
Webhook 验证:GitHub、Stripe 和许多其他服务使用 HMAC-SHA256 对 Webhook 载荷进行签名,使接收者可以验证载荷的真实性。
计算 HMAC
// Node.js
const crypto = require('crypto');
const hmac = crypto.createHmac('sha256', '我的密钥')
.update('需要认证的消息')
.digest('hex');
console.log(hmac);
import hmac
import hashlib
key = b'my-secret-key'
message = b'message to authenticate'
sig = hmac.new(key, message, hashlib.sha256).hexdigest()
print(sig)
8. 彩虹表与加盐
什么是彩虹表?
彩虹表是一个预计算的数据库,将已知的哈希值映射回其原始明文输入。如果攻击者获得了你的密码哈希数据库,他们不需要逐个破解每个哈希——只需在表中查找即可。
对于 MD5 和 SHA-1,涵盖所有长度不超过 8 个字符的 ASCII 密码的彩虹表多年来已经可以免费获取。CrackStation 等网站维护着数十亿个哈希到密码的映射数据库。
加盐如何击败彩虹表
盐是在哈希之前附加到密码的随机值:
hash(salt + password) = stored_hash
盐与哈希值一起存储(它不需要保密)。因为每个用户都得到一个唯一的随机盐,攻击者无法使用预计算的表——他们需要为每个可能的盐值单独建立彩虹表,这在计算上是不可能的。
bcrypt:自动加盐与刻意的慢速
bcrypt 于 1999 年专门为密码哈希而设计。它自动生成并整合随机盐,并包含一个控制哈希计算速度的代价因子:
const bcrypt = require('bcrypt');
// 对密码进行哈希(代价因子 12——在现代硬件上约需 250ms)
const hash = await bcrypt.hash('用户密码', 12);
// 验证
const isMatch = await bcrypt.compare('用户密码', hash);
存储的哈希值如下:$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
$2b$12$ 前缀编码了算法版本和代价因子——bcrypt 自动处理一切。
9. 算法对比表
| 算法 | 输出大小 | 速度 | 安全状态 | 最适合 |
|---|---|---|---|---|
| MD5 | 128 位 | 非常快 | ❌ 已破解(碰撞) | 仅用于非安全校验和 |
| SHA-1 | 160 位 | 快 | ❌ 已破解(SHAttered) | 仅用于遗留系统 |
| SHA-256 | 256 位 | 快 | ✅ 安全 | 通用、TLS、签名 |
| SHA-512 | 512 位 | 64 位平台上快 | ✅ 安全 | 高安全性应用 |
| SHA-3/Keccak | 可变 | 中等 | ✅ 安全 | SHA-2 的替代方案 |
| BLAKE2b | 可变 | 非常快 | ✅ 安全 | 性能关键型哈希 |
| bcrypt | 184 位 | 慢(刻意) | ✅ 安全 | 密码存储 |
| Argon2id | 可变 | 慢(刻意) | ✅ 安全 | 密码存储(推荐) |
10. 密码哈希最佳实践
密码需要特殊处理,因为它们是用户账户的钥匙。密码数据库泄露可能造成灾难性后果。毫无例外地遵循以下规则:
规则一:绝不存储明文密码
这应该是显而易见的,但仍然有人这样做。2019 年,Facebook 被发现在内部以明文形式存储了数亿个密码。
规则二:绝不对密码使用快速哈希
MD5、SHA-1、SHA-256 和 SHA-512 对密码哈希来说太快了。现代 GPU 每秒可以计算数十亿次 SHA-256 哈希,使暴力破解攻击在数小时内成为可能。
规则三:使用专为密码设计的哈希算法
bcrypt(推荐最低标准):使用代价因子 12 或更高。广泛支持,久经考验。
scrypt:内存困难型,对 GPU 和 ASIC 攻击具有抵抗力。可配置内存和 CPU 成本。
Argon2id(当今推荐):2015 年密码哈希竞赛的获胜者。Argon2id 是推荐的变体,因为它既能抵抗侧信道攻击,又能抵抗时间-内存权衡攻击。最低配置:
- 内存:64 MB
- 迭代次数:3
- 并行度:4
规则四:每个密码使用唯一的盐
即使使用了 bcrypt/scrypt/Argon2(它们包含自动加盐),也要理解其重要性:相同的密码必须产生不同的哈希值,这样即使一个密码被破解,也不会暴露其他密码。
规则五:随时间调整代价因子
随着硬件变得更快,提高代价因子。对于 bcrypt,目标是约 250–500ms。在下次登录时重新哈希密码。
规则六:考虑使用胡椒(Pepper)
胡椒是一个服务器端密钥(与盐不同,它不存储在数据库中)。它在哈希之前添加到密码中:hash(pepper + salt + password)。即使攻击者盗取了你的数据库,没有胡椒也无法破解密码。
结论
密码学哈希函数是互联网安全、完整性和信任的基础。理解它们——从数学属性到实际漏洞——能够帮助你构建真正安全的系统。
核心要点:
- SHA-256 和 SHA-512 是通用哈希的首选
- MD5 和 SHA-1 在密码学用途上已被破解
- 对于密码,始终使用 bcrypt、scrypt 或 Argon2
- 当你需要认证而不仅仅是完整性验证时,使用 HMAC
- 加盐能够击败彩虹表;bcrypt 和 Argon2 会自动完成加盐
使用 Tool3M 哈希生成器,直接在浏览器中快速计算 SHA-256、SHA-512、MD5 等哈希值——无需安装任何软件。