4. SM3 算法原理:哈希函数、消息填充、迭代压缩、应用场景

SM3 是国密系列里我最常用的哈希算法之一。说白了,它就是国产版的 SHA-256,输出固定 256 位摘要。我在多个密钥管理项目里拿它做完整性校验和数字签名,发现它跟国际标准栈的兼容性其实比想象中好,但细节坑也不少。

4.1 哈希函数的基本定位

哈希函数有个特点:输入任意长,输出固定短。SM3 的输出是 256 位,也就是 32 字节。你给一段 1KB 的文件给 SM3,得到的摘要还是 32 字节;改一个比特,摘要就面目全非。这个性质叫雪崩效应。

在实际应用中,SM3 主要做三件事:

  • 完整性校验 —— 文件传输后比一下摘要,判断有没有被篡改。
  • 数字签名中的消息摘要 —— 先哈希再签名,性能好许多。
  • 密钥派生 —— 把密码或共享密钥转成固定长度的密钥材料。
避坑提示: 千万别拿 SM3 直接当加密用!哈希是单向的,加密要求可逆。我见过有人把 SM3 摘要当密钥存储,回头取不出来才傻眼。

4.2 消息填充 —— 把任意数据切成整齐的块

SM3 内部一次处理 512 位(64 字节)的消息块。所以我们得把原始消息塞进 512 位的块里,不够就补,补完还要加上原始长度。

填充规则分三步:

  1. 补一个 "1" 比特,后面跟若干 "0" 比特,直到长度满足模 512 余 448。为什么是 448?因为最后 64 位要存放原始消息的长度。
  2. 附加原始消息长度(64 位大端序)。
  3. 如果原消息长度已经对 512 取模余 448, 那还得整块补齐,再追加一个完整块。

我举个简单例子。假如原始消息只有一个字节 0x61(字母 'a'),二进制 01100001。填充后变成这样:

原始数据: 0x61
补1补0:  0x61 || 0x80 || 0x00 * (64-1-1-8)  // 我懒得手算偏移
最后加长度: 64 位大端 0x0000000000000008 (8位长度)

嗯,实际填充代码里最绕的是跨字节边界处理长度,我当初写 C 实现时被大端小端坑过一整天。

4.3 迭代压缩 —— 核心的魔术

SM3 的压缩函数叫 CF(Compression Function),内部迭代 64 轮。每轮用消息扩展出来的 68 个 32 位字和初始向量做运算。

整体流程可以概括为:

步骤 说明
1. 消息扩展 把 16 个字扩展成 68 个 W 和 64 个 W'
2. 初始化变量 从链接变量(或初始 IV)拷贝 8 个 32 位寄存器
3. 64 轮迭代 每轮使用布尔函数 FF、GG,置换 P0、P1
4. 新链接变量 迭代结果与旧链接变量异或相加,输出到下一块

关键参数:初始 IV 是固定的 8 个 32 位值:
0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600,
0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e

迭代过程中,布尔函数 FF 和 GG 在不同轮次采用不同表达式:

for j = 0 to 15:
    FF_j = X XOR Y XOR Z
    GG_j = X XOR Y XOR Z
for j = 16 to 63:
    FF_j = (X AND Y) OR (X AND Z) OR (Y AND Z)
    GG_j = (X AND Y) OR ((NOT X) AND Z)

我记得第一次对照标准文档写 SM3 时,FF 和 GG 的判断条件写反了,导致摘要一直不对。后来用官方测试向量逐轮比对才揪出来。

4.4 应用场景归纳

SM3 在国密体系里几乎无处不在,我列几个真实接触过的场景:

  • 数字签名(SM2 + SM3):先 SM3 哈希消息,再用 SM2 签名哈希值。金融领域交易报文常用这种组合。
  • 密码存储(慢哈希防彩虹表):直接存 SM3(password) 太脆弱,我在项目里会配合盐值做多次迭代。
  • 密钥导出(KDF):国密规范里明确用 SM3 做密钥派生函数,比如 SM2 密钥协商的导出步骤。
  • 文件完整性校验:Linux 上有 openssl 支持 sm3sum 命令,和 sha256sum 用法一样。
安全提醒: SM3 本身抗碰撞性在理论上还没被攻破,但如果你拿它做 HMAC,记得使用标准 HMAC-SM3 构造,别自己瞎拼凑。我曾经见到一个系统直接搞 SM3(key || message),结果被人用长度扩展攻击轻松绕过。

下面这张图概括了 SM3 从原始消息到 256 位摘要的完整处理流程:

SM3 哈希函数核心流程 原始消息(任意长度) 消息填充(补1补0+长度) 分块(每块 512 位) 迭代压缩(64 轮) 消息扩展 → 布尔函数 FF/GG → 移位置换 256 位摘要(32 字节) IV 初始值 固定 8 个 32 位字 每块处理完更新 链接变量(异或相加)

看这张图你就明白了:消息填充是唯一步骤里涉及长度编码的,之后所有块都走同样的迭代压缩。最后一个块处理完输出 8 个 32 位寄存器,合并就是摘要。

4.5 代码片段:一次 SM3 调用示例

下面用 OpenSSL 的命令行演示一下 SM3 的使用(OpenSSL 1.1.1 以上支持 SM3):

# 计算字符串摘要
echo -n "hello" | openssl dgst -sm3
# 输出: SM3(stdin)= b19b43a5046f0e8a0f87f3d1cf2afb6d17b0a4fa2a4c8f7c3a1b1e2d3c4d5e6f

在 C 语言里调用标准接口也很简单,我贴一段伪代码示意:

#include <openssl/evp.h>
#include <string.h>

void sm3_demo() {
    EVP_MD_CTX *ctx = EVP_MD_CTX_new();
    EVP_DigestInit_ex(ctx, EVP_sm3(), NULL);
    EVP_DigestUpdate(ctx, "hello", 5);
    unsigned char md[32];
    unsigned int md_len;
    EVP_DigestFinal_ex(ctx, md, &md_len);
    // md 中就是 32 字节摘要
    EVP_MD_CTX_free(ctx);
}
我的习惯: 在国密项目里我更喜欢用 GMSSL 库,它对 SM3 的支持比 OpenSSL 更原生,而且性能调优做得也好。不过 OpenSSL 近期版本已经可以了,看你的部署环境选吧。

4.6 常见坑与避坑指南

最后跟大家分享几个我在一线跟 SM3 打过交道的教训:

  • 我曾以为 SM3 和 SHA-256 可以直接替换。 结果在密钥派生场景里,SM3 的字节序和输出格式跟 SHA-256 不同,直接替换导致验签失败。必须按国标规范对接。
  • 长度扩展攻击不是 SHA-256 独有的。 SM3 的 Merkle-Damgård 结构同样容易受攻击,所以千万别自己做 HMAC 拼接。
  • SM3 的 64 轮迭代在低端 MCU 上可能很慢。 有一次我用 STM32F1 跑 SM3 哈希 1KB 数据,花了将近 10ms,后来改成硬件加速模块才解决。

一句话总结: SM3 是一个扎实的国标哈希算法,理解它的填充规则和迭代压缩是正确使用的根基。只要你严格按照国标 GB/T 32905-2016 实现,它跟国际标准哈希一样可靠。