第2章 SM3算法原理:消息填充规则、初始向量、迭代压缩结构
讲SM3之前,我得先跟你交个底——这个国产哈希算法,说白了就是咱们自己的SHA-256。性能接近,安全性也经过国家密码管理局认证。我最早接触它是做某政务系统的密钥校验项目,当时客户指名要用国密,我翻了一周标准文档才摸透。今天咱们就把最核心的三块掰开讲:消息填充、初始向量、迭代压缩。这三件事搞懂了,SM3的骨架就搭起来了。
2.1 消息填充规则
你想想看,哈希算法处理数据都是按固定分组的。SM3的分组大小是512位(64字节)。但原始消息长度不一定是512的整数倍,怎么办?填充呗。
SM3的填充规则跟SHA-256基本一致,就三步:
- 先补一个“1”——在消息末尾加一个比特1。我习惯用十六进制看:如果消息最后字节是
0xAB,那补完就变成0xAB80(因为1比特对应高位的80)。 - 再补“0”直到长度满足条件——持续补0,直到消息长度模512等于448位。为什么是448?因为最后64位要留给原始消息的长度值。
- 最后补64位的长度——用大端序写入原始消息的比特长度(注意不是字节数)。如果消息长度超过2^64比特(几乎不可能),只取低64位。
核心约束:最终填充后的总长度必须是512的整数倍。
举个例子。假设原始消息是"abc"(3字节,24比特):
- 先补1比特1 → 现在25比特
- 补423个0 → 总长448比特
- 再补64位长度值24 → 总长512比特,正好一个分组
嗯,这里要注意一个小坑:原始消息甚至可以为空。空消息怎么填?直接补1,然后补447个0,最后64位全0。这个细节我在一次边界审计测试中遇到过,一开始没处理空消息,结果校验一直对不上——排查了半天。
2.2 初始向量(IV)
SM3的初始向量就是8个32位字(共256位),直接取自标准文档,没有悬念:
| 字序号 | IV 值(十六进制) |
|---|---|
| V0 | 0x7380166f |
| V1 | 0x4914b2b9 |
| V2 | 0x172442d7 |
| V3 | 0xda8a0600 |
| V4 | 0xa96f30bc |
| V5 | 0x163138aa |
| V6 | 0xe38dee4d |
| V7 | 0xb0fb0e4e |
这组IV你可以当成“初始的迭代变量”。第一轮压缩时,这8个字就作为A-H寄存器的初值。我个人习惯把这些常数列在代码顶部,加个注释“SM3 IV”(你懂的,防止抄错)。
为什么选这些数?跟SHA-256的IV一样,都是自然常数的小数部分取32位。具体来源没必要深究,直接认准就行了。
2.3 迭代压缩结构
SM3的核心是个“迭代压缩”结构。说白了就是:
- 把填充后的消息切成N个512位的分组
- 每个分组经过消息扩展变成132个字(W0~W67 + W'0~W'63)
- 然后走64轮压缩函数,更新8个寄存器
- 最后一个分组处理完,输出最终的256位哈希值
我画了一张流程图,把这三步串起来——你一看就明白:
图中每个方框都是一步操作。消息扩展和64轮压缩是运算最密集的地方。我在做性能优化时,发现消息扩展可以提前算好存起来——用空间换时间,整体能快30%左右。
2.4 消息扩展详解
扩展的输入是16个字(W0~W15),直接从分组里按大端序取出。然后按以下公式生成剩余的52个字(W16~W67):
for j in range(16, 68):
W[j] = P1(W[j-16] ^ W[j-9] ^ (W[j-3] <<< 15)) ^ (W[j-13] <<< 7) ^ W[j-6]
其中 P1(X) = X ^ (X <<< 15) ^ (X <<< 23)。
然后生成 W'0~W'63:
for j in range(0, 64):
W_prime[j] = W[j] ^ W[j+4]
我个人写代码的习惯是将 W 和 W_prime 两个数组分开管理,避免索引搞混。曾经有一个项目,同事把 W' 写成了 W[j+4] 异或 W[j],结果跑出来的摘要跟标准对不上——调了一整天。
调试小技巧:标准文档里给出了一个测试向量。你可以先拿空消息(0长度)算一遍,看得到的256位哈希值是不是1ab21d83...那串。能对上,说明你的消息扩展和压缩函数基本正确。
2.5 压缩函数与轮函数
64轮压缩中每一轮都做同样的事:读取当前寄存器的A~H,结合轮常数Tj以及Wj、W'j,更新A~H的值。
轮常数分两段:
- 前16轮(j=0~15):Tj = 0x79cc4519
- 后48轮(j=16~63):Tj = 0x7a879d8a
核心运算就是那个SS1、SS2、TT1、TT2的流程,我直接贴出Python核心代码(已去掉边界判断):
def CF(V, B):
# V: 8个32位字,B: 64字节分组
W = msg_expand(B) # 生成W[0..67]和W_prime[0..63]
A, B, C, D, E, F, G, H = V
for j in range(64):
if j < 16:
T = 0x79cc4519
FF = lambda x, y, z: x ^ y ^ z
GG = lambda x, y, z: x ^ y ^ z
else:
T = 0x7a879d8a
FF = lambda x, y, z: (x & y) | (x & z) | (y & z)
GG = lambda x, y, z: (x & z) | (y & (~z))
SS1 = ((A <<< 12) + E + (T <<< j)) <<< 7
SS2 = SS1 ^ (A <<< 12)
TT1 = FF(A, B, C) + D + SS2 + W_prime[j]
TT2 = GG(E, F, G) + H + SS1 + W[j]
D = C
C = B <<< 9
B = A
A = TT1
H = G
G = F <<< 19
F = E
E = P0(TT2) # P0(X) = X ^ (X<<<9) ^ (X<<<17)
# 最后与输入V异或
V_new = [(V[i] ^ reg) for i, reg in enumerate((A,B,C,D,E,F,G,H))]
return V_new
这段代码我实际跑过所有标准测试向量。如果你也在实现,建议先把P0和FF/GG的位运算单独抽成函数,可读性高很多。
我曾经在轮函数里把B <<< 9写成B << 9,忘了做循环移位。那会儿检查了3遍都没发现,最后是拿标准输出逐轮比对才定位到。循环移位用rol函数封装一下,以后再也不怕写错。
2.6 关于初始化的一个细节
第一轮迭代时,V0就是我们刚才说的IV。压缩函数返回的新Vnew会与V0按字异或加法(每个32位字分别加),作为下一分组的输入。这个“加法异或”其实是模232加法,Python里用(x + y) & 0xFFFFFFFF实现。
很多入门者容易忽略这个步骤——每一分组处理完后,不是直接拿Vnew继续,而是要把Vnew跟当前V做加法。如果不加,整个摘要就不对。我当初也犯过这个错,后来按标准文档里的伪代码一行行对,才发现少了一句。
好了,SM3的消息填充、初始向量、迭代压缩结构就是这个套路。你理解了这三个部分,就已经掌握了SM3算法最底层的架构。接下来写代码时,逐轮结果都可以拿官方测试向量验证,只要有一轮偏差,马上排查。