4. SM3算法原理(下):压缩函数中的布尔函数与置换函数、输出哈希值
前面我们把SM3的消息扩展讲透了,今天进入最硬核的部分——压缩函数。说实话,我刚接触SM3那会儿,看到布尔函数和置换函数的公式,第一反应是头大。后来在项目里撸了一遍代码才明白,这些操作其实非常规整,甚至有点优雅。好,我们直接开干。
4.1 压缩函数概览
压缩函数是SM3的"发动机"。消息扩展之后生成了132个字(W₀~W₆₇ 和 W'₀~W'₆₃),压缩函数把这132个字"吞"进去,经过64轮迭代,最终吐出256位的哈希值。
每一轮压缩都用到两类核心操作:布尔函数和置换函数。嗯,这里要注意——这两类函数在64轮中并不是一成不变的,它们会"变脸"。
4.2 布尔函数:FF 与 GG
布尔函数说白了就是对三个字(X, Y, Z)做逻辑运算。SM3里有两个布尔函数:FF 和 GG。它们的定义分两段:
| 轮数范围 | FF(X, Y, Z) | GG(X, Y, Z) |
|---|---|---|
| 0 ≤ j ≤ 15 | X ⊕ Y ⊕ Z | X ⊕ Y ⊕ Z |
| 16 ≤ j ≤ 63 | (X ∧ Y) ∨ (X ∧ Z) ∨ (Y ∧ Z) | (X ∧ Y) ∨ (¬X ∧ Z) |
我个人觉得,前16轮用异或(⊕)是为了让数据充分"搅拌",后48轮改用与或组合,是为了引入非线性。你想想看,如果全程只用异或,整个压缩函数就变成线性的了,那安全性直接崩塌。
4.3 置换函数:P0 与 P1
置换函数做的是"移位+异或"的活。SM3里也有两个:P0 用在压缩函数内部,P1 用在消息扩展中。定义如下:
// 压缩函数内部的置换
P0(X) = X ⊕ (X <<< 9) ⊕ (X <<< 17)
// 消息扩展中的置换
P1(X) = X ⊕ (X <<< 15) ⊕ (X <<< 23)
我记得在项目中调试SM3时,发现P1的移位参数(15和23)和P0(9和17)很容易敲反。当时有个同事把P1的15写成了9,结果哈希值全错,查了两天才定位到。嗯,这种移位参数,建议你直接写死成宏定义,别手敲数字。
为什么非要这两个不同的置换函数?我的理解是:P0和P1分别服务不同的阶段——P0在压缩函数内部做"本地扩散",P1在消息扩展中做"跨字扩散"。各司其职,让雪崩效应更充分。
4.4 消息扩展与压缩流程
这部分我把流程串起来给你捋一遍:
- 消息扩展: 512位的消息分组扩展成132个字(W₀~W₆₇ 和 W'₀~W'₆₃),其中 W' 是通过 P1 置换生成的。
- 初始化: 8个工作寄存器(A~H)载入上一轮压缩结果或初始向量IV。
- 64轮压缩: 每一轮依次执行——
- 计算 SS1、SS2、TT1、TT2 四个中间变量;
- 其中 TT1 和 TT2 的计算会调用 FF / GG 布尔函数;
- 计算结果经 P0 置换后更新寄存器。
- 状态更新: 本轮的 A~H 与上一轮的对应值相加,进入下一轮。
我曾经帮一个团队审查SM3实现,发现他们把P0和P1用反了——压缩函数里用了P1,消息扩展里用了P0。结果就是:功能测试能过(因为都是置换),但向量测试全红。这类问题,单元测试测不出来,必须拿标准测试向量(比如GM/T 0004-2012里的示例)去验。
4.5 输出哈希值
64轮压缩结束后,8个寄存器(A~H)各32位,拼成一个256位(32字节)的输出。具体的拼接顺序是:
Hash = A || B || C || D || E || F || G || H
// 每个寄存器32位,大端序(Big-Endian)输出
大端序的意思是:每个寄存器的高位字节排在前面。我建议你在验证SM3输出时,先确认平台的字节序——x86是小端,ARM默认小端但可切,很多嵌入式平台也是小端。如果字节序搞反了,哈希值就会"翻跟头",检查起来非常头疼。
4.6 避坑清单
- 布尔函数分段: 前16轮和后48轮的FF/GG定义不同,务必用轮数
j做分支判断。 - 置换参数别混淆: P0用 (9,17),P1用 (15,23)。写成宏或常量,不要硬编码。
- 字节序: 输出哈希值一定是大端序。如果你在小端平台上做位运算,提前统一字节序。
- 寄存器初始化: 首次压缩用IV(7380166f 4914b2b9...),后续用上一轮的输出。别把IV用在非首块上。
- 测试向量验证: 写完实现后,一定跑GM/T 0004-2012附录里的示例。跑不过就别往下走了。
好了,压缩函数的底层算子我们就讲清楚了。布尔函数负责"混淆",置换函数负责"扩散"——这两个概念在密码学里反复出现,SM3把它们的组合做到了一个非常高效的平衡。下节课我们把这些算子组装起来,看完整的消息压缩流程如何运转。我在项目里等你。