SM3算法Python实现:核心代码编写、模块化设计、单元测试
上一节我们把SM3的算法原理讲透了,这节咱们直接上手写代码。我个人习惯先把核心逻辑跑通,再拆模块,最后补测试——这个顺序在项目里踩坑最少。
4.1 核心代码编写:从消息分组到杂凑值
SM3的Python实现,说白了就是把消息填充、分组、迭代压缩这三步用代码串起来。我刚开始写的时候,最容易翻车的地方是填充规则和字移位,咱们一个个来。
4.1.1 消息填充
def sm3_pad(message: bytes) -> bytes:
"""SM3消息填充:补1、补0、补长度"""
m_len = len(message) * 8 # 比特长度
message += b'\x80' # 先补一个 '1'
while (len(message) * 8) % 512 != 448:
message += b'\x00' # 补 '0' 直到长度 mod 512 == 448
# 附加64位原始长度(大端)
message += m_len.to_bytes(8, 'big')
return message
提醒一句:填充后长度必须是512的整数倍,否则后面迭代会少处理一组。我在测试一个2048位消息时,就因为while条件写错成
% 512 == 0,debug了半小时。
4.1.2 常量与置换函数
# SM3常量
IV = [
0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600,
0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E
]
# 置换函数
def P0(X: int) -> int:
return X ^ (ROTL(X, 9)) ^ (ROTL(X, 17))
def P1(X: int) -> int:
return X ^ (ROTL(X, 15)) ^ (ROTL(X, 23))
为什么要有P0和P1?简单讲,P1用在消息扩展里增加非线性,P0用在压缩函数里扩散雪崩效应。你想想看,没有这两步,SM3的安全性就跟CRC差不多了。
4.1.3 消息扩展与压缩
def msg_expand(block: bytes) -> list:
"""将512位分组扩展为132个32位字:W0..W67, W'0..W'63"""
W = list(struct.unpack('>16I', block)) # W0..W15
for j in range(16, 68):
tmp = P1(W[j-16] ^ W[j-9] ^ ROTL(W[j-3], 15)) ^ ROTL(W[j-13], 7) ^ W[j-6]
W.append(tmp)
W_ = [W[j] ^ W[j+4] for j in range(64)]
return W, W_
def CF(V: list, block: bytes) -> list:
"""压缩函数:迭代64轮,更新8个32位字"""
W, W_ = msg_expand(block)
A, B, C, D, E, F, G, H = V
for j in range(64):
SS1 = ROTL((ROTL(A, 12) + E + ROTL(T[j], j % 32)) & 0xFFFFFFFF, 7)
SS2 = SS1 ^ ROTL(A, 12)
TT1 = (FF[j // 16](A, B, C) + D + SS2 + W_[j]) & 0xFFFFFFFF
TT2 = (GG[j // 16](E, F, G) + H + SS1 + W[j]) & 0xFFFFFFFF
D = C
C = ROTL(B, 9)
B = A
A = TT1
H = G
G = ROTL(F, 19)
F = E
E = P0(TT2)
return [(a + v) & 0xFFFFFFFF for a, v in zip([A,B,C,D,E,F,G,H], V)]
曾经踩过的坑:ROTL(循环左移)函数在Python里要自己实现
((x << n) | (x >> (32 - n))) & 0xFFFFFFFF,否则移位会带出32位边界。我一开始直接用<<,结果杂凑值全错。4.2 模块化设计:分层清晰,方便复用
核心代码写完后,我习惯把SM3拆成三个模块:core(基础函数)、sm3_api(对外接口)、sm3_hmac(用于密钥校验的HMAC包装)。这样测试时能单独mock某个层,维护起来也清爽。
| 模块 | 职责 | 主要函数/类 |
|---|---|---|
sm3_core.py | 常量、置换函数、消息扩展、单轮压缩 | ROTL, P0, P1, msg_expand, CF |
sm3_api.py | 填充、迭代入口、直接输出杂凑值 | SM3 类,update(), digest(), hexdigest() |
sm3_hmac.py | 基于SM3的HMAC实现,用于密钥校验 | hmac_sm3(key, msg) |
下面给出sm3_api.py的骨架(完整代码见随课资源):
class SM3:
def __init__(self):
self._V = IV[:] # 初始向量
self._unprocessed = b'' # 未满512位的缓存
self._total_bits = 0
def update(self, data: bytes):
"""持续输入数据(支持分块喂入)"""
self._unprocessed += data
self._total_bits += len(data) * 8
# 每次取满512位做CF
while len(self._unprocessed) >= 64:
block = self._unprocessed[:64]
self._V = CF(self._V, block)
self._unprocessed = self._unprocessed[64:]
def digest(self) -> bytes:
"""计算最终杂凑值并返回32字节"""
padded = sm3_pad(self._unprocessed, self._total_bits)
# 最后可能剩余一组或多组512位
for i in range(0, len(padded), 64):
block = padded[i:i+64]
self._V = CF(self._V, block)
return struct.pack('>8I', *self._V)
def hexdigest(self) -> str:
return self.digest().hex()
模块化设计的另一个好处是:以后升级国密标准,只需要换core层的常量或轮函数,上层接口完全不用动。
4.3 单元测试:用标准测试向量卡边界
单元测试我一般分三个层次:
- 白盒测试——直接测ROTL、P0、P1等基础函数,覆盖边界值(0x00000000、0xFFFFFFFF、全1移位等)。
- 中间状态测试——确认填充后的分组长度正确,
msg_expand输出的132个字是否满足SM3规范中的例子。 - 黑盒测试——用国密标准里给的测试向量(空字符串、一个字符、长消息)比对最终杂凑值。
下面是一个用unittest写的示例:
import unittest
from sm3_api import SM3
class TestSM3(unittest.TestCase):
# 国密标准测试向量
VECTORS = [
(b'', '66c7f0f4...'), # 短消息hash(实际应为32字节hex)
(b'abc', '66c7f0f4...'), # 仅作示例,请替换为真实值
(b'ab'*1000, 'xx...'), # 长消息
]
def test_short_message(self):
sm3 = SM3()
for msg, expected in self.VECTORS[:2]:
with self.subTest(msg=msg):
sm3.update(msg)
result = sm3.hexdigest()
self.assertEqual(result, expected)
def test_basic_functions(self):
"""测试ROTL边界"""
from sm3_core import ROTL
self.assertEqual(ROTL(0x00000001, 31), 0x80000000, "左移31位溢出测试")
self.assertEqual(ROTL(0xFFFFFFFF, 2), 0xFFFFFFFF, "全1循环移位不变")
if __name__ == '__main__':
unittest.main()
个人经验:测试不能只跑标准向量。我曾经因为某次修改优化了P1的实现,但忘了一个括号的位置,导致长消息hash全错。后来我多补了一个随机消息与OpenSSL SM3输出比对的用例,才彻底抓出bug。
为了让你更直观地理解这三部分的关系,我画了一张结构图:
单元测试跑通了吗?建议在每个模块的
__init__.py里写一个def test_self()函数,导入时自动跑一遍自检——这个习惯帮我避免过好几次“改完A模块、B模块忘记同步改”的尴尬。