散列函数:MD5、SHA、SM3 与 HMAC
作者:林 | 系列:密码学 | 适合读者:后端开发 / 安全工程师
一、什么是散列函数
散列函数(Hash Function)将任意长度的输入映射为固定长度的输出,且这个过程不可逆。
输入(任意长度) → 散列函数 → 输出(固定长度)
"hello" → SHA-256 → 2cf24dba5fb0a30e...(64 hex = 256 bit)
"hello world" → SHA-256 → b94d27b9934d3e08...(64 hex = 256 bit)
整本《红楼梦》(2MB) → SHA-256 → 固定 64 hex 字符1.1 密码学安全散列的四个性质
| 性质 | 含义 | 被破坏的后果 |
|---|---|---|
| 单向性(Preimage Resistance) | 给定 h,找不到 m 使得 H(m)=h | 能从哈希值反推出原文 |
| 第二原像抗性(Second Preimage) | 给定 m1,找不到 m2≠m1 使得 H(m1)=H(m2) | 能伪造替换文件 |
| 抗碰撞性(Collision Resistance) | 找不到任意 m1≠m2 使得 H(m1)=H(m2) | 能伪造证书/签名 |
| 雪崩效应(Avalanche Effect) | 输入改 1 bit,输出至少变化 50% | 能通过输出推测输入特征 |
二、主流算法对比
2.1 算法一览
| 算法 | 输出长度 | 分组长度 | 结构 | 安全状态 | 性能(相对) |
|---|---|---|---|---|---|
| MD5 | 128 bit | 512 bit | Merkle-Damgård | ❌ 碰撞已破解 | 速度高 |
| SHA-1 | 160 bit | 512 bit | Merkle-Damgård | ❌ 碰撞已破解 | 快 |
| SHA-256 | 256 bit | 512 bit | Merkle-Damgård | ✅ 安全 | 中 |
| SHA-512 | 512 bit | 1024 bit | Merkle-Damgård | ✅ 安全 | 64位机更快 |
| SHA-3(256) | 256 bit | 1088 bit | Sponge(海绵结构) | ✅ 安全 | 略慢 |
| SM3 | 256 bit | 512 bit | Merkle-Damgård | ✅ 安全 | 与 SHA-256 相当 |
| BLAKE3 | 256 bit | 64 byte | Merkle Tree | ✅ 安全 | 极快(可并行) |
2.2 MD5:为什么不能再用
MD5 的碰撞攻击已经非常成熟:
2004 年:王小云团队首次攻破 MD5 碰撞
2006 年:碰撞攻击可在数秒内完成
2008 年:利用 MD5 碰撞伪造了合法的 CA 证书
2012 年:Flame 恶意软件利用 MD5 碰撞伪造 Windows Update 签名唯一还能用 MD5 的场景:非安全性的文件校验(如下载校验、缓存 key),且你能接受碰撞风险。
2.3 SHA-256:当前主力
SHA-256 属于 SHA-2 家族,由 NSA 设计,2001 年发布。目前没有已知的实际攻击:
import java.security.MessageDigest;
import java.util.HexFormat;
public class SHA256Example {
public static String sha256(String input) {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes("UTF-8"));
return HexFormat.of().formatHex(hash);
}
}sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
sha256("hello ") = 32926e0a1bc2be1e5e21da61595d8e5f42a3c0c1cf17bc1eb6a81beaac3a46d3
↑ 多一个空格,输出完全不同2.4 SHA-3:海绵结构
SHA-3 使用 Keccak 算法,结构与 SHA-2 完全不同(海绵结构 vs Merkle-Damgård),作为 SHA-2 的备选方案存在:
Sponge Construction(海绵结构):
输入分块: M1, M2, M3, ...
│
┌────▼────┐
│ Absorb │ ← 逐块吸收输入
│ Phase │
└────┬────┘
│
┌────▼────┐
│ Squeeze │ ← 挤出固定长度输出
│ Phase │
└────┬────┘
│
输出 HSHA-3 的优势:即使 SHA-2 未来被发现结构性弱点,SHA-3 不受影响(不同结构)。
2.5 SM3:国密散列
SM3 由国家密码管理局发布(GM/T 0004),输出 256 bit,结构类似 SHA-256 但使用了不同的压缩函数和常量:
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.security.MessageDigest;
import java.security.Security;
public class SM3Example {
static {
Security.addProvider(new BouncyCastleProvider());
}
public static String sm3(String input) {
MessageDigest md = MessageDigest.getInstance("SM3");
byte[] hash = md.digest(input.getBytes("UTF-8"));
return HexFormat.of().formatHex(hash);
}
}sm3("hello") = becbbfaae6548b8bf0cfcad5a27183cd1be6093b1cceccc303d9c61d0a645268SM3 与 SHA-256 性能接近,在信创/国密项目中作为 SHA-256 的替代。
三、HMAC:带密钥的消息认证
3.1 为什么需要 HMAC
普通 Hash 有一个问题:任何人都能计算 SHA256("hello")。如果攻击者能篡改消息和 Hash 值,Hash 校验就失效了。
HMAC(Hash-based Message Authentication Code)引入密钥,只有持有密钥的人才能生成和验证:
HMAC(K, M) = H((K ⊕ opad) || H((K ⊕ ipad) || M))
其中:
K = 密钥(不足则补零,超长则先 Hash)
ipad = 0x36 重复到块长度
opad = 0x5C 重复到块长度3.2 Java 实现
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;
public class HMACUtil {
public static String hmacSha256(String key, String message) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256"));
byte[] result = mac.doFinal(message.getBytes("UTF-8"));
return HexFormat.of().formatHex(result);
}
// HMAC-SM3(需要 BouncyCastle)
public static String hmacSm3(String key, String message) {
Mac mac = Mac.getInstance("HmacSM3", "BC");
mac.init(new SecretKeySpec(key.getBytes("UTF-8"), "HmacSM3"));
byte[] result = mac.doFinal(message.getBytes("UTF-8"));
return HexFormat.of().formatHex(result);
}
}3.3 HMAC 的典型应用
| 场景 | 示例 |
|---|---|
| API 签名 | HMAC-SHA256(secretKey, method+path+timestamp+body) |
| JWT | Header.Payload 用 HMAC-SHA256 签名 |
| Cookie 防篡改 | value + "." + HMAC(server_secret, value) |
| Webhook 验证 | GitHub/Stripe 用 HMAC 验证回调真实性 |
3.4 HMAC vs 直接 Hash(key+message)
❌ 错误做法:SHA256(key + message)
存在"长度扩展攻击":攻击者不知道 key,但能根据 H(key+msg)
算出 H(key+msg+padding+extra) 的值
✅ 正确做法:HMAC-SHA256(key, message)
双层 Hash 结构,免疫长度扩展攻击SHA-256 基于 Merkle-Damgård 结构,存在长度扩展漏洞。SHA256(secret + data) 这种拼接方式可以被攻击者在不知道 secret 的情况下,构造出 SHA256(secret + data + padding + evil) 的合法哈希值。
HMAC 的双层结构专门防御这种攻击。永远不要用 Hash(key+msg) 替代 HMAC。
四、密码存储:BCrypt、SCrypt、Argon2
4.1 为什么不能直接 Hash 存密码
❌ 数据库存储:SHA256("123456") = 8d969...
问题:
1. 彩虹表攻击:预计算常见密码的 Hash,直接反查
2. 速度太快:GPU 每秒可算数十亿次 SHA-256,暴力破解轻松
3. 无盐:相同密码相同 Hash,一个泄露全部泄露4.2 正确方案:慢哈希 + 盐
| 算法 | 特点 | 推荐度 |
|---|---|---|
| BCrypt | 自带盐,成本因子可调,久经考验 | ✅ 成熟首选 |
| SCrypt | 内存密集型,抗 GPU/ASIC | ✅ 需要抗硬件攻击时 |
| Argon2 | 2015 年密码哈希竞赛冠军,可调 CPU/内存/并行度 | ✅ 较新的方案 |
| PBKDF2 | 迭代次数可调,NIST 标准 | ⚠️ 可用但不如上面三个 |
4.3 BCrypt 实战
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12); // cost=12
// 注册时:生成哈希
String hashed = encoder.encode("user_password");
// 结果:$2a$12$LJ3m/Y7xmG0k8.hFzK8O5e5x5X5Y5Y5Y5Y5Y5Y5Y5Y5Y5Y5Y5Y
// 登录时:验证
boolean matches = encoder.matches("user_password", hashed); // trueBCrypt 输出格式解析:
$2a$12$LJ3m/Y7xmG...(共 60 字符)
│ │ │
│ │ └── 22字符盐 + 31字符哈希(Base64 编码)
│ └── cost factor = 12(2^12 = 4096 次迭代)
└── 算法版本(2a/2b)4.4 Argon2 实战
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
// 参数:saltLength, hashLength, parallelism, memory(KB), iterations
Argon2PasswordEncoder encoder = new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
String hashed = encoder.encode("user_password");
boolean matches = encoder.matches("user_password", hashed);Argon2 三个变体:
| 变体 | 特点 | 适用场景 |
|---|---|---|
| Argon2d | 抗 GPU,数据依赖内存访问 | 加密货币、非交互场景 |
| Argon2i | 抗侧信道攻击,数据无关内存访问 | 密码哈希(推荐) |
| Argon2id | 混合模式,前半 Argon2i 后半 Argon2d | 首选(OWASP 推荐) |
4.5 参数选择建议
BCrypt:
cost = 12(约 250ms / 次),每年 +1 跟随硬件发展
Argon2id:
memory = 64 MB(65536 KB)
iterations = 3
parallelism = 1
目标:单次验证耗时 200-500ms五、KDF:密钥派生函数
5.1 什么是 KDF
KDF(Key Derivation Function)从一个密码或主密钥派生出一个或多个加密密钥:
用户密码 "mypassword"
│
▼ KDF(加盐、迭代)
│
├── 派生密钥 1 → 用于 AES 加密
├── 派生密钥 2 → 用于 HMAC
└── 派生密钥 3 → 用于其他用途5.2 HKDF(HMAC-based KDF)
HKDF 分两步:Extract(提取熵)+ Expand(扩展为多个密钥):
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class HKDF {
// Extract: 从输入密钥材料中提取固定长度的伪随机密钥
public static byte[] extract(byte[] salt, byte[] ikm) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
return mac.doFinal(ikm);
}
// Expand: 将伪随机密钥扩展为所需长度
public static byte[] expand(byte[] prk, byte[] info, int length) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
byte[] result = new byte[length];
byte[] block = new byte[0];
int offset = 0;
int counter = 1;
while (offset < length) {
mac.update(block);
mac.update(info);
mac.update((byte) counter);
block = mac.doFinal();
int toCopy = Math.min(block.length, length - offset);
System.arraycopy(block, 0, result, offset, toCopy);
offset += toCopy;
counter++;
}
return result;
}
}5.3 KDF 的应用场景
| 场景 | 方案 |
|---|---|
| 用户密码 → 加密密钥 | PBKDF2 / Argon2 |
| TLS 握手 → 会话密钥 | HKDF |
| 主密钥 → 多个子密钥 | HKDF-Expand |
| 密码 → SSH 密钥 | BCrypt-PBKDF |
六、实战:接口签名方案
一个完整的 HMAC 签名验签流程:
/**
* API 请求签名工具
* 签名算法:HMAC-SHA256
* 签名串:method + "\n" + path + "\n" + timestamp + "\n" + body
*/
public class ApiSignUtil {
// 生成签名
public static String sign(String secretKey, String method,
String path, long timestamp, String body) {
String signString = method.toUpperCase() + "\n"
+ path + "\n"
+ timestamp + "\n"
+ (body == null ? "" : body);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] sig = mac.doFinal(signString.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(sig);
}
// 验证签名
public static boolean verify(String secretKey, String method,
String path, long timestamp,
String body, String signature) {
// 检查时间戳(5 分钟有效期)
if (Math.abs(System.currentTimeMillis() - timestamp) > 300_000) {
return false;
}
String expected = sign(secretKey, method, path, timestamp, body);
return MessageDigest.isEqual(
expected.getBytes(), signature.getBytes());
}
}MessageDigest.isEqual() 做比较,而不是 String.equals()。普通字符串比较会在第一个不匹配字符处提前返回,攻击者可以通过测量响应时间逐字节猜测签名值(时序攻击)。恒定时间比较无论是否匹配都遍历完整长度。七、性能怎么测
摘要吞吐受输入块大小、CPU 指令集、密码提供者、JIT 预热和并行方式影响。基准应固定算法实现与提供者,分别测量小消息延迟和大数据流吞吐,报告 JDK、CPU、操作系统、预热轮次和分位数。只写 CPU 型号与一组平均值无法复现。
BCrypt 与 Argon2 的成本由参数控制,目标是让离线猜测付出可调节的时间与内存代价。参数需要在认证服务器上测量,并根据登录并发设定预算。SHA-256、SM3 等快速摘要适合完整性与协议构造,不能替代密码哈希。
八、选型决策树
需要做什么?
│
├─ 校验数据完整性(文件/消息)
│ └─ SHA-256 / SM3
│
├─ 带密钥的消息认证
│ └─ HMAC-SHA256 / HMAC-SM3
│
├─ 存储用户密码
│ ├─ 新项目 → Argon2id
│ └─ 已有项目 → BCrypt (cost≥12)
│
├─ 从密码派生加密密钥
│ └─ PBKDF2 (≥600000次) 或 Argon2
│
├─ 从主密钥派生多个子密钥
│ └─ HKDF
│
├─ 高性能场景(大文件哈希)
│ └─ BLAKE3
│
└─ 国密合规要求
└─ SM3 / HMAC-SM3九、常见问题
Q:SHA-256 和 SHA-512 选哪个?
64 位系统上 SHA-512 反而更快(一次处理更多数据)。但 SHA-256 的 256 bit 输出已经足够安全。如果没有特殊需求,SHA-256 就够了。
Q:SM3 性能比 SHA-256 差吗?
差距在 10%-20%,工程上可忽略。信创项目直接用 SM3,不用纠结性能差异。
Q:HMAC 的密钥长度有要求吗?
建议密钥长度 ≥ Hash 输出长度(HMAC-SHA256 用 32 字节密钥)。太短会降低安全性,太长会先被 Hash 一次再使用(不影响安全性,但浪费)。
Q:BCrypt 的 cost 设多少合适?
在你的生产服务器上实测,让单次计算耗时在 200-500ms。2024 年通常建议 cost=12 起步。每 1-2 年评估一次是否需要 +1。
十、总结
| 要点 | 内容 |
|---|---|
| 安全哈希 | SHA-256 / SM3,不要再用 MD5/SHA-1 |
| 消息认证 | HMAC,不要用 Hash(key+msg) |
| 密码存储 | BCrypt/Argon2id,不要用 SHA256 |
| 防时序攻击 | 恒定时间比较 |
| 密钥派生 | HKDF(从主密钥),Argon2/PBKDF2(从密码) |