SM3 算法流程详解:初始化、迭代压缩、输出

SM3 算法到底是怎么算出哈希值的?

很多人拿过来直接调用,从没看过它内部的运作。但如果你想做安全开发,或者移植到嵌入式硬件上,不了解流程可不行。

这一章我带你拆开 SM3 的肚子,看看它到底干了些什么。

核心一句话:SM3 是迭代型哈希函数。它先把消息切成块,再用固定逻辑一块一块地「碾压」,最后拼出结果。

SM3 算法核心流程 输入消息 消息填充 补 1 → 补 0 → 补长度 最终长度为 512 位整数倍 分组 (512 bit/块) 迭代压缩(64 轮) 消息扩展 + 压缩函数 + 中间状态 Vi 输出 256 bit 哈希 核心思想 Merkle-Damgård 结构 将长消息分块迭代压缩 最终得到固定长度摘要 初始值 IV 8 个 32 位常量 作为第 0 轮状态 V0 = IV

第一步:初始化——设定起点

说白了,就是给算法设置一个起点。这里的初始值 IV 是固定的 8 个 32 位字,一共 256 位。我刚开始学的时候总是记不住,后来写了个小脚本打印出来才熟。

寄存器初始值(十六进制)
V0[0]7380166f
V0[1]4914b2b9
V0[2]172442d7
V0[3]da8a0600
V0[4]a96f30bc
V0[5]163138aa
V0[6]e38dee4d
V0[7]b0fb0e4e

一点经验:这些 IV 值不是随便写的,它们来自 π 的二进制展开,能保证初始状态的随机性。我个人开发时习惯直接把这串常量写死在代码里,别用公式现场算,容易出错。

第二步:填充——让消息「对齐」

SM3 只处理 512 位的块。如果消息不满 512 位,或者长度不是 512 的整数倍,就得填充。

填充规则其实很简单:

  1. 先补一个 1 位(比特 1)
  2. 再补若干个 0,直到长度对 512 取模等于 448
  3. 最后用 64 位表示原始消息的长度(大端序)

举个例子。假设原始消息只有 5 个字节(40 比特)。填充后:

  • 补 1:1 比特 → 41 比特
  • 补 0:407 个零 → 达到 448 比特
  • 补长度:64 比特的 40(0x00000000 00000028)
  • 最终总长:512 比特,正好一块

我曾经踩过坑:有一次写 Python 实现,忘记处理长度刚好等于 448 的情况。按标准,即使长度已经是 448,也要补一个完整的 512 块。别问我怎么知道的,调试了一天。

第三步:迭代压缩——真正「干活」的地方

迭代压缩是 SM3 的主战场。你想想看,64 轮运算要从 16 个字扩展到 132 个字,再做各种移位、异或、布尔函数……我第一次对着标准看的时候,头都大了。

我们分两段来讲。

消息扩展

原始消息块是 16 个 32 位字。但压缩函数需要 132 个字。怎么来的?三步走:

  1. W0 ~ W15:直接从消息块取
  2. W16 ~ W67:靠 Wj-16、Wj-9、Wj-3 混合生成
  3. W'0 ~ W'63:W'j = Wj ⊕ Wj+4

你看,这里用了很多 XOR 和循环移位。如果对位运算不熟,建议先补一补。

64 轮压缩函数

这是最核心的部分。每轮用不同的常数 Tj,把 8 个状态字(A~H)揉到一起。为什么说「揉」?因为做了这些操作:

  • 布尔函数 FFj 和 GGj(前 16 轮和后 48 轮不一样)
  • 循环左移 P0 和 P1
  • 模 232 加法
  • 加上消息字 Wj 和 W'j

我写一段 Python 代码,帮你快速理解循环结构:

def sm3_compress(V, B):
    """压缩一个消息块 B,更新状态 V"""
    W = message_extension(B)  # 扩展出 132 个字
    A, B, C, D, E, F, G, H = V
    
    for j in range(64):
        if j < 16:
            FF = A ^ B ^ C
            GG = E ^ F ^ G
            T = 0x79cc4519
        else:
            FF = (A & B) | (A & C) | (B & C)
            GG = (E & F) | ((~E) & G)
            T = 0x7a879d8a
        
        SS1 = rotl((rotl(A, 12) + E + T) & 0xffffffff, 7)
        SS2 = SS1 ^ rotl(A, 12)
        TT1 = (FF + D + SS2 + W_prime[j]) & 0xffffffff
        TT2 = (GG + H + SS1 + W[j]) & 0xffffffff
        
        D = C
        C = rotl(B, 9)
        B = A
        A = TT1
        H = G
        G = rotl(F, 19)
        F = E
        E = P0(TT2)
    
    # 与本块初始状态异或
    return [ (V[i] ^ val) for i, val in enumerate([A,B,C,D,E,F,G,H]) ]

注意:64 轮必须完全执行,不能提前退出。每一轮的中间结果都会影响最终输出。我曾见过有人在测试时只跑前 16 轮验证,结果后面出 bug 没发现。

关键常数 Tj

轮次范围Tj 值
0 ≤ j ≤ 150x79cc4519
16 ≤ j ≤ 630x7a879d8a

为什么只分两段?因为 SM3 的设计者对布尔函数做了不对称处理。前 16 轮用简单的线性函数加速,后 48 轮用非线性函数增加复杂度。这个设计很有想法,既保证了速度又兼顾了安全。

第四步:输出——拿到最终结果

所有消息块都处理完后,Vn 就是 8 个 32 位字。按顺序拼起来就是 256 位的哈希值。

def sm3_final(V):
    """从最终状态拼接哈希值"""
    hash_bytes = b''
    for word in V:
        hash_bytes += word.to_bytes(4, 'big')
    return hash_bytes

这里唯一要注意的是字节序。每个字必须转成 4 字节的大端序。我见过有人用默认的「小端序」拼,结果哈希值怎么都不对。

一个小建议:输出之前先跟官方测试向量对一下。我每次写新的 SM3 实现,都会准备几组已知结果做回归测试。这能省掉很多 debug 时间。

到这里,SM3 的整个流程就说完了。初始化、填充、迭代压缩、输出,每步都不难,但合在一起就很有分量。你想想看,几十行代码背后是对抗了几十年的密码分析。