2. SM3 算法原理(上):消息填充、初始值与消息扩展

聊 SM3,咱们得先从根上把它的骨架搭起来。很多人一上来就捧着标准文档啃,结果被那一堆位运算和迭代公式劝退了。其实核心逻辑并不复杂,我就先把这一章要讲的三件事摆出来——填充、初始化、扩展。这三步是 SM3 算法运行的起点,也是很多人掉坑的地方。

SM3 算法原理(上)知识体系框架图 SM3 算法原理(上):三大核心模块 ① 消息填充 追加比特 “1” + 若干 “0” + 64 位长度 ② 初始值设定 V⁰ = IV,8 个 32 位寄存器的初态 ③ 消息扩展 16 → 132 个字:W₀…W₆₇, W′₀…W′₆₃ 💡 一句话串联 原始消息 → 填充成 512 位的整数倍 → 设定 8 个初始哈希值 → 对每个 512 位分组执行消息扩展 → 进入 64 轮压缩函数。

2.1 消息填充:补齐最后一块的讲究

SM3 处理消息的基本单元是 512 位的分组。但真实消息长度千奇百怪,不可能正好是 512 的整数倍。填充的目的,就是让原始消息加上填充数据后,总长度对 512 取模等于 448 —— 嗯,留出 64 位放原始长度。

具体规则我列个表,记牢了,面试和开发都经常考:

步骤操作说明
1末尾加一个比特 “1”字节角度就是 0x80
2填充 k 个比特 “0”k 是最小非负整数,使总长度 ≡ 448 (mod 512)
3追加 64 位大端表示的原始长度单位:比特(bit),不是字节

关键点:填充后的消息长度 = 原始长度 + 1 + k + 64,必然是 512 的整数倍。每个分组独立进行后续处理。

我曾经在项目中踩过这个坑:用 C 语言实现时,一开始把长度以字节为单位去填充,结果哈希值跟标准测试向量对不上。查了一下午才发现——SM3 标准里长度是比特数,不是字节数!所以如果你用 64 位存储长度,一定要先把字节数乘 8 再塞进去。尤其平台字节序不同时,还要注意大端序的转换。

实战技巧:我个人的习惯是在消息缓冲区里先用 memcpy 把原始数据拷进去,然后直接在缓冲区末尾写 0x80,再根据偏移计算要补的零的个数,最后用大端序写 64 位长度。这样不容易写错指针。

2.2 初始值设定:8 个“魔术数字”

SM3 的初始哈希值(IV)是 8 个 32 位字,分别赋给 8 个寄存器的初始状态 V⁰。这些值不是随便选的,都是标准里固定写死的。我直接列出来:

V⁰[0] = 0x7380166F
V⁰[1] = 0x4914B2B9
V⁰[2] = 0x172442D7
V⁰[3] = 0xDA8A0600
V⁰[4] = 0xA96F30BC
V⁰[5] = 0x163138AA
V⁰[6] = 0xE38DEE4D
V⁰[7] = 0xB0FB0E4E

为什么是这个值?你可以理解为它们是对前 8 个素数(2, 3, 5, 7, 11, 13, 17, 19)的平方根的小数部分取 32 位得到的。不过说实话,开发中你直接复制粘贴就好,不用自己算。但要注意——初始值必须严格按大端序存入寄存器。有的库会把 IV 按小端存,那算出来肯定不对。

我记得有一次帮同事排查 SM3 的 FPGA 实现,仿真结果总是差一点,最后发现是他把 IV 的字节序写反了。从那以后我每次写 SM3 都先打印一遍 IV 的十六进制值,跟标准对一遍才放心。

2.3 消息扩展:把 16 个字扩成 132 个字

消息扩展是 SM3 算法比较有特色的地方。原始每组 512 位(即 16 个 32 位字)会被扩展为 132 个字:W₀ ~ W₆₇(68 个)+ W′₀ ~ W′₆₃(64 个)。其中 W′ 是通过对 W 进行置换和异或生成的。整个过程是硬性规定好的,没有讨价还价的余地。

直接看公式吧——

  • W 的生成(48个字扩展):
    对于 j = 16 到 67:
    Wⱼ = P₁( Wⱼ₋₁₆ ⊕ Wⱼ₋₉ ⊕ (Wⱼ₋₃ <<< 15) ) ⊕ (Wⱼ₋₁₃ <<< 7) ⊕ Wⱼ₋₆
    其中 P₁(X) = X ⊕ (X <<< 15) ⊕ (X <<< 23)
  • W′ 的生成(从 W 派生):
    对于 j = 0 到 63:
    W′ⱼ = Wⱼ ⊕ Wⱼ₊₄

你可能觉得这堆移位和异或有点眼花。我跟你讲,实际写代码的时候,照着标准文档把循环翻译成 for 循环,注意每步的移位值不要搞错(15、7、23 这几个数字特别容易记混),然后测试一下标准输入输出,一次跑通就没问题。

注意:W 和 W′ 都是 32 位无符号整数。左移 15 位之类操作在 C 语言里必须配合 & 0xFFFFFFFF 做掩码,否则符号扩展会把你坑到怀疑人生。Java 里默认溢出截断就省事一些。

我个人习惯是先实现一个循环移位宏 #define ROTL(x, n) ((x) << (n) | (x) >> (32 - (n))),然后用它来写 P₁ 和消息扩展公式。这样代码可读性好,以后维护也轻松。对了,如果你在 Verilog 里写 SM3,这步是纯组合逻辑,时序约束时要特别关注这条路径的延时。

2.4 这一章的小结

消息填充、初始值、消息扩展,三个部分环环相扣。填充决定了每个分组的起点;初始值是整个压缩迭代的种子;扩展把 16 个字变成 132 个字,为后续 64 轮压缩提供充足的输入。

你在实现时,可以先把这三个模块单独写好并测试。我会先用一个非常短的字符串(比如 "abc")过一遍标准测试向量,查表确认填充后的分组是否正确,再扩展看看前几个 W 值和文档是否一致。这一步验证到位了,后面压缩函数就算不出错,整体哈希结果也基本稳了。