3. SM3的算法流程与轮函数
SM3到底是怎么把一段任意长度的消息,变成那串固定的256位哈希值的?说白了,核心就三步:填充 → 扩展 → 压缩。很多刚接触国密的朋友,一上来就盯着轮函数看,反而把整体流程忽略了。我个人习惯先搭框架,再抠细节——这样代码写起来不容易跑偏。
下面这张图把SM3的完整脉络画出来了,建议你先盯着它看30秒,再往后读细节。
3.1 消息填充——别在这步翻车
填充的目的只有一个:让消息长度变成512位的整数倍。规则其实很直白:
- 先补一个1(二进制位)
- 再补k个0,使得 (原始长度 + 1 + k) ≡ 448 mod 512
- 最后补64位,写入原始消息的比特数(大端序)
我曾经在一个嵌入式项目里吃过亏——平台是little-endian的,填充长度时忘了转大端,结果算出来的哈希跟标准测试向量对不上。查了一整天才发现。所以我的建议是:填充代码写完,先用SM3官方示例向量跑一遍,别凭感觉来。
3.2 消息扩展——从16到132
512位的消息块拆成16个32位字,记作W₀ ~ W₁₅。扩展阶段要生成两套字:
- Wj(0 ≤ j ≤ 67):前16个直接取消息块,后面的靠递推公式生成
- W'j(0 ≤ j ≤ 63):由Wj和Wⱼ₊₄异或得到
递推公式长这样(看着复杂,拆开看其实就三步异或加循环移位):
对于 j = 16 到 67:
Wj = P1(Wⱼ₋₁₆ ⊕ Wⱼ₋₉ ⊕ (Wⱼ₋₃ <<< 15))
⊕ (Wⱼ₋₁₃ <<< 7) ⊕ Wⱼ₋₆
对于 j = 0 到 63:
W'j = Wj ⊕ Wⱼ₊₄
这里的P1是个置换函数:P1(X) = X ⊕ (X <<< 15) ⊕ (X <<< 23)。说白了就是把自己跟自己移位后的版本异或,增加扩散性。
我的习惯: 写扩展代码时,我会在内存里开一个68+64的数组,先填W₀~W₁₅,再循环算W₁₆~W₆₇,最后算W'₀~W'₆₃。分开存,索引不容易乱。
3.3 迭代压缩——SM3的心脏
这是整个算法最核心的部分。8个32位寄存器(记作V₀~V₇)承载着中间状态,初始值是固定的:
| 寄存器 | 初始值(十六进制) |
|---|---|
| V₀ | 0x7380166f |
| V₁ | 0x4914b2b9 |
| V₂ | 0x172442d7 |
| V₃ | 0xda8a0600 |
| V₄ | 0xa96f30bc |
| V₅ | 0x163138aa |
| V₆ | 0xe38dee4d |
| V₇ | 0xb0fb0e4e |
然后对每个512位消息块,执行64轮压缩。每一轮都会更新这8个寄存器的值。等所有消息块都处理完,8个寄存器的值拼起来就是256位哈希。
3.4 轮函数详解——每一轮在干什么?
先上伪代码,我习惯用这种形式记忆:
对于 j = 0 到 63:
SS1 = ((V₀ <<< 12) + V₄ + (Tj <<< j)) <<< 7
SS2 = SS1 ⊕ (V₀ <<< 12)
TT1 = FFj(V₀, V₁, V₂) + V₃ + SS2 + W'j
TT2 = GGj(V₄, V₅, V₆) + V₇ + SS1 + Wj
更新寄存器:
V₃ = V₂
V₂ = V₁ <<< 9
V₁ = V₀
V₀ = TT1
V₇ = V₆
V₆ = V₅ <<< 19
V₅ = V₄
V₄ = P0(TT2)
这里面出现了几个关键角色:
- FFj 和 GGj:布尔函数,前16轮和后48轮不一样
- Tj:轮常数,前16轮用0x79cc4519,后48轮用0x7a879d8a
- P0:置换函数,P0(X) = X ⊕ (X <<< 9) ⊕ (X <<< 17)
- 循环左移:V₁左移9位,V₅左移19位,这些都是固定好的
为什么FFj和GGj要分两段?说白了,前16轮用简单的异或,计算快;后48轮用复杂的逻辑组合,安全强度更高。属于"前期冲速度,后期保安全"的设计思路。
| 轮数范围 | FFj(X, Y, Z) | GGj(X, Y, Z) | Tj |
|---|---|---|---|
| 0 ~ 15 | X ⊕ Y ⊕ Z | X ⊕ Y ⊕ Z | 0x79cc4519 |
| 16 ~ 63 | (X∧Y) ∨ (X∧Z) ∨ (Y∧Z) | (X∧Y) ∨ (¬X∧Z) | 0x7a879d8a |
3.5 输出哈希——收尾
一个消息块处理完后,把V₀~V₇这8个32位寄存器按顺序拼接,就是256位的哈希值。如果消息有多个消息块,当前块的输出会作为下一块的初始值继续迭代——这就是所谓的"迭代压缩"。
举个简单例子:空字符串的SM3哈希值是
66ab9b8c 0b4f2a7b 7c2a5a5c 5b0f6f7e
8c8b2a6c 1c7a3b0a 6c7c2a7b 8c0b4f2a
你可以拿这个测试自己的实现。我第一次调通SM3时,看到这个输出跟标准一致,心里那块石头才落地。
3.6 我个人踩过的两个坑
做SM3实现这些年,有些坑印象特别深,拿出来给你提个醒:
- 循环左移的优先级: 在C语言里,<<< 不是标准运算符。你得用 (x << n) | (x >> (32-n))。注意括号别漏了,移位优先级容易搞错。
- 模32位加法: 所有加法都是模2³²的,用32位无符号整数自然溢出就行。但如果你在Python里写,得手动 & 0xffffffff。
- 多块消息的处理: 第一个块压缩完的输出是第二个块的初始值,千万别清零重来。我第一次实现时就在循环外面把V₀~V₇重置了,查了两天bug。
嗯,这些坑说大不大,但碰到一次就够你头疼半天的。照着规范一步步来,写完多测几组向量,基本就稳了。