SM3的压缩函数详解:消息扩展与迭代压缩
1. 压缩函数的整体架构
压缩函数是SM3的心脏。说白了,它每次吞进512bit的消息分组和256bit的链接变量,吐出一个新的256bit摘要块。密钥派生场景下,这个函数会被反复调用——我见过不少开发者在边界处栽跟头,就是因为没搞懂压缩函数内部的数据流。
整个压缩过程分两大阶段:消息扩展和迭代压缩。先扩展,再压缩,顺序不能乱。我个人习惯把压缩函数看作一个"带状态更新的黑盒",理解它内部的两个阶段,调试时能省不少时间。
核心要点:压缩函数 = 消息扩展(16字→132字)+ 迭代压缩(64轮更新A~H寄存器)
2. 消息扩展:从16个字到132个字
消息扩展的目标很简单——把16个消息字变成132个。但为什么要扩这么多?你想想看,64轮压缩每轮要用到2个字,加上递推需要的中间变量,132个字刚好够用。
具体分三步:
- 直接赋值:将512bit分组B_i拆成16个32bit字W₀~W₁₅。这部分0开销,就是一次内存拷贝。
- 递推生成W₁₆~W₆₇:核心公式👇
Wⱼ = P₁(Wⱼ₋₁₆ ⊕ Wⱼ₋₉ ⊕ (Wⱼ₋₃ ⋘ 15)) ⊕ (Wⱼ₋₁₃ ⋘ 7) ⊕ Wⱼ₋₆
其中P₁(X) = X ⊕ (X ⋘ 15) ⊕ (X ⋘ 23)。 - 异或生成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ⱼ(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的核心。