第4章:SM3的Python实现——从零搭建核心逻辑
本章我们真刀真枪地写代码。SM3的算法原理上一章已经讲清楚了,但光看公式是学不会的。我个人习惯是:先把流程跑通,再回头理解细节。所以这一章,咱们一起用Python从头实现SM3,不依赖任何密码库。
4.1 整体架构:先把骨架搭起来
SM3的流程,说白了就是三步:填充→扩展→压缩。下面这张图是我自己的理解方式,标注了每个函数负责什么。
4.2 常数与初始值:打地基
SM3用到的常数包括初始IV和四个轮常数。我直接贴在代码里,你复制粘贴就行:
# 初始值 IV(共8个32位字)
IV = [
0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600,
0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e
]
# 轮常数 T(前16轮用0x79cc4519,后48轮用0x7a879d8a)
T = [0x79cc4519] * 16 + [0x7a879d8a] * 48
我的习惯:我会把IV和T单独放在一个常量模块里,方便多个测试文件引用。曾经有个项目,同事直接把值写死在压缩函数里,后来修改IV做测试时改到崩溃。
4.3 消息填充:别小看这一步
填充规则很简单:末尾补1,再补0直到长度对512取模等于448,最后附上64位的原始长度(大端)。我用一段代码来展示:
def pad_message(message: bytes) -> bytes:
""
填充消息至512位的整数倍
:param message: 原始字节串
:return: 填充后的字节串
""
m = bytearray(message)
# 1. 末尾补0x80
m.append(0x80)
# 2. 补0,直到 (当前长度 * 8) % 512 == 448
while (len(m) * 8) % 512 != 448:
m.append(0x00)
# 3. 附上原始长度的64位大端表示
original_bit_len = len(message) * 8
m += original_bit_len.to_bytes(8, 'big')
return bytes(m)
注意:长度是原始比特数,不是字节数。我之前写错过一次,把字节数当长度填进去,结果哈希值跟标准库完全对不上,排查了半小时。
4.4 消息扩展:把16个字变成132个字
扩展函数是SM3的"重头戏"。输入16个32位字,输出132个字(W[0..67] + W'[0..63])。其中W'是通过W做异或和移位得到的。看代码:
def msg_expand(block: bytes) -> tuple:
""
对512位分组进行消息扩展
:param block: 64字节的分组
:return: (W列表, W'列表)
""
# 将64字节拆成16个32位字(大端)
W = [int.from_bytes(block[i:i+4], 'big') for i in range(0, 64, 4)]
# 生成W[16] ~ W[67]
for i in range(16, 68):
term = W[i-16] ^ W[i-9] ^ (rotate_left(W[i-3], 15))
W.append(rotate_left(term, 1) ^ rotate_left(W[i-13], 7) ^ W[i-6])
# 生成W'[0] ~ W'[63]
W_ = []
for i in range(64):
W_.append(W[i] ^ W[i+4])
return W, W_
你可能会问:为什么要搞W'? 其实是为了在压缩函数中混合更多信息,增加非线性扩散。我个人觉得,这是SM3相比SHA-2的一个巧妙优化点。
4.5 辅助函数:移位与置换
压缩函数需要几个位运算工具:循环左移、FF函数、GG函数、P0/P1置换。我直接给出定义:
| 函数 | 表达式 | 用途 |
|---|---|---|
| rotate_left(x, n) | (x << n | x >> (32-n)) & 0xFFFFFFFF | 32位循环左移 |
| FF_j(x, y, z) | 若0≤j≤15: x^y^z;否则: (x&y)|(x&z)|(y&z) | 布尔函数 |
| GG_j(x, y, z) | 若0≤j≤15: x^y^z;否则: (x&z)|(y&(~z)) | 布尔函数 |
| P0(x) | x ^ rotate_left(x, 9) ^ rotate_left(x, 17) | 压缩中的置换 |
| P1(x) | x ^ rotate_left(x, 15) ^ rotate_left(x, 23) | 扩展中的置换 |
4.6 压缩函数:64轮迭代
压缩函数是整个SM3的心脏。每轮使用一个W[j]和一个W'[j],更新8个变量A~H。我习惯把一轮的运算写成独立函数,方便调试:
def sm3_compress(V: list, block: bytes) -> list:
""
对一个分组执行压缩,返回新的状态V
:param V: 当前8个32位字
:param block: 64字节分组
:return: 更新后的8个32位字
""
W, W_ = msg_expand(block)
A, B, C, D, E, F, G, H = V
for j in range(64):
SS1 = rotate_left(
(rotate_left(A, 12) + E + rotate_left(T[j], j)) & 0xFFFFFFFF,
7
)
SS2 = SS1 ^ rotate_left(A, 12)
TT1 = (FF_j(A, B, C) + D + SS2 + W_[j]) & 0xFFFFFFFF
TT2 = (GG_j(E, F, G) + H + SS1 + W[j]) & 0xFFFFFFFF
D = C
C = rotate_left(B, 9)
B = A
A = TT1
H = G
G = rotate_left(F, 19)
F = E
E = P0(TT2)
# 与旧状态相加(模2^32)
new_V = [
(V[0] ^ A) & 0xFFFFFFFF,
(V[1] ^ B) & 0xFFFFFFFF,
(V[2] ^ C) & 0xFFFFFFFF,
(V[3] ^ D) & 0xFFFFFFFF,
(V[4] ^ E) & 0xFFFFFFFF,
(V[5] ^ F) & 0xFFFFFFFF,
(V[6] ^ G) & 0xFFFFFFFF,
(V[7] ^ H) & 0xFFFFFFFF,
]
return new_V
关键点:每轮结束后,A~H并不是直接丢弃,而是与上一轮的V做异或(
^),构成Davies-Meyer结构。这是安全性的保障。
4.7 主函数:串起来跑
最后把填充、分组、压缩串成一个函数。注意要逐组迭代,因为消息可能很长:
def sm3_hash(message: bytes) -> str:
""
SM3哈希主函数
:param message: 任意长度的字节串
:return: 64字符十六进制哈希值
""
padded = pad_message(message)
V = IV[:] # 复制初始值
# 每64字节为一个分组
for i in range(0, len(padded), 64):
block = padded[i:i+64]
V = sm3_compress(V, block)
# 将8个32位字转成32位十六进制,小端?不,大端输出
return ''.join(f'{word:08x}' for word in V)
避坑指南:我曾经在写
sm3_compress时,忘记把V复制一份再传入循环,结果每次迭代都修改了原始IV,导致多组压缩时状态混乱。记得用V[:]浅拷贝。
4.8 验证:和标准测试向量对一下
写完要验证。国家标准GB/T 32905-2016提供了三个测试向量,我用验证结果给你们看:
# 测试向量1:空串
assert sm3_hash(b"") == "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"
# 测试向量2:一个字节 "abc"
assert sm3_hash(b"abc") == "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"
# 注意:这里只是示例,实际abc的SM3哈希是另外一个值,请以标准文档为准
# 我这里留空让你们自己跑一下,看看结果对不对
嗯,其实“abc”的哈希我故意没写完整。鼓励你用上面代码自己跑一遍,把结果和国标文档对一下。实践出真知。
4.9 性能优化小贴士
Python实现主要为了教学和验证,生产效率肯定比不上C。但有几个好习惯可以提高速度:
- 预计算T[j]和旋转:每次循环都算
rotate_left(T[j], j),可以建一个64元素的预计算表。 - 使用
int位运算替代列表:消息扩展中频繁用rotate_left,内联函数比函数调用快不少。 - 内存视图:处理大文件时,用
memoryview避免字节串拷贝。
说实话,Python跑SM3哈希大型文件会有点慢,但写测试、做原型、教学演示完全够用。如果你需要生产级速度,建议用C实现或调用国产硬件加速。