3. SM3密码杂凑算法:从原理到实战

SM3,国密标准里的哈希算法。说白了,就是给任意长度的数据算出一个固定长度的“指纹”。这个指纹有两大特性:一是别人很难伪造(抗碰撞),二是你改一个比特,指纹就完全变样(雪崩效应)。

我最早接触SM3是在一个政务系统的数据完整性校验项目里。当时客户要求必须用国密,不能用SHA-256。嗯,从那以后我就把SM3的细节啃了个透。今天咱们就把它彻底讲明白。

3.1 Merkle-Damgård结构:SM3的骨架

SM3用的是经典的Merkle-Damgård结构。这个结构长什么样?我画个图你就懂了。

核心思想:把消息切成固定大小的块,一块一块地处理。每一块的输出,都跟下一块搅在一起。最后一块处理完,就是最终的哈希值。

SM3 Merkle-Damgård 迭代压缩结构 消息块 M₁ 消息块 M₂ 消息块 M₃ 填充块 Mₙ IV (初始向量) CF 压缩函数 CF 压缩函数 CF 压缩函数 256位哈希值 每一块的输出(链值) 都作为下一块压缩函数的输入

你看这个图,消息被切成512位的块。第一块跟初始向量IV一起进压缩函数,算出一个256位的中间值。这个中间值再跟第二块一起进压缩函数……直到最后一块处理完,得到的就是最终的256位哈希值。

我的经验:Merkle-Damgård结构有个经典问题——长度扩展攻击。SM3在设计时已经考虑了这一点,但如果你自己实现哈希链式校验,记得把长度信息也编进去。我曾经见过一个系统因为这个漏洞被伪造了消息认证码,教训深刻。

3.2 消息填充:别小看这一步

SM3处理消息前,必须先填充。为什么?因为压缩函数一次吃512位,消息长度不一定正好是512的倍数。

填充规则其实很简单,就三步:

  1. 补一个"1":在消息末尾加一个比特1
  2. 补"0"直到满足条件:一直补0,直到消息长度 mod 512 = 448
  3. 补原始长度:最后64位放原始消息的长度(单位是比特)

举个例子。假设原始消息是"abc",长度24比特。填充后长这样:

原始消息:    01100001 01100010 01100011  (24位)
补1:         01100001 01100010 01100011 1
补0:         01100001 01100010 01100011 1000...000  (直到448位)
补长度:      最后64位放 24 的二进制表示
最终长度:    512位整

注意:填充是必须的,哪怕消息长度已经是512的倍数。我曾经在代码里忘了处理边界情况,结果两个不同长度的消息算出了相同的哈希值——这在密码学里叫碰撞,是严重事故。

3.3 迭代压缩:SM3的心脏

压缩函数是SM3最核心的部分。它接收两个输入:

  • 上一轮的256位链值(Vᵢ)
  • 当前512位消息块(Bᵢ)

输出一个新的256位链值(Vᵢ₊₁)。

压缩过程分三步:

  1. 消息扩展:把512位的消息块扩展成132个32位字(W₀~W₆₇和W'₀~W'₆₃)
  2. 64轮迭代:每轮用两个消息字,更新8个32位寄存器(A~H)
  3. 输出链值:把8个寄存器的值拼成256位输出

我刚开始看SM3的迭代公式时,觉得挺复杂。后来发现它跟SHA-256很像,只是用了不同的布尔函数和常数。说白了,就是一堆异或、与、或、循环移位的组合。

关键点:SM3的64轮迭代中,每轮都用了两个不同的消息字(Wⱼ和W'ⱼ)。这种设计增加了攻击者的分析难度。我个人觉得,这是SM3比SHA-256更抗某些攻击的原因之一。

3.4 SM3 vs SHA-256:到底差在哪?

很多人问我:SM3和SHA-256到底哪个好?我的回答是:看场景。国密要求用SM3,那就用SM3。但从技术角度,我列个对比表:

对比项 SM3 SHA-256
输出长度 256位 256位
消息块大小 512位 512位
迭代轮数 64轮 64轮
布尔函数 GG、FF(自定义) Σ、Ch、Maj(标准)
消息扩展 生成132个字 生成64个字
安全性 等效于SHA-256 等效于SM3
国密合规 是 否

你看,技术上两者半斤八两。SM3的消息扩展更复杂(132个字 vs 64个字),理论上抗差分攻击更强。但实际应用中,两者的安全性都在可接受范围内。

避坑指南:我曾经在一个跨国项目里,外方坚持用SHA-256,中方要求用SM3。最后方案是:两套哈希都算,各自验证。虽然多了一倍计算量,但合规和兼容都满足了。这种妥协在工程里很常见。

3.5 Python实现SM3:动手写一个

理论说完了,咱们动手。下面是一个完整的SM3实现,我加了详细注释。

import struct

# SM3初始向量
IV = [0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600,
      0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E]

# 常量T
def T(j):
    if j < 16:
        return 0x79CC4519
    else:
        return 0x7A879D8A

# 循环左移
def left_rotate(x, n):
    return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF

# 布尔函数
def FF(X, Y, Z, j):
    if j < 16:
        return X ^ Y ^ Z
    else:
        return (X & Y) | (X & Z) | (Y & Z)

def GG(X, Y, Z, j):
    if j < 16:
        return X ^ Y ^ Z
    else:
        return (X & Y) | ((~X) & Z)

# 置换函数
def P0(X):
    return X ^ left_rotate(X, 9) ^ left_rotate(X, 17)

def P1(X):
    return X ^ left_rotate(X, 15) ^ left_rotate(X, 23)

# 消息扩展
def msg_extension(B):
    W = list(struct.unpack('>16I', B))
    for j in range(16, 68):
        W.append(P1(W[j-16] ^ W[j-9] ^ left_rotate(W[j-3], 15)) ^ 
                 left_rotate(W[j-13], 7) ^ W[j-6])
    W1 = [W[j] ^ W[j+4] for j in range(64)]
    return W, W1

# 压缩函数
def CF(V, B):
    W, W1 = msg_extension(B)
    A, B_reg, C, D, E, F, G, H = V
    for j in range(64):
        SS1 = left_rotate((left_rotate(A, 12) + E + left_rotate(T(j), j)) & 0xFFFFFFFF, 7)
        SS2 = SS1 ^ left_rotate(A, 12)
        TT1 = (FF(A, B_reg, C, j) + D + SS2 + W1[j]) & 0xFFFFFFFF
        TT2 = (GG(E, F, G, j) + H + SS1 + W[j]) & 0xFFFFFFFF
        D = C
        C = left_rotate(B_reg, 9)
        B_reg = A
        A = TT1
        H = G
        G = left_rotate(F, 19)
        F = E
        E = P0(TT2)
    return [x ^ y for x, y in zip(V, [A, B_reg, C, D, E, F, G, H])]

# 填充
def padding(msg):
    m = bytearray(msg)
    length = len(m) * 8
    m.append(0x80)
    while (len(m) * 8) % 512 != 448:
        m.append(0x00)
    m += struct.pack('>Q', length)
    return bytes(m)

# SM3主函数
def sm3(msg):
    padded = padding(msg)
    V = IV[:]
    for i in range(0, len(padded), 64):
        block = padded[i:i+64]
        V = CF(V, block)
    return ''.join(f'{x:08x}' for x in V)

# 测试
if __name__ == '__main__':
    test_msg = b'abc'
    print(f'SM3("{test_msg.decode()}") = {sm3(test_msg)}')
    # 预期输出: 66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0

运行建议:把这段代码保存为 sm3.py,直接运行就能看到结果。你可以试试改一个字符,看看哈希值的变化——这就是雪崩效应。

代码里我用了纯Python,没依赖任何第三方库。这样你就能看清每一步在干什么。实际生产环境建议用 gmssl 或 pysmx 这类优化过的库,性能会好很多。

性能提醒:纯Python实现SM3,处理大文件会很慢。我曾经用这个版本算一个100MB的文件,等了快两分钟。生产环境一定要用C扩展或硬件加速。

好了,SM3的原理和实现就讲到这里。你把这个代码跑一遍,再对照前面的流程图看一遍,基本就掌握了。哈希算法这东西,光看理论不行,一定要动手写代码、看输出、改参数,才能真正理解它的行为。