SM3 算法核心三件套:消息扩展、压缩与置换
SM3 的轮函数其实不难理解,但很多人刚接触时容易被那一堆位运算吓住。我个人习惯把它的核心拆成三个模块:消息扩展先喂出 132 个 32 位字,压缩函数用 64 轮循环吃进去,置换函数在每一轮的最后做一次小小的洗牌。嗯,这样一拆,你想想看,是不是清晰多了?
消息扩展:从 16 到 132
为什么需要消息扩展?简单说,原始消息分组只有 512 位(16 个 32 位字),但 SM3 的压缩函数一次要用 132 个字。差的那 116 个字哪来?靠扩展算出来。
- 先把 512 位消息分成 16 个字:
W[0] ~ W[15]。 - 然后计算
W[16] ~ W[67]:
W[j] = P1(W[j-16] ⊕ W[j-9] ⊕ (W[j-3] <<< 15)) ⊕ (W[j-13] <<< 7) ⊕ W[j-6]
其中 P1(X) = X ⊕ (X <<< 15) ⊕ (X <<< 23)。别怕,就是个三位异或的循环移位。
我在项目中遇到过一个问题:新手容易把 <<< 写成普通左移,结果后面校验总对不上。记住,SM3 的循环移位是左移后把高位移到低位,Python 里可以 ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF。
最后计算 W'[0] ~ W'[63]:
W'[j] = W[j] ⊕ W[j+4]
扩展完成后,你手里就有了 68 个 W 和 64 个 W',一共 132 个字,刚好够压缩函数一顿吃。
压缩函数:64 轮“搅拌”
压缩函数是整个 SM3 最复杂的部分,但说白了就是 64 轮循环,每轮拿一个字 W[j] 和一个 W'[j] 去更新 8 个 32 位寄存器 V[0] ~ V[7]。
我用一张图把流程串起来——
每轮计算的核心就两个临时值 TT1 和 TT2,然后用它们更新寄存器。公式长这样(以第 j 轮,j 从 0 到 63):
SS1 = ((V[0] <<< 12) + V[4] + (T[j] <<< (j mod 32))) <<< 7
SS2 = SS1 ⊕ (V[0] <<< 12)
TT1 = FF[j](V[0], V[1], V[2]) + V[3] + SS2 + W'[j]
TT2 = GG[j](V[4], V[5], V[6]) + V[7] + SS1 + W[j]
V[3] = V[2]
V[2] = V[1] <<< 9
V[1] = V[0]
V[0] = TT1
V[7] = V[6]
V[6] = V[5] <<< 19
V[5] = V[4]
V[4] = P0(TT2)
这里 T[j] 是预设常数:前 16 轮用 0x79CC4519,后 48 轮用 0x7A879D8A,记得别搞混。
V[0] 的前三轮数值才定位到一个移位方向错误。
置换函数:P0 与 P1
SM3 里有两个置换函数,名字很像但用法不同:
| 函数 | 公式 | 使用位置 |
|---|---|---|
P0(X) | X ⊕ (X <<< 9) ⊕ (X <<< 17) | 压缩函数每轮最后更新 V[4] |
P1(X) | X ⊕ (X <<< 15) ⊕ (X <<< 23) | 消息扩展中计算 W[j] |
你可能会问:为什么搞两个不同的?其实目的都一样——增加扩散性,让输入的一点变化迅速传递到整个状态。P0 的移位量是 9 和 17,P1 是 15 和 23,都是精心选的素数偏移。
def P0(x): return x ^ ((x<<9)|(x>>23)) ^ ((x<<17)|(x>>15)) & 0xFFFFFFFF
我曾经在审计别人的 SM3 实现时,发现他把 P0 和 P1 用反了,结果消息扩展出来的 W 全部错位,后面压缩出来的摘要自然也对不上。嗯,这种 bug 靠黑盒测试很难发现,但做边界审计时多打印几轮中间值一眼就能看出来。
64 轮之后的收尾
所有轮次跑完后,8 个寄存器的值与输入分组时的初始寄存器值做一次异或加,就是本分组的输出。具体是:
V_new[i] = V[i] ⊕ V_old[i] (i = 0..7)
然后这个 V_new 作为下一个分组的初始值继续,直到所有消息分组处理完毕。最后一个分组的 V_new 直接拼成 256 位就是 SM3 的杂凑值。
说起来,这个“异或加”其实是迭代哈希的标准做法,能防止长度扩展攻击?嗯,SM3 的设计本身已经考虑了,不过这是后话。
W[j] 或 W'[j] 数组本身,只读不写。我见过有人试图省内存,直接覆盖 W[0] 当临时变量,结果下一轮用到这个位置时数值已经变了,血亏。