SM3 算法流程详解:初始化、迭代压缩、输出
SM3 算法到底是怎么算出哈希值的?
很多人拿过来直接调用,从没看过它内部的运作。但如果你想做安全开发,或者移植到嵌入式硬件上,不了解流程可不行。
这一章我带你拆开 SM3 的肚子,看看它到底干了些什么。
核心一句话:SM3 是迭代型哈希函数。它先把消息切成块,再用固定逻辑一块一块地「碾压」,最后拼出结果。
第一步:初始化——设定起点
说白了,就是给算法设置一个起点。这里的初始值 IV 是固定的 8 个 32 位字,一共 256 位。我刚开始学的时候总是记不住,后来写了个小脚本打印出来才熟。
| 寄存器 | 初始值(十六进制) |
|---|---|
| V0[0] | 7380166f |
| V0[1] | 4914b2b9 |
| V0[2] | 172442d7 |
| V0[3] | da8a0600 |
| V0[4] | a96f30bc |
| V0[5] | 163138aa |
| V0[6] | e38dee4d |
| V0[7] | b0fb0e4e |
一点经验:这些 IV 值不是随便写的,它们来自 π 的二进制展开,能保证初始状态的随机性。我个人开发时习惯直接把这串常量写死在代码里,别用公式现场算,容易出错。
第二步:填充——让消息「对齐」
SM3 只处理 512 位的块。如果消息不满 512 位,或者长度不是 512 的整数倍,就得填充。
填充规则其实很简单:
- 先补一个 1 位(比特 1)
- 再补若干个 0,直到长度对 512 取模等于 448
- 最后用 64 位表示原始消息的长度(大端序)
举个例子。假设原始消息只有 5 个字节(40 比特)。填充后:
- 补 1:1 比特 → 41 比特
- 补 0:407 个零 → 达到 448 比特
- 补长度:64 比特的 40(0x00000000 00000028)
- 最终总长:512 比特,正好一块
我曾经踩过坑:有一次写 Python 实现,忘记处理长度刚好等于 448 的情况。按标准,即使长度已经是 448,也要补一个完整的 512 块。别问我怎么知道的,调试了一天。
第三步:迭代压缩——真正「干活」的地方
迭代压缩是 SM3 的主战场。你想想看,64 轮运算要从 16 个字扩展到 132 个字,再做各种移位、异或、布尔函数……我第一次对着标准看的时候,头都大了。
我们分两段来讲。
消息扩展
原始消息块是 16 个 32 位字。但压缩函数需要 132 个字。怎么来的?三步走:
- W0 ~ W15:直接从消息块取
- W16 ~ W67:靠 Wj-16、Wj-9、Wj-3 混合生成
- W'0 ~ W'63:W'j = Wj ⊕ Wj+4
你看,这里用了很多 XOR 和循环移位。如果对位运算不熟,建议先补一补。
64 轮压缩函数
这是最核心的部分。每轮用不同的常数 Tj,把 8 个状态字(A~H)揉到一起。为什么说「揉」?因为做了这些操作:
- 布尔函数 FFj 和 GGj(前 16 轮和后 48 轮不一样)
- 循环左移 P0 和 P1
- 模 232 加法
- 加上消息字 Wj 和 W'j
我写一段 Python 代码,帮你快速理解循环结构:
def sm3_compress(V, B):
"""压缩一个消息块 B,更新状态 V"""
W = message_extension(B) # 扩展出 132 个字
A, B, C, D, E, F, G, H = V
for j in range(64):
if j < 16:
FF = A ^ B ^ C
GG = E ^ F ^ G
T = 0x79cc4519
else:
FF = (A & B) | (A & C) | (B & C)
GG = (E & F) | ((~E) & G)
T = 0x7a879d8a
SS1 = rotl((rotl(A, 12) + E + T) & 0xffffffff, 7)
SS2 = SS1 ^ rotl(A, 12)
TT1 = (FF + D + SS2 + W_prime[j]) & 0xffffffff
TT2 = (GG + H + SS1 + W[j]) & 0xffffffff
D = C
C = rotl(B, 9)
B = A
A = TT1
H = G
G = rotl(F, 19)
F = E
E = P0(TT2)
# 与本块初始状态异或
return [ (V[i] ^ val) for i, val in enumerate([A,B,C,D,E,F,G,H]) ]
注意:64 轮必须完全执行,不能提前退出。每一轮的中间结果都会影响最终输出。我曾见过有人在测试时只跑前 16 轮验证,结果后面出 bug 没发现。
关键常数 Tj
| 轮次范围 | Tj 值 |
|---|---|
| 0 ≤ j ≤ 15 | 0x79cc4519 |
| 16 ≤ j ≤ 63 | 0x7a879d8a |
为什么只分两段?因为 SM3 的设计者对布尔函数做了不对称处理。前 16 轮用简单的线性函数加速,后 48 轮用非线性函数增加复杂度。这个设计很有想法,既保证了速度又兼顾了安全。
第四步:输出——拿到最终结果
所有消息块都处理完后,Vn 就是 8 个 32 位字。按顺序拼起来就是 256 位的哈希值。
def sm3_final(V):
"""从最终状态拼接哈希值"""
hash_bytes = b''
for word in V:
hash_bytes += word.to_bytes(4, 'big')
return hash_bytes
这里唯一要注意的是字节序。每个字必须转成 4 字节的大端序。我见过有人用默认的「小端序」拼,结果哈希值怎么都不对。
一个小建议:输出之前先跟官方测试向量对一下。我每次写新的 SM3 实现,都会准备几组已知结果做回归测试。这能省掉很多 debug 时间。
到这里,SM3 的整个流程就说完了。初始化、填充、迭代压缩、输出,每步都不难,但合在一起就很有分量。你想想看,几十行代码背后是对抗了几十年的密码分析。