SM3的压缩函数详解:消息扩展与迭代压缩

1. 压缩函数的整体架构

压缩函数是SM3的心脏。说白了,它每次吞进512bit的消息分组和256bit的链接变量,吐出一个新的256bit摘要块。密钥派生场景下,这个函数会被反复调用——我见过不少开发者在边界处栽跟头,就是因为没搞懂压缩函数内部的数据流。

整个压缩过程分两大阶段:消息扩展和迭代压缩。先扩展,再压缩,顺序不能乱。我个人习惯把压缩函数看作一个"带状态更新的黑盒",理解它内部的两个阶段,调试时能省不少时间。

核心要点:压缩函数 = 消息扩展(16字→132字)+ 迭代压缩(64轮更新A~H寄存器)

SM3压缩函数核心流程 消息分组 B_i + V_i 消息扩展 W₀~W₁₅(直接赋值) W₁₆~W₆₇(递推生成) W'₀~W'₆₃(异或生成) 迭代压缩 加载 A~H 寄存器 64轮循环(SS1/SS2/TT1/TT2) 更新 A~H 寄存器 Vᵢ₊₁(256bit) B_i W_j, W'_j Vᵢ₊₁' Vᵢ₊₁ = Vᵢ ⊕ Vᵢ₊₁'

2. 消息扩展:从16个字到132个字

消息扩展的目标很简单——把16个消息字变成132个。但为什么要扩这么多?你想想看,64轮压缩每轮要用到2个字,加上递推需要的中间变量,132个字刚好够用。

具体分三步:

  1. 直接赋值:将512bit分组B_i拆成16个32bit字W₀~W₁₅。这部分0开销,就是一次内存拷贝。
  2. 递推生成W₁₆~W₆₇:核心公式👇
    Wⱼ = P₁(Wⱼ₋₁₆ ⊕ Wⱼ₋₉ ⊕ (Wⱼ₋₃ ⋘ 15)) ⊕ (Wⱼ₋₁₃ ⋘ 7) ⊕ Wⱼ₋₆
    其中P₁(X) = X ⊕ (X ⋘ 15) ⊕ (X ⋘ 23)。
  3. 异或生成W'₀~W'₆₃:W'ⱼ = Wⱼ ⊕ Wⱼ₊₄。注意j的范围是0~63,这个异或操作非常廉价。

我的经验:第一次实现消息扩展时,我把W₁₆~W₆₇的递推公式里的循环移位位数搞反了。当时测试向量死活对不上,查了整整半天才发现是P₁里的移位写成了常量。建议你先把单步递推的中间结果打印出来,跟标准测试向量逐字比对。

消息扩展参数速查
变量 数量 生成方式
W₀~W₁₅ 16 直接拆分B_i
W₁₆~W₆₇ 52 递推公式 + P₁置换
W'₀~W'₆₃ 64 W'ⱼ = Wⱼ ⊕ Wⱼ₊₄

3. 迭代压缩:64轮的精妙设计

迭代压缩是整个SM3最"绕"的部分。8个寄存器A~H,64轮循环,每轮用不同的Wⱼ和W'ⱼ,还有两套布尔函数和常量。

初始化很简单:把Vᵢ的256bit拆成8个字,分别塞进A~H。然后开始64轮"翻新"——每轮生成两个中间变量SS1和SS2,再算出TT1和TT2,最后按特定规则更新寄存器。

⚠️ 寄存器更新顺序是重灾区:每轮结束时D=C、C=B⋘9、B=A、A=TT1;H=G、G=F⋘19、F=E、E=P₀(TT2)。我遇到过好几次因为赋值顺序写错导致摘要全乱的情况。建议画一个寄存器流转图贴在显示器旁边。

布尔函数FFⱼ和GGⱼ在前后16轮行为不同:

FFⱼ / GGⱼ 定义对照
轮次范围 FFⱼ(A,B,C) GGⱼ(E,F,G)
j = 0~15 A ⊕ B ⊕ C E ⊕ F ⊕ G
j = 16~63 (A∧B) ∨ (A∧C) ∨ (B∧C) (E∧F) ∨ (¬E∧G)

常量Tⱼ也分两段:前16轮用0x79CC4519,后48轮用0x7A879D8A。你可能注意到了,后48轮的Tⱼ其实还会跟着轮次j做循环左移——但标准实现里通常预计算好每轮的Tⱼ值,运行时直接查表。

4. 实现要点与避坑指南

  • 循环展开:64轮压缩如果逐轮循环,性能堪忧。我习惯按8轮一组展开,同时利用SIMD指令并行处理多个消息分组。密钥派生场景下,消息长度固定,展开后吞吐量能翻倍。
  • 内存对齐:SM3处理的是32bit字,建议用内存对齐的缓冲区(比如32字节对齐)。我在一个嵌入式项目里踩过坑——未对齐的缓冲区在ARM Cortex-M上引发了硬fault。
  • 边界防篡改:密钥派生中压缩函数的输入可能来自不可信源。务必做边界检查:消息分组必须是完整的512bit,链接变量必须是256bit。我曾经遇到过一个case,攻击者通过截断消息分组让压缩函数进入了死循环。

小技巧:调试时准备一份SM3标准测试向量。先用单个分组的消息验证消息扩展的中间值,再验证压缩后的输出。分步验证比一步到位省心得多。

5. 代码示例:消息扩展 + 迭代压缩核心

下面是我在项目中使用的消息扩展代码片段。注意W数组的大小是68+64=132个字,别申明小了:

// 消息扩展:W[0..67] + W_[0..63]
void sm3_msg_expand(const uint32_t *block, uint32_t *W, uint32_t *W_) {
    int j;
    // W[0..15] 直接赋值
    for (j = 0; j < 16; j++) {
        W[j] = __builtin_bswap32(block[j]);  // 注意字节序!
    }
    // W[16..67] 递推
    for (j = 16; j < 68; j++) {
        uint32_t tmp = W[j-16] ^ W[j-9] ^ ROTL(W[j-3], 15);
        W[j] = P1(tmp) ^ ROTL(W[j-13], 7) ^ W[j-6];
    }
    // W_[0..63] 异或生成
    for (j = 0; j < 64; j++) {
        W_[j] = W[j] ^ W[j+4];
    }
}

迭代压缩的核心循环(只展示一轮的逻辑):

// 单轮压缩(j 为轮次,0..63)
void sm3_round(uint32_t *A, uint32_t *B, uint32_t *C, uint32_t *D,
               uint32_t *E, uint32_t *F, uint32_t *G, uint32_t *H,
               uint32_t Wj, uint32_t Wj_, uint32_t Tj) {
    uint32_t SS1 = ROTL(ROTL(*A, 12) + *E + ROTL(Tj, j), 7);
    uint32_t SS2 = SS1 ^ ROTL(*A, 12);
    uint32_t TT1 = FF(*A, *B, *C, j) + *D + SS2 + Wj_;
    uint32_t TT2 = GG(*E, *F, *G, j) + *H + SS1 + Wj;
    *D = *C;
    *C = ROTL(*B, 9);
    *B = *A;
    *A = TT1;
    *H = *G;
    *G = ROTL(*F, 19);
    *F = *E;
    *E = P0(TT2);
}

压缩函数完成后,别忘了把寄存器A~H的值与原来的Vᵢ做异或,得到Vᵢ₊₁。这个步骤常被新手遗漏——我自己第一次实现时就忘了,结果调试了一整晚。

记住:消息扩展和迭代压缩是SM3压缩函数的两条腿,缺一不可。前者把16个字"发酵"成132个字,后者用64轮循环把8个寄存器"揉"成一个新的256bit摘要。理解这两个过程,你就掌握了SM3的核心。