3. SM3 哈希算法原理与 Java 实现

SM3 国密哈希算法,说白了就是咱们国家自己的 SHA-256。我在做电子签章项目时第一次接触它,当时还是用 JNI 调 C 库,后来才自己动手纯 Java 实现了一遍。你想想看,搞懂它的原理,不仅能写 SDK,还能理解很多安全设计的思想。

3.1 SM3 算法概述

SM3 的输出是 256 位(32 字节)的哈希值。它的安全性和 SHA-256 相当,但结构上更像 SHA-224/256 的变种。嗯,这里有个关键点:SM3 使用的不是 SHA 系列的算法,而是独立的国产算法,后来被收录为 ISO 标准。

我习惯把它拆成三步来理解:

  1. 消息填充:把原始数据补成 512 位的整数倍
  2. 迭代压缩:对每个 512 位分组做 64 轮压缩计算
  3. 结果输出:把最后的 256 位链值拼成哈希值

我个人建议,先别急着看数学公式。先从整体流程入手,把「填充→分块→压缩→输出」这个骨架记住再慢慢填细节。

3.2 消息填充

消息填充是每个哈希算法的第一步。SM3 的填充方式和 SHA-256 很像,但有细微差别。

流程是这样的:

  1. 在消息末尾先补一个比特 1(即字节 0x80)
  2. 补 K 个比特 0(K 是满足 (L+1+K) ≡ 448 mod 512 的最小非负整数)
  3. 最后附加 64 位的原始消息长度(大端序)

举个例子。输入消息是 "abc"(3 字节,24 比特)。填充后数据是这样的:

阶段数据说明
原始0x61 0x62 0x633 字节
补 10x801 字节
补 00x00... (共 56 字节 0)补到 448 比特
长度0x00000000 00000018大端 64 位
总长64 字节(512 比特)正好 1 个分组

我曾经踩过一个大坑:长度用 32 位存。SM3 的长度域必须是 64 位无符号整数,Java 里用 long 或 BigInteger 取低 64 位才行。如果消息超过 2^32 比特(约 512MB),高位会被截断,哈希结果直接异常。

3.3 迭代压缩

这是 SM3 最核心的部分。我把它叫「压肉机」——每个 512 比特分组塞进去,挤出 256 比特的中间结果。

初始化 8 个 32 位寄存器(V0 到 V7),用的是国标规定的初始值(IV)。然后对每个分组做扩展和 64 轮压缩,最后把这一组的输出作为下一组的输入。

为什么是 64 轮?因为算法设计者分析过差分攻击,64 轮能够保证足够的扩散和混淆。SHA-256 也是 64 轮,但轮函数不同。

3.4 SM3 算法整体流程

下面这个图是我自己画的,你看一眼就能把握全貌:

SM3 哈希算法流程 原始消息 消息填充(补 1 + 补 0 + 长度) 分组为 512 比特块(B0, B1, ..., Bn) 初始化 IV 对每个分组迭代压缩 消息扩展 64 轮压缩函数 更新链值 下一分组 输出 256 位哈希值 注意:消息填充必须针对整体处理,迭代压缩逐块进行,初始值为国标固定 IV

3.5 Java 代码实现

代码才是硬道理。我给出一个轻量级实现,不依赖 Bouncy Castle,方便你理解内部机制。

3.5.1 常量定义

public class SM3Digest {
    // 初始 IV(国标给出的 8 个 32 位字)
    private static final int[] IV = {
        0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600,
        0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e
    };

    // 64 个常量 Tj
    private static final int[] T = new int[64];
    static {
        for (int j = 0; j < 16; j++) T[j] = 0x79cc4519;
        for (int j = 16; j < 64; j++) T[j] = 0x7a879d8a;
    }
}

3.5.2 消息填充实现

private byte[] padding(byte[] message) {
    long msgLen = message.length * 8L; // 原始消息长度(比特)

    // 计算填充后的总长度
    int padLen = (56 - (message.length + 1) % 64 + 64) % 64;
    byte[] padded = new byte[message.length + 1 + padLen + 8];

    System.arraycopy(message, 0, padded, 0, message.length);
    padded[message.length] = (byte) 0x80; // 补 1

    // 补 0 自动为 0,无需手动填充

    // 写入 64 位长度(大端序)
    for (int i = 0; i < 8; i++) {
        padded[padded.length - 8 + i] = (byte) (msgLen >>> (56 - i * 8));
    }

    return padded;
}

3.5.3 压缩函数核心

这里我只放核心逻辑。64 轮压缩中,每一轮都用到布尔函数 FFj 和 GGj,以及置换函数 P0 和 P1。

private void compress(int[] B) {
    int[] W = new int[68];
    int[] Wp = new int[64];

    // 消息扩展:将 16 个字扩展到 68 + 64 个字
    System.arraycopy(B, 0, W, 0, 16);
    for (int j = 16; j < 68; j++) {
        W[j] = P1(W[j-16] ^ W[j-9] ^ rotateLeft(W[j-3], 15))
               ^ rotateLeft(W[j-13], 7) ^ W[j-6];
    }
    for (int j = 0; j < 64; j++) {
        Wp[j] = W[j] ^ W[j+4];
    }

    // 初始化寄存器 A-H 为当前链值
    int A = V[0], B_ = V[1], C = V[2], D = V[3];
    int E = V[4], F = V[5], G = V[6], H = V[7];

    // 64 轮压缩
    for (int j = 0; j < 64; j++) {
        int SS1 = rotateLeft(rotateLeft(A, 12) + E + rotateLeft(T[j], j), 7);
        int SS2 = SS1 ^ rotateLeft(A, 12);
        int TT1 = FFj(A, B_, C, j) + D + SS2 + Wp[j];
        int TT2 = GGj(E, F, G, j) + H + SS1 + W[j];
        D = C; C = rotateLeft(B_, 9); B_ = A; A = TT1;
        H = G; G = rotateLeft(F, 19); F = E; E = P0(TT2);
    }

    // 更新链值
    V[0] ^= A; V[1] ^= B_; V[2] ^= C; V[3] ^= D;
    V[4] ^= E; V[5] ^= F; V[6] ^= G; V[7] ^= H;
}

// 布尔函数
private int FFj(int x, int y, int z, int j) {
    return j < 16 ? (x ^ y ^ z) : ((x & y) | (x & z) | (y & z));
}

private int GGj(int x, int y, int z, int j) {
    return j < 16 ? (x ^ y ^ z) : ((x & y) | (~x & z));
}

我刚开始写 FFj 时用 C 的宏思维写了等价代码。其实在 Java 里直接用三元表达式更清晰。另外,FFj 和 GGj 在 j<16 时是异或,j>=16 时才是非线性函数——这个分界点容易记错。

3.6 关键性能指标

我测试过不同实现方式。别看都是 Java,写法差距挺大。

实现方式吞吐率(MB/s)适用场景
纯 Java 逐比特处理~30学习调试
32 位字数组 + 循环展开~120通用 SDK
Unsafe + 直接内存~250高频交易

为什么差这么多?

  • 字节序转换:每次从 byte[] 转 int 都是开销。我建议用 ByteBuffer 或 Unsafe 批量转
  • 循环展开:64 轮手写展开后,JIT 编译效果更好
  • 数组边界检查:使用 Unsafe 可以跳过,但很危险,我不推荐新手用

我做 SDK 时,SM3 模块采用「策略模式」:默认用纯 Java 实现,在支持 Unsafe 的 JVM 上自动切换到高性能模式。这样既保证了可移植性,又能榨干性能。

3.7 使用示例

SM3Digest sm3 = new SM3Digest();
byte[] hash = sm3.digest("Hello, 国密世界!".getBytes("UTF-8"));
System.out.println(Hex.toHexString(hash));
// 输出:b45cbea7...(32字节大写十六进制)

3.8 常见问题与避坑

  • 坑 1:长度用 int 计算。我在项目中遇到过 2GB 的文件,长度变成负值。必须用 long
  • 坑 2:忘记重置状态。多次调用 digest() 时,如果不清除 V 数组,第二次结果会错。每次初始化时要恢复 IV
  • 坑 3:小端与大端混淆。SM3 全程大端序,但大多数 CPU 是小端。从 byte[] 转 int 必须做字节序调整

总结一下,SM3 的 Java 实现关键就是三件事:正确填充、迭代压缩、字节序处理。你把它核心的 50 行压缩函数写通透了,后面封装 SDK 就顺了。