← 返回

散列函数: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 算法一览

算法输出长度分组长度结构安全状态性能(相对)
MD5128 bit512 bitMerkle-Damgård❌ 碰撞已破解速度高
SHA-1160 bit512 bitMerkle-Damgård❌ 碰撞已破解
SHA-256256 bit512 bitMerkle-Damgård✅ 安全
SHA-512512 bit1024 bitMerkle-Damgård✅ 安全64位机更快
SHA-3(256)256 bit1088 bitSponge(海绵结构)✅ 安全略慢
SM3256 bit512 bitMerkle-Damgård✅ 安全与 SHA-256 相当
BLAKE3256 bit64 byteMerkle 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   │
   └────┬────┘
      输出 H

SHA-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") = becbbfaae6548b8bf0cfcad5a27183cd1be6093b1cceccc303d9c61d0a645268

SM3 与 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)
JWTHeader.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✅ 需要抗硬件攻击时
Argon22015 年密码哈希竞赛冠军,可调 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); // true

BCrypt 输出格式解析:

$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
怎么确定参数
在你的生产服务器上跑 benchmark,调整参数使单次哈希耗时在 200-500ms。太快不安全,太慢影响用户体验。BCrypt 的 cost 每 +1 耗时翻倍。

五、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(从密码)

上一篇密码学基础:Hash、对称加密、非对称加密

下一篇对称加密:AES 与 SM4 模式选择指南