3、SM3算法原理:密码杂凑算法结构、填充规则、迭代过程

SM3 是国密标准里的密码杂凑算法。说白了,它就是一把“数字指纹生成器”——任意长度的输入,都能压成一个 256 位的固定摘要。我最早接触 SM3 是在一个政务系统的签名项目里,当时总想着“不就是个 hash 嘛”,结果调试时因为填充细节没注意,死活对不上标准向量。从那以后,我再也不敢小看填充规则了。

3.1 算法总体结构

SM3 的结构其实很经典:先对消息做填充,再按 512 位分组,然后用一个压缩函数反复迭代。我个人习惯把它拆成四个阶段:

  • 消息预处理:填充 + 长度编码,保证分组完整。
  • 初始化:设置 8 个 32 位初始寄存器(IV)。
  • 消息扩展:每个 512 位分组扩展出 132 个 32 位字。
  • 迭代压缩:64 轮置换,更新寄存器值。

你想想看,整个流程就像工厂流水线——进来一条消息,先“整形”,然后“切块”,再“锻造”,最后出成品。下面这张图能帮你把全貌看清楚:

我的经验:调试 SM3 时,先验证填充后的字节是否正确,再检查扩展过程。很多坑都出在第一步,千万别跳过去。
SM3 算法整体流程 原始消息 (任意长度) 消息填充 (补1+0+长度) 按 512 位分组 迭代压缩 (64轮) 初始 IV (8个32位字) 消息扩展 W0~W67 + W'0~W'63 256 位摘要 注意 • 消息填充后长度 必须是512的倍数 • 初始IV固定 0x7380166f ...

3.2 填充规则

SM3 的填充跟 SHA-256 很像,但细节有差异。我曾在一次跨平台联调中,因为某个 SDK 把大端字节序搞反了,导致填充后的长度字段解析错误,哈希值整个不对。那感觉……嗯,查了一整天。

填充分三步:

  1. 补一个“1”:在消息末尾添加一个比特 1,即字节 0x80。
  2. 补“0”:直到消息长度与 448 模 512 同余(即剩余 64 位留给长度字段)。
  3. 加 64 位长度:原始消息长度(比特数)用大端序写在最后 8 个字节。

举个例子:若原始消息只有 1 字节 “0x61”(字符 'a'),填充后是这样:

原始:        0x61
补1:         0x61 0x80
补0至448位:  0x61 0x80 后跟 54 个 0x00
加长度:      最后8字节表示原始长度 8 比特 → 0x00 0x00 ... 0x00 0x08
曾经踩过的坑:协议里消息长度单位是“比特”而不是“字节”。如果你用字节数乘以8时漏了转成 64 位大端序,结果会完全跑偏。我自己就在一个 Go 实现的 SDK 里犯过这个错,调试打印才发现。

3.3 迭代过程与消息扩展

填充完成后,消息被切成 n 个 512 位的分组。每个分组都要经历一次“消息扩展”和“压缩函数”。我习惯把迭代逻辑写成伪代码,这样更直观:

// 假设 V0 为初始 IV(8 个 32 位字)
for i = 0 to n-1:
    // 1. 消息扩展:将 B_i 扩展成 132 个字
    W[0..15] = B_i  // 直接按字拷贝
    for j = 16 to 67:
        W[j] = P1(W[j-16] ⊕ W[j-9] ⊕ (W[j-3] << 15))
               ⊕ (W[j-13] << 7) ⊕ W[j-6]
    for j = 0 to 63:
        W'[j] = W[j] ⊕ W[j+4]

    // 2. 压缩函数:64 轮迭代
    V_{i+1} = CF(V_i, B_i)  // 里面跑 64 轮布尔+置换
    // 最终 V_{i+1} 作为下一组的初始值

// 输出 V_n 作为摘要

这里的 P1 是 SM3 特有的线性置换函数:

P1(X) = X ⊕ (X << 9) ⊕ (X << 17) (循环左移)。

压缩函数 CF 每轮用到两个消息字 Wj 和 W'j,加上 8 个寄存器 A~H。具体轮函数我就不贴全了,网上标准文档都有。但我可以分享一个调试技巧:

调试技巧:我习惯在每轮结束后打印 A~H 的值,跟标准测试向量对。如果某一轮出差错,多半是消息扩展算错了——特别是循环左移的位数容易写错。

3.4 压缩函数核心

SM3 的压缩函数本质是一个 64 轮的 Feistel 结构。每轮由三部分组成:

  • 布尔函数 FFj 与 GGj:前 16 轮用 FF1/GG1(异或为主),后 48 轮用 FF2/GG2(与/或为主)。
  • 置换函数 P0:对 32 位字做 X ⊕ (X << 9) ⊕ (X << 17)。
  • 常数 Tj:前 16 轮固定 0x79cc4519,后 48 轮固定 0x7a879d8a。

每一轮更新寄存器的方式可以概括为:

SS1 = ((A << 12) + E + (Tj << j)) << 7
SS2 = SS1 ⊕ (A << 12)
TT1 = (FFj(A,B,C) + D + SS2) + W'[j]
TT2 = (GGj(E,F,G) + H + SS1) + W[j]
D = C; C = B << 9; B = A; A = TT1
H = G; G = F << 19; F = E; E = P0(TT2)

注意这里循环左移的位数(12、7、9、19)都是 SM3 特有的。我记得早期有个开源实现把 B << 9 写成了 B << 8,结果只在最后几轮出错,排查起来特别痛苦。

总结一下:SM3 的迭代过程其实就是“扩展+压缩”的重复。每处理完一个分组,8 个寄存器的值就更新一次。全部处理完后,连在一起就是 256 位的摘要。我强烈建议你在实现时先跑一遍国标附录里的“示例 1”,确保每个中间结果都能对上。

3.5 参数速查表

为了方便你快速查阅,我把几个核心参数列成了表格:

参数值说明
摘要长度256 位(32 字节)最终输出固定
分组大小512 位(64 字节)迭代处理的基本单位
初始 IV0x7380166f, 0x4914b2b9, ...共 8 个字,国标明文
消息扩展字数132 个 (W0..67 + W'0..63)每组扩展一次
压缩轮数64 轮每轮消耗 2 个消息字
循环左移位数7,9,12,15,17,19分布在扩展和压缩中

嗯,SM3 的原理就聊到这儿。记住:填充是地基,扩展是骨架,压缩是核心算法。把这三部分拆开实现、分开验证,基本不会出大问题。