SM3 哈希计算流程

要理解校验为什么会失败,得先搞清楚 SM3 到底怎么算的,对吧?

SM3 的计算流程,说白了就是三步走:消息填充 → 迭代压缩 → 输出。听起来简单,但每一步都有坑。我个人习惯把这三个阶段比作「备料、烹饪、装盘」,任何一个环节出问题,最终出来的「菜」——也就是哈希值——就不对。

我记得有一次排查一个校验失败的问题,折腾了大半天,最后发现是消息填充时少补了一个比特。这种错误,你光看输出根本看不出来,得把流程拆开一步一步对。

下面我们逐一拆解,看看每个环节都做了什么,容易在哪里翻车。

核心要点:SM3 的输入是任意长度的消息(比特串),输出固定 256 比特(32 字节)的哈希值。中间过程全部在 32 位字(word)上操作。

2.1 消息填充(Padding)

SM3 要求输入的消息长度必须是 512 比特(64 字节)的整数倍。如果不满足,就要填充。

填充规则很简单,就三步:

  1. 补一个 1:在消息末尾追加一个比特 '1'。
  2. 补若干 0:接着补 k 个 '0',k 满足 (原始比特长度 + 1 + k) ≡ 448 mod 512。
  3. 补 64 比特长度:最后把原始消息的长度(用 64 位无符号整数表示)附加上去。

为什么要这么设计?你想想看,这样做的好处是:

  • 第一步的 '1' 是个哨兵,告诉算法「这里开始是填充数据了」。
  • 它保证了填充后的消息正好是 512 比特的整数倍。
  • 长度信息的补入,让算法知道原始消息有多长,从而消除不同长度产生相同填充结果的可能。
我的一点经验:在软件实现中,消息填充通常不是性能瓶颈。但在硬件实现或嵌入式环境中,尤其是处理大文件时,填充逻辑的边界条件极其容易出错。我曾经碰到过一个案例,因为消息长度刚好是 448 的倍数,填充时多补了一个完整的 512 块,导致整个哈希值跑偏。

举个具体的例子。假设原始消息只有 1 比特:

原始消息: 1 (二进制:'1')
原始长度: 1 bit

填充步骤:
1. 补 '1': 1|1 = '11'
2. 补 k 个 '0': k = 448 - (1 + 1) mod 512 = 448 - 2 = 446
   所以这里补 446 个 0
   -> '11' + 446个'0'
3. 补 64 比特长度: 原始长度 1 = 0x0000 0000 0000 0001
   -> 最终填充后的消息是 512 比特整

2.2 迭代压缩(Iterative Compression)

填充完之后,消息就被分成一个个 512 比特的分组。每个分组都会经过一个叫「压缩函数」的东西处理。处理的时候,会带一个 256 比特的「中间状态」进去,输出一个新的 256 比特状态。这个状态一路传下去,最后一个分组处理完,就得到了最终的哈希值。

我建议把迭代压缩理解成一个「流水线」:

  1. 初始化状态(IV):SM3 规定了一个初始值,共 8 个 32 位字。这个值是固定的,谁也改不了。如果初始化不对,后面全错。
  2. 消息扩展(Message Expansion):这也是很容易出错的一步。每个 512 比特分组(16 个 32 位字)被扩展成 132 个 32 位字(W0~W67 和 W'0~W'63)。扩展算法本质上是个线性反馈移位寄存器,用到的循环移位和异或操作,在软件里容易写岔。
  3. 64 轮压缩(64 Rounds):用扩展出来的 132 个字,加上当前的状态,进行 64 轮迭代。每一轮都用到了布尔函数(异或、与、或、非)和循环移位。这一步的计算量最大,也是校验失败的高发区——轮函数的一个位移写错,结果就全歪了。
注意:迭代压缩中的消息扩展和 64 轮压缩,使用的是大端(Big-Endian)字节序。如果你在 x86 等小端机器上实现,必须做字节序转换。我之前帮人排查过一个 Bug,就是在消息扩展前忘了把字转换成大端,结果哈希值完全不对。

每轮的压缩过程可以用这个伪代码表示:

// 假设有 8 个寄存器 A, B, C, D, E, F, G, H
// 每轮输入:扩展字 Wj 和 W'j

for j = 0 to 63:
    SS1 = ((A leftrotate 12) + E + (Tj leftrotate j)) leftrotate 7
    SS2 = SS1 XOR (A leftrotate 12)
    TT1 = FFj(A, B, C) + D + SS2 + W'j
    TT2 = GGj(E, F, G) + H + SS1 + Wj
    D = C
    C = B leftrotate 9
    B = A
    A = TT1
    H = G
    G = F leftrotate 19
    F = E
    E = P0(TT2)
// 其中 Tj 是固定常数,FFj 和 GGj 是布尔函数,P0 是置换函数

为什么要做这么多轮?说白了,轮数越多,消息之间的「雪崩效应」越强——输入稍微变一个比特,输出就天翻地覆。这也是哈希函数安全性的基石。

2.3 输出(Output)

最后一个分组处理完之后,当前的状态(A, B, C, D, E, F, G, H 这 8 个 32 位字)就是最终的哈希值。把它们按顺序拼接起来,得到 256 比特的输出。

这一步本身逻辑简单,但容易出幺蛾子的地方是:

  • 字节序问题:输出时每个字是按大端还是小端拼接?SM3 规定是大端。如果你的系统默认是小端,输出前需要反转每个字内的字节顺序。
  • 截断问题:SM3 标准输出就是 256 比特。如果你只取前 128 比特或其他长度,那就不是标准的 SM3 了。
  • 十六进制表示:我们通常看到的哈希值是 64 个十六进制字符。如果大小写、多余的空格或换行符混入,会导致字符串比较失败。每次输出前,我习惯先统一转成小写,去掉所有空白字符,再去做校验。
我的一个小习惯:在调试 SM3 的时候,我通常会打印出每个中间步骤的状态——包括填充后的分组、扩展后的前几个字、以及每一轮后的 A~H 值。这样一旦最终结果不对,我可以分段定位,看是分组错了,扩展错了,还是哪一轮压错了。比直接猜快多了。

整体流程框架图

为了让你更直观地看到这三个阶段之间的关系,我画了一张流程图。别小看这张图,它是我排查 SM3 问题时最常用的「地图」。上面标出了每个阶段的输入、输出和关键操作,你在对照调试时一眼就能看到自己卡在哪一步。

SM3 哈希计算流程框架 阶段1:消息填充 补 '1' 补 k 个 '0' 补 64 位长度 512 比特分组 阶段2:迭代压缩 初始化 IV (8个字) 消息扩展(16→132) 64 轮压缩函数 中间状态 (256位) 逐块 迭代 阶段3:输出 输出 256 比特哈希值 注意点 大端字节序 64个十六进制字符 统一小写、无空格 常见踩坑点 · 填充时 k 计算错误 · 扩展函数位移写错 · 轮函数Tj常数用错

看到这张图,你应该对整个流程有个整体印象了。从消息填充开始,到最后的 256 比特哈希值输出,中间每一步都有严格的规则和固定的参数。任何一个环节出现偏差,最终的哈希值就会完全不一样。

这就是为什么排查 SM3 校验失败时,需要「拆开来看」。别只看最后的结果对不对,要从消息填充、消息扩展、64 轮压缩、以及最终的输出格式上,逐个环节去排查。很多看似玄学的校验失败,查到最后,往往就是填充时少补了一个零,或者字节序没转过来。