3. SM3的算法流程与轮函数

SM3到底是怎么把一段任意长度的消息,变成那串固定的256位哈希值的?说白了,核心就三步:填充 → 扩展 → 压缩。很多刚接触国密的朋友,一上来就盯着轮函数看,反而把整体流程忽略了。我个人习惯先搭框架,再抠细节——这样代码写起来不容易跑偏。

下面这张图把SM3的完整脉络画出来了,建议你先盯着它看30秒,再往后读细节。

SM3 总体流程图 原始消息 消息填充(补位+补长度) 消息块(512位分组) 消息扩展(Wj, W'j) 迭代压缩(64轮) 256位哈希值(输出) 轮函数核心(每轮) ① SS1, SS2 计算 ② TT1, TT2 计算 ③ FFj / GGj 布尔函数 ④ P0 置换 & 寄存器更新
读图提示: 消息扩展和迭代压缩是SM3的两大引擎。扩展负责把16个消息字"繁衍"成132个;压缩则用64轮操作把这些字"揉"进8个寄存器里。

3.1 消息填充——别在这步翻车

填充的目的只有一个:让消息长度变成512位的整数倍。规则其实很直白:

  1. 先补一个1(二进制位)
  2. 再补k个0,使得 (原始长度 + 1 + k) ≡ 448 mod 512
  3. 最后补64位,写入原始消息的比特数(大端序)

我曾经在一个嵌入式项目里吃过亏——平台是little-endian的,填充长度时忘了转大端,结果算出来的哈希跟标准测试向量对不上。查了一整天才发现。所以我的建议是:填充代码写完,先用SM3官方示例向量跑一遍,别凭感觉来。

注意: 填充长度是比特数,不是字节数。消息长度超过2^64比特的情况极少,但标准里写了要处理溢出,工业级实现必须考虑。

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
记忆窍门: 前16轮FFj和GGj都是"三输入异或",后48轮FFj是"多数表决函数",GGj是"选择函数"。写代码时我习惯用一个if-else或者查表法来区分,别在64轮循环里写两份重复代码。

3.5 输出哈希——收尾

一个消息块处理完后,把V₀~V₇这8个32位寄存器按顺序拼接,就是256位的哈希值。如果消息有多个消息块,当前块的输出会作为下一块的初始值继续迭代——这就是所谓的"迭代压缩"。

举个简单例子:空字符串的SM3哈希值是

66ab9b8c 0b4f2a7b 7c2a5a5c 5b0f6f7e
8c8b2a6c 1c7a3b0a 6c7c2a7b 8c0b4f2a

你可以拿这个测试自己的实现。我第一次调通SM3时,看到这个输出跟标准一致,心里那块石头才落地。

避坑: 寄存器更新时有先后顺序——V₀~V₃和V₄~V₇是并行更新的,但代码里必须用临时变量存旧值,否则后面的计算会被覆盖。我曾经见过有人V₂还没保存就被V₁覆盖了,结果算出来全错。

3.6 我个人踩过的两个坑

做SM3实现这些年,有些坑印象特别深,拿出来给你提个醒:

  1. 循环左移的优先级: 在C语言里,<<< 不是标准运算符。你得用 (x << n) | (x >> (32-n))。注意括号别漏了,移位优先级容易搞错。
  2. 模32位加法: 所有加法都是模2³²的,用32位无符号整数自然溢出就行。但如果你在Python里写,得手动 & 0xffffffff。
  3. 多块消息的处理: 第一个块压缩完的输出是第二个块的初始值,千万别清零重来。我第一次实现时就在循环外面把V₀~V₇重置了,查了两天bug。

嗯,这些坑说大不大,但碰到一次就够你头疼半天的。照着规范一步步来,写完多测几组向量,基本就稳了。